Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// BreadcrumbItem is one level of a Breadcrumb path.
type BreadcrumbItem struct {
Label string
Icon *ui.SVG
}
// BreadcrumbOptions configure a Breadcrumb.
type BreadcrumbOptions struct {
// MaxItems shown before middle items fold into a menu; zero means 4, minimum 3.
MaxItems int
// MaxItemWidth cuts longer labels with an ellipsis; zero means 160 DIPs.
MaxItemWidth float32
// Label names the path for assistive technology; empty uses the built-in name.
Label string
}
// BreadcrumbResult is the built Breadcrumb.
type BreadcrumbResult struct {
Element *ui.Element
chosen int
}
// Chosen returns the index of the item the user activated in this frame.
func (r BreadcrumbResult) Chosen() (int, bool) { return r.chosen, r.chosen >= 0 }
// Breadcrumb shows a path whose last item is the current one; earlier items are links.
func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) BreadcrumbResult {
if len(items) == 0 {
panic("mujica: Breadcrumb needs at least one item")
}
limit := opts.MaxItems
if limit == 0 {
limit = 4
}
if limit < 3 {
panic("mujica: Breadcrumb MaxItems must be at least 3")
}
width := opts.MaxItemWidth
if width == 0 {
width = 160
}
label := opts.Label
if label == "" {
label = core.Msg(c, "breadcrumb.label")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
row := ui.Row(c).AlignItems(ui.Center).Gap(u).MinWidth(0).Role(ui.RoleGroup).Label(label)
res := BreadcrumbResult{Element: row, chosen: -1}
choose := func(i int) {
res.chosen = i
slog.Debug("mujica: breadcrumb choose", "index", i, "label", items[i].Label)
}
// Folded items are those between the first and the last limit-2.
fold := len(items) > limit
hideFrom, hideTo := 1, len(items)-(limit-2)
row.Children(func() {
for i, it := range items {
if it.Label == "" {
panic("mujica: BreadcrumbItem needs a Label")
}
if fold && i >= hideFrom && i < hideTo {
if i == hideFrom {
breadcrumbSeparator(c)
breadcrumbMore(c, items[hideFrom:hideTo], func(j int) { choose(hideFrom + j) })
}
continue
}
if i > 0 {
breadcrumbSeparator(c)
}
if i == len(items)-1 {
cur := ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(u, u*1.5).MinWidth(0).
FontWeight(600).TextColor(k.Text).Tooltip(it.Label).Label(it.Label + ", " + core.Msg(c, "breadcrumb.current"))
cur.Children(func() { breadcrumbContent(c, it, width) })
continue
}
link := ui.ButtonBase(c).Role(ui.RoleLink).Gap(u).Padding(u, u*1.5).Radius(theme.ControlRadius).
Shrink(1).MinWidth(0).Cursor(ui.CursorPointer).Tooltip(it.Label).Label(it.Label)
internal.Ring(link, k.Focus, theme.ControlRadius)
fg := k.TextMuted
if link.Hovered() {
link.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(link, core.Motion(c, theme.StateDuration), fg)
link.Children(func() { breadcrumbContent(c, it, width) })
if link.Clicked() {
choose(i)
}
}
})
return res
}
func breadcrumbContent(c *ui.Context, it BreadcrumbItem, width float32) {
if it.Icon != nil {
ui.Icon(c, it.Icon).Size(14, 14).Shrink(0)
}
ui.Text(c, it.Label).SingleLine().MaxWidth(width).MinWidth(0)
}
func breadcrumbSeparator(c *ui.Context) {
ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).TextColor(core.Tokens(c).Ornament).Role(ui.RoleNone)
}
// breadcrumbMore is the ellipsis button listing the folded items.
func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
k := core.Tokens(c)
u := core.Density(c).Unit()
b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize).
Label(core.Msg(c, "breadcrumb.more")).Tooltip(core.Msg(c, "breadcrumb.more")).Cursor(ui.CursorPointer)
internal.Ring(b, k.Focus, theme.ControlRadius)
open := ui.Local(b, "open", func() bool { return false })
fg := k.TextMuted
if b.Hovered() || *open {
b.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(b, core.Motion(c, theme.StateDuration), fg)
if b.Clicked() {
*open = !*open
}
b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) })
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Margin(u, 0, 0, 0).Padding(u).Gap(u * 0.5).MinWidth(160).MaxWidth(320)
for j, it := range hidden {
item := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).Gap(u*2).Padding(u*1.5, u*2).
Radius(theme.ControlRadius).MinHeight(core.Density(c).ControlHeight() - u*2).Label(it.Label).TextColor(k.Text)
if j == 0 {
item.AutoFocus()
}
internal.Ring(item, k.Focus, theme.ControlRadius)
if item.Hovered() {
item.Background(k.SurfaceHover)
}
internal.Ease(item, core.Motion(c, theme.StateDuration), k.Text)
item.Children(func() {
if it.Icon != nil {
ui.Icon(c, it.Icon).Size(14, 14).Shrink(0)
}
ui.Text(c, it.Label).SingleLine().MinWidth(0)
})
if item.Clicked() {
*open = false
choose(j)
}
}
})
}
+111
View File
@@ -0,0 +1,111 @@
package navigation
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func breadcrumbPath(names ...string) []BreadcrumbItem {
var items []BreadcrumbItem
for _, n := range names {
items = append(items, BreadcrumbItem{Label: n})
}
return items
}
func TestBreadcrumbChoose(t *testing.T) {
got := -1
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if i, ok := Breadcrumb(c, breadcrumbPath("Home", "Library", "Poems"), BreadcrumbOptions{}).Chosen(); ok {
got = i
}
}, 500, 100)
if err := tt.Click("Library"); err != nil {
t.Fatal(err)
}
if got != 1 {
t.Fatalf("chose %d, want 1", got)
}
got = -1
_ = tt.Click("Poems")
if got != -1 {
t.Fatalf("current item acted as a link: %d", got)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if got != 0 {
t.Fatalf("Enter on first link chose %d", got)
}
}
func TestBreadcrumbFolds(t *testing.T) {
got := -1
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if i, ok := Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{}).Chosen(); ok {
got = i
}
}, 600, 300)
for _, hidden := range []string{"Alpha", "Beta", "Gamma"} {
if tt.HasText(hidden) {
t.Fatalf("%s should be folded: %q", hidden, tt.Texts())
}
}
if !tt.HasText("Omega") || !tt.HasText("Root") {
t.Fatalf("ends missing: %q", tt.Texts())
}
if err := tt.Click("Show hidden path"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Beta") || !tt.Focused("Alpha") {
t.Fatalf("menu did not open with focus on first: %q", tt.Texts())
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Beta") {
t.Fatal("Escape did not close the menu")
}
_ = tt.Click("Show hidden path")
if err := tt.Click("Gamma"); err != nil {
t.Fatal(err)
}
if got != 3 || tt.HasText("Beta") {
t.Fatalf("chose %d, want 3, menu closed", got)
}
}
func TestBreadcrumbInvalid(t *testing.T) {
for name, tc := range map[string]struct {
items []BreadcrumbItem
opts BreadcrumbOptions
}{
"empty": {nil, BreadcrumbOptions{}},
"max": {breadcrumbPath("a", "b"), BreadcrumbOptions{MaxItems: 2}},
"label": {[]BreadcrumbItem{{Icon: icons.Must("house")}}, BreadcrumbOptions{}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Breadcrumb(c, tc.items, tc.opts)
}, 300, 100, 1)
})
}
}
func TestBreadcrumbSnapshot(t *testing.T) {
snapshot(t, "breadcrumb", 560, 120, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() {
Breadcrumb(c, []BreadcrumbItem{{Label: "Home", Icon: icons.Must("house")}, {Label: "Library"}, {Label: "Collected poems"}}, BreadcrumbOptions{})
Breadcrumb(c, breadcrumbPath("Archive", "2026", "October", "Correspondence of the court", "Letter to the Duchess of Mujica"), BreadcrumbOptions{MaxItemWidth: 140})
})
})
}
+225
View File
@@ -0,0 +1,225 @@
package navigation
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Command is an action of a CommandPalette.
type Command struct {
ID string
Label string
// Group heads the command in the list; commands keep their order within groups.
Group string
// Keywords also match the search.
Keywords []string
Icon *ui.SVG
}
// CommandPaletteOptions configure a CommandPalette.
type CommandPaletteOptions struct {
// OpenMods and OpenKey open the palette; zero key means Cmd+K (Ctrl+K off macOS).
OpenMods ui.Modifiers
OpenKey ui.Key
}
// CommandPaletteResult is the built CommandPalette; Element is nil while closed.
type CommandPaletteResult struct {
Element *ui.Element
chosen string
}
// Chosen returns the ID of the command run in this frame; running closes the palette.
func (r CommandPaletteResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" }
type commandPaletteKey struct{ open *bool }
type commandPaletteState struct {
query string
hl int
ptr [2]float32
wasOpen bool
reveal bool
}
// CommandPalette is a searchable command dialog opened by a shortcut or by setting *open.
func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandPaletteOptions) CommandPaletteResult {
seen := map[string]bool{}
for _, cmd := range commands {
if cmd.ID == "" || cmd.Label == "" {
panic("mujica: Command needs an ID and a Label")
}
if seen[cmd.ID] {
panic("mujica: duplicate Command ID " + cmd.ID)
}
seen[cmd.ID] = true
}
mods, key := opts.OpenMods, opts.OpenKey
if key == 0 {
mods, key = ui.Cmd, ui.KeyK
}
st := ui.Local(c.Root(), commandPaletteKey{open}, func() commandPaletteState { return commandPaletteState{} })
if c.Shortcut(mods, key) && !*open {
*open = true
}
if *open && !st.wasOpen {
st.query, st.hl = "", 0
slog.Debug("mujica: command palette open")
}
st.wasOpen = *open
res := CommandPaletteResult{}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
ww, wh := c.Size()
p := core.OverlayEnter(c, panel)
back.Background(ui.RGBA(0, 0, 0, 0.4*p)).Justify(ui.Start)
core.OverlayStyle(c, panel, p)
panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww - 32).MaxHeight(wh * 0.76).
AlignItems(ui.Stretch).Label(core.Msg(c, "command_palette.label"))
found := commandPaletteFilter(commands, st.query)
var in *ui.Element
ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*3, u*4).Children(func() {
ui.Icon(c, icons.Must("search")).Size(16, 16).TextColor(k.TextMuted)
in = ui.TextInputBase(c, &st.query).FillWidth().AutoFocus().Label(core.Msg(c, "command_palette.search"))
})
ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone)
if in.Changed() {
st.hl, st.reveal = 0, true
}
n := len(found)
switch {
case n == 0:
case in.Shortcut(0, ui.KeyDown):
st.hl, st.reveal = (st.hl+1)%n, true
case in.Shortcut(0, ui.KeyUp):
st.hl, st.reveal = (st.hl-1+n)%n, true
}
st.hl = max(0, min(st.hl, n-1))
// Focus stays in the search box, so the highlighted command is read out.
if n > 0 {
in.Description(found[st.hl].Label)
if st.reveal {
c.Announce(found[st.hl].Label)
}
} else {
in.Description(core.Msg(c, "command_palette.empty"))
}
run := -1
if in.Submitted() && n > 0 {
run = st.hl
}
list := ui.Scroll(c).Padding(u * 2).Gap(2).MaxHeight(360).Role(ui.RoleList)
list.Children(func() {
if n == 0 {
ui.Text(c, core.Msg(c, "command_palette.empty")).TextColor(k.TextMuted).Padding(u*6, u*2).TextAlign(ui.Center).FillWidth()
return
}
gutter := slices.ContainsFunc(found, func(cmd Command) bool { return cmd.Icon != nil })
group := "\x00"
for i, cmd := range found {
if cmd.Group != group {
group = cmd.Group
if group != "" {
ui.Text(c, group).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize)).
TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u*2, u*2, u, u*2).SingleLine()
}
}
if commandPaletteRow(c, cmd, i, st, gutter) {
run = i
}
}
})
ui.Row(c).Gap(u*4).Padding(u*2, u*4).Background(k.Background).Children(func() {
for _, hint := range []string{"command_palette.hint_move", "command_palette.hint_run", "command_palette.hint_close"} {
ui.Text(c, core.Msg(c, hint)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
if run >= 0 {
res.chosen = found[run].ID
*open = false
slog.Debug("mujica: command palette run", "id", res.chosen)
}
})
if !*open {
st.wasOpen = false
}
return res
}
// commandPaletteRow builds row i and reports a click on it.
func commandPaletteRow(c *ui.Context, cmd Command, i int, st *commandPaletteState, gutter bool) bool {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
row := ui.Row(c).AlignItems(ui.Center).Gap(u*3).MinHeight(d.ControlHeight()).Padding(0, u*3).
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(cmd.Label).TextColor(k.Text).Cursor(ui.CursorPointer)
on := i == st.hl
if on {
row.Background(k.Selection).FontWeight(600)
row.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5)
})
if st.reveal {
row.ScrollIntoView()
st.reveal = false
}
}
internal.Ease(row, core.Motion(c, theme.StateDuration), k.Text)
px, py, over := row.PointerPosition()
b := row.Bounds()
if at := [2]float32{b.X + px, b.Y + py}; over && at != st.ptr {
st.ptr = at
if !on {
st.hl = i
c.Invalidate()
}
}
row.Children(func() {
if cmd.Icon != nil {
ui.Icon(c, cmd.Icon).Size(16, 16).TextColor(k.TextMuted)
} else if gutter {
ui.Box(c).Size(16, 16).Role(ui.RoleNone)
}
ui.Text(c, cmd.Label).SingleLine()
})
return row.Clicked()
}
// commandPaletteFilter keeps commands matching every word of query, grouped by first appearance.
func commandPaletteFilter(commands []Command, query string) []Command {
words := strings.Fields(strings.ToLower(query))
var groups []string
byGroup := map[string][]Command{}
for _, cmd := range commands {
hay := strings.ToLower(cmd.Label + " " + cmd.Group + " " + strings.Join(cmd.Keywords, " "))
ok := true
for _, w := range words {
if !strings.Contains(hay, w) {
ok = false
break
}
}
if !ok {
continue
}
if _, seen := byGroup[cmd.Group]; !seen {
groups = append(groups, cmd.Group)
}
byGroup[cmd.Group] = append(byGroup[cmd.Group], cmd)
}
var out []Command
for _, g := range groups {
out = append(out, byGroup[g]...)
}
return out
}
+177
View File
@@ -0,0 +1,177 @@
package navigation
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func commandPaletteSample() []Command {
return []Command{
{ID: "new", Label: "New document", Group: "File", Icon: icons.Must("file")},
{ID: "open", Label: "Open recent", Group: "File", Icon: icons.Must("folder")},
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"theme", "night"}, Icon: icons.Must("moon")},
{ID: "zoom", Label: "Zoom in", Group: "View", Icon: icons.Must("zoom-in")},
{ID: "save", Label: "Save all", Group: "File"},
}
}
type paletteApp struct {
open bool
ran []string
}
func (a *paletteApp) view(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Fill().Children(func() {
ui.Text(c, "Editor")
if id, ok := CommandPalette(c, &a.open, commandPaletteSample(), CommandPaletteOptions{}).Chosen(); ok {
a.ran = append(a.ran, id)
}
})
}
func TestCommandPaletteFilter(t *testing.T) {
ids := func(cs []Command) (out []string) {
for _, c := range cs {
out = append(out, c.ID)
}
return
}
got := ids(commandPaletteFilter(commandPaletteSample(), ""))
// Groups keep first-appearance order: File's Save joins its group.
want := []string{"new", "open", "save", "theme", "zoom"}
if len(got) != len(want) {
t.Fatalf("got %v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v want %v", got, want)
}
}
if got := ids(commandPaletteFilter(commandPaletteSample(), "NIGHT")); len(got) != 1 || got[0] != "theme" {
t.Fatalf("keyword search %v", got)
}
if got := ids(commandPaletteFilter(commandPaletteSample(), "file save")); len(got) != 1 || got[0] != "save" {
t.Fatalf("multi-word search %v", got)
}
}
func TestCommandPaletteShortcutSearchRun(t *testing.T) {
a := &paletteApp{}
tt := ui.NewTester(a.view, 800, 600)
tt.Key(ui.Cmd, ui.KeyK)
if !a.open || !tt.HasText("Zoom in") {
t.Fatalf("Cmd+K did not open: %q", tt.Texts())
}
tt.Type("zo")
if tt.HasText("New document") || !tt.HasText("Zoom in") {
t.Fatalf("search did not filter: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if a.open || len(a.ran) != 1 || a.ran[0] != "zoom" {
t.Fatalf("Enter: open %v ran %q", a.open, a.ran)
}
tt.Key(ui.Cmd, ui.KeyK)
if !tt.HasText("New document") {
t.Fatal("query not reset on reopen")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyUp) // wraps to the last
tt.Key(0, ui.KeyEnter)
if len(a.ran) != 2 || a.ran[1] != "zoom" {
t.Fatalf("arrows ran %q", a.ran)
}
}
func TestCommandPaletteAnnouncesHighlight(t *testing.T) {
a := &paletteApp{open: true}
tt := ui.NewTester(a.view, 800, 600)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
got := tt.Announcements()
if len(got) < 2 || got[len(got)-2] != "Open recent" || got[len(got)-1] != "Save all" {
t.Fatalf("announcements %q", got)
}
}
func TestCommandPaletteFilterRevealsHighlight(t *testing.T) {
var cmds []Command
for i := range 30 {
cmds = append(cmds, Command{ID: fmt.Sprint(i), Label: fmt.Sprintf("Command %02d", i), Keywords: []string{"royal"}})
}
open := true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
CommandPalette(c, &open, cmds, CommandPaletteOptions{})
}, 800, 600)
tt.Key(0, ui.KeyUp) // wraps to the last, scrolling to the bottom
tt.Frame()
if r, _ := tt.Find("Command 00"); r.H > 0 {
t.Fatalf("list did not scroll to the end: first row at %+v", r)
}
tt.Type("royal")
tt.Frame()
if r, _ := tt.Find("Command 00"); r.H == 0 {
t.Fatal("highlighted first match is off screen after filtering")
}
}
func TestCommandPaletteEmptyClickEscape(t *testing.T) {
a := &paletteApp{open: true}
tt := ui.NewTester(a.view, 800, 600)
tt.Type("qqq")
if !tt.HasText("No matching commands") {
t.Fatalf("no empty state: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if !a.open || len(a.ran) != 0 {
t.Fatal("Enter with no results ran something")
}
tt.Key(0, ui.KeyEscape)
if a.open {
t.Fatal("Escape did not close")
}
a.open = true
tt.Frame()
if err := tt.Click("Open recent"); err != nil {
t.Fatal(err)
}
if a.open || len(a.ran) != 1 || a.ran[0] != "open" {
t.Fatalf("click: open %v ran %q", a.open, a.ran)
}
}
func TestCommandPaletteInvalid(t *testing.T) {
for name, cmds := range map[string][]Command{
"id": {{Label: "A"}},
"dup": {{ID: "a", Label: "A"}, {ID: "a", Label: "B"}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
open := true
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
CommandPalette(c, &open, cmds, CommandPaletteOptions{})
}, 300, 100, 1)
})
}
}
func TestCommandPaletteSnapshot(t *testing.T) {
open := true
snapshot(t, "command_palette", 640, 460, func(c *ui.Context) {
CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{})
})
}
+2
View File
@@ -0,0 +1,2 @@
// Package navigation holds MujicaUI tabs, menus, breadcrumbs, pagination and other navigation.
package navigation
+156
View File
@@ -0,0 +1,156 @@
package navigation_test
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
)
func ExampleTabs() {
tab := 0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if navigation.Tabs(c, &tab, []navigation.Tab{
{Label: "General", Panel: func() { ui.Text(c, "General settings") }},
{Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }},
{Label: "Archive", Disabled: true},
}, navigation.TabsOptions{Label: "Settings"}).Changed() {
fmt.Println("tab", tab)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSegmentedControl() {
view := 0
build := func(c *ui.Context) {
core.Use(c, core.Settings{})
if navigation.SegmentedControl(c, &view, []navigation.Segment{
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
}, navigation.SegmentedControlOptions{Label: "View"}).Changed() {
fmt.Println("view", view)
}
}
ui.Render(build, 400, 300, 1)
}
func ExampleBreadcrumb() {
path := []navigation.BreadcrumbItem{{Label: "Home", Icon: icons.Must("house")}, {Label: "Library"}, {Label: "Poems"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if i, ok := navigation.Breadcrumb(c, path, navigation.BreadcrumbOptions{}).Chosen(); ok {
path = path[:i+1]
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePagination() {
page, size := 1, 20
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if navigation.Pagination(c, &page, &size, navigation.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() {
fmt.Println("load page", page, "of size", size)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSteps() {
current := 1
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
navigation.Steps(c, &current, []navigation.Step{
{Title: "Account", Clickable: true},
{Title: "Profile", Description: "Name and seal"},
{Title: "Confirm"},
}, navigation.StepsOptions{Label: "Sign-up"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSidebar() {
selected := "inbox"
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
navigation.Sidebar(c, &selected, []navigation.SidebarSection{
{Title: "Mail", Items: []navigation.SidebarItem{
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3},
{Label: "Folders", Items: []navigation.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}},
}},
}, navigation.SidebarOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleNavigationMenu() {
router := ui.NewRouter("/")
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
navigation.NavigationMenu(c, router, []navigation.NavigationLink{
{Label: "Home", Path: "/"},
{Label: "Library", Items: []navigation.NavigationLink{
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
}},
}, navigation.NavigationMenuOptions{History: true})
router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) })
}
ui.Render(view, 400, 300, 1)
}
func ExampleMenubar() {
wrap := false
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
res := navigation.Menubar(c, []navigation.MenubarMenu{
{Label: "File", Items: []navigation.MenubarItem{
{ID: "new", Label: "New", Shortcut: navigation.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
{Label: "Export", Items: []navigation.MenubarItem{{ID: "pdf", Label: "PDF"}}},
}},
{Label: "View", Items: []navigation.MenubarItem{
{ID: "wrap", Label: "Word wrap", Kind: navigation.MenubarCheck, Checked: wrap},
}},
}, navigation.MenubarOptions{})
if id, ok := res.Chosen(); ok && id == "wrap" {
wrap = !wrap
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolbar() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
navigation.Toolbar(c, navigation.ToolbarOptions{Label: "Format"}, func() {
navigation.ToolbarGroup(c, func() {
navigation.ToolbarButton(c, "Bold", navigation.ToolbarButtonOptions{Icon: icons.Must("bold")})
navigation.ToolbarButton(c, "Italic", navigation.ToolbarButtonOptions{Icon: icons.Must("italic")})
})
navigation.ToolbarSeparator(c)
if navigation.ToolbarButton(c, "Copy", navigation.ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() {
fmt.Println("copy")
}
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCommandPalette() {
open := false
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
res := navigation.CommandPalette(c, &open, []navigation.Command{
{ID: "new", Label: "New document", Group: "File"},
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}},
}, navigation.CommandPaletteOptions{})
if id, ok := res.Chosen(); ok {
fmt.Println("run", id)
}
}
ui.Render(view, 400, 300, 1)
}
+59
View File
@@ -0,0 +1,59 @@
package navigation
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
type menubarApp struct {
wrap bool
zoom string
chosen []string
}
// snapshotAfter is snapshot with drive sending input before the capture.
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
func (a *menubarApp) menus() []MenubarMenu {
return []MenubarMenu{
{Label: "File", Items: []MenubarItem{
{ID: "new", Label: "New", Shortcut: MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
{ID: "open", Label: "Open…", Shortcut: MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyO}},
{Label: "Export", Items: []MenubarItem{
{ID: "pdf", Label: "PDF"},
{ID: "png", Label: "Image", Disabled: true},
{ID: "svg", Label: "Vector"},
}},
{Kind: MenubarSeparator},
{ID: "quit", Label: "Quit"},
}},
{Label: "View", Items: []MenubarItem{
{ID: "wrap", Label: "Word wrap", Kind: MenubarCheck, Checked: a.wrap},
{Kind: MenubarSeparator},
{ID: "zoom-fit", Label: "Fit", Kind: MenubarRadio, Checked: a.zoom == "zoom-fit"},
{ID: "zoom-100", Label: "Actual size", Kind: MenubarRadio, Checked: a.zoom == "zoom-100"},
}},
{Label: "Help", Items: []MenubarItem{{ID: "about", Label: "About"}}},
}
}
+315
View File
@@ -0,0 +1,315 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MenubarItemKind is what a menu item does.
type MenubarItemKind int
const (
// MenubarAction runs a command.
MenubarAction MenubarItemKind = iota
// MenubarCheck toggles a setting the caller keeps in Checked.
MenubarCheck
// MenubarRadio picks one of a group; the caller sets Checked on one.
MenubarRadio
// MenubarSeparator draws a line between groups of items.
MenubarSeparator
)
// MenubarShortcut is the key hint shown beside an item; Key zero shows none.
type MenubarShortcut struct {
Mods ui.Modifiers
Key ui.Key
}
// MenubarItem is an item of a menu, or with Items a submenu.
type MenubarItem struct {
// ID is reported by Chosen; required for actions, checks and radios.
ID string
Label string
Kind MenubarItemKind
Checked bool
Disabled bool
// Shortcut is only shown; handle the key with c.Shortcut.
Shortcut MenubarShortcut
Items []MenubarItem
}
// MenubarMenu is a title of the bar and the menu it opens.
type MenubarMenu struct {
Label string
Items []MenubarItem
}
// MenubarOptions configure a Menubar.
type MenubarOptions struct {
// Label names the bar; empty uses the built-in name.
Label string
}
// MenubarResult is the built Menubar.
type MenubarResult struct {
Element *ui.Element
chosen string
}
// Chosen returns the ID of the item the user chose in this frame.
func (r MenubarResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" }
type menubarState struct {
open int
hl []int
focusTo int
ptr [2]float32
// reveal scrolls the highlighted item into view once.
reveal bool
}
// Menubar is an in-window menu bar drawn by MujicaUI, with submenus and full keyboard control.
func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarResult {
if len(menus) == 0 {
panic("mujica: Menubar needs menus")
}
for _, m := range menus {
if m.Label == "" {
panic("mujica: MenubarMenu needs a Label")
}
menubarCheck(m.Items)
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
label := opts.Label
if label == "" {
label = core.Msg(c, "menubar.label")
}
bar := ui.Row(c).AlignItems(ui.Center).Gap(u * 0.5).Padding(u).FocusGroup(ui.Horizontal).
Role(ui.RoleToolbar).Label(label).Background(k.Surface)
bar.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0)
})
st := ui.Local(bar, "state", func() *menubarState { return &menubarState{open: -1, focusTo: -1} })
m := &menubarRun{c: c, menus: menus, st: *st}
res := MenubarResult{Element: bar}
titles := make([]ui.Rect, len(menus))
bar.Children(func() {
for i, menu := range menus {
t := m.title(i, menu.Label)
titles[i] = t.Bounds()
}
})
if m.st.open >= 0 && bar.Shortcut(0, ui.KeyEscape) {
m.close()
}
if m.st.open >= 0 {
m.overlay(titles)
}
// Presses in the menu refocus the title, so lost focus means Tab left the menubar.
if m.st.open >= 0 && m.st.focusTo < 0 && !bar.FocusWithin() {
slog.Debug("mujica: menubar close on focus out")
m.st.open, m.st.hl = -1, nil
}
// Focus stays on the title, so the highlighted item is read out.
if name := m.current(); m.moved && name != "" {
c.Announce(name)
}
res.chosen = m.chosen
return res
}
// menubarCheck panics on items that cannot work.
func menubarCheck(items []MenubarItem) {
for _, it := range items {
if it.Kind == MenubarSeparator {
continue
}
if it.Label == "" {
panic("mujica: MenubarItem needs a Label")
}
if it.Items != nil {
if it.Kind != MenubarAction {
panic("mujica: a submenu item must be of kind MenubarAction")
}
menubarCheck(it.Items)
} else if it.ID == "" {
panic("mujica: MenubarItem " + it.Label + " needs an ID")
}
}
}
// menubarRun is one frame of a Menubar.
type menubarRun struct {
c *ui.Context
menus []MenubarMenu
st *menubarState
chosen string
moved bool
}
// current names the highlighted item of the open menu, "" for none.
func (m *menubarRun) current() string {
st := m.st
if st.open < 0 || len(st.hl) == 0 {
return ""
}
l := len(st.hl) - 1
if h := st.hl[l]; h >= 0 {
return menubarName(m.c, m.items(l)[h])
}
return ""
}
func (m *menubarRun) title(i int, label string) *ui.Element {
c, st := m.c, m.st
k := core.Tokens(c)
d := core.Density(c)
open := st.open == i
t := ui.ButtonBase(c).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*3).Radius(theme.ControlRadius).
Role(ui.RoleMenuButton).Label(label).TextColor(k.Text)
internal.Ring(t, k.Focus, theme.ControlRadius)
switch {
case open:
t.Background(k.Selection)
case t.Hovered():
t.Background(k.SurfaceHover)
}
internal.Ease(t, core.Motion(c, theme.StateDuration), k.Text)
if st.focusTo == i {
t.Focus()
st.focusTo = -1
}
if st.open < 0 {
switch {
case t.Shortcut(0, ui.KeyRight):
m.focus((i + 1) % len(m.menus))
case t.Shortcut(0, ui.KeyLeft):
m.focus((i - 1 + len(m.menus)) % len(m.menus))
case t.Shortcut(0, ui.KeyDown), t.Shortcut(0, ui.KeyUp):
m.openMenu(i, true)
}
} else if open {
m.keys(t)
t.Description(m.current())
}
if t.Clicked() {
if open {
m.close()
} else {
m.openMenu(i, t.FocusVisible())
}
}
t.Children(func() { ui.Text(c, label).SingleLine() })
return t
}
// keys drives an open menu from the focused title.
func (m *menubarRun) keys(t *ui.Element) {
st := m.st
l := len(st.hl) - 1
items := m.items(l)
h := st.hl[l]
m.moved, st.reveal = true, true
switch {
case t.Shortcut(0, ui.KeyDown):
st.hl[l] = menubarStep(items, h, 1)
case t.Shortcut(0, ui.KeyUp):
st.hl[l] = menubarStep(items, h, -1)
case t.Shortcut(0, ui.KeyHome):
st.hl[l] = menubarStep(items, -1, 1)
case t.Shortcut(0, ui.KeyEnd):
st.hl[l] = menubarStep(items, len(items), -1)
case t.Shortcut(0, ui.KeyRight):
if h >= 0 && items[h].Items != nil {
st.hl = append(st.hl, menubarStep(items[h].Items, -1, 1))
} else {
m.openMenu((st.open+1)%len(m.menus), true)
}
case t.Shortcut(0, ui.KeyLeft):
if l > 0 {
st.hl = st.hl[:l]
} else {
m.openMenu((st.open-1+len(m.menus))%len(m.menus), true)
}
case t.Shortcut(0, ui.KeyEnter), t.Shortcut(0, ui.KeySpace):
if h >= 0 {
m.activate(l, h)
}
case t.Shortcut(0, ui.KeyEscape):
if l > 0 {
st.hl = st.hl[:l]
} else {
m.close()
}
default:
m.moved, st.reveal = false, false
}
}
// items returns the items shown at level l of the open menu.
func (m *menubarRun) items(l int) []MenubarItem {
items := m.menus[m.st.open].Items
for i := 0; i < l; i++ {
items = items[m.st.hl[i]].Items
}
return items
}
func (m *menubarRun) focus(i int) {
m.st.focusTo = i
m.c.Invalidate()
}
func (m *menubarRun) openMenu(i int, highlight bool) {
first := -1
if highlight {
first = menubarStep(m.menus[i].Items, -1, 1)
}
m.st.open, m.st.hl = i, []int{first}
m.moved = highlight
m.focus(i)
slog.Debug("mujica: menubar open", "menu", m.menus[i].Label)
}
func (m *menubarRun) close() {
if m.st.open >= 0 {
m.focus(m.st.open)
}
m.st.open, m.st.hl = -1, nil
slog.Debug("mujica: menubar close")
}
// activate chooses item h of level l, or opens it when it is a submenu.
func (m *menubarRun) activate(l, h int) {
it := m.items(l)[h]
if it.Disabled || it.Kind == MenubarSeparator {
return
}
if it.Items != nil {
m.st.hl = append(m.st.hl[:l+1], menubarStep(it.Items, -1, 1))
m.focus(m.st.open)
return
}
m.chosen = it.ID
slog.Debug("mujica: menubar choose", "id", it.ID)
m.close()
}
// menubarStep returns the next enabled item from h in direction dir, wrapping.
func menubarStep(items []MenubarItem, h, dir int) int {
n := len(items)
for k := 1; k <= n; k++ {
j := ((h+dir*k)%n + n) % n
if it := items[j]; it.Kind != MenubarSeparator && !it.Disabled {
return j
}
}
return -1
}
+211
View File
@@ -0,0 +1,211 @@
package navigation
import (
"runtime"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// overlay draws the open menu and its submenus above the window.
func (m *menubarRun) overlay(titles []ui.Rect) {
c, st := m.c, m.st
ui.Overlay(c, func() {
back := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0)
if back.Clicked() {
m.close()
return
}
if back.Pressed() {
m.focus(st.open)
}
// Title hotspots above the backdrop switch menus as the pointer moves.
for i, r := range titles {
hot := ui.Box(c).Absolute().Left(r.X).Top(r.Y).Size(r.W, r.H).Cursor(ui.CursorPointer)
if hot.Clicked() {
if i == st.open {
m.close()
} else {
m.openMenu(i, false)
}
return
}
if hot.Pressed() {
m.focus(st.open)
}
if hot.Hovered() && i != st.open {
m.openMenu(i, false)
}
}
if st.open < 0 {
return
}
r := titles[st.open]
x, y := r.X, r.Y+r.H+2
var prev ui.Rect
for l := 0; l < len(st.hl) && st.open >= 0; l++ {
panel, anchor := m.panel(l, x, y, prev)
if anchor == nil || l+1 >= len(st.hl) {
break
}
a := anchor.Bounds()
prev = panel.Bounds()
x, y = prev.X+prev.W-2, a.Y-core.Density(c).Unit()
}
})
}
// panel builds level l at (x, y) and returns it with its highlighted row.
func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *ui.Element) {
c, st := m.c, m.st
k := core.Tokens(c)
u := core.Density(c).Unit()
items := m.items(l)
ww, wh := c.Size()
// Long menus scroll within the window instead of running off it.
panel := ui.Scroll(c).Absolute().Padding(u).MinWidth(200).MaxHeight(wh - 8).AlignItems(ui.Stretch).Role(ui.RolePopup)
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
pb := panel.Bounds()
if x+pb.W > ww-4 {
x = ww - 4 - pb.W
if l > 0 {
x = parent.X - pb.W + 2
}
}
if y+pb.H > wh-4 {
y = max(4, wh-4-pb.H)
}
panel.Left(max(4, x)).Top(y)
if panel.Clicked() || panel.Pressed() {
m.focus(st.open)
}
var anchor *ui.Element
panel.Children(func() {
for j, it := range items {
if it.Kind == MenubarSeparator {
ui.Box(c).Height(1).Margin(u, u*2).Background(k.Border).Role(ui.RoleNone)
continue
}
row := m.item(it, st.hl[l] == j)
if st.hl[l] == j {
anchor = row
if st.reveal && l == len(st.hl)-1 {
row.ScrollIntoView()
st.reveal = false
}
}
if row.Clicked() {
st.hl = append(st.hl[:l], j)
m.activate(l, j)
return
}
if row.Pressed() {
// A press takes the focus off the title, which drives the keys.
m.focus(st.open)
}
px, py, over := row.PointerPosition()
b := row.Bounds()
at := [2]float32{b.X + px, b.Y + py}
if over && at != st.ptr && !it.Disabled {
st.ptr = at
if st.hl[l] != j || it.Items != nil && len(st.hl) == l+1 {
st.hl = append(st.hl[:l], j)
if it.Items != nil {
st.hl = append(st.hl, -1)
}
c.Invalidate()
}
}
}
})
return panel, anchor
}
func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element {
c := m.c
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
name := menubarName(c, it)
row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2).
Radius(theme.ControlRadius).Label(name).TextColor(k.Text).Disabled(it.Disabled).Role(ui.RoleButton)
if hl {
row.Background(k.Selection)
}
internal.Ease(row, core.Motion(c, theme.StateDuration), k.Text)
row.Children(func() {
mark := ui.Box(c).Size(16, 16).Shrink(0).Center().Role(ui.RoleNone)
if it.Checked && it.Kind == MenubarCheck {
mark.TextColor(k.AccentText).Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14) })
}
if it.Checked && it.Kind == MenubarRadio {
mark.Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText)
})
}
ui.Text(c, it.Label).SingleLine()
ui.Box(c).Grow(1).MinWidth(u * 6)
if it.Shortcut.Key != 0 {
ui.Text(c, MenubarShortcutText(it.Shortcut)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
if it.Items != nil {
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted)
}
})
return row
}
// menubarName is an item's label, with its checked state for assistive technology.
func menubarName(c *ui.Context, it MenubarItem) string {
if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked {
return it.Label + ", " + core.Msg(c, "menubar.checked")
}
return it.Label
}
var menubarKeyNames = map[ui.Key]string{
ui.KeyEnter: "Enter", ui.KeyEscape: "Esc", ui.KeyBackspace: "Backspace", ui.KeyTab: "Tab", ui.KeySpace: "Space",
ui.KeyDelete: "Del", ui.KeyHome: "Home", ui.KeyEnd: "End", ui.KeyPageUp: "PgUp", ui.KeyPageDown: "PgDn",
ui.KeyLeft: "←", ui.KeyRight: "→", ui.KeyUp: "↑", ui.KeyDown: "↓", ui.KeyMinus: "-", ui.KeyEqual: "=",
ui.KeyComma: ",", ui.KeyPeriod: ".", ui.KeySlash: "/", ui.KeySemicolon: ";", ui.KeyQuote: "'",
ui.KeyF1: "F1", ui.KeyF2: "F2", ui.KeyF3: "F3", ui.KeyF4: "F4", ui.KeyF5: "F5", ui.KeyF6: "F6",
ui.KeyF7: "F7", ui.KeyF8: "F8", ui.KeyF9: "F9", ui.KeyF10: "F10", ui.KeyF11: "F11", ui.KeyF12: "F12",
ui.KeyBracketLeft: "[", ui.KeyBracketRight: "]", ui.KeyBackslash: "\\", ui.KeyBackquote: "`",
}
// MenubarShortcutText spells a shortcut the platform's way: ⌘S on macOS, Ctrl+S elsewhere.
func MenubarShortcutText(s MenubarShortcut) string {
key, ok := menubarKeyNames[s.Key]
switch {
case ok:
case s.Key >= ui.KeyA && s.Key <= ui.KeyZ:
key = string(rune('A' + s.Key - ui.KeyA))
case s.Key >= ui.Key0 && s.Key <= ui.Key9:
key = string(rune('0' + s.Key - ui.Key0))
default:
panic("mujica: MenubarShortcut key has no name")
}
mac := runtime.GOOS == "darwin"
var parts []string
for _, m := range []struct {
mod ui.Modifiers
mac, name string
}{{ui.Ctrl, "⌃", "Ctrl"}, {ui.Alt, "⌥", "Alt"}, {ui.Shift, "⇧", "Shift"}, {ui.Super, "⌘", "Win"}} {
if s.Mods&m.mod != 0 {
if mac {
parts = append(parts, m.mac)
} else {
parts = append(parts, m.name)
}
}
}
if mac {
return strings.Join(parts, "") + key
}
return strings.Join(append(parts, key), "+")
}
+223
View File
@@ -0,0 +1,223 @@
package navigation
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// NavigationLink is a page of a NavigationMenu, or with Items a group of pages.
type NavigationLink struct {
Label string
// Path is pushed on the router; groups need none.
Path string
Icon *ui.SVG
Description string
Items []NavigationLink
}
// NavigationMenuOptions configure a NavigationMenu.
type NavigationMenuOptions struct {
// History shows back and forward buttons for the router.
History bool
// Label names the menu for assistive technology.
Label string
}
// NavigationMenu shows links pushing pages on router; the router's path marks the current one.
func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element {
if router == nil {
panic("mujica: NavigationMenu needs a router")
}
if len(links) == 0 {
panic("mujica: NavigationMenu needs links")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
path := router.Path()
label := opts.Label
if label == "" {
label = core.Msg(c, "navigation_menu.label")
}
push := func(p string) {
if p != path {
slog.Debug("mujica: navigation menu push", "from", path, "to", p)
router.Push(p)
}
}
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2)
row.Children(func() {
if opts.History {
ui.Row(c).Gap(u).Children(func() {
navigationMenuHistory(c, ui.BackButton(c, router), core.Msg(c, "navigation_menu.back"))
navigationMenuHistory(c, ui.ForwardButton(c, router), core.Msg(c, "navigation_menu.forward"))
})
ui.Box(c).Size(1, 20).Background(k.Ornament).Role(ui.RoleNone)
}
ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(label).Children(func() {
for _, l := range links {
navigationMenuTop(c, l, path, push)
}
})
})
return row
}
func navigationMenuHistory(c *ui.Context, b *ui.Element, name string) {
k := core.Tokens(c)
d := core.Density(c)
b.Label(name).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Radius(theme.ControlRadius).Background(k.Surface)
internal.Ring(b, k.Focus, theme.ControlRadius)
}
// navigationMenuActive reports whether l or a link inside it is the page at path.
func navigationMenuActive(l NavigationLink, path string) bool {
// The root page is current only on itself, not under every path.
if l.Path != "" && (path == l.Path || l.Path != "/" && strings.HasPrefix(path, strings.TrimSuffix(l.Path, "/")+"/")) {
return true
}
for _, it := range l.Items {
if navigationMenuActive(it, path) {
return true
}
}
return false
}
func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(string)) {
if l.Label == "" {
panic("mujica: NavigationLink needs a Label")
}
if l.Items == nil && l.Path == "" {
panic("mujica: NavigationLink needs a Path or Items")
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
on := navigationMenuActive(l, path)
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius).
Label(l.Label).Cursor(ui.CursorPointer)
open := ui.Local(b, "open", func() bool { return false })
if l.Items == nil {
b.Role(ui.RoleLink)
} else {
b.Role(ui.RoleMenuButton)
}
fg := k.TextMuted
if on {
b.FontWeight(600)
fg = k.Text
}
if b.Hovered() || *open {
b.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(b, core.Motion(c, theme.StateDuration), fg)
b.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if on {
p.Fill(ui.Rect{X: r.X + u*3, Y: r.Y + r.H - 2, W: r.W - u*6, H: 2}, k.AccentText, 1)
}
if b.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
b.Children(func() {
if l.Icon != nil {
ui.Icon(c, l.Icon).Size(16, 16)
}
ui.Text(c, l.Label).SingleLine()
if l.Items != nil {
ui.Icon(c, icons.Must("chevron-down")).Size(12, 12).TextColor(k.TextMuted)
}
})
if l.Items == nil {
if b.Clicked() {
push(l.Path)
}
return
}
if b.Clicked() {
*open = !*open
} else if b.Shortcut(0, ui.KeyDown) {
*open = true
}
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
// Capped to the room beside the anchor; the links scroll inside.
_, wh := c.Size()
r := b.Bounds()
panel.Margin(u, 0, 0, 0).Padding(u * 2).MaxHeight(max(r.Y, wh-r.Y-r.H) - 16).MinWidth(240).MaxWidth(360)
ui.Scroll(c).Shrink(1).Gap(u).FocusGroup(ui.Vertical).Children(func() {
navigationMenuPanel(c, l.Items, path, open, push)
})
})
}
// navigationMenuPanel lists a group's links, its subgroups under serif headings.
func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, open *bool, push func(string)) {
k := core.Tokens(c)
u := core.Density(c).Unit()
first := true
var list func(items []NavigationLink)
list = func(items []NavigationLink) {
for _, it := range items {
if it.Label == "" {
panic("mujica: NavigationLink needs a Label")
}
if it.Items != nil {
ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).
Padding(u*2, u*2, u, u*2).SingleLine()
list(it.Items)
continue
}
if it.Path == "" {
panic("mujica: NavigationLink needs a Path or Items")
}
if navigationMenuItem(c, it, it.Path == path, first).Clicked() {
*open = false
push(it.Path)
}
first = false
}
}
list(items)
}
func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element {
k := core.Tokens(c)
u := core.Density(c).Unit()
e := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).AlignItems(ui.Start).Gap(u*3).
Padding(u*2, u*3).Radius(theme.ControlRadius).Label(l.Label).TextColor(k.Text).Cursor(ui.CursorPointer)
if first {
e.AutoFocus()
}
internal.Ring(e, k.Focus, theme.ControlRadius)
switch {
case on:
e.Background(k.Selection).FontWeight(600)
e.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5)
})
case e.Hovered():
e.Background(k.SurfaceHover)
}
internal.Ease(e, core.Motion(c, theme.StateDuration), k.Text)
e.Children(func() {
if l.Icon != nil {
ui.Icon(c, l.Icon).Size(16, 16).Margin(2, 0, 0, 0)
}
ui.Column(c).Gap(2).MinWidth(0).Children(func() {
ui.Text(c, l.Label).SingleLine()
if l.Description != "" {
ui.Text(c, l.Description).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontWeight(400)
}
})
})
return e
}
+140
View File
@@ -0,0 +1,140 @@
package navigation
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func navigationMenuSample() []NavigationLink {
return []NavigationLink{
{Label: "Home", Path: "/", Icon: icons.Must("house")},
{Label: "Library", Items: []NavigationLink{
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
{Label: "Letters", Path: "/library/letters"},
{Label: "Archive", Items: []NavigationLink{
{Label: "Older letters", Path: "/archive/letters"},
}},
}},
{Label: "About", Path: "/about"},
}
}
func TestNavigationMenuPush(t *testing.T) {
r := ui.NewRouter("/")
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
NavigationMenu(c, r, navigationMenuSample(), NavigationMenuOptions{History: true})
}, 700, 400)
if err := tt.Click("About"); err != nil {
t.Fatal(err)
}
if r.Path() != "/about" {
t.Fatalf("path %q", r.Path())
}
if err := tt.Click("Library"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Verse of the court") || !tt.HasText("Older letters") {
t.Fatalf("group did not open: %q", tt.Texts())
}
if err := tt.Click("Older letters"); err != nil {
t.Fatal(err)
}
if r.Path() != "/archive/letters" || tt.HasText("Verse of the court") {
t.Fatalf("path %q, panel open %v", r.Path(), tt.HasText("Verse of the court"))
}
if err := tt.Click("Back"); err != nil {
t.Fatal(err)
}
if r.Path() != "/about" {
t.Fatalf("Back went to %q", r.Path())
}
if err := tt.Click("Forward"); err != nil {
t.Fatal(err)
}
if r.Path() != "/archive/letters" {
t.Fatalf("Forward went to %q", r.Path())
}
}
func TestNavigationMenuKeyboard(t *testing.T) {
r := ui.NewRouter("/")
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
NavigationMenu(c, r, navigationMenuSample(), NavigationMenuOptions{})
}, 700, 400)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if !tt.Focused("Library") {
t.Fatal("Right did not reach Library")
}
tt.Key(0, ui.KeyDown)
if !tt.Focused("Poems") {
t.Fatalf("Down did not open into the panel: %q", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if r.Path() != "/library/letters" {
t.Fatalf("keyboard went to %q", r.Path())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEscape)
if tt.HasText("Poems") || !tt.Focused("Library") {
t.Fatalf("Escape: open %v, focus on Library %v", tt.HasText("Poems"), tt.Focused("Library"))
}
}
func TestNavigationMenuActive(t *testing.T) {
l := navigationMenuSample()
for _, tc := range []struct {
path string
want [3]bool
}{
{"/", [3]bool{true, false, false}},
{"/library/poems", [3]bool{false, true, false}},
{"/archive/letters/7", [3]bool{false, true, false}},
{"/about", [3]bool{false, false, true}},
{"/aboutx", [3]bool{false, false, false}},
} {
for i := range l {
got := navigationMenuActive(l[i], tc.path)
if got != tc.want[i] {
t.Errorf("%s: link %s active %v", tc.path, l[i].Label, got)
}
}
}
}
func TestNavigationMenuInvalid(t *testing.T) {
for name, links := range map[string][]NavigationLink{
"empty": nil,
"path": {{Label: "X"}},
"label": {{Path: "/x"}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
NavigationMenu(c, ui.NewRouter("/"), links, NavigationMenuOptions{})
}, 300, 100, 1)
})
}
}
func TestNavigationMenuSnapshot(t *testing.T) {
r := ui.NewRouter("/")
r.Push("/library/poems")
snapshot(t, "navigation_menu", 620, 120, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
NavigationMenu(c, r, navigationMenuSample(), NavigationMenuOptions{History: true})
})
})
}
+127
View File
@@ -0,0 +1,127 @@
package navigation
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TestNavigationPopupsFadeIn checks each popup fades in over PopupDuration instead of appearing at once.
func TestNavigationPopupsFadeIn(t *testing.T) {
for _, tc := range []struct {
name, open, sample string
view func(c *ui.Context)
}{
{"breadcrumb", "Show hidden path", "Alpha", func(c *ui.Context) {
Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{})
}},
{"pagination", "10 / page", "50 / page", func(c *ui.Context) {
page, size := 1, 10
Pagination(c, &page, &size, PaginationOptions{Total: 100, PageSizes: []int{10, 50}})
}},
{"navigation menu", "Library", "Letters", func(c *ui.Context) {
NavigationMenu(c, ui.NewRouter("/"), navigationMenuSample(), NavigationMenuOptions{})
}},
{"menubar", "File", "Quit", func(c *ui.Context) {
Menubar(c, (&menubarApp{}).menus(), MenubarOptions{})
}},
{"command palette", "", "Esc close", func(c *ui.Context) {
open := true
CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{})
}},
} {
opened := time.Now()
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Fill().Padding(16).AlignItems(ui.Start).Children(func() { tc.view(c) })
}, 700, 500)
if tc.open != "" {
if err := tt.Click(tc.open); err != nil {
t.Fatal(err)
}
}
r, ok := tt.Find(tc.sample)
if !ok {
t.Fatalf("%s: no %q", tc.name, tc.sample)
}
x, y := int(r.X-3), int(r.Y+r.H/2)
early := tt.Image().RGBAAt(x, y)
mid := time.Since(opened) < theme.PopupDuration/2
time.Sleep(200 * time.Millisecond)
tt.Frame()
// A slow run may finish the fade before the first sample.
if late := tt.Image().RGBAAt(x, y); mid && early == late {
t.Errorf("%s: popup appeared at once (%v)", tc.name, late)
}
}
}
// TestNavigationMenuLongPanelScrolls checks a tall group stays in the window and follows the focus.
func TestNavigationMenuLongPanelScrolls(t *testing.T) {
var links []NavigationLink
for i := range 30 {
links = append(links, NavigationLink{Label: fmt.Sprintf("Link %02d", i), Path: fmt.Sprintf("/l/%d", i)})
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
NavigationMenu(c, ui.NewRouter("/"), []NavigationLink{{Label: "Group", Items: links}}, NavigationMenuOptions{})
}, 400, 300)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnd)
tt.Frame()
if !tt.Focused("Link 29") {
t.Fatal("End did not focus the last link")
}
r, _ := tt.Find("Link 29")
if r.H == 0 || r.Y+r.H > 300 {
t.Fatalf("focused link off screen at %+v", r)
}
}
// TestNavigationCaptionsFollowTextScale checks small text grows with the desktop text scale.
func TestNavigationCaptionsFollowTextScale(t *testing.T) {
for _, tc := range []struct {
text string
view func(c *ui.Context)
}{
{"Name and seal", func(c *ui.Context) {
cur := 0
Steps(c, &cur, []Step{{Title: "Account", Description: "Name and seal"}}, StepsOptions{})
}},
{"12", func(c *ui.Context) {
sel := "a"
Sidebar(c, &sel, []SidebarSection{{Title: "Mail", Items: []SidebarItem{{ID: "a", Label: "Inbox", Icon: icons.Must("inbox"), Count: 12}}}}, SidebarOptions{})
}},
{"Verse of the court", func(c *ui.Context) {
navigationMenuItem(c, NavigationLink{Label: "Poems", Path: "/p", Description: "Verse of the court"}, false, false)
}},
{"Esc close", func(c *ui.Context) {
open := true
CommandPalette(c, &open, []Command{{ID: "a", Label: "A"}}, CommandPaletteOptions{})
}},
} {
height := func(scale float32) float32 {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Fill().AlignItems(ui.Start).Children(func() { tc.view(c) })
}, 900, 700)
tt.SetPreferences(ui.Preferences{TextScale: scale})
tt.Frame()
r, ok := tt.Find(tc.text)
if !ok {
t.Fatalf("no %q", tc.text)
}
return r.H
}
if h1, h2 := height(1), height(2); h2 < h1*1.8 {
t.Errorf("%q: height %v at 2x text, %v at 1x", tc.text, h2, h1)
}
}
}
+243
View File
@@ -0,0 +1,243 @@
package navigation
import (
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PaginationOptions configure a Pagination.
type PaginationOptions struct {
// Total is the number of records; it must not be negative.
Total int
// PageSizes lists the page sizes offered; nil hides the choice.
PageSizes []int
// Jump shows a field going to a typed page.
Jump bool
// Siblings is how many pages show each side of the current; zero means 1.
Siblings int
Disabled bool
}
// Pagination pages *page (from 1) through Total records, clamping it when Total shrinks.
func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) core.ChoiceResult {
if opts.Total < 0 {
panic("mujica: Pagination Total is negative")
}
if *pageSize <= 0 {
panic("mujica: Pagination pageSize must be positive")
}
if opts.PageSizes != nil && !slices.Contains(opts.PageSizes, *pageSize) {
panic("mujica: Pagination pageSize is not one of PageSizes")
}
sib := opts.Siblings
if sib == 0 {
sib = 1
}
if sib < 0 {
panic("mujica: Pagination Siblings is negative")
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Wrap().Role(ui.RoleGroup).Label(core.Msg(c, "pagination.label")).Disabled(opts.Disabled)
res := core.ChoiceResult{Element: row}
pages := paginationPages(opts.Total, *pageSize)
if p := max(1, min(*page, pages)); p != *page {
slog.Debug("mujica: pagination correct", "from", *page, "to", p, "pages", pages)
*page, res = p, core.NewChoiceResult(res.Element, true)
}
set := func(p int) {
if p != *page {
slog.Debug("mujica: pagination page", "from", *page, "to", p)
*page, res = p, core.NewChoiceResult(res.Element, true)
}
}
row.Children(func() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "pagination.total"), opts.Total)).TextColor(k.TextMuted).SingleLine()
ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Children(func() {
if paginationButton(c, "", icons.Must("chevron-left"), core.Msg(c, "pagination.prev"), false, *page <= 1).Clicked() {
set(*page - 1)
}
for _, p := range paginationItems(*page, pages, sib) {
if p == 0 {
ui.Text(c, "…").TextColor(k.TextMuted).Padding(0, u).Role(ui.RoleNone)
continue
}
n := strconv.Itoa(p)
name := fmt.Sprintf(core.Msg(c, "pagination.page"), p)
if p == *page {
name += ", " + core.Msg(c, "pagination.current")
}
if paginationButton(c, n, nil, name, p == *page, false).Clicked() {
set(p)
}
}
if paginationButton(c, "", icons.Must("chevron-right"), core.Msg(c, "pagination.next"), false, *page >= pages).Clicked() {
set(*page + 1)
}
})
if opts.PageSizes != nil {
paginationSizes(c, page, pageSize, opts.PageSizes, &res)
}
if opts.Jump {
paginationJump(c, pages, set)
}
})
return res
}
func paginationPages(total, size int) int { return max(1, (total+size-1)/size) }
// paginationItems lists the page numbers to show, 0 standing for an ellipsis.
func paginationItems(cur, pages, sib int) []int {
var out []int
if pages <= 2*sib+5 {
for p := 1; p <= pages; p++ {
out = append(out, p)
}
return out
}
start := max(min(cur-sib, pages-2*sib-2), 3)
end := min(max(cur+sib, 2*sib+3), pages-2)
out = append(out, 1)
if start > 3 {
out = append(out, 0)
} else {
out = append(out, 2)
}
for p := start; p <= end; p++ {
out = append(out, p)
}
if end < pages-2 {
out = append(out, 0)
} else {
out = append(out, pages-1)
}
return append(out, pages)
}
func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on, disabled bool) *ui.Element {
k := core.Tokens(c)
h := core.Density(c).ControlHeight()
b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, core.Density(c).Unit()*2).Radius(theme.ControlRadius).
Label(name).Disabled(disabled)
internal.Ring(b, k.Focus, theme.ControlRadius)
fg := k.Text
switch {
case on:
b.Background(k.Accent).Border(theme.BorderWidth, k.Accent).FontWeight(600)
fg = k.OnAccent
case b.Pressed():
b.Background(k.Selection).Border(theme.BorderWidth, k.ControlBorder)
case b.Hovered():
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
default:
b.Border(theme.BorderWidth, k.Border)
}
internal.Ease(b, core.Motion(c, theme.StateDuration), fg)
if icon != nil {
b.Tooltip(name)
}
b.Children(func() {
if icon != nil {
ui.Icon(c, icon).Size(16, 16)
} else {
ui.Text(c, text).SingleLine().FontFeatures("tnum")
}
})
return b
}
func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *core.ChoiceResult) {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
old := *pageSize
sel := ui.SelectBase(c, pageSize)
sel.Trigger.MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u*2).Radius(theme.ControlRadius).
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(core.Msg(c, "pagination.size"))
internal.Ring(sel.Trigger, k.Focus, theme.ControlRadius)
if sel.Trigger.Hovered() {
sel.Trigger.Background(k.SurfaceHover)
}
internal.Ease(sel.Trigger, core.Motion(c, theme.StateDuration), k.Text)
sel.Trigger.Children(func() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "pagination.per_page"), *pageSize)).SingleLine()
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
})
sel.Popup(func(panel *ui.Element) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Margin(u, 0, 0, 0).Padding(u)
for _, s := range sizes {
it := sel.Item(s).Padding(u*1.5, u*3).Radius(theme.ControlRadius).Gap(u * 2)
if it.Highlighted() {
it.Background(k.Selection)
}
internal.Ease(it, core.Motion(c, theme.StateDuration), k.Text)
it.Children(func() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "pagination.per_page"), s)).SingleLine().Grow(1)
if s == *pageSize {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
})
}
})
if *pageSize != old {
// Keep the first record of the old page in view.
first := (*page-1)*old + 1
*page = (first-1) / *pageSize + 1
*res = core.NewChoiceResult(res.Element, true)
slog.Debug("mujica: pagination size", "from", old, "to", *pageSize, "page", *page)
}
}
func paginationJump(c *ui.Context, pages int, set func(int)) {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
ui.Text(c, core.Msg(c, "pagination.jump")).TextColor(k.TextMuted).SingleLine()
box := ui.Row(c).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Width(64).Padding(0, u*2).
Radius(theme.ControlRadius).Background(k.Surface)
text := ui.Local(box, "text", func() string { return "" })
bad := ui.Local(box, "bad", func() bool { return false })
var in *ui.Element
box.Children(func() {
in = ui.TextInputBase(c, text).FillWidth().Label(core.Msg(c, "pagination.field"))
})
if in.Changed() {
*bad = false
}
if in.Submitted() {
p, err := strconv.Atoi(strings.TrimSpace(*text))
if err != nil || p < 1 || p > pages {
*bad = true
slog.Debug("mujica: pagination jump rejected", "text", *text, "pages", pages)
} else {
*text, *bad = "", false
set(p)
}
}
border := k.ControlBorder
if *bad {
border = k.Danger
in.Error(fmt.Sprintf(core.Msg(c, "pagination.range"), pages))
} else if in.Focused() {
border = k.Focus
}
internal.Ease(box.Border(theme.BorderWidth, border), core.Motion(c, theme.StateDuration), k.Text)
if *bad {
ui.Text(c, fmt.Sprintf(core.Msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine()
}
})
}
+168
View File
@@ -0,0 +1,168 @@
package navigation
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestPaginationItems(t *testing.T) {
for _, tc := range []struct {
cur, pages int
want []int
}{
{1, 1, []int{1}},
{3, 7, []int{1, 2, 3, 4, 5, 6, 7}},
{1, 20, []int{1, 2, 3, 4, 5, 0, 20}},
{10, 20, []int{1, 0, 9, 10, 11, 0, 20}},
{20, 20, []int{1, 0, 16, 17, 18, 19, 20}},
} {
if got := paginationItems(tc.cur, tc.pages, 1); !slices.Equal(got, tc.want) {
t.Errorf("items(%d, %d) = %v, want %v", tc.cur, tc.pages, got, tc.want)
}
}
}
func TestPaginationNavigate(t *testing.T) {
page, size, changes := 1, 10, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Pagination(c, &page, &size, PaginationOptions{Total: 95}).Changed() {
changes++
}
}, 700, 120)
// Previous is disabled on page 1, so Tab lands on "1".
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if page != 2 || changes != 1 {
t.Fatalf("keyboard: page %d changes %d", page, changes)
}
if err := tt.Click("Next page"); err != nil {
t.Fatal(err)
}
if page != 3 || changes != 2 {
t.Fatalf("next: page %d changes %d", page, changes)
}
if err := tt.Click("10"); err != nil {
t.Fatal(err)
}
if page != 10 {
t.Fatalf("click 10: page %d", page)
}
_ = tt.Click("Next page")
if page != 10 {
t.Fatalf("next past the end: page %d", page)
}
}
func TestPaginationCorrectsOnTotalChange(t *testing.T) {
page, size, total, changes := 9, 10, 95, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Pagination(c, &page, &size, PaginationOptions{Total: total}).Changed() {
changes++
}
}, 700, 120)
total = 31
tt.Frame()
if page != 4 || changes != 1 {
t.Fatalf("page %d changes %d, want 4 and 1", page, changes)
}
total = 0
tt.Frame()
if page != 1 {
t.Fatalf("empty total: page %d", page)
}
}
func TestPaginationPageSizeKeepsRecord(t *testing.T) {
page, size := 3, 10
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Pagination(c, &page, &size, PaginationOptions{Total: 200, PageSizes: []int{10, 20, 50}})
}, 800, 300)
if err := tt.Click("10 / page"); err != nil {
t.Fatal(err)
}
if err := tt.Click("50 / page"); err != nil {
t.Fatal(err)
}
// Record 21 was first on page 3; with 50 per page it is on page 1.
if size != 50 || page != 1 {
t.Fatalf("size %d page %d", size, page)
}
}
func TestPaginationJump(t *testing.T) {
page, size := 1, 10
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: core.ZhCN})
Pagination(c, &page, &size, PaginationOptions{Total: 100, Jump: true})
}, 900, 120)
if err := tt.Click("页码"); err != nil {
t.Fatal(err)
}
tt.Type("42")
tt.Key(0, ui.KeyEnter)
if page != 1 || !tt.HasText("1–10") {
t.Fatalf("out of range accepted: page %d texts %q", page, tt.Texts())
}
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
tt.Type("7")
tt.Key(0, ui.KeyEnter)
if page != 7 || tt.HasText("1–10") {
t.Fatalf("jump: page %d texts %q", page, tt.Texts())
}
}
func TestPaginationDisabled(t *testing.T) {
page, size := 2, 10
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Pagination(c, &page, &size, PaginationOptions{Total: 50, Disabled: true})
}, 700, 120)
_ = tt.Click("Next page")
_ = tt.Click("4")
if page != 2 {
t.Fatalf("disabled pagination moved to %d", page)
}
}
func TestPaginationInvalid(t *testing.T) {
for name, tc := range map[string]struct {
size int
opts PaginationOptions
}{
"total": {10, PaginationOptions{Total: -1}},
"size": {0, PaginationOptions{}},
"sizes": {15, PaginationOptions{PageSizes: []int{10, 20}}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
page := 1
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Pagination(c, &page, &tc.size, tc.opts)
}, 300, 100, 1)
})
}
}
func TestPaginationSnapshot(t *testing.T) {
snapshot(t, "pagination", 760, 140, func(c *ui.Context) {
a, b, s := 10, 1, 20
ui.Column(c).Padding(16).Gap(16).Children(func() {
Pagination(c, &a, &s, PaginationOptions{Total: 400, PageSizes: []int{10, 20, 50}, Jump: true})
Pagination(c, &b, &s, PaginationOptions{Total: 45, Disabled: true})
})
})
}
+98
View File
@@ -0,0 +1,98 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Segment is one choice of a SegmentedControl.
type Segment struct {
// Label is required: it is shown, or with HideLabel names the icon.
Label string
Icon *ui.SVG
HideLabel bool
}
// SegmentedControlOptions configure a SegmentedControl.
type SegmentedControlOptions struct {
// Equal gives every segment the width of the widest.
Equal bool
// Label names the group for assistive technology.
Label string
Disabled bool
}
// SegmentedControl chooses one of segments into *selected; Changed reports a new choice.
func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element {
if len(segments) == 0 {
panic("mujica: SegmentedControl needs at least one segment")
}
if *selected < 0 || *selected >= len(segments) {
panic("mujica: SegmentedControl selected index out of range")
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
inset := float32(2)
prev := *selected
parts := ui.SegmentedBase(c, selected, len(segments))
track := parts.Track.Padding(inset).Gap(inset).Radius(theme.ControlRadius+inset).
Background(k.Background).Border(theme.BorderWidth, k.ControlBorder).AlignItems(ui.Stretch).
MinHeight(d.ControlHeight()).Disabled(opts.Disabled)
if opts.Equal {
track.Grid().Columns(len(segments))
}
if opts.Label != "" {
track.Label(opts.Label)
}
track.Children(func() {
for i, s := range segments {
if s.Label == "" {
panic("mujica: Segment needs a Label")
}
if s.HideLabel && s.Icon == nil {
panic("mujica: Segment with HideLabel needs an Icon")
}
on := i == *selected
seg := parts.Segment(i).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius).
MinWidth(theme.MinHitSize).Label(s.Label)
if !opts.Disabled {
seg.Cursor(ui.CursorPointer)
}
fg := k.TextMuted
switch {
case on:
seg.Background(k.Accent).FontWeight(600)
fg = k.OnAccent
case seg.Pressed():
seg.Background(k.Selection)
fg = k.Text
case seg.Hovered():
seg.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(seg, core.Motion(c, theme.StateDuration), fg)
internal.Ring(seg, k.Focus, theme.ControlRadius)
if s.HideLabel {
seg.Tooltip(s.Label)
}
seg.Children(func() {
if s.Icon != nil {
ui.Icon(c, s.Icon).Size(16, 16)
}
if !s.HideLabel {
ui.Text(c, s.Label).SingleLine()
}
})
}
})
if track.Changed() {
slog.Debug("mujica: segmented control change", "from", prev, "to", *selected)
}
return track
}
+113
View File
@@ -0,0 +1,113 @@
package navigation
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func TestSegmentedControlClickAndKeys(t *testing.T) {
sel, changes := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if SegmentedControl(c, &sel, []Segment{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}, SegmentedControlOptions{Label: "Range"}).Changed() {
changes++
}
}, 400, 100)
if err := tt.Click("Month"); err != nil {
t.Fatal(err)
}
if sel != 2 || changes != 1 {
t.Fatalf("click: sel %d changes %d", sel, changes)
}
tt.Key(0, ui.KeyRight)
if sel != 0 || changes != 2 {
t.Fatalf("Right wraps to %d, changes %d", sel, changes)
}
tt.Key(0, ui.KeyLeft)
if sel != 2 {
t.Fatalf("Left wraps to %d", sel)
}
tt.Key(0, ui.KeyHome)
if sel != 0 || !tt.Focused("Day") {
t.Fatalf("Home chose %d", sel)
}
}
func TestSegmentedControlIconsAndDisabled(t *testing.T) {
sel := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SegmentedControl(c, &sel, []Segment{
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
}, SegmentedControlOptions{Disabled: true})
}, 400, 100)
if _, ok := tt.Find("Grid"); !ok {
t.Fatal("icon segment has no name")
}
_ = tt.Click("Grid")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if sel != 0 {
t.Fatalf("disabled control changed to %d", sel)
}
}
func TestSegmentedControlEqualWidths(t *testing.T) {
sel := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).AlignItems(ui.Start).Children(func() {
SegmentedControl(c, &sel, []Segment{{Label: "A"}, {Label: "Much longer"}}, SegmentedControlOptions{Equal: true})
})
}, 400, 100)
a, _ := tt.Find("A")
b, _ := tt.Find("Much longer")
// Equal segments center their text: A's center sits a segment width before the other's.
da := (b.X + b.W/2) - (a.X + a.W/2)
if da < b.W {
t.Fatalf("segments not equal: A %+v, long %+v", a, b)
}
}
func TestSegmentedControlInvalid(t *testing.T) {
for name, segs := range map[string][]Segment{
"empty": nil,
"label": {{Icon: icons.Must("list")}},
"noicon": {{Label: "X", HideLabel: true}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
sel := 0
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
SegmentedControl(c, &sel, segs, SegmentedControlOptions{})
}, 300, 100, 1)
})
}
}
func TestSegmentedControlSnapshot(t *testing.T) {
snapshot(t, "segmented_control", 480, 180, func(c *ui.Context) {
a, b, e := 1, 0, 2
ui.Column(c).Padding(16).Gap(16).AlignItems(ui.Start).Children(func() {
SegmentedControl(c, &a, []Segment{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}, SegmentedControlOptions{})
ui.Row(c).Gap(16).Children(func() {
SegmentedControl(c, &b, []Segment{
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
}, SegmentedControlOptions{})
SegmentedControl(c, &b, []Segment{{Label: "Light", Icon: icons.Must("sun")}, {Label: "Dark", Icon: icons.Must("moon")}}, SegmentedControlOptions{Disabled: true})
})
SegmentedControl(c, &e, []Segment{{Label: "S"}, {Label: "Medium"}, {Label: "XL"}}, SegmentedControlOptions{Equal: true})
})
})
}
+222
View File
@@ -0,0 +1,222 @@
package navigation
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SidebarItem is a destination of a Sidebar, or with Items a nested group.
type SidebarItem struct {
// ID is what choosing the item stores; unused for groups.
ID string
Label string
Icon *ui.SVG
// Count shows beside the label when positive.
Count int
// Items nest under the item, which then expands and collapses.
Items []SidebarItem
}
// SidebarSection is a titled group of a Sidebar; its title collapses it.
type SidebarSection struct {
Title string
Items []SidebarItem
}
// SidebarOptions configure a Sidebar.
type SidebarOptions struct {
// Compact shows only icons, labels as tooltips; every item needs an Icon.
Compact bool
// Width is 240 DIPs when zero, 56 when Compact.
Width float32
}
// Sidebar is a source list choosing an item ID into *selected, built on MyGo's Sidebar.
func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts SidebarOptions) core.ChoiceResult {
k := core.Tokens(c)
width := opts.Width
if width == 0 {
width = 240
if opts.Compact {
width = 56
}
}
wrap := ui.Column(c).Width(width).FillHeight().AlignItems(ui.Stretch).Shrink(0)
closed := ui.Local(wrap, "closed", func() map[string]*bool { return map[string]*bool{} })
res := core.ChoiceResult{Element: wrap}
prev := *selected
wrap.Children(func() {
// MyGo draws icons in Accent: swap in readable state colors for the sidebar.
saved := c.Theme()
t := *saved
t.Accent, t.AccentText, t.Surface = k.AccentText, k.AccentText, k.Surface
c.SetTheme(&t)
sb := ui.Sidebar(c, selected, func() {
for si, s := range sections {
if opts.Compact {
if si > 0 {
sidebarRule(c)
}
sidebarFlat(c, *selected, s.Items)
continue
}
if s.Title == "" {
panic("mujica: SidebarSection needs a Title")
}
path := s.Title
sidebarGroup(c, s.Title, sidebarOpen(*closed, path), true, 0, func() {
sidebarItems(c, *selected, s.Items, *closed, path, 0)
})
}
}).FillHeight().FillWidth()
sb.DrawOver(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, k.Border, 0)
})
c.SetTheme(saved)
if sb.Changed() {
res = core.NewChoiceResult(res.Element, true)
slog.Debug("mujica: sidebar choose", "from", prev, "to", *selected)
}
})
return res
}
// sidebarOpen returns the shown state of a collapsible part, open at first.
func sidebarOpen(closed map[string]*bool, key string) *bool {
o, ok := closed[key]
if !ok {
o = new(bool)
*o = true
closed[key] = o
}
return o
}
func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed map[string]*bool, parent string, depth int) {
u := core.Density(c).Unit()
for _, it := range items {
if it.Label == "" {
panic("mujica: SidebarItem needs a Label")
}
if it.Items != nil {
// The key holds the whole path, so same-named groups stay apart.
path := parent + "\x00" + it.Label
sidebarGroup(c, it.Label, sidebarOpen(closed, path), false, depth, func() {
sidebarItems(c, selected, it.Items, closed, path, depth+1)
})
continue
}
ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() {
sidebarItem(c, selected, it, false)
})
}
}
// sidebarGroup is a section title or nested group that Enter, Space or a click opens and closes.
func sidebarGroup(c *ui.Context, title string, open *bool, section bool, depth int, fn func()) {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
ui.Column(c).Gap(u*0.5).Shrink(0).Padding(0, 0, 0, float32(depth)*u*4).Children(func() {
was := *open
tr := ui.CollapsibleBase(c, open).Trigger
if *open != was {
slog.Debug("mujica: sidebar group toggle", "group", title, "open", *open)
}
tr.AlignItems(ui.Center).Gap(u*2).Padding(u, u*2).Radius(theme.ControlRadius).Label(title).Cursor(ui.CursorPointer)
if section {
tr.Margin(u*2, 0, 0, 0).FontWeight(600).FontSize(core.FontSize(c, theme.CaptionSize))
} else {
tr.MinHeight(d.ControlHeight())
}
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() {
tr.Background(k.SurfaceHover)
}
fg := k.Text
if section {
fg = k.TextMuted
}
internal.Ease(tr, core.Motion(c, theme.StateDuration), fg)
turn := tr.Animate("mujica.turn", sidebarTurn(*open), core.Motion(c, theme.StateDuration))
tr.Children(func() {
ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).Rotate(turn).TextColor(k.TextMuted)
ui.Text(c, title).SingleLine().Grow(1)
})
if *open {
fn()
}
})
}
func sidebarTurn(open bool) float32 {
if open {
return 90
}
return 0
}
// sidebarFlat lists every leaf for the compact rail.
func sidebarFlat(c *ui.Context, selected string, items []SidebarItem) {
for _, it := range items {
if it.Items != nil {
sidebarFlat(c, selected, it.Items)
continue
}
if it.Icon == nil {
panic("mujica: compact Sidebar items need an Icon")
}
sidebarItem(c, selected, it, true)
}
}
func sidebarItem(c *ui.Context, selected string, it SidebarItem, compact bool) {
if it.ID == "" {
panic("mujica: SidebarItem needs an ID")
}
k := core.Tokens(c)
d := core.Density(c)
on := it.ID == selected
label := it.Label
if compact {
label = ""
}
e := ui.SidebarItem(c, it.ID, it.Icon, label).MinHeight(d.ControlHeight()).Radius(theme.ControlRadius)
name := it.Label
if it.Count > 0 {
name = fmt.Sprintf("%s (%d)", it.Label, it.Count)
}
if compact {
e.Tooltip(name).Label(name)
} else if it.Count > 0 {
e.Label(name).Children(func() {
ui.Text(c, sidebarCount(it.Count)).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
TextColor(k.TextMuted).Padding(0, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background)
})
}
internal.Ease(e, core.Motion(c, theme.StateDuration), k.Text)
if on {
e.Background(k.Selection).FontWeight(600)
e.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5)
})
}
}
func sidebarCount(n int) string {
if n > 999 {
return "999+"
}
return fmt.Sprint(n)
}
func sidebarRule(c *ui.Context) {
ui.Box(c).Height(1).Margin(core.Density(c).Unit()*2, 4).Background(core.Tokens(c).Ornament).Role(ui.RoleNone)
}
+174
View File
@@ -0,0 +1,174 @@
package navigation
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func sidebarSample() []SidebarSection {
return []SidebarSection{
{Title: "Library", Items: []SidebarItem{
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 12},
{ID: "starred", Label: "Starred", Icon: icons.Must("star")},
{Label: "Projects", Items: []SidebarItem{
{ID: "opera", Label: "Opera", Icon: icons.Must("folder")},
{ID: "ballet", Label: "Ballet", Icon: icons.Must("folder")},
}},
}},
{Title: "System", Items: []SidebarItem{
{ID: "settings", Label: "Settings", Icon: icons.Must("settings")},
}},
}
}
func TestSidebarChooseAndKeys(t *testing.T) {
sel, changes := "inbox", 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Sidebar(c, &sel, sidebarSample(), SidebarOptions{}).Changed() {
changes++
}
}, 400, 500)
if err := tt.Click("Opera"); err != nil {
t.Fatal(err)
}
if sel != "opera" || changes != 1 {
t.Fatalf("click: %q %d", sel, changes)
}
tt.Key(0, ui.KeyDown)
if sel != "ballet" {
t.Fatalf("Down chose %q", sel)
}
tt.Key(0, ui.KeyEnd)
if sel != "settings" {
t.Fatalf("End chose %q", sel)
}
tt.Key(0, ui.KeyHome)
if sel != "inbox" || changes != 4 {
t.Fatalf("Home chose %q, changes %d", sel, changes)
}
}
func TestSidebarCollapse(t *testing.T) {
sel := "inbox"
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Sidebar(c, &sel, sidebarSample(), SidebarOptions{})
}, 400, 500)
if err := tt.Click("Projects"); err != nil {
t.Fatal(err)
}
if tt.HasText("Opera") {
t.Fatal("nested group did not collapse")
}
if err := tt.Click("Library"); err != nil {
t.Fatal(err)
}
if tt.HasText("Starred") || !tt.HasText("Settings") {
t.Fatalf("section did not collapse: %q", tt.Texts())
}
_ = tt.Click("Library")
if !tt.HasText("Starred") || tt.HasText("Opera") {
t.Fatalf("collapse state lost: %q", tt.Texts())
}
}
func TestSidebarCompact(t *testing.T) {
sel := "inbox"
var w float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).FillHeight().Children(func() {
w = Sidebar(c, &sel, sidebarSample(), SidebarOptions{Compact: true}).Element.Bounds().W
})
}, 400, 500)
tt.Frame()
if w != 56 {
t.Fatalf("compact width %v", w)
}
if err := tt.Click("Ballet"); err != nil {
t.Fatal(err)
}
if sel != "ballet" {
t.Fatalf("compact click chose %q", sel)
}
if _, ok := tt.Find("Inbox (12)"); !ok {
t.Fatalf("compact item lost its name: %q", tt.Texts())
}
}
func TestSidebarInvalid(t *testing.T) {
for name, tc := range map[string]struct {
s []SidebarSection
opts SidebarOptions
}{
"title": {[]SidebarSection{{Items: []SidebarItem{{ID: "a", Label: "A"}}}}, SidebarOptions{}},
"id": {[]SidebarSection{{Title: "T", Items: []SidebarItem{{Label: "A"}}}}, SidebarOptions{}},
"compact": {[]SidebarSection{{Title: "T", Items: []SidebarItem{{ID: "a", Label: "A"}}}}, SidebarOptions{Compact: true}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
sel := ""
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Sidebar(c, &sel, tc.s, tc.opts)
}, 300, 300, 1)
})
}
}
func TestSidebarGroupKeyboard(t *testing.T) {
sel := "inbox"
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Sidebar(c, &sel, sidebarSample(), SidebarOptions{})
}, 400, 500)
// Tab stops: the item list, the Library title, the Projects group.
for range 3 {
tt.Key(0, ui.KeyTab)
}
tt.Key(0, ui.KeyEnter)
if tt.HasText("Opera") {
t.Fatal("Enter did not collapse the group")
}
tt.Key(0, ui.KeySpace)
if !tt.HasText("Opera") {
t.Fatal("Space did not expand the group")
}
}
func TestSidebarSameNamedGroups(t *testing.T) {
sel := "a1"
sections := []SidebarSection{
{Title: "Court", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "a1", Label: "Edict"}}}}},
{Title: "Chapel", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "b1", Label: "Hymn"}}}}},
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Sidebar(c, &sel, sections, SidebarOptions{})
}, 400, 500)
if err := tt.Click("Drafts"); err != nil {
t.Fatal(err)
}
if tt.HasText("Edict") || !tt.HasText("Hymn") {
t.Fatalf("same-named groups share state: %q", tt.Texts())
}
}
func TestSidebarSnapshot(t *testing.T) {
snapshot(t, "sidebar", 420, 360, func(c *ui.Context) {
a, b := "opera", "starred"
ui.Row(c).Fill().Gap(24).Children(func() {
Sidebar(c, &a, sidebarSample(), SidebarOptions{})
Sidebar(c, &b, sidebarSample(), SidebarOptions{Compact: true})
})
})
}
+174
View File
@@ -0,0 +1,174 @@
package navigation
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Step is one stage of Steps.
type Step struct {
Title string
Description string
// Error marks the step as failed, whatever its place.
Error bool
// Clickable lets the user go to the step.
Clickable bool
}
// StepsOptions configure Steps.
type StepsOptions struct {
Vertical bool
// Label names the steps for assistive technology.
Label string
}
type stepsState int
const (
stepsWaiting stepsState = iota
stepsCurrent
stepsDone
stepsFailed
)
// Steps shows a process whose step *current is under way: earlier steps are done.
func Steps(c *ui.Context, current *int, steps []Step, opts StepsOptions) core.ChoiceResult {
if len(steps) == 0 {
panic("mujica: Steps needs at least one step")
}
if *current < 0 || *current > len(steps) {
panic("mujica: Steps current out of range")
}
u := core.Density(c).Unit()
root := ui.Row(c).AlignItems(ui.Start).Gap(u * 2).FocusGroup(ui.Horizontal | ui.Vertical).Role(ui.RoleList)
if opts.Vertical {
root.Column().AlignItems(ui.Stretch).Gap(0)
}
if opts.Label != "" {
root.Label(opts.Label)
}
res := core.ChoiceResult{Element: root}
root.Children(func() {
for i, s := range steps {
if s.Title == "" {
panic("mujica: Step needs a Title")
}
st := stepsWaiting
switch {
case s.Error:
st = stepsFailed
case i < *current:
st = stepsDone
case i == *current:
st = stepsCurrent
}
if !opts.Vertical && i > 0 {
stepsLine(c, steps, i-1, *current)
}
if stepsItem(c, i, len(steps), s, st, opts.Vertical, i < len(steps)-1 && opts.Vertical, i < *current) && i != *current {
slog.Debug("mujica: steps choose", "from", *current, "to", i)
*current, res = i, core.NewChoiceResult(res.Element, true)
}
}
})
return res
}
// stepsItem builds one step and reports a click on it.
func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, doneLine bool) bool {
k := core.Tokens(c)
u := core.Density(c).Unit()
var e *ui.Element
if s.Clickable {
e = ui.ButtonBase(c).Cursor(ui.CursorPointer)
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Hovered() {
e.Background(k.SurfaceHover)
}
internal.Ease(e, core.Motion(c, theme.StateDuration), k.Text)
} else {
e = ui.Row(c).Shrink(0).Role(ui.RoleListItem)
}
e.AlignItems(ui.Stretch).Justify(ui.Start).Gap(u * 3).Padding(u).Radius(theme.ControlRadius).
Label(fmt.Sprintf(core.Msg(c, "steps.item"), i+1, n, s.Title, core.Msg(c, stepsStateKey[st])))
e.Children(func() {
ui.Column(c).AlignItems(ui.Center).Gap(u).Shrink(0).Children(func() {
stepsMarker(c, i, st)
if tail {
ui.Box(c).Width(2).Grow(1).MinHeight(u * 4).Background(stepsLineColor(k, doneLine)).Radius(1)
}
})
texts := ui.Column(c).Gap(u*0.5).MinWidth(0).Padding(2, 0, 0, 0)
if vertical {
texts.Padding(2, 0, u*4, 0)
}
texts.Children(func() {
title := ui.Text(c, s.Title).SingleLine()
switch st {
case stepsCurrent:
title.FontWeight(600).TextColor(k.Text)
case stepsFailed:
title.FontWeight(600).TextColor(k.Danger)
case stepsDone:
title.TextColor(k.Text)
default:
title.TextColor(k.TextMuted)
}
if s.Description != "" {
ui.Text(c, s.Description).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).MaxWidth(240)
}
})
})
return s.Clickable && e.Clicked()
}
var stepsStateKey = map[stepsState]string{
stepsWaiting: "steps.waiting", stepsCurrent: "steps.current", stepsDone: "steps.done", stepsFailed: "steps.error",
}
func stepsMarker(c *ui.Context, i int, st stepsState) {
k := core.Tokens(c)
m := ui.Box(c).Size(24, 24).Radius(12).Center().Shrink(0).Role(ui.RoleNone)
var icon string
switch st {
case stepsDone:
m.Background(k.Accent).TextColor(k.OnAccent)
icon = "check"
case stepsCurrent:
m.Background(k.Surface).Border(2, k.AccentText).TextColor(k.AccentText).FontWeight(700)
case stepsFailed:
m.Background(k.Surface).Border(2, k.Danger).TextColor(k.Danger)
icon = "x"
default:
m.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.TextMuted)
}
m.Children(func() {
if icon != "" {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
} else {
ui.Text(c, strconv.Itoa(i+1)).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
}
})
}
// stepsLine joins step i to the next in the horizontal layout.
func stepsLine(c *ui.Context, steps []Step, i, current int) {
k := core.Tokens(c)
ui.Box(c).Height(2).Grow(1).MinWidth(24).Margin(core.Density(c).Unit()+11, 0, 0, 0).Radius(1).
Background(stepsLineColor(k, i < current && !steps[i].Error)).Role(ui.RoleNone)
}
func stepsLineColor(k theme.Tokens, done bool) ui.Color {
if done {
return k.AccentText
}
return k.Border
}
+111
View File
@@ -0,0 +1,111 @@
package navigation
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func stepsSample() []Step {
return []Step{
{Title: "Account", Clickable: true},
{Title: "Profile", Clickable: true},
{Title: "Payment"},
{Title: "Confirm"},
}
}
func TestStepsClickable(t *testing.T) {
cur, changes := 2, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Steps(c, &cur, stepsSample(), StepsOptions{}).Changed() {
changes++
}
}, 700, 120)
_ = tt.Click("Confirm")
if cur != 2 || changes != 0 {
t.Fatalf("non-clickable step chosen: %d", cur)
}
if err := tt.Click("Account"); err != nil {
t.Fatal(err)
}
if cur != 0 || changes != 1 {
t.Fatalf("click: cur %d changes %d", cur, changes)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if cur != 1 || changes != 2 {
t.Fatalf("keyboard: cur %d changes %d", cur, changes)
}
}
func TestStepsStatesNamed(t *testing.T) {
cur := 1
steps := stepsSample()
steps[2].Error = true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Steps(c, &cur, steps, StepsOptions{Vertical: true})
}, 400, 400)
for _, name := range []string{
"Step 1 of 4: Account, done",
"Step 2 of 4: Profile, current",
"Step 3 of 4: Payment, failed",
"Step 4 of 4: Confirm, waiting",
} {
if _, ok := tt.Find(name); !ok {
t.Errorf("missing %q in %q", name, tt.Texts())
}
}
// All done: current past the last step.
cur = 4
tt.Frame()
if _, ok := tt.Find("Step 4 of 4: Confirm, done"); !ok {
t.Errorf("finished steps: %q", tt.Texts())
}
}
func TestStepsInvalid(t *testing.T) {
for name, tc := range map[string]struct {
cur int
steps []Step
}{
"empty": {0, nil},
"range": {5, stepsSample()},
"title": {0, []Step{{}}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Steps(c, &tc.cur, tc.steps, StepsOptions{})
}, 300, 100, 1)
})
}
}
func TestStepsSnapshot(t *testing.T) {
snapshot(t, "steps", 640, 380, func(c *ui.Context) {
a, b := 2, 1
steps := []Step{
{Title: "Account", Description: "Name and seal"},
{Title: "Profile", Description: "Titles and house"},
{Title: "Payment", Description: "Treasury"},
{Title: "Confirm"},
}
ui.Column(c).Padding(16).Gap(24).Children(func() {
Steps(c, &a, steps, StepsOptions{})
v := append([]Step(nil), steps...)
v[2].Error = true
v[2].Description = "The card was declined"
Steps(c, &b, v, StepsOptions{Vertical: true})
})
})
}
+171
View File
@@ -0,0 +1,171 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Tab is one tab of Tabs.
type Tab struct {
// Label is required; it names the tab and its panel.
Label string
Icon *ui.SVG
Disabled bool
// Panel builds the tab's content while it is chosen; nil shows none.
Panel func()
}
// TabsOptions configure Tabs.
type TabsOptions struct {
// Vertical stacks the tabs at the start with the panel beside them.
Vertical bool
// Label names the tab list for assistive technology.
Label string
}
// Tabs shows tabs choosing *selected and the chosen tab's panel. Disabled tabs are skipped by the arrows.
func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) core.ChoiceResult {
if len(tabs) == 0 {
panic("mujica: Tabs needs at least one tab")
}
if *selected < 0 || *selected >= len(tabs) {
panic("mujica: Tabs selected index out of range")
}
if tabs[*selected].Disabled {
panic("mujica: Tabs selected tab is disabled")
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
pad := theme.FocusGap + theme.FocusWidth
// TabsBase only sees enabled tabs, so the arrows skip disabled ones.
var enabled []int
at := 0
for i, t := range tabs {
if !t.Disabled {
if i == *selected {
at = len(enabled)
}
enabled = append(enabled, i)
}
}
root := ui.Column(c).AlignItems(ui.Stretch)
if root.IsDisabled() {
for _, key := range []ui.Key{ui.KeyRight, ui.KeyDown, ui.KeyLeft, ui.KeyUp, ui.KeyHome, ui.KeyEnd} {
root.Shortcut(0, key)
}
}
if opts.Vertical {
root.Row()
}
res := core.ChoiceResult{Element: root}
root.Children(func() {
var strip *ui.Element
if opts.Vertical {
strip = ui.Scroll(c).Shrink(0).Padding(pad)
} else {
strip = ui.ScrollHorizontal(c).Shrink(0).MinWidth(0).Padding(pad, pad, 0, pad)
}
strip.Draw(func(p *ui.Painter, r ui.Rect) {
if opts.Vertical {
p.Fill(ui.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, k.Border, 0)
} else {
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0)
}
})
strip.Children(func() {
base := ui.TabsBase(c, &at, len(enabled))
list := base.List.Gap(u)
if opts.Vertical {
list.Column().AlignItems(ui.Stretch)
}
if opts.Label != "" {
list.Label(opts.Label)
}
built := make([]*ui.Element, len(tabs))
list.Children(func() {
n := 0
for i, t := range tabs {
var tab *ui.Element
if t.Disabled {
tab = ui.Row(c).Shrink(0).Role(ui.RoleTab).Disabled(true)
} else {
tab = base.Tab(n)
n++
}
TabsStyle(c, tab, t, i == enabled[at], opts.Vertical)
built[i] = tab
}
})
if !list.IsDisabled() && list.Changed() {
prev := *selected
*selected = enabled[at]
res = core.NewChoiceResult(res.Element, true)
built[*selected].ScrollIntoView()
slog.Debug("mujica: tabs change", "from", prev, "to", *selected)
}
})
panel := ui.Column(c).MinWidth(0).Padding(u * 4).Role(ui.RoleGroup).Label(tabs[*selected].Label)
if opts.Vertical {
panel.Grow(1)
}
if p := tabs[*selected].Panel; p != nil {
panel.Children(p)
}
})
return res
}
func TabsStyle(c *ui.Context, tab *ui.Element, t Tab, on, vertical bool) {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
tab.MinHeight(d.ControlHeight()).Padding(0, u*4).Gap(u * 2).AlignItems(ui.Center).Cursor(ui.CursorPointer)
if vertical {
tab.Radius(theme.ControlRadius, 0, 0, theme.ControlRadius)
} else {
tab.Radius(theme.ControlRadius, theme.ControlRadius, 0, 0)
}
if t.Disabled {
tab.Cursor(ui.CursorNotAllowed)
}
if t.Label == "" {
panic("mujica: Tab needs a Label")
}
fg := k.TextMuted
switch {
case on:
tab.Background(k.Selection).FontWeight(600)
fg = k.Text
case !t.Disabled && tab.Pressed():
tab.Background(k.Selection)
fg = k.Text
case !t.Disabled && tab.Hovered():
tab.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(tab, core.Motion(c, theme.StateDuration), fg)
tab.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if on {
if vertical {
p.Fill(ui.Rect{X: r.X + r.W - 2, Y: r.Y, W: 2, H: r.H}, k.AccentText, 0)
} else {
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, k.AccentText, 0)
}
}
if tab.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
tab.Children(func() {
if t.Icon != nil {
ui.Icon(c, t.Icon).Size(16, 16)
}
ui.Text(c, t.Label).SingleLine()
})
}
+227
View File
@@ -0,0 +1,227 @@
package navigation
import (
"image/color"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func tabsSample() []Tab {
return []Tab{
{Label: "General", Panel: func() {}},
{Label: "Fonts", Disabled: true},
{Label: "Colors"},
{Label: "Advanced"},
}
}
func TestTabsClickAndPanel(t *testing.T) {
sel, changes := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ts := tabsSample()
ts[0].Panel = func() { ui.Text(c, "General page") }
ts[2].Panel = func() { ui.Text(c, "Colors page") }
if Tabs(c, &sel, ts, TabsOptions{Label: "Settings"}).Changed() {
changes++
}
}, 500, 200)
if !tt.HasText("General page") || tt.HasText("Colors page") {
t.Fatalf("panel of tab 0 not shown: %q", tt.Texts())
}
if err := tt.Click("Colors"); err != nil {
t.Fatal(err)
}
if sel != 2 || changes != 1 || !tt.HasText("Colors page") {
t.Fatalf("click Colors: sel %d changes %d texts %q", sel, changes, tt.Texts())
}
_ = tt.Click("Fonts")
if sel != 2 || changes != 1 {
t.Fatalf("disabled tab chosen: sel %d changes %d", sel, changes)
}
}
func TestTabsKeyboardSkipsDisabled(t *testing.T) {
for _, vertical := range []bool{false, true} {
sel := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Tabs(c, &sel, tabsSample(), TabsOptions{Vertical: vertical})
}, 500, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("General") {
t.Fatalf("vertical=%v: Tab did not focus the chosen tab", vertical)
}
next := ui.KeyRight
if vertical {
next = ui.KeyDown
}
tt.Key(0, next)
if sel != 2 || !tt.Focused("Colors") {
t.Fatalf("vertical=%v: arrow chose %d, want 2 skipping disabled", vertical, sel)
}
tt.Key(0, ui.KeyEnd)
if sel != 3 {
t.Fatalf("vertical=%v: End chose %d", vertical, sel)
}
tt.Key(0, next)
if sel != 0 {
t.Fatalf("vertical=%v: wrap chose %d", vertical, sel)
}
}
}
func TestTabsOverflowScrolls(t *testing.T) {
sel := 0
var tabs []Tab
for _, l := range []string{"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel"} {
tabs = append(tabs, Tab{Label: l})
}
var w float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(240).Children(func() {
w = Tabs(c, &sel, tabs, TabsOptions{}).Element.Bounds().W
})
}, 400, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
tt.Frame()
r, ok := tt.Find("Hotel")
if sel != 7 || !ok || r.X+r.W > 240 {
t.Fatalf("last tab not scrolled into view: sel %d rect %+v", sel, r)
}
if w > 240 {
t.Fatalf("tabs widened their parent to %v", w)
}
}
func TestTabsInvalid(t *testing.T) {
for name, tc := range map[string]struct {
sel int
tabs []Tab
}{
"empty": {0, nil},
"range": {4, tabsSample()},
"disabled": {1, tabsSample()},
"label": {0, []Tab{{Icon: icons.Must("star")}}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Tabs(c, &tc.sel, tc.tabs, TabsOptions{})
}, 300, 100, 1)
})
}
}
func tabsView(c *ui.Context) {
sel, vsel := 0, 2
ui.Column(c).Padding(16).Gap(16).Children(func() {
Tabs(c, &sel, []Tab{
{Label: "Overview", Icon: icons.Must("house"), Panel: func() {
ui.Text(c, "The chosen tab's panel.")
}},
{Label: "Members", Icon: icons.Must("user")},
{Label: "Archive", Disabled: true},
{Label: "Settings", Icon: icons.Must("settings")},
}, TabsOptions{})
Tabs(c, &vsel, []Tab{
{Label: "Profile"},
{Label: "Security", Disabled: true},
{Label: "Billing", Panel: func() { ui.Text(c, "Vertical layout.") }},
}, TabsOptions{Vertical: true})
})
}
func TestTabsSnapshot(t *testing.T) {
snapshot(t, "tabs", 520, 300, tabsView)
for _, d := range []theme.Density{theme.Compact, theme.Comfortable} {
snapshot(t, "tabs-"+strings.ToLower(d.String()), 520, 340, func(c *ui.Context) {
core.Use(c, core.Settings{Mode: tabsMode(c), Density: d})
tabsView(c)
})
}
}
func TestTabsStateSnapshots(t *testing.T) {
center := func(tt *ui.Tester, s string) (float32, float32) {
r, ok := tt.Find(s)
if !ok {
t.Fatalf("no %q", s)
}
return r.X + r.W/2, r.Y + r.H/2
}
snapshotAfter(t, "tabs-hover", 520, 300, tabsView, func(tt *ui.Tester) { tt.Move(center(tt, "Members")) })
snapshotAfter(t, "tabs-pressed", 520, 300, tabsView, func(tt *ui.Tester) { tt.Press(center(tt, "Settings")) })
snapshotAfter(t, "tabs-focus", 520, 300, tabsView, func(tt *ui.Tester) {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
})
}
// TestTabsStateColorsEase checks hover fades in over StateDuration, not at once.
func TestTabsStateColorsEase(t *testing.T) {
sel := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
Tabs(c, &sel, []Tab{{Label: "One"}, {Label: "Two"}}, TabsOptions{})
}, 300, 120)
r, _ := tt.Find("Two")
x, y := int(r.X-8), int(r.Y+r.H/2)
hover := tabsRGBA(theme.Light().SurfaceHover)
tt.Move(r.X+r.W/2, r.Y+r.H/2)
if got := tt.Image().RGBAAt(x, y); got == hover {
t.Fatal("hover color applied at once")
}
time.Sleep(theme.StateDuration + 40*time.Millisecond)
tt.Frame()
if got := tt.Image().RGBAAt(x, y); got != hover {
t.Fatalf("hover color %v after the transition, want %v", got, hover)
}
}
func tabsRGBA(c ui.Color) color.RGBA { return color.RGBA{c.R, c.G, c.B, c.A} }
// tabsMode recovers the mode snapshot applied, to add a density.
func tabsMode(c *ui.Context) core.Mode {
if core.Tokens(c).Dark {
return core.Dark
}
return core.Light
}
func TestTabsAncestorDisabledNavigation(t *testing.T) {
for _, key := range []ui.Key{ui.KeyRight, ui.KeyDown, ui.KeyEnd} {
selected, changes := 0, 0
disabled := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
if Tabs(c, &selected, tabsSample(), TabsOptions{}).Changed() {
changes++
}
})
}, 500, 200)
if err := tt.Click("General"); err != nil {
t.Fatal(err)
}
disabled = true
tt.Key(0, key)
if selected != 0 || changes != 0 {
t.Fatalf("disabled tab navigation: selected=%d events=%d", selected, changes)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package navigation
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolbarOptions configure a Toolbar.
type ToolbarOptions struct {
// Label names the toolbar for assistive technology.
Label string
}
// Toolbar is one Tab stop of actions; those that do not fit go to an overflow menu.
func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element {
k := core.Tokens(c)
tb := ui.Toolbar(c, fn).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
MinHeight(core.Density(c).ControlHeight() + 8)
if opts.Label != "" {
tb.Label(opts.Label)
}
return tb
}
// ToolbarButtonOptions configure a ToolbarButton.
type ToolbarButtonOptions struct {
Icon *ui.SVG
// ShowLabel shows the label beside the icon; without an icon it always shows.
ShowLabel bool
Disabled bool
}
// ToolbarButton is an action of a Toolbar; label names it and its overflow menu item.
func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.Element {
if label == "" {
panic("mujica: ToolbarButton needs a label")
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
text := opts.ShowLabel || opts.Icon == nil
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Gap(u * 2).
Radius(theme.ControlRadius).Label(label).Disabled(opts.Disabled).TextColor(k.Text)
if text {
b.Padding(0, u*3)
}
if !text {
b.Tooltip(label)
}
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case b.Pressed():
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
internal.Ease(b, core.Motion(c, theme.StateDuration), k.Text)
b.Children(func() {
if opts.Icon != nil {
ui.Icon(c, opts.Icon).Size(16, 16)
}
if text {
ui.Text(c, label).SingleLine()
}
})
return b
}
// ToolbarGroup keeps related actions together; the overflow menu sets groups apart.
func ToolbarGroup(c *ui.Context, fn func()) *ui.Element {
return ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit() * 0.5).Shrink(0).Role(ui.RoleGroup).Children(fn)
}
// ToolbarSeparator draws a thin rule between actions.
func ToolbarSeparator(c *ui.Context) {
ui.Box(c).Size(1, core.Density(c).ControlHeight()-12).Margin(0, core.Density(c).Unit()).Shrink(0).
Background(core.Tokens(c).Ornament).Role(ui.RoleNone)
}
+106
View File
@@ -0,0 +1,106 @@
package navigation
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
type toolbarLog struct{ clicks []string }
func (l *toolbarLog) view(width float32) func(c *ui.Context) {
return func(c *ui.Context) {
ui.Column(c).Width(width).Children(func() {
Toolbar(c, ToolbarOptions{Label: "Format"}, func() {
ToolbarGroup(c, func() {
for _, n := range []string{"Bold", "Italic", "Underline"} {
if ToolbarButton(c, n, ToolbarButtonOptions{Icon: icons.Must(map[string]string{"Bold": "bold", "Italic": "italic", "Underline": "underline"}[n])}).Clicked() {
l.clicks = append(l.clicks, n)
}
}
})
ToolbarSeparator(c)
if ToolbarButton(c, "Copy", ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() {
l.clicks = append(l.clicks, "Copy")
}
if ToolbarButton(c, "Delete", ToolbarButtonOptions{Icon: icons.Must("trash-2"), Disabled: true}).Clicked() {
l.clicks = append(l.clicks, "Delete")
}
if ToolbarButton(c, "Share", ToolbarButtonOptions{}).Clicked() {
l.clicks = append(l.clicks, "Share")
}
})
})
}
}
func TestToolbarClickAndArrows(t *testing.T) {
l := &toolbarLog{}
tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); l.view(600)(c) }, 700, 100)
if err := tt.Click("Italic"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyRight) // from Italic to Underline
tt.Key(0, ui.KeyRight) // Copy
tt.Key(0, ui.KeyRight) // Share, skipping disabled Delete
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeySpace)
_ = tt.Click("Delete")
if want := []string{"Italic", "Share", "Bold"}; !slices.Equal(l.clicks, want) {
t.Fatalf("clicks %q, want %q", l.clicks, want)
}
}
func TestToolbarOverflow(t *testing.T) {
l := &toolbarLog{}
// The snapshot's layout: MyGo caches the overflow chevrons by position.
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(16).Gap(16).Children(func() { l.view(180)(c) })
}, 520, 300)
tt.Frame()
if r, ok := tt.Find("Share"); ok && r.W > 0 {
t.Fatalf("Share should overflow, at %+v", r)
}
if err := tt.Click("More"); err != nil {
t.Fatal(err)
}
menu := tt.Menu()
if !slices.Contains(menu, "Share") || !slices.Contains(menu, "Copy") {
t.Fatalf("overflow menu %q", menu)
}
if err := tt.ChooseMenuItem("Share"); err != nil {
t.Fatal(err)
}
tt.Frame()
if !slices.Equal(l.clicks, []string{"Share"}) {
t.Fatalf("overflow choice clicked %q", l.clicks)
}
}
func TestToolbarButtonNeedsLabel(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolbarButton(c, "", ToolbarButtonOptions{Icon: icons.Must("copy")})
}, 200, 100, 1)
}
func TestToolbarSnapshot(t *testing.T) {
l := &toolbarLog{}
snapshot(t, "toolbar", 520, 140, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(16).Children(func() {
l.view(180)(c)
l.view(420)(c)
})
})
}