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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user