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

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

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BreadcrumbsOptions struct {
Disabled bool
}
type BreadcrumbsResult struct {
Element *ui.Element
// Clicked is the part clicked, or -1.
Clicked int
}
// Breadcrumbs is a path whose parts, but the last, link.
func Breadcrumbs(c *ui.Context, text string, parts []string, o BreadcrumbsOptions) BreadcrumbsResult {
k := pixel.K(c)
res := BreadcrumbsResult{Clicked: -1}
res.Element = ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Children(func() {
for i, part := range parts {
if i == len(parts)-1 {
pixel.Text(c, part, pixel.TextOptions{})
break
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Role(ui.RoleLink).Label(part).Disabled(o.Disabled)
})
if base.Clicked() {
res.Clicked = i
}
shell.Size(k.D(pixel.Measure(part)+2), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Accent
if st.Hover {
ink = pixel.AccentLit
}
g.Text(1, 0, part, ink, 1)
g.HLine(1, g.H-2, g.W-2, ink)
g.Finish(st)
})
ui.Box(c).Size(k.D(pointer.W), k.D(pointer.H)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, pointer, 0, pixel.Faint, 1)
})
}
})
if res.Clicked >= 0 {
slog.Debug("8bit: breadcrumb", "part", parts[res.Clicked])
}
return res
}
+84
View File
@@ -0,0 +1,84 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CommandMenuOptions struct {
Disabled bool
}
type CommandMenuResult struct {
Element *ui.Element
// Chosen is the item taken this frame, or -1.
Chosen int
Canceled bool
}
type cursorKey struct{}
// CommandMenu is a JRPG command window with a bobbing ▶.
func CommandMenu(c *ui.Context, text string, items []MenuItem, o CommandMenuOptions) CommandMenuResult {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleList).Label(text).Disabled(o.Disabled)
})
cur := ui.Local(base, cursorKey{}, func() int { return 0 })
res := CommandMenuResult{Element: shell, Chosen: -1}
n := len(items)
pick := func(i int) {
if !items[i].Disabled {
res.Chosen = i
}
}
if base.Shortcut(0, ui.KeyDown) {
*cur = (*cur + 1) % n
}
if base.Shortcut(0, ui.KeyUp) {
*cur = (*cur + n - 1) % n
}
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
if enter || space {
pick(*cur)
}
res.Canceled = base.Shortcut(0, ui.KeyEscape)
// Enter and Space are taken, so clicks come from pointers.
if base.Clicked() {
_, y, _ := base.PointerPosition()
if i := int(y/k.Dot()-4) / pixel.LineHeight; i >= 0 && i < n {
*cur = i
pick(i)
}
}
base.Description(items[*cur].Label)
w := 0
for _, it := range items {
w = max(w, pixel.Measure(it.Label))
}
shell.Size(k.D(w+28), k.D(n*pixel.LineHeight+8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
for i, it := range items {
y, ink := 4+i*pixel.LineHeight, pixel.Ink
if it.Disabled {
ink = pixel.Faint
}
if i == *cur {
g.Sprite(6+g.Step(4)%2, y+5, pointer, 0, pixel.Accent, 1)
}
g.Text(16, y, it.Label, ink, 1)
}
g.Finish(st)
})
switch {
case res.Chosen >= 0:
slog.Debug("8bit: command", "label", text, "item", items[res.Chosen].Label)
case res.Canceled:
slog.Debug("8bit: command canceled", "label", text)
}
return res
}
+118
View File
@@ -0,0 +1,118 @@
package navigation
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CommandPaletteOptions struct {
Disabled bool
}
type CommandPaletteResult struct {
Element *ui.Element
// Ran is the command run this frame, or -1.
Ran int
}
type paletteKey struct{}
// palette is the query, lit match and first shown row.
type palette struct {
query string
lit, top int
}
// paletteRows is how many matches show at once.
const paletteRows = 8
// CommandPalette filters commands as you type; Cmd+K opens it.
func CommandPalette(c *ui.Context, text string, commands []MenuItem, open *bool, o CommandPaletteOptions) CommandPaletteResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
if !off && c.Shortcut(ui.Cmd, ui.KeyK) {
*open = !*open
}
if off {
*open = false
}
home := ui.Box(c)
st := ui.Local(home, paletteKey{}, func() palette { return palette{} })
res := CommandPaletteResult{Element: home, Ran: -1}
if !*open {
*st = palette{}
return res
}
shown := filter(commands, st.query)
run := func(i int) {
if !commands[i].Disabled {
res.Ran, *open = i, false
}
}
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
back.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Checker(0, 0, g.W, g.H, pixel.Back)
})
panel.Width(k.D(240)).Padding(k.D(6)).Gap(k.D(4)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
f := input.TextInput(c, text, &st.query, input.TextInputOptions{Placeholder: "> …", Width: 228})
f.Input.AutoFocus()
if f.Changed {
st.lit = 0
}
if f.Element.Shortcut(0, ui.KeyDown) {
st.lit++
}
if f.Element.Shortcut(0, ui.KeyUp) {
st.lit--
}
st.lit = max(0, min(st.lit, len(shown)-1))
st.top = max(0, min(st.top, st.lit, len(shown)-paletteRows), st.lit-paletteRows+1)
// A Shortcut, not Submitted: the frame builds again closed.
if f.Element.Shortcut(0, ui.KeyEnter) && len(shown) > 0 {
run(shown[st.lit])
}
ui.Column(c).AlignItems(ui.Stretch).Children(func() {
for j := st.top; j < min(len(shown), st.top+paletteRows); j++ {
it, lit := commands[shown[j]], j == st.lit
row := ui.Row(c).Role(ui.RoleListItem).Label(it.Label).Height(k.D(pixel.LineHeight))
if it.Disabled {
row.Description(k.Msg("disabled"))
}
if row.Clicked() {
run(shown[j])
}
row.Draw(func(p *ui.Painter, r ui.Rect) { menuRow(k.Canvas(p, r), it, lit) })
}
})
})
})
if res.Ran >= 0 {
slog.Debug("8bit: command palette", "label", text, "ran", commands[res.Ran].Label)
}
return res
}
// filter indexes commands holding q, prefix matches first.
func filter(commands []MenuItem, q string) []int {
q = strings.ToLower(strings.TrimSpace(q))
var starts, holds []int
for i, it := range commands {
switch l := strings.ToLower(it.Label); {
case it.Separator:
case strings.HasPrefix(l, q):
starts = append(starts, i)
case strings.Contains(l, q):
holds = append(holds, i)
}
}
return append(starts, holds...)
}
+81
View File
@@ -0,0 +1,81 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ContextMenuOptions struct {
Disabled bool
}
type ContextMenuResult struct {
Element *ui.Element
// Chosen is the item taken this frame, or -1.
Chosen int
}
type contextKey struct{}
// spot is the menu and where it opened, in DIPs.
type spot struct {
m menu
x, y float32
}
// ContextMenu opens on right click or the menu key.
func ContextMenu(c *ui.Context, text string, items []MenuItem, o ContextMenuOptions, content func()) ContextMenuResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
area := ui.Box(c).FocusRing(false).Role(ui.RoleGroup).Label(text)
// The area takes focus, so the menu key reaches it.
if off {
area.Description(k.Msg("disabled"))
} else {
area.Focusable()
}
s := ui.Local(area, contextKey{}, func() spot { return spot{m: menu{lit: -1}} })
pixel.Park(c, area, off)
if off {
s.m.open = false
}
res := ContextMenuResult{Element: area, Chosen: -1}
if area.RightClicked() && !off {
s.x, s.y, _ = area.PointerPosition()
s.m.show(items)
}
key, shiftF10 := area.Shortcut(0, ui.KeyContextMenu), area.Shortcut(ui.Shift, ui.KeyF10)
if (key || shiftF10) && !off {
// At the pointer when over the area, else its corner.
x, y, over := area.PointerPosition()
if !over {
x, y = 0, 0
}
s.x, s.y = x, y
s.m.show(items)
}
// Right clicks keep the focus, so the window takes keys.
res.Chosen = s.m.keys(c.Shortcut, items)
focus := area.FocusVisible()
area.DrawOver(func(p *ui.Painter, r ui.Rect) {
if focus {
g := k.Canvas(p, r)
g.FocusMark(0, 0, g.W, g.H)
}
}).Children(content)
panel, clicked := s.m.list(c, area, items)
if clicked >= 0 {
res.Chosen = clicked
}
if panel != nil {
b := area.Bounds()
panel.Left(b.X + s.x).Top(b.Y + s.y)
}
if res.Chosen >= 0 {
slog.Debug("8bit: context menu", "item", items[res.Chosen].Label)
}
return res
}
+85
View File
@@ -0,0 +1,85 @@
package navigation_test
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation"
)
// Each control, disabled after taking focus, ignores every input.
func TestDisabledNavigationTakesNoInput(t *testing.T) {
type state struct{ n, events int }
items := []navigation.MenuItem{{Label: "A"}, {Label: "B"}}
cases := []struct {
name, label string
build func(c *ui.Context, v *state, off bool)
}{
{"Tabs", "Two", func(c *ui.Context, v *state, off bool) {
navigation.Tabs(c, "T", []string{"One", "Two", "Six"}, &v.n, navigation.TabsOptions{Disabled: off}, func() {})
}},
{"Sidebar", "Two", func(c *ui.Context, v *state, off bool) {
navigation.Sidebar(c, "S", []navigation.SidebarSection{{Items: []navigation.SidebarItem{{Label: "One"}, {Label: "Two"}, {Label: "Six"}}}}, &v.n, navigation.SidebarOptions{Disabled: off})
}},
{"PageDots", "Dots", func(c *ui.Context, v *state, off bool) {
v.n = max(v.n, 1)
navigation.PageDots(c, "Dots", &v.n, 5, navigation.PageDotsOptions{Disabled: off})
}},
{"CommandMenu", "Cmd", func(c *ui.Context, v *state, off bool) {
if r := navigation.CommandMenu(c, "Cmd", items, navigation.CommandMenuOptions{Disabled: off}); r.Chosen >= 0 || r.Canceled {
v.events++
}
}},
{"DropdownMenu", "Drop", func(c *ui.Context, v *state, off bool) {
if navigation.DropdownMenu(c, "Drop", items, navigation.DropdownMenuOptions{Disabled: off}).Chosen >= 0 {
v.events++
}
}},
{"LevelSelect", "Stages", func(c *ui.Context, v *state, off bool) {
if navigation.LevelSelect(c, "Stages", []navigation.Stage{{Name: "1"}, {Name: "2"}, {Name: "3"}}, &v.n, navigation.LevelSelectOptions{Disabled: off}).Chosen {
v.events++
}
}},
{"WorldMap", "World", func(c *ui.Context, v *state, off bool) {
nodes := []navigation.MapNode{{Name: "A", X: 20, Y: 20}, {Name: "B", X: 80, Y: 20}}
if navigation.WorldMap(c, "World", nodes, [][2]int{{0, 1}}, &v.n, navigation.WorldMapOptions{Disabled: off}).Entered {
v.events++
}
}},
{"DocumentTabs", "Two", func(c *ui.Context, v *state, off bool) {
if navigation.DocumentTabs(c, "D", []string{"One", "Two"}, &v.n, navigation.DocumentTabsOptions{Disabled: off}).Closed >= 0 {
v.events++
}
}},
{"NavRail", "Map", func(c *ui.Context, v *state, off bool) {
navigation.NavRail(c, "R", []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Map, Label: "Map"}, {Icon: icon.Gem, Label: "Gem"}}, &v.n, navigation.NavRailOptions{Disabled: off})
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v state
off := false
tt := view(500, 400, newClock(), false, func(c *ui.Context) {
tc.build(c, &v, off)
ui.Box(c).Size(4, 4).Label("elsewhere")
})
click(t, tt, tc.label)
off = true
tt.Frame()
if tt.Focused(tc.label) && !tt.Focused("elsewhere") {
t.Fatal("a disabled control kept the focus")
}
before := fmt.Sprint(v)
for _, k := range []ui.Key{ui.KeyDown, ui.KeyRight, ui.KeyUp, ui.KeyLeft, ui.KeyEnd, ui.KeyEnter, ui.KeySpace, ui.KeyEscape, ui.KeyDelete} {
tt.Key(0, k)
}
click(t, tt, tc.label)
if now := fmt.Sprint(v); now != before {
t.Fatalf("disabled control changed: %s to %s", before, now)
}
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var cross = pixel.MustSprite(`
#...#
.#.#.
..#..
.#.#.
#...#
`)
type DocumentTabsOptions struct {
Disabled bool
}
type DocumentTabsResult struct {
Element *ui.Element
Changed bool
// Closed is the tab × or Delete closed, or -1.
Closed int
}
// DocumentTabs are closable tabs; × or Delete reports Closed.
func DocumentTabs(c *ui.Context, text string, titles []string, selected *int, o DocumentTabsOptions) DocumentTabsResult {
k := pixel.K(c)
res := DocumentTabsResult{Closed: -1}
p := ui.TabsBase(c, selected, len(titles))
p.List.Label(text).Children(func() {
for i, t := range titles {
var x *ui.Element
shell, base, st := pixel.Shell(c, func() *ui.Element {
tab := p.Tab(i).Label(t).Disabled(o.Disabled)
// The × is no Tab stop; Delete closes by keyboard.
tab.Children(func() {
x = ui.Box(c).Absolute().Right(k.D(4)).Top(k.D(6)).Size(k.D(7), k.D(7)).Role(ui.RoleButton).Label(k.Msg("close") + " " + t)
})
return tab
})
if x.Clicked() || base.Shortcut(0, ui.KeyDelete) {
res.Closed = i
}
hot := x.Hovered()
shell.Size(k.D(pixel.Measure(t)+24), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
folder(g, t, i == *selected, false, st)
ink := pixel.Faint
if hot {
ink = pixel.Bad
}
g.Sprite(g.W-10, 7, cross, 0, ink, 1)
})
}
})
res.Element, res.Changed = p.List, p.List.Changed()
if res.Closed >= 0 {
slog.Debug("8bit: tab closed", "label", text, "tab", titles[res.Closed])
}
return res
}
+49
View File
@@ -0,0 +1,49 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DropdownMenuOptions struct {
Disabled bool
}
type DropdownMenuResult struct {
Element *ui.Element
// Chosen is the item taken this frame, or -1.
Chosen int
}
// DropdownMenu is a button opening a menu of items.
func DropdownMenu(c *ui.Context, text string, items []MenuItem, o DropdownMenuOptions) DropdownMenuResult {
off := o.Disabled || pixel.Current(c).Inert
b := input.Button(c, text+" ▼", input.ButtonOptions{Label: text, Disabled: o.Disabled})
m := ui.Local(b.Element, menuKey{}, func() menu { return menu{lit: -1} })
if off {
// Closing on disable must not return focus to it.
if m.open {
c.Root().Focus()
}
m.open = false
}
res := DropdownMenuResult{Element: b.Element, Chosen: -1}
switch {
case b.Clicked && m.open:
m.open = false
case b.Clicked, !m.open && !off && b.Element.Shortcut(0, ui.KeyDown):
m.show(items)
}
res.Chosen = m.keys(b.Element.Shortcut, items)
if _, clicked := m.list(c, b.Element, items); clicked >= 0 {
res.Chosen = clicked
}
if res.Chosen >= 0 {
slog.Debug("8bit: dropdown", "label", text, "item", items[res.Chosen].Label)
}
return res
}
+84
View File
@@ -0,0 +1,84 @@
package navigation
import (
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var star5 = pixel.MustSprite(`
..#..
.###.
#####
.###.
.#.#.
`)
type Stage struct {
Name string
// Locked stages cannot be entered.
Locked bool
// Cleared stages show Stars, 0 to 3.
Cleared bool
Stars int
}
type LevelSelectOptions struct {
// Columns is the grid's width; zero means 5.
Columns int
Disabled bool
}
type LevelSelectResult struct {
Element *ui.Element
// Chosen is Enter or a click on an open stage.
Changed, Chosen bool
}
// LevelSelect is a grid of locked, open or starred stages.
func LevelSelect(c *ui.Context, text string, stages []Stage, selected *int, o LevelSelectOptions) LevelSelectResult {
k := pixel.K(c)
cols := pixel.Or("LevelSelect Columns", o.Columns, 5)
old := *selected
shell, base, st, picked := pixel.Pad(c, text, cols, len(stages), 30, 30, selected, o.Disabled)
res := LevelSelectResult{Element: shell, Changed: *selected != old, Chosen: picked >= 0 && !stages[picked].Locked}
base.Description(stages[*selected].Name)
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, s := range stages {
x, y := i%cols*30, i/cols*30
fill := pixel.Face
if s.Locked {
fill = pixel.Shade
}
if i == *selected {
g.Cursor(x, y, 30, 30)
}
g.Frame(x+2, y+2, 26, 26, pixel.Raised, fill)
if s.Locked {
g.Sprite(x+9, y+8, icon.Lock, 0, pixel.Faint, 1)
continue
}
n := strconv.Itoa(i + 1)
g.Micro(x+15-pixel.MicroWidth(n), y+5, n, pixel.Ink, 2)
if s.Cleared {
for j := range 3 {
ink := pixel.Shade
if j < s.Stars {
ink = pixel.Warn
}
g.Sprite(x+5+j*7, y+20, star5, 0, ink, 1)
}
}
}
g.Finish(st)
})
if res.Chosen {
slog.Debug("8bit: stage chosen", "label", text, "stage", stages[*selected].Name)
}
return res
}
+111
View File
@@ -0,0 +1,111 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type Menu struct {
Title string
Items []MenuItem
}
type MenuBarOptions struct {
Disabled bool
}
type MenuBarResult struct {
Element *ui.Element
// Menu and Item are this frame's choice, else -1.
Menu, Item int
}
type barKey struct{}
// bar is the open menu, its state and the pointer.
type bar struct {
open int
m menu
ptr [2]float32
}
// MenuBar is a bar of menus; arrows go across, down.
func MenuBar(c *ui.Context, text string, menus []Menu, o MenuBarOptions) MenuBarResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
row := ui.Row(c).Gap(k.D(2)).Padding(k.D(2), k.D(4)).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text)
b := ui.Local(row, barKey{}, func() bar { return bar{open: -1} })
res := MenuBarResult{Element: row, Menu: -1, Item: -1}
open := func(i int) {
b.open = i
b.m.show(menus[i].Items)
}
if off && b.open >= 0 {
// Closing on disable must not return focus to it.
c.Root().Focus()
b.open = -1
}
if b.open >= 0 && !b.m.open {
b.open = -1
}
n := len(menus)
// Left and Right hand an open menu to its neighbor.
if b.open >= 0 {
left, right := row.Shortcut(0, ui.KeyLeft), row.Shortcut(0, ui.KeyRight)
switch {
case left:
open((b.open + n - 1) % n)
case right:
open((b.open + 1) % n)
}
}
// Only a moving pointer hands the bar to another title.
px, py, over := row.PointerPosition()
moved := over && [2]float32{px, py} != b.ptr
b.ptr = [2]float32{px, py}
var anchor *ui.Element
row.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.HLine(0, g.H-1, g.W, pixel.Edge)
}).Children(func() {
for i, mn := range menus {
t := input.Button(c, mn.Title, input.ButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled})
if _, _, on := t.Element.PointerPosition(); on && moved && b.open >= 0 && i != b.open {
open(i)
}
if i == b.open {
anchor = t.Element
}
switch {
case t.Clicked && b.open == i:
b.open = -1
case t.Clicked:
open(i)
anchor = t.Element
case b.open < 0 && !off && t.Element.FocusWithin() && row.Shortcut(0, ui.KeyDown):
open(i)
anchor = t.Element
}
}
})
if b.open >= 0 && anchor != nil {
items := menus[b.open].Items
res.Item = b.m.keys(row.Shortcut, items)
if _, clicked := b.m.list(c, anchor, items); clicked >= 0 {
res.Item = clicked
}
if res.Item >= 0 {
res.Menu = b.open
b.open = -1
}
}
if res.Item >= 0 {
slog.Debug("8bit: menu bar", "menu", menus[res.Menu].Title, "item", menus[res.Menu].Items[res.Item].Label)
}
return res
}
+151
View File
@@ -0,0 +1,151 @@
package navigation
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MenuItem struct {
Label string
// Icon and Keys, a shortcut's name, sit beside the label.
Icon *pixel.Sprite
Keys string
Disabled bool
// Separator is a rule, not an item.
Separator bool
}
type menuKey struct{}
// menu is a popup's state: open, lit item, pointer.
type menu struct {
open bool
lit int
ptr [2]float32
}
// show opens the menu with its first item lit.
func (m *menu) show(items []MenuItem) {
m.open, m.lit = true, -1
m.step(items, 1)
}
// step moves the light d items, past rules and disabled.
func (m *menu) step(items []MenuItem, d int) {
for i, n := m.lit, 0; n < len(items); n++ {
i = ((i+d)%len(items) + len(items)) % len(items)
if !items[i].Separator && !items[i].Disabled {
m.lit = i
return
}
}
}
// keys moves the light; Enter or Space picks, else -1.
func (m *menu) keys(short func(ui.Modifiers, ui.Key) bool, items []MenuItem) int {
if !m.open {
return -1
}
down, up := short(0, ui.KeyDown), short(0, ui.KeyUp)
enter, space := short(0, ui.KeyEnter), short(0, ui.KeySpace)
switch {
case down:
m.step(items, 1)
case up:
m.step(items, -1)
case (enter || space) && m.lit >= 0 && !items[m.lit].Disabled:
m.open = false
return m.lit
}
return -1
}
// list shows the menu under anchor and returns clicks.
func (m *menu) list(c *ui.Context, anchor *ui.Element, items []MenuItem) (panel *ui.Element, clicked int) {
k := pixel.K(c)
clicked = -1
panel = ui.PopoverBase(c, anchor, &m.open, func(panel *ui.Element) {
// Only a pointer that moves lights the item under it.
px, py, over := panel.PointerPosition()
moved := over && [2]float32{px, py} != m.ptr
m.ptr = [2]float32{px, py}
panel.Padding(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
ui.Column(c).AlignItems(ui.Stretch).Children(func() {
for i, it := range items {
if it.Separator {
ui.Box(c).Height(k.D(5)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.HLine(2, 2, g.W-4, pixel.Shade)
})
continue
}
row := ui.Row(c).Role(ui.RoleListItem).Label(it.Label).Height(k.D(pixel.LineHeight)).MinWidth(k.D(menuWidth(it)))
if it.Disabled {
row.Description(k.Msg("disabled"))
}
if moved && row.Hovered() && !it.Disabled {
m.lit = i
}
// Any click closes; MyGo hands focus back to the opener.
if row.Clicked() {
m.open = false
if !it.Disabled {
clicked = i
}
}
lit := i == m.lit
row.Draw(func(p *ui.Painter, r ui.Rect) { menuRow(k.Canvas(p, r), it, lit) })
}
})
})
})
// Clicks on a rule or the edge close it too.
if panel != nil && panel.Clicked() {
m.open = false
}
return panel, clicked
}
// menuWidth is a row's room for icon, label and keys.
func menuWidth(it MenuItem) int {
w := 26 + pixel.Measure(it.Label)
if it.Keys != "" {
w += 12 + keysWidth(it.Keys)
}
return w
}
// keysWidth measures keys in micro when the font has them.
func keysWidth(s string) int {
if pixel.MicroOK(s) {
return pixel.MicroWidth(s)
}
return pixel.Measure(s)
}
// menuRow draws an item; a hand marks the lit one.
func menuRow(g pixel.Canvas, it MenuItem, lit bool) {
ink := pixel.Ink
if it.Disabled {
ink = pixel.Faint
}
if lit {
g.Rect(0, 0, g.W, g.H, pixel.Lit)
g.Sprite(0, (g.H-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1)
}
if it.Icon != nil {
g.Sprite(12, (g.H-it.Icon.H)/2, it.Icon, 0, ink, 1)
}
g.Text(26, 0, it.Label, ink, 1)
switch {
case it.Keys == "":
case pixel.MicroOK(it.Keys):
g.Micro(g.W-pixel.MicroWidth(it.Keys)-2, 6, it.Keys, pixel.Faint, 1)
default:
g.Text(g.W-pixel.Measure(it.Keys)-2, 0, it.Keys, pixel.Faint, 1)
}
}
+247
View File
@@ -0,0 +1,247 @@
package navigation_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var commands = []navigation.MenuItem{{Label: "FIGHT"}, {Label: "MAGIC", Disabled: true}, {Label: "ITEM"}, {Label: "RUN"}}
func TestCommandMenuChoosesAndCancels(t *testing.T) {
chosen, canceled := -1, 0
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
r := navigation.CommandMenu(c, "Command", commands, navigation.CommandMenuOptions{})
if r.Chosen >= 0 {
chosen = r.Chosen
}
if r.Canceled {
canceled++
}
})
r := find(t, tt, "Command")
tt.ClickAt(r.X+20, r.Y+8+2*16*2+8)
if chosen != 2 {
t.Fatalf("a click on ITEM chose %d", chosen)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
if chosen != 2 {
t.Fatal("Enter took the disabled MAGIC")
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEscape)
if chosen != 0 || canceled != 1 {
t.Fatalf("Up, Enter chose %d; Escape canceled %d times", chosen, canceled)
}
}
func TestDropdownMenuKeysAndClicks(t *testing.T) {
chosen, disabled := -1, false
items := []navigation.MenuItem{{Label: "Save", Icon: icon.Save, Keys: "CTRL+S"}, {Separator: true}, {Label: "Load", Disabled: true}, {Label: "Quit"}}
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if r := navigation.DropdownMenu(c, "File", items, navigation.DropdownMenuOptions{Disabled: disabled}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
click(t, tt, "File")
find(t, tt, "Quit")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if chosen != 3 || shows(tt, "Quit") {
t.Fatalf("Down skipped to %d, or the menu stayed", chosen)
}
tt.Key(0, ui.KeyDown)
click(t, tt, "Save")
if chosen != 0 {
t.Fatalf("a click chose %d", chosen)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEscape)
if shows(tt, "Quit") || !tt.Focused("File") {
t.Fatal("Escape left the menu open or lost the focus")
}
disabled = true
tt.Frame()
click(t, tt, "File")
if shows(tt, "Quit") {
t.Fatal("a disabled dropdown opened")
}
}
func TestMenusIgnoreDisabledItems(t *testing.T) {
chosen := -1
items := []navigation.MenuItem{{Label: "Save"}, {Label: "Load", Disabled: true}, {Label: "Quit"}}
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
input.Button(c, "before", input.ButtonOptions{})
if r := navigation.DropdownMenu(c, "File", items, navigation.DropdownMenuOptions{}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
click(t, tt, "File")
click(t, tt, "Load")
if shows(tt, "Quit") || !tt.Focused("File") {
t.Fatal("a click on a disabled item left the menu or the focus astray")
}
// A click on the panel's edge, below the last row.
click(t, tt, "File")
q := find(t, tt, "Quit")
tt.ClickAt(q.X+q.W/2, q.Y+q.H+3)
if shows(tt, "Quit") || !tt.Focused("File") {
t.Fatal("a click on the panel's edge left the menu or the focus astray")
}
tt.Key(0, ui.KeyDown)
items[0].Disabled = true
tt.Key(0, ui.KeyEnter)
if chosen != -1 {
t.Fatalf("Enter took an item disabled while lit: %d", chosen)
}
}
func TestMenuBarMovesAcrossAndDown(t *testing.T) {
m, item := -1, -1
menus := []navigation.Menu{
{Title: "File", Items: []navigation.MenuItem{{Label: "New"}, {Label: "Open"}}},
{Title: "Edit", Items: []navigation.MenuItem{{Label: "Undo"}, {Label: "Redo"}}},
}
tt := view(500, 300, newClock(), false, func(c *ui.Context) {
if r := navigation.MenuBar(c, "Menus", menus, navigation.MenuBarOptions{}); r.Item >= 0 {
m, item = r.Menu, r.Item
}
})
click(t, tt, "File")
find(t, tt, "Open")
tt.Key(0, ui.KeyRight)
if shows(tt, "Open") || !shows(tt, "Redo") {
t.Fatal("Right did not move to the Edit menu")
}
// The pointer rests on File and must not take over.
tt.Frame()
if !shows(tt, "Redo") {
t.Fatal("a resting pointer took the bar back")
}
tt.Key(0, ui.KeyLeft)
if !shows(tt, "Open") {
t.Fatal("Left did not move back to File")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if m != 1 || item != 1 {
t.Fatalf("Down, Enter chose menu %d item %d", m, item)
}
click(t, tt, "File")
e := find(t, tt, "Edit")
tt.Move(e.X+e.W/2, e.Y+e.H/2)
if !shows(tt, "Undo") || shows(tt, "New") {
t.Fatal("moving onto Edit did not open it")
}
}
func TestContextMenuGivesUpFocusWhenOff(t *testing.T) {
off := false
items := []navigation.MenuItem{{Label: "Inspect"}}
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
input.Button(c, "before", input.ButtonOptions{})
navigation.ContextMenu(c, "Area", items, navigation.ContextMenuOptions{Disabled: off}, func() {
ui.Box(c).Size(100, 60)
})
ui.Box(c).Size(4, 4).Label("elsewhere")
})
click(t, tt, "before")
tt.Key(0, ui.KeyTab)
off = true
tt.Frame()
if tt.Focused("Area") && !tt.Focused("elsewhere") {
t.Fatal("a disabled area kept the focus")
}
off = false
tt.Frame()
if !tt.Focused("Area") || tt.Focused("elsewhere") {
t.Fatal("the focus did not come back")
}
}
func TestContextMenuOpensAtThePointer(t *testing.T) {
chosen := -1
items := []navigation.MenuItem{{Label: "Inspect"}, {Label: "Throw"}}
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if r := navigation.ContextMenu(c, "Area", items, navigation.ContextMenuOptions{}, func() {
ui.Box(c).Size(200, 100).Label("Slime")
}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
tt.Key(0, ui.KeyTab)
a := find(t, tt, "Area")
tt.Move(a.X+120, a.Y+80)
tt.Key(0, ui.KeyContextMenu)
if it := find(t, tt, "Inspect"); it.X < a.X+110 || it.Y < a.Y+70 {
t.Fatalf("the menu key opened at %v, away from the pointer", it)
}
tt.Key(0, ui.KeyEscape)
s := find(t, tt, "Slime")
if err := tt.RightClick("Slime"); err != nil {
t.Fatal(err)
}
it := find(t, tt, "Inspect")
if it.X < s.X+s.W/2-2 || it.Y < s.Y+s.H/2-2 {
t.Fatalf("the menu opened at %v, away from the pointer", it)
}
click(t, tt, "Throw")
if chosen != 1 {
t.Fatalf("Throw gave %d", chosen)
}
}
func TestCommandPaletteFiltersAndRuns(t *testing.T) {
open, ran := false, -1
cmds := []navigation.MenuItem{{Label: "Save game"}, {Label: "Load game"}, {Label: "Settings", Disabled: true}, {Label: "Quit"}}
tt := view(500, 400, newClock(), false, func(c *ui.Context) {
if r := navigation.CommandPalette(c, "Commands", cmds, &open, navigation.CommandPaletteOptions{}); r.Ran >= 0 {
ran = r.Ran
}
})
tt.Key(ui.Cmd, ui.KeyK)
if !open {
t.Fatal("Cmd+K did not open the palette")
}
tt.Type("game")
if shows(tt, "Quit") || !shows(tt, "Load game") {
t.Fatal("typing did not filter")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if ran != 1 || open {
t.Fatalf("Down, Enter ran %d, open %v", ran, open)
}
tt.Key(ui.Cmd, ui.KeyK)
tt.Type("set")
tt.Key(0, ui.KeyEnter)
if ran != 1 || !open {
t.Fatal("a disabled command ran")
}
tt.Key(0, ui.KeyEscape)
if open {
t.Fatal("Escape did not close the palette")
}
inert := false
tt = view(500, 400, newClock(), false, func(c *ui.Context) {
s := pixel.Current(c)
s.Inert = inert
pixel.Scope(c, s, func() { navigation.CommandPalette(c, "Commands", cmds, &open, navigation.CommandPaletteOptions{}) })
})
tt.Key(ui.Cmd, ui.KeyK)
inert = true
tt.Frame()
tt.Key(ui.Cmd, ui.KeyK)
if open {
t.Fatal("the palette stayed or opened under Inert")
}
}
+274
View File
@@ -0,0 +1,274 @@
package navigation_test
import (
"image"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} }
// view builds on a fake clock; reduce stops motion.
func view(w, h int, clk *clock, reduce bool, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now})
build(c)
}, w, h)
if reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
}
return tt
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
func shows(tt *ui.Tester, label string) bool {
_, ok := tt.Find(label)
return ok
}
// slots are the palette slots of a box's dots.
func slots(t *testing.T, img *image.RGBA, r ui.Rect) []pixel.Slot {
t.Helper()
var out []pixel.Slot
for y := int(r.Y); y < int(r.Y+r.H); y += 2 {
for x := int(r.X); x < int(r.X+r.W); x += 2 {
c, found := img.RGBAAt(x, y), false
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
out, found = append(out, pixel.Slot(s)), true
break
}
}
if !found {
t.Fatalf("pixel %d,%d off the palette", x, y)
}
}
}
return out
}
func count(s []pixel.Slot, want pixel.Slot) int {
n := 0
for _, v := range s {
if v == want {
n++
}
}
return n
}
func TestTabsChooseAndShowTheirPanel(t *testing.T) {
for _, side := range []bool{false, true} {
tab, changes := 0, 0
tt := view(500, 300, newClock(), false, func(c *ui.Context) {
build := navigation.Tabs
if side {
build = navigation.VerticalTabs
}
names := []string{"Items", "Magic", "Status"}
if build(c, "Menu", names, &tab, navigation.TabsOptions{}, func() {
pixel.Text(c, "panel "+names[tab], pixel.TextOptions{})
}).Changed {
changes++
}
})
click(t, tt, "Magic")
find(t, tt, "panel Magic")
// The panel's edge breaks open under the chosen tab only.
edge := func(l string) pixel.Slot {
r := find(t, tt, l)
if side {
return slots(t, tt.Image(), ui.Rect{X: r.X + r.W, Y: r.Y + r.H/2, W: 2, H: 2})[0]
}
return slots(t, tt.Image(), ui.Rect{X: r.X + r.W/2, Y: r.Y + r.H, W: 2, H: 2})[0]
}
if edge("Magic") == pixel.Edge || edge("Items") != pixel.Edge {
t.Fatalf("side %v: the chosen tab does not join the panel", side)
}
tt.Key(0, ui.KeyRight)
if tab != 2 || changes != 2 || !shows(tt, "panel Status") {
t.Fatalf("side %v: click and Right gave tab %d after %d changes", side, tab, changes)
}
}
}
func TestSidebarChoosesAcrossSections(t *testing.T) {
sel := 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
navigation.Sidebar(c, "Places", []navigation.SidebarSection{
{Title: "WORLD", Items: []navigation.SidebarItem{{Icon: icon.Home, Label: "Town"}, {Icon: icon.Map, Label: "Field"}}},
{Title: "MAIL", Items: []navigation.SidebarItem{{Icon: icon.Mail, Label: "Inbox", Badge: "3"}}},
}, &sel, navigation.SidebarOptions{})
})
click(t, tt, "Field")
tt.Key(0, ui.KeyDown)
if sel != 2 {
t.Fatalf("click and Down chose %d, want the next section's item", sel)
}
if count(slots(t, tt.Image(), find(t, tt, "Inbox")), pixel.Bad) == 0 {
t.Fatal("the badge does not show")
}
}
func TestBreadcrumbsLinkAllButTheLast(t *testing.T) {
clicked := -2
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
if r := navigation.Breadcrumbs(c, "Path", []string{"World", "Cave", "B2"}, navigation.BreadcrumbsOptions{}); r.Clicked >= 0 {
clicked = r.Clicked
}
})
click(t, tt, "B2")
n := 0
for _, s := range tt.Texts() {
if s == "B2" {
n++
}
}
if clicked != -2 || n != 1 {
t.Fatalf("the last part linked, or shows %d times", n)
}
click(t, tt, "Cave")
if clicked != 1 {
t.Fatalf("Cave reported %d", clicked)
}
}
func TestPaginationAndDots(t *testing.T) {
page, dot := 5, 1
tt := view(600, 200, newClock(), false, func(c *ui.Context) {
navigation.Pagination(c, "Pages", &page, 9, navigation.PaginationOptions{})
navigation.PageDots(c, "Slides", &dot, 4, navigation.PageDotsOptions{})
})
for _, l := range []string{"1", "4", "5", "6", "9"} {
find(t, tt, l)
}
if shows(tt, "3") || shows(tt, "7") {
t.Fatal("pages far from the current one show")
}
click(t, tt, "6")
tt.Key(0, ui.KeySpace)
if page != 6 || !tt.Focused("6") {
t.Fatalf("the focus left the clicked page: page %d", page)
}
click(t, tt, "NEXT")
if page != 7 {
t.Fatalf("6 then NEXT gave %d", page)
}
page = 1
tt.Frame()
click(t, tt, "PREVIOUS")
if page != 1 {
t.Fatal("PREVIOUS moved past page 1")
}
// The click lands on the third of four dots.
click(t, tt, "Slides")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if dot != 4 {
t.Fatalf("a click and Right gave dot %d", dot)
}
r := find(t, tt, "Slides")
tt.ClickAt(r.X+4, r.Y+r.H/2)
if dot != 1 {
t.Fatalf("a click on the first dot gave %d", dot)
}
}
func TestStepsWaveUnlessReduced(t *testing.T) {
for _, reduce := range []bool{false, true} {
clk := newClock()
tt := view(400, 100, clk, reduce, func(c *ui.Context) {
navigation.Steps(c, []string{"Gate", "Cave", "Peak"}, 1, navigation.StepsOptions{})
})
r := find(t, tt, "Gate, Cave, Peak")
before := slots(t, tt.Image(), r)
clk.t = clk.t.Add(300 * time.Millisecond)
tt.Frame()
if waved := !slices.Equal(before, slots(t, tt.Image(), r)); waved == reduce {
t.Errorf("reduce %v: the flag waved %v", reduce, waved)
}
}
}
func TestToolbarIsOneTabStop(t *testing.T) {
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
input.Button(c, "before", input.ButtonOptions{})
navigation.Toolbar(c, "Tools", navigation.ToolbarOptions{}, func() {
input.IconButton(c, icon.Edit, "Pen", input.IconButtonOptions{})
input.IconButton(c, icon.Trash, "Erase", input.IconButtonOptions{})
})
input.Button(c, "after", input.ButtonOptions{})
})
click(t, tt, "before")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if !tt.Focused("Erase") {
t.Fatal("Right did not move along the toolbar")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("after") {
t.Fatal("Tab did not leave the toolbar")
}
}
func TestBackForwardWalksHistory(t *testing.T) {
r := ui.NewRouter("/a")
r.Push("/b")
tt := view(300, 100, newClock(), false, func(c *ui.Context) {
navigation.BackForward(c, r, navigation.BackForwardOptions{})
})
click(t, tt, "FORWARD")
click(t, tt, "BACK")
if r.Path() != "/a" {
t.Fatalf("BACK went to %q", r.Path())
}
click(t, tt, "BACK")
click(t, tt, "FORWARD")
if r.Path() != "/b" {
t.Fatalf("FORWARD went to %q", r.Path())
}
}
func TestTableOfContentsMarksTheCurrent(t *testing.T) {
cur := 0
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
navigation.TableOfContents(c, "Manual", []navigation.TocItem{{Title: "Controls"}, {Title: "Jumping", Level: 1}, {Title: "Bosses"}}, &cur, navigation.TableOfContentsOptions{})
})
click(t, tt, "Jumping")
tt.Key(0, ui.KeyDown)
if cur != 2 {
t.Fatalf("click and Down gave %d", cur)
}
// The marker sits left of the title's indent.
b := find(t, tt, "Bosses")
if count(slots(t, tt.Image(), ui.Rect{X: b.X, Y: b.Y, W: 12, H: b.H}), pixel.Accent) == 0 {
t.Fatal("no ▶ at the current section")
}
}
+139
View File
@@ -0,0 +1,139 @@
package navigation
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PaginationOptions struct {
Disabled bool
}
type PaginationResult struct {
Element *ui.Element
Changed bool
}
// Pagination steps through pages; the current one is sunken.
func Pagination(c *ui.Context, text string, page *int, total int, o PaginationOptions) PaginationResult {
k := pixel.K(c)
check(*page, total)
res := PaginationResult{}
set := func(p int) {
if p != *page {
*page, res.Changed = p, true
}
}
small := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || *page == 1}
res.Element = ui.Row(c).Gap(k.D(2)).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(text).Children(func() {
// Keys keep each button's focus as the pages shift.
ui.Box(c).Key("prev").Children(func() {
if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), small).Clicked {
set(*page - 1)
}
})
for i, p := range pages(*page, total) {
if p == 0 {
ui.Box(c).Key(-i).Children(func() { pixel.Text(c, "…", pixel.TextOptions{Slot: pixel.Faint}) })
continue
}
ui.Box(c).Key(p).Children(func() {
on := p == *page
if input.Toggle(c, strconv.Itoa(p), &on, input.ToggleOptions{Size: input.Small, Disabled: o.Disabled}).Changed {
set(p)
}
})
}
ui.Box(c).Key("next").Children(func() {
small.Disabled = o.Disabled || *page == total
if input.IconButton(c, icon.ChevronRight, k.Msg("next"), small).Clicked {
set(*page + 1)
}
})
})
if res.Changed {
slog.Debug("8bit: page", "label", text, "page", *page)
}
return res
}
// pages lists 1, the pages round p, and n.
func pages(p, n int) []int {
var out []int
for i := 1; i <= n; i++ {
switch {
case i == 1 || i == n || i >= p-1 && i <= p+1:
out = append(out, i)
case len(out) > 0 && out[len(out)-1] != 0:
out = append(out, 0)
}
}
return out
}
// check panics on a page outside 1 to total.
func check(page, total int) {
if total < 1 || page < 1 || page > total {
panic(fmt.Sprintf("navigation: page %d of %d", page, total))
}
}
type PageDotsOptions struct {
Disabled bool
}
type PageDotsResult struct {
Element *ui.Element
Changed bool
}
// PageDots shows a dot per page; clicks jump, arrows move.
func PageDots(c *ui.Context, text string, page *int, total int, o PageDotsOptions) PageDotsResult {
k := pixel.K(c)
check(*page, total)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(fmt.Sprintf("%d/%d", *page, total)).Disabled(o.Disabled)
})
old := *page
for _, m := range []struct {
key ui.Key
to func() int
}{
{ui.KeyLeft, func() int { return *page - 1 }}, {ui.KeyRight, func() int { return *page + 1 }},
{ui.KeyHome, func() int { return 1 }}, {ui.KeyEnd, func() int { return total }},
} {
if base.Shortcut(0, m.key) {
*page = max(1, min(total, m.to()))
}
}
// Taking Enter and Space keeps keys from clicking.
base.Shortcut(0, ui.KeyEnter)
base.Shortcut(0, ui.KeySpace)
if base.Clicked() {
x, _, _ := base.PointerPosition()
*page = max(1, min(total, int(x/k.Dot())/8+1))
}
shell.Size(k.D(total*8), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range total {
if i+1 == *page {
g.Rect(i*8+1, 1, 6, 6, pixel.Accent)
} else {
g.Rect(i*8+2, 2, 4, 4, pixel.Shade)
}
}
g.Finish(st)
})
res := PageDotsResult{Element: shell, Changed: *page != old}
if res.Changed {
slog.Debug("8bit: page dots", "label", text, "page", *page)
}
return res
}
+116
View File
@@ -0,0 +1,116 @@
package navigation_test
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var navItems = []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Map, Label: "Map"}, {Icon: icon.Settings, Label: "Options"}}
func TestNavRailNamesTheHoveredItem(t *testing.T) {
sel := 0
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
navigation.NavRail(c, "Rail", navItems, &sel, navigation.NavRailOptions{})
})
m := find(t, tt, "Map")
label := ui.Rect{X: m.X + m.W + 8, Y: m.Y, W: 60, H: m.H}
before := slots(t, tt.Image(), label)
tt.Move(m.X+m.W/2, m.Y+m.H/2)
if slices.Equal(before, slots(t, tt.Image(), label)) {
t.Fatal("hovering showed no name")
}
click(t, tt, "Map")
tt.Key(0, ui.KeyDown)
if sel != 2 {
t.Fatalf("click and Down gave %d", sel)
}
}
func TestBottomNavChooses(t *testing.T) {
sel, changes := 0, 0
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
if navigation.BottomNav(c, "Bottom", navItems, &sel, navigation.BottomNavOptions{}).Changed {
changes++
}
})
click(t, tt, "Options")
tt.Key(0, ui.KeyLeft)
if sel != 1 || changes != 2 {
t.Fatalf("click and Left gave %d after %d changes", sel, changes)
}
}
func TestLevelSelectMovesAndEnters(t *testing.T) {
sel, chosen := 0, 0
stages := []navigation.Stage{{Name: "1-1", Cleared: true, Stars: 3}, {Name: "1-2", Cleared: true, Stars: 1}, {Name: "1-3"}, {Name: "1-4", Locked: true}}
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
if navigation.LevelSelect(c, "Stages", stages, &sel, navigation.LevelSelectOptions{Columns: 2}).Chosen {
chosen++
}
})
click(t, tt, "Stages")
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if sel != 3 || chosen != 0 {
t.Fatalf("moved to %d; a locked stage was chosen %d times", sel, chosen)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyEnter)
if sel != 2 || chosen != 1 {
t.Fatalf("Enter on an open stage: %d, chosen %d", sel, chosen)
}
if count(slots(t, tt.Image(), find(t, tt, "Stages")), pixel.Warn) == 0 {
t.Fatal("cleared stages show no stars")
}
}
func TestWorldMapWalksAndEnters(t *testing.T) {
for _, reduce := range []bool{false, true} {
clk := newClock()
at, entered := 0, 0
nodes := []navigation.MapNode{{Name: "Home", X: 30, Y: 70}, {Name: "Woods", X: 110, Y: 70}, {Name: "Cave", X: 110, Y: 20, Locked: true}}
tt := view(600, 400, clk, reduce, func(c *ui.Context) {
if navigation.WorldMap(c, "World", nodes, [][2]int{{0, 1}, {1, 2}}, &at, navigation.WorldMapOptions{}).Entered {
entered++
}
})
click(t, tt, "World")
tt.Key(0, ui.KeyRight)
mid := slots(t, tt.Image(), find(t, tt, "World"))
clk.t = clk.t.Add(time.Second)
tt.Frame()
walked := !slices.Equal(mid, slots(t, tt.Image(), find(t, tt, "World")))
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
if at != 1 || entered != 1 || walked == reduce {
t.Fatalf("reduce %v: at %d, entered %d, walked %v", reduce, at, entered, walked)
}
}
}
func TestDocumentTabsClose(t *testing.T) {
sel, closed := 0, -1
tt := view(500, 100, newClock(), false, func(c *ui.Context) {
if r := navigation.DocumentTabs(c, "Files", []string{"map.txt", "save.dat"}, &sel, navigation.DocumentTabsOptions{}); r.Closed >= 0 {
closed = r.Closed
}
})
click(t, tt, "CLOSE save.dat")
if closed != 1 || sel != 0 {
t.Fatalf("× closed %d and chose %d", closed, sel)
}
click(t, tt, "map.txt")
tt.Key(0, ui.KeyDelete)
if closed != 0 {
t.Fatalf("Delete closed %d", closed)
}
}
+123
View File
@@ -0,0 +1,123 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type NavItem struct {
Icon *pixel.Sprite
Label string
}
type NavRailOptions struct {
Disabled bool
}
type NavRailResult struct {
Element *ui.Element
Changed bool
}
// NavRail is an icon rail, named on hover or focus.
func NavRail(c *ui.Context, text string, items []NavItem, selected *int, o NavRailOptions) NavRailResult {
k := pixel.K(c)
res := NavRailResult{}
res.Element = ui.RadioGroup(c, func() {
for i, it := range items {
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled)
})
if base.Changed() {
res.Changed = true
}
shell.Size(k.D(22), k.D(22)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Ink
switch {
case i == *selected:
g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Accent)
ink = pixel.AccentInk
case st.Hover:
g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Lit)
}
g.Sprite(5, 5, it.Icon, 0, ink, 1)
g.Finish(st)
})
if st.Hover || st.Focus {
tip(c, k, shell.Bounds(), it.Label)
}
}
}).AlignSelf(ui.Start).Gap(k.D(2)).Padding(k.D(3)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.VLine(g.W-1, 0, g.H, pixel.Edge)
})
if res.Changed {
slog.Debug("8bit: nav rail", "label", text, "selected", *selected)
}
return res
}
// tip names an item to its right, letting presses by.
func tip(c *ui.Context, k pixel.Kit, at ui.Rect, s string) {
ui.Overlay(c, func() {
w := pixel.Measure(s) + 8
ui.Box(c).Absolute().Left(at.X+at.W+k.D(4)).Top(at.Y+(at.H-k.D(pixel.LineHeight+2))/2).
Size(k.D(w), k.D(pixel.LineHeight+2)).PassThrough().Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Ink)
g.Text(4, 1, s, pixel.Back, 1)
})
})
}
type BottomNavOptions struct {
Disabled bool
}
type BottomNavResult struct {
Element *ui.Element
Changed bool
}
// BottomNav is a bar of labeled icons for narrow screens.
func BottomNav(c *ui.Context, text string, items []NavItem, selected *int, o BottomNavOptions) BottomNavResult {
k := pixel.K(c)
res := BottomNavResult{}
res.Element = ui.RadioGroup(c, func() {
for i, it := range items {
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled)
})
if base.Changed() {
res.Changed = true
}
w := max(pixel.Measure(it.Label), 12) + 12
shell.Size(k.D(w), k.D(16+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Faint
if i == *selected {
ink = pixel.Accent
g.HLine(2, 0, g.W-4, pixel.Accent)
} else if st.Hover {
ink = pixel.Ink
}
g.Sprite((g.W-it.Icon.W)/2, 2, it.Icon, 0, ink, 1)
g.Text((g.W-pixel.Measure(it.Label))/2, 14, it.Label, ink, 1)
g.Finish(st)
})
}
}).Row().Gap(k.D(4)).Padding(k.D(2), k.D(6)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.HLine(0, 0, g.W, pixel.Edge)
})
if res.Changed {
slog.Debug("8bit: bottom nav", "label", text, "selected", *selected)
}
return res
}
+88
View File
@@ -0,0 +1,88 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SidebarItem struct {
Icon *pixel.Sprite
Label string
// Badge is a short count or word at the right.
Badge string
}
type SidebarSection struct {
Title string
Items []SidebarItem
}
type SidebarOptions struct {
// Width is in dots; zero means 140.
Width int
Disabled bool
}
type SidebarResult struct {
Element *ui.Element
Changed bool
}
// Sidebar lists sections of items; Up and Down choose.
func Sidebar(c *ui.Context, text string, sections []SidebarSection, selected *int, o SidebarOptions) SidebarResult {
k := pixel.K(c)
w := pixel.Or("Sidebar Width", o.Width, 140)
res := SidebarResult{}
n := 0
res.Element = ui.RadioGroup(c, func() {
for _, sec := range sections {
if sec.Title != "" {
pixel.Text(c, sec.Title, pixel.TextOptions{Slot: pixel.Faint})
}
for _, it := range sec.Items {
i := n
n++
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled)
})
if base.Changed() {
res.Changed = true
}
shell.Size(k.D(w), k.D(pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) {
entry(k.Canvas(p, r), it, i == *selected, st)
})
}
}
}).Gap(k.D(1)).Label(text)
if res.Changed {
slog.Debug("8bit: sidebar", "label", text, "selected", *selected)
}
return res
}
// entry draws a sidebar row: icon, label, badge.
func entry(g pixel.Canvas, it SidebarItem, on bool, st pixel.State) {
ink := pixel.Ink
switch {
case on:
g.Rect(0, 0, g.W, g.H, pixel.Accent)
ink = pixel.AccentInk
case st.Hover:
g.Rect(0, 0, g.W, g.H, pixel.Lit)
}
x := 4
if it.Icon != nil {
g.Sprite(x, (g.H-it.Icon.H)/2, it.Icon, 0, ink, 1)
x += it.Icon.W + 4
}
g.Text(x, 1, it.Label, ink, 1)
if it.Badge != "" {
bw := pixel.Measure(it.Badge) + 6
g.Frame(g.W-bw-3, 2, bw, g.H-4, pixel.Notched, pixel.Bad)
g.Text(g.W-bw, 1, it.Badge, pixel.AccentInk, 1)
}
g.Finish(st)
}
+78
View File
@@ -0,0 +1,78 @@
package navigation
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// waving is a flag in two frames of wind.
var waving = pixel.MustSprite(`
.#..........
.#######....
.########...
.#########..
.########...
.#######....
.#..........
.#..........
.#..........
.#..........
.#..........
.##.#####...
.#########..
.#########..
.##########.
.##.#####...
.#..........
.#..........
.#..........
.#..........
`)
type StepsOptions struct{}
// Steps are flags on a path: done, waving, then locked.
func Steps(c *ui.Context, labels []string, current int, o StepsOptions) *ui.Element {
k := pixel.K(c)
if current < 0 || current >= len(labels) {
panic(fmt.Sprintf("navigation: step %d of %d", current, len(labels)))
}
ws, total := make([]int, len(labels)), 0
for i, l := range labels {
ws[i] = max(pixel.Measure(l), 12) + 16
total += ws[i]
}
return ui.Box(c).Role(ui.RoleList).Label(strings.Join(labels, ", ")).Description(fmt.Sprintf("%d/%d", current+1, len(labels))).
Size(k.D(total), k.D(14+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
x := 0
for i, l := range labels {
mid := x + ws[i]/2
if i > 0 {
for d := x - ws[i-1]/2 + 10; d < mid-8; d += 3 {
g.Dot(d, 6, pixel.Faint)
}
}
switch {
case i < current:
g.Sprite(mid-6, 0, waving, 0, pixel.Good, 1)
case i == current:
g.Sprite(mid-6, 0, waving, g.Step(4)%2, pixel.Accent, 1)
default:
g.Sprite(mid-6, 0, icon.Lock, 0, pixel.Faint, 1)
}
ink := pixel.Faint
if i <= current {
ink = pixel.Ink
}
g.Text(mid-pixel.Measure(l)/2, 14, l, ink, 1)
x += ws[i]
}
})
}
+125
View File
@@ -0,0 +1,125 @@
// Package navigation moves the player between screens and places.
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TabsOptions struct {
Disabled bool
}
type TabsResult struct {
Element *ui.Element
Changed bool
}
// Tabs are folder tabs; the chosen one joins its panel.
func Tabs(c *ui.Context, text string, labels []string, selected *int, o TabsOptions, panel func()) TabsResult {
return tabs(c, text, labels, selected, false, o, panel)
}
// VerticalTabs are tabs down the panel's left edge.
func VerticalTabs(c *ui.Context, text string, labels []string, selected *int, o TabsOptions, panel func()) TabsResult {
return tabs(c, text, labels, selected, true, o, panel)
}
// tabs lays tabs beside a panel; side means left.
func tabs(c *ui.Context, text string, labels []string, selected *int, side bool, o TabsOptions, panel func()) TabsResult {
k := pixel.K(c)
th := pixel.LineHeight + 6
tw := 0
for _, l := range labels {
tw = max(tw, pixel.Measure(l)+12)
}
// size is tab i's extent along the strip.
size := func(i int) int {
if side {
return th
}
return pixel.Measure(labels[i]) + 12
}
outer := ui.Column(c)
if side {
outer = ui.Row(c).FillWidth().AlignItems(ui.Stretch)
}
res := TabsResult{Element: outer}
outer.Children(func() {
p := ui.TabsBase(c, selected, len(labels))
if side {
p.List.Column().Width(k.D(tw))
}
p.List.Label(text).Children(func() {
for i, l := range labels {
shell, _, st := pixel.Shell(c, func() *ui.Element { return p.Tab(i).Label(l).Disabled(o.Disabled) })
w, h := size(i), th
if side {
w = tw
}
shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
folder(k.Canvas(p, r), l, i == *selected, side, st)
})
}
})
res.Changed = p.List.Changed()
at := 0
for i := range *selected {
at += size(i)
}
body := ui.Column(c).Padding(k.D(8)).Gap(k.D(4))
if side {
body.Grow(1)
}
body.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if g.W < 3 || g.H < 3 {
return
}
g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face)
// The chosen tab opens into the panel.
if side {
g.Rect(0, at+1, 2, th-2, pixel.Face)
} else {
g.Rect(at+1, 0, size(*selected)-2, 2, pixel.Face)
}
}).Children(panel)
})
if res.Changed {
slog.Debug("8bit: tabs", "label", text, "selected", *selected)
}
return res
}
// folder draws a tab: the chosen one stands taller, open-ended.
func folder(g pixel.Canvas, l string, on, side bool, st pixel.State) {
fill, ink, in := pixel.Shade, pixel.Faint, 2
if on {
fill, ink, in = pixel.Face, pixel.Ink, 0
} else if st.Hover {
fill = pixel.Lit
}
if side {
g.VLine(in, 1, g.H-2, pixel.Edge)
g.HLine(in+1, 0, g.W-in-1, pixel.Edge)
g.HLine(in+1, g.H-1, g.W-in-1, pixel.Edge)
g.Rect(in+1, 1, g.W-in-1, g.H-2, fill)
g.Text(in+6, 3, l, ink, 1)
if !on {
g.VLine(g.W-1, 0, g.H, pixel.Edge)
}
} else {
g.HLine(1, in, g.W-2, pixel.Edge)
g.VLine(0, in+1, g.H-in-1, pixel.Edge)
g.VLine(g.W-1, in+1, g.H-in-1, pixel.Edge)
g.Rect(1, in+1, g.W-2, g.H-in-1, fill)
g.Text(6, in+3, l, ink, 1)
if !on {
g.HLine(0, g.H-1, g.W, pixel.Edge)
}
}
g.Finish(st)
}
+67
View File
@@ -0,0 +1,67 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var pointer = pixel.MustSprite(`
#..
##.
###
##.
#..
`)
type TocItem struct {
Title string
// Level indents sub-sections; zero is the top.
Level int
}
type TableOfContentsOptions struct {
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type TableOfContentsResult struct {
Element *ui.Element
Changed bool
}
// TableOfContents lists sections; ▶ marks the current one.
func TableOfContents(c *ui.Context, text string, items []TocItem, current *int, o TableOfContentsOptions) TableOfContentsResult {
k := pixel.K(c)
w := pixel.Or("TableOfContents Width", o.Width, 160)
res := TableOfContentsResult{}
res.Element = ui.RadioGroup(c, func() {
for i, it := range items {
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, current, i).Label(it.Title).Disabled(o.Disabled)
})
if base.Changed() {
res.Changed = true
}
shell.Size(k.D(w), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
x, ink := 8+it.Level*10, pixel.Faint
if i == *current {
g.Sprite(x-6, 5, pointer, 0, pixel.Accent, 1)
ink = pixel.Accent
} else if st.Hover {
ink = pixel.Ink
}
g.Text(x, 0, it.Title, ink, 1)
g.Finish(st)
})
}
}).Gap(0).Label(text)
if res.Changed {
slog.Debug("8bit: toc", "label", text, "section", items[*current].Title)
}
return res
}
+54
View File
@@ -0,0 +1,54 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ToolbarOptions struct{}
// Toolbar holds tool buttons as one Tab stop; arrows move.
func Toolbar(c *ui.Context, text string, o ToolbarOptions, content func()) *ui.Element {
k := pixel.K(c)
return ui.Row(c).AlignSelf(ui.Start).Gap(k.D(2)).Padding(k.D(3)).AlignItems(ui.Center).Role(ui.RoleToolbar).Label(text).FocusGroup(ui.Horizontal).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face)
}).Children(content)
}
type BackForwardOptions struct {
Disabled bool
}
type BackForwardResult struct {
Element *ui.Element
// Moved reports a step through the history.
Moved bool
}
// BackForward walks a router's history with ◀ and ▶.
func BackForward(c *ui.Context, r *ui.Router, o BackForwardOptions) BackForwardResult {
k := pixel.K(c)
res := BackForwardResult{}
res.Element = ui.Row(c).Gap(k.D(2)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(k.Msg("history")).Children(func() {
back := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoBack()}
if input.IconButton(c, icon.ChevronLeft, k.Msg("back"), back).Clicked {
r.Back()
res.Moved = true
}
forth := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoForward()}
if input.IconButton(c, icon.ChevronRight, k.Msg("forward"), forth).Clicked {
r.Forward()
res.Moved = true
}
})
if res.Moved {
slog.Debug("8bit: history", "path", r.Path())
}
return res
}
+189
View File
@@ -0,0 +1,189 @@
package navigation
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// hero is the walker, two frames of stepping feet.
var hero = pixel.MustSprite(`
..eee...
.eaaae..
.efffe..
..eee...
.eaaae..
e.aaa.e.
..e.e...
.e...e..
..eee...
.eaaae..
.efffe..
..eee...
.eaaae..
.eaaae..
..e.e...
..e.e...
`)
type MapNode struct {
Name string
// X and Y place it on the map, in dots.
X, Y int
Locked bool
}
type WorldMapOptions struct {
// Width and Height are dots; zeros mean 240 by 140.
Width, Height int
Disabled bool
}
type WorldMapResult struct {
Element *ui.Element
// Entered reports Enter, or a click, at the hero's node.
Changed, Entered bool
}
type walkKey struct{}
// walk is the node the hero left and when.
type walk struct {
from int
at time.Time
}
// WorldMap joins nodes with dotted paths; arrows walk the hero.
func WorldMap(c *ui.Context, text string, nodes []MapNode, paths [][2]int, at *int, o WorldMapOptions) WorldMapResult {
k := pixel.K(c)
if *at < 0 || *at >= len(nodes) {
panic(fmt.Sprintf("navigation: node %d of %d", *at, len(nodes)))
}
w, h := pixel.Or("WorldMap Width", o.Width, 240), pixel.Or("WorldMap Height", o.Height, 140)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Description(nodes[*at].Name).Disabled(o.Disabled)
})
wk := ui.Local(base, walkKey{}, func() walk { return walk{from: *at} })
res := WorldMapResult{Element: shell}
walkTo := func(j int) {
if j != *at && !nodes[j].Locked {
wk.from, wk.at, *at, res.Changed = *at, k.Now(), j, true
}
}
for _, m := range []struct {
key ui.Key
dx, dy int
}{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} {
if base.Shortcut(0, m.key) {
if j := toward(nodes, paths, *at, m.dx, m.dy); j >= 0 {
walkTo(j)
}
}
}
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
res.Entered = enter || space
// Enter and Space are taken, so clicks come from pointers.
if base.Clicked() {
px, py, _ := base.PointerPosition()
if j := nearest(nodes, int(px/k.Dot()), int(py/k.Dot())); j == *at {
res.Entered = true
} else if j >= 0 && linked(paths, *at, j) {
walkTo(j)
}
}
// Six steps carry the hero; Reduce Motion lands it.
step := 6
if !k.Reduce() {
step = k.Since(c, wk.at, 12, 6)
}
a, b := nodes[wk.from], nodes[*at]
hx, hy := a.X+(b.X-a.X)*step/6, a.Y+(b.Y-a.Y)*step/6
shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Dither(0, 0, g.W, g.H, pixel.Face, pixel.Good, 2)
for _, e := range paths {
trail(g, nodes[e[0]], nodes[e[1]])
}
for i, n := range nodes {
fill := pixel.Accent
if n.Locked {
fill = pixel.Shade
}
g.Disc(n.X, n.Y, 4, pixel.Edge)
g.Disc(n.X, n.Y, 3, fill)
ink := pixel.Faint
if i == *at {
ink = pixel.Ink
}
g.Text(n.X-pixel.Measure(n.Name)/2, n.Y+6, n.Name, ink, 1)
}
g.Sprite(hx-4, hy-12, hero, g.Step(4)%2, pixel.Ink, 1)
g.Finish(st)
})
switch {
case res.Changed:
slog.Debug("8bit: world map", "label", text, "node", nodes[*at].Name)
case res.Entered:
slog.Debug("8bit: world map enter", "label", text, "node", nodes[*at].Name)
}
return res
}
// toward is the linked node lying most along dx, dy.
func toward(nodes []MapNode, paths [][2]int, at, dx, dy int) int {
best, score := -1, 0.0
for _, e := range paths {
j := e[1]
if e[1] == at {
j = e[0]
} else if e[0] != at {
continue
}
vx, vy := float64(nodes[j].X-nodes[at].X), float64(nodes[j].Y-nodes[at].Y)
if cos := (vx*float64(dx) + vy*float64(dy)) / math.Hypot(vx, vy); cos > 0.3 && cos > score {
best, score = j, cos
}
}
return best
}
// linked reports a path between nodes a and b.
func linked(paths [][2]int, a, b int) bool {
for _, e := range paths {
if e == [2]int{a, b} || e == [2]int{b, a} {
return true
}
}
return false
}
// nearest is the node within 8 dots, or -1.
func nearest(nodes []MapNode, x, y int) int {
for i, n := range nodes {
if (n.X-x)*(n.X-x)+(n.Y-y)*(n.Y-y) <= 64 {
return i
}
}
return -1
}
// trail dots the path between two nodes.
func trail(g pixel.Canvas, a, b MapNode) {
n := max(abs(b.X-a.X), abs(b.Y-a.Y))
for i := 0; i <= n; i += 3 {
g.Dot(a.X+(b.X-a.X)*i/n, a.Y+(b.Y-a.Y)*i/n, pixel.Edge)
}
}
func abs(v int) int {
if v < 0 {
return -v
}
return v
}