Merge branch 'worktree-agent-acb1218b465c60e42'
No files matched your search
@@ -0,0 +1,157 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"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 {
|
||||
*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 = msg(c, "breadcrumb.label")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := 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 + ", " + 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)
|
||||
link.TextColor(k.TextMuted)
|
||||
if link.Hovered() {
|
||||
link.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
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(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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize).
|
||||
Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).TextColor(k.TextMuted).Cursor(ui.CursorPointer)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
if b.Hovered() || *open {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
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) {
|
||||
panel.Margin(u, 0, 0, 0).Padding(u).Gap(u*0.5).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).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(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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"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 {
|
||||
*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 := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
|
||||
ww, wh := c.Size()
|
||||
back.Background(ui.RGBA(0, 0, 0, 0.4)).Justify(ui.Start)
|
||||
panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww-32).MaxHeight(wh*0.76).
|
||||
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Shadow(0, 12, 32, 0, ui.RGBA(0, 0, 0, 0.3)).AlignItems(ui.Stretch).Label(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(msg(c, "command_palette.search"))
|
||||
})
|
||||
ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
||||
if in.Changed() {
|
||||
st.hl = 0
|
||||
}
|
||||
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))
|
||||
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, 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(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, msg(c, hint)).FontSize(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 := Tokens(c)
|
||||
d := 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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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) {
|
||||
Use(c, 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 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) {
|
||||
Use(c, 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{})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 059 Tabs 标签页
|
||||
|
||||
## 用途
|
||||
|
||||
在同一区域内切换若干面板。标签列表与当前标签的面板一起构建;支持横向与纵向布局、禁用项和溢出滚动。Tabs 是视觉回归基准组件,快照覆盖三种密度。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Tabs(c, &tab, []mujica.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},
|
||||
}, mujica.TabsOptions{Label: "Settings"})
|
||||
```
|
||||
|
||||
签名:`func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) TabsResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`selected`:当前标签下标(从 0 开始)。越界或指向禁用标签时 panic。`tabs` 为空时 panic。
|
||||
|
||||
`Tab`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 标签文字,也是面板的无障碍名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 标签文字前的 16 DIP 图标。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不可点击、不进入方向键序列。 |
|
||||
| `Panel` | `func()` | nil | 选中时构建的面板内容;nil 显示空面板。 |
|
||||
|
||||
`TabsOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Vertical` | `bool` | false | 标签竖排在左侧,面板在右侧。 |
|
||||
| `Label` | `string` | "" | 标签列表的无障碍名称。 |
|
||||
|
||||
返回 `TabsResult`,内嵌包含标签与面板的 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 选中:Selection 背景、2 DIP AccentText 边线(横向在底边,纵向在右边)、600 字重,三者同时标记。
|
||||
- 悬停:SurfaceHover 背景;按下:Selection 背景。
|
||||
- 焦点:2 DIP Focus 焦点环,外留 2 DIP。
|
||||
- 禁用:MyGo 统一降低不透明度,不响应指针与键盘。
|
||||
- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧用户选择了新标签(点击或方向键)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入标签列表,落在当前标签;再按 Tab 离开。
|
||||
- ←/→、↑/↓:选择上一个/下一个可用标签,跳过禁用项,首尾循环;焦点随选择移动。
|
||||
- Home / End:第一个 / 最后一个可用标签。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo `TabsBase` 的方向键不知道禁用项(`ui/base.go:192` 直接选择 i±1)。本组件只把可用标签交给 `TabsBase`,禁用标签以普通 `RoleTab` 行显示,因此禁用标签不在 MyGo 的标签计数内。
|
||||
- MyGo 没有 tabpanel 角色与 aria-controls 式关联;面板以 `RoleGroup` 呈现,名称取自所选标签。
|
||||
- 不提供标签关闭、拖动排序。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 060 SegmentedControl 分段控件
|
||||
|
||||
## 用途
|
||||
|
||||
在少量互斥选项之间切换视图或范围,如“日 / 周 / 月”或“列表 / 网格”。基于 MyGo `SegmentedBase`,辅助技术看到单选组。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.SegmentedControl(c, &view, []mujica.Segment{
|
||||
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
|
||||
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
|
||||
}, mujica.SegmentedControlOptions{Label: "View"})
|
||||
```
|
||||
|
||||
签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
`selected`:当前分段下标;越界时 panic。`segments` 为空时 panic。
|
||||
|
||||
`Segment`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 分段文字与无障碍名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 文字前的图标。 |
|
||||
| `HideLabel` | `bool` | false | 只显示图标,`Label` 作为名称与提示文字;没有 `Icon` 时 panic。 |
|
||||
|
||||
`SegmentedControlOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Equal` | `bool` | false | 所有分段等宽(取最宽者);否则按内容自适应。 |
|
||||
| `Label` | `string` | "" | 单选组的无障碍名称。 |
|
||||
| `Disabled` | `bool` | false | 整组禁用。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 选中:Accent 填充、OnAccent 文字、600 字重。
|
||||
- 悬停 SurfaceHover,按下 Selection;焦点环 2 DIP。
|
||||
- 禁用:整组降低不透明度,点击与方向键不改变值。
|
||||
|
||||
## 事件
|
||||
|
||||
- 返回元素的 `Changed()`:本帧用户选择了新分段。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入控件,落在当前分段(一个 Tab 停靠点)。
|
||||
- 方向键:选择上一个/下一个分段并循环;Home / End 到两端。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不支持单个分段禁用:MyGo `SegmentedBase` 的焦点组在方向键移动时直接点击目标分段。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 061 Breadcrumb 面包屑
|
||||
|
||||
## 用途
|
||||
|
||||
显示当前位置的层级路径。最后一项为当前页(非链接),前面各项可点击返回;层级过多时中间项折叠到“…”菜单;过长名称以省略号截断,悬停显示完整名称。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok {
|
||||
path = path[:i+1]
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) BreadcrumbResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`items` 为空时 panic。
|
||||
|
||||
`BreadcrumbItem`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前的 14 DIP 图标。 |
|
||||
|
||||
`BreadcrumbOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MaxItems` | `int` | 0(即 4) | 最多显示的项数;超出时保留第一项和最后 `MaxItems-2` 项,其余折叠。小于 3 时 panic。 |
|
||||
| `MaxItemWidth` | `float32` | 0(即 160 DIP) | 单项文字最大宽度,超出以省略号截断。 |
|
||||
| `Label` | `string` | 内置“路径导航” | 整组的无障碍名称。 |
|
||||
|
||||
返回 `BreadcrumbResult`,内嵌 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 链接:TextMuted 文字,悬停 SurfaceHover 背景与 Text 文字,焦点环 2 DIP。
|
||||
- 当前项:Text 文字、600 字重,无障碍名称附“当前页”。
|
||||
- 分隔:旧金(Ornament)小箭头,不进入无障碍树。
|
||||
- 折叠按钮“…”:打开弹层列出被折叠的项,弹层打开时焦点在第一项。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Chosen() (int, bool)`:本帧被激活的项下标(含折叠菜单中的项)。组件不修改路径,由调用方处理。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab / Shift+Tab:在各链接与折叠按钮之间移动;Enter 或 Space 激活。
|
||||
- 折叠菜单:Tab 在项之间移动,Enter 选择,Escape 关闭并把焦点还给“…”按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 完整名称提示对每一项都提供,MyGo 不报告文字是否被截断。
|
||||
- 未复用 MyGo `ui.Breadcrumbs`:它不支持折叠与提示,本组件用 `ButtonBase`、`PopoverBase` 组合实现。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 062 Pagination 分页
|
||||
|
||||
## 用途
|
||||
|
||||
在分页数据之间移动:页码、上一页、下一页、页容量选择与跳页。总数变化后,超出范围的页码自动修正到最后一页并报告变化。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() {
|
||||
load(page, size)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`page`:当前页,从 1 开始;每帧被限制在 `[1, 总页数]`。`pageSize`:每页条数,必须为正,否则 panic。
|
||||
|
||||
`PaginationOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Total` | `int` | 0 | 记录总数;为负 panic。为 0 时仍显示 1 页。 |
|
||||
| `PageSizes` | `[]int` | nil | 可选的页容量;nil 隐藏选择器。非 nil 时须包含 `*pageSize`,否则 panic。 |
|
||||
| `Jump` | `bool` | false | 显示跳页输入框。 |
|
||||
| `Siblings` | `int` | 0(即 1) | 当前页两侧显示的页码数;为负 panic。 |
|
||||
| `Disabled` | `bool` | false | 整体禁用。 |
|
||||
|
||||
返回 `PaginationResult`,内嵌 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 当前页:Accent 填充、OnAccent 文字、600 字重,名称附“当前”。
|
||||
- 页码按钮:Border 细边框,悬停 SurfaceHover 与 ControlBorder 边框;首页时“上一页”禁用,末页时“下一页”禁用。
|
||||
- 页码多时首尾保留,中间以“…”省略,按钮数固定为 `2×Siblings+5`,位置稳定。
|
||||
- 跳页错误:输入非数字或超出范围时边框变为 Danger,并在旁显示有效范围;输入保留,可直接修改。
|
||||
- 修改页容量时,保持原页第一条记录可见。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧页码或页容量改变(用户操作或总数变化导致的修正)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 页码按钮组是一个 Tab 停靠点:←/→ 移动,Home / End 到两端,Enter 或 Space 跳到该页。
|
||||
- 页容量:↑/↓ 打开并移动,Enter 选择,Escape 关闭。
|
||||
- 跳页框:输入页码后按 Enter。
|
||||
|
||||
## 限制
|
||||
|
||||
- 页容量选择基于 MyGo `SelectBase`,弹层由 MujicaUI 绘制。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 063 Steps 步骤条
|
||||
|
||||
## 用途
|
||||
|
||||
显示多步流程的进度:已完成、当前、等待和出错四种状态;横向或纵向排列;每一步是否可点击由调用方决定。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Steps(c, ¤t, []mujica.Step{
|
||||
{Title: "Account", Clickable: true},
|
||||
{Title: "Profile", Description: "Name and seal"},
|
||||
{Title: "Confirm"},
|
||||
}, mujica.StepsOptions{Label: "Sign-up"})
|
||||
```
|
||||
|
||||
签名:`func Steps(c *ui.Context, current *int, steps []Step, opts StepsOptions) StepsResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`current`:进行中的步骤下标;之前的步骤视为已完成。等于 `len(steps)` 表示全部完成;超出范围 panic。`steps` 为空时 panic。
|
||||
|
||||
`Step`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 步骤标题;为空时 panic。 |
|
||||
| `Description` | `string` | "" | 标题下的说明文字。 |
|
||||
| `Error` | `bool` | false | 标为出错,优先于位置推导的状态。 |
|
||||
| `Clickable` | `bool` | false | 允许用户点击跳到该步。 |
|
||||
|
||||
`StepsOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Vertical` | `bool` | false | 纵向排列,连接线在标记下方。 |
|
||||
| `Label` | `string` | "" | 整组的无障碍名称。 |
|
||||
|
||||
返回 `StepsResult`,内嵌 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 已完成:Accent 圆形标记内 OnAccent 勾号;之后的连接线为 AccentText。
|
||||
- 当前:AccentText 圆环与序号,标题 600 字重。
|
||||
- 等待:ControlBorder 圆环与 TextMuted 序号、标题。
|
||||
- 出错:Danger 圆环与叉号,标题 Danger 色。
|
||||
- 每步的无障碍名称为“第 n 步,共 m 步:标题,状态”,状态不只靠颜色表达。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧用户点击了可点击的步骤,`*current` 已更新。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 可点击的步骤组成一个 Tab 停靠点,方向键在其间移动,Enter 或 Space 跳到该步。不可点击的步骤不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 没有 step 或 aria-current 角色;步骤以列表项(可点击时为按钮)呈现,状态写入名称。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 064 Sidebar 侧边栏
|
||||
|
||||
## 用途
|
||||
|
||||
应用的源列表导航:分组、嵌套、图标、计数和选中项,分组与嵌套组可展开折叠;紧凑模式只显示图标。基于 MyGo `ui.Sidebar`,保留其键盘、首字母跳转与树形无障碍结构。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Sidebar(c, &selected, []mujica.SidebarSection{
|
||||
{Title: "Mail", Items: []mujica.SidebarItem{
|
||||
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3},
|
||||
{Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}},
|
||||
}},
|
||||
}, mujica.SidebarOptions{})
|
||||
```
|
||||
|
||||
签名:`func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts SidebarOptions) SidebarResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`selected`:选中项的 ID。
|
||||
|
||||
`SidebarSection`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 分组标题,点击展开折叠;为空时 panic(紧凑模式不显示)。 |
|
||||
| `Items` | `[]SidebarItem` | nil | 分组内的项。 |
|
||||
|
||||
`SidebarItem`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `string` | 叶子项必填 | 选择时写入 `*selected`;叶子项为空时 panic。 |
|
||||
| `Label` | `string` | 必填 | 名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前图标;紧凑模式下必填。 |
|
||||
| `Count` | `int` | 0 | 大于 0 时在右侧显示计数,超过 999 显示 “999+”。 |
|
||||
| `Items` | `[]SidebarItem` | nil | 非 nil 时该项成为可展开折叠的嵌套组,子项缩进。 |
|
||||
|
||||
`SidebarOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Compact` | `bool` | false | 图标栏:隐藏文字与组标题,组之间以旧金细线分隔,名称作为悬停提示。 |
|
||||
| `Width` | `float32` | 0(240,紧凑时 56) | 宽度,DIP。高度填满父容器。 |
|
||||
|
||||
返回 `SidebarResult`,内嵌 `*ui.Element`。展开折叠状态由组件保存。
|
||||
|
||||
## 状态
|
||||
|
||||
- 选中:Selection 背景、左侧 3 DIP AccentText 边线、600 字重,三者同时标记。
|
||||
- 悬停:SurfaceHover 背景。
|
||||
- 焦点:侧边栏获得焦点时,在选中项上画焦点环。
|
||||
- 计数:等宽数字,名称附计数(如 “Inbox (12)”)。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧用户选择了新项。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入侧边栏(一个停靠点)。
|
||||
- ↑/↓:上一项/下一项;Home / End:首项/末项;输入名称开头字母跳到匹配项。
|
||||
- 分组与嵌套组标题用指针点击展开折叠。
|
||||
|
||||
## 限制
|
||||
|
||||
- 复用 MyGo `ui.SidebarItem`,它用主题 Accent 绘制图标(`ui/sidebar.go:182`);深色主题 Accent 对比度不足,本组件在构建侧边栏时临时把 MyGo 主题的 Accent 换成 AccentText。
|
||||
- MyGo `SidebarItem` 总是追加名称文字(`ui/sidebar.go:212`);紧凑模式传入空名称,名称改由 `Label` 与提示提供,因此紧凑模式下首字母跳转不可用。
|
||||
- 嵌套组用 MyGo `SidebarSection` 实现,组标题本身不能被选中,且只能用指针展开折叠(MyGo 不给标题注册按键)。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 065 NavigationMenu 导航菜单
|
||||
|
||||
## 用途
|
||||
|
||||
站点式的顶层导航:直接链接与下拉分组(分组内可再分小节),用应用已有的 `*ui.Router` 标记当前路径并跳转;可选显示 MyGo 的后退、前进按钮。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.NavigationMenu(c, router, []mujica.NavigationLink{
|
||||
{Label: "Home", Path: "/"},
|
||||
{Label: "Library", Items: []mujica.NavigationLink{
|
||||
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
|
||||
}},
|
||||
}, mujica.NavigationMenuOptions{History: true})
|
||||
router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) })
|
||||
```
|
||||
|
||||
签名:`func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
`router` 为 nil 或 `links` 为空时 panic。路由状态由应用持有,组件只读 `Path()` 并调用 `Push`。
|
||||
|
||||
`NavigationLink`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 名称;为空时 panic。 |
|
||||
| `Path` | `string` | 叶子必填 | 点击后 `router.Push(Path)`;既无 `Path` 也无 `Items` 时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前图标。 |
|
||||
| `Description` | `string` | "" | 下拉面板中名称下方的说明。 |
|
||||
| `Items` | `[]NavigationLink` | nil | 非 nil 时为分组:顶层显示为下拉按钮,面板内显示为小节标题。 |
|
||||
|
||||
`NavigationMenuOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `History` | `bool` | false | 在前面显示后退、前进按钮(MyGo `BackButton` / `ForwardButton`)。 |
|
||||
| `Label` | `string` | 内置“导航菜单” | 链接组的无障碍名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 当前路径:路径等于链接,或位于其下(`/library` 覆盖 `/library/poems`;`/` 只匹配自身)。顶层项以 600 字重与底部 AccentText 线标记;面板中的当前项为 Selection 背景、左侧边线与 600 字重。
|
||||
- 悬停或展开:SurfaceHover 背景。焦点环 2 DIP。
|
||||
- 历史按钮在历史两端禁用;右键列出可前往的页面(MyGo 行为)。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无返回事件:跳转直接作用于 `router`,调用方读取 `router.Path()`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入顶层链接组;←/→ 在顶层项之间移动。
|
||||
- 在分组上按 ↓、Enter 或 Space 打开面板,焦点在第一个链接;↑/↓ 在面板内移动,Enter 跳转并关闭,Escape 关闭并把焦点还给分组按钮。
|
||||
- 历史:Cmd+[ / Cmd+](macOS),Alt+←/→(其他平台),以及鼠标侧键,由 MyGo 路由处理。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 历史按钮的提示文字固定为英文 “Back” / “Forward”(`ui/router.go:713`);本组件覆盖了无障碍名称,但提示仍为英文。
|
||||
- 历史按钮的外观来自 MyGo 主题按钮样式(经 `mujica.Use` 映射为 MujicaUI 颜色)。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 066 Menubar 菜单栏
|
||||
|
||||
## 用途
|
||||
|
||||
应用窗口内由 MujicaUI 自绘的菜单栏(不是系统菜单):多级子菜单、勾选项、单选项、分隔线、快捷键提示和完整键盘导航。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
res := mujica.Menubar(c, []mujica.MenubarMenu{
|
||||
{Label: "File", Items: []mujica.MenubarItem{
|
||||
{ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
|
||||
{Label: "Export", Items: []mujica.MenubarItem{{ID: "pdf", Label: "PDF"}}},
|
||||
}},
|
||||
{Label: "View", Items: []mujica.MenubarItem{
|
||||
{ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: wrap},
|
||||
}},
|
||||
}, mujica.MenubarOptions{})
|
||||
if id, ok := res.Chosen(); ok && id == "wrap" {
|
||||
wrap = !wrap
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`menus` 为空或标题为空时 panic。
|
||||
|
||||
`MenubarItem`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `string` | 必填(子菜单与分隔线除外) | `Chosen` 报告的标识。 |
|
||||
| `Label` | `string` | 必填(分隔线除外) | 名称。 |
|
||||
| `Kind` | `MenubarItemKind` | `MenubarAction` | `MenubarAction`、`MenubarCheck`、`MenubarRadio`、`MenubarSeparator`。子菜单必须为 `MenubarAction`。 |
|
||||
| `Checked` | `bool` | false | 勾选项与单选项的当前值,由调用方持有。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不可选择,方向键跳过。 |
|
||||
| `Shortcut` | `MenubarShortcut` | 无 | 仅显示的快捷键提示:macOS 显示 ⌘⇧ 等符号,其他平台显示 Ctrl+Shift+。键名无法表示时 panic。 |
|
||||
| `Items` | `[]MenubarItem` | nil | 非 nil 时为子菜单。 |
|
||||
|
||||
`MenubarOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 内置“菜单栏” | 菜单栏的无障碍名称。 |
|
||||
|
||||
返回 `MenubarResult`,内嵌菜单栏 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 打开的菜单标题:Selection 背景;高亮项:Selection 背景。
|
||||
- 勾选项:AccentText 勾号;单选项:AccentText 菱形标记;名称附“已勾选”。
|
||||
- 禁用项:降低不透明度,指针与键盘均跳过。
|
||||
- 弹层:6 DIP 圆角、Border 边框;靠近窗口边缘时左移,子菜单放不下时翻到父菜单左侧,过高时上移。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Chosen() (string, bool)`:本帧被选择的项 ID。选择后菜单关闭;勾选与单选由调用方更新 `Checked`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入菜单栏(一个停靠点);←/→ 在标题间移动;↓、↑、Enter 或 Space 打开菜单。
|
||||
- 菜单中:↑/↓ 移动(跳过分隔线与禁用项并循环),Home / End 到首末项。
|
||||
- →:打开子菜单;在普通项上则打开右侧下一个菜单。←:关闭子菜单;在顶层则打开左侧菜单。
|
||||
- Enter / Space:选择或打开子菜单。Escape:关闭一级。
|
||||
- 指针:点击标题打开;菜单打开时移到其他标题即切换;悬停子菜单项展开;点击外部关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 快捷键只做提示,不注册;用 `c.Shortcut` 处理。
|
||||
- MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。
|
||||
- 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。
|
||||
- 菜单打开期间,焦点保持在标题上,高亮项由组件管理(与 MyGo `SelectBase` 相同的方式)。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 067 Toolbar 工具栏
|
||||
|
||||
## 用途
|
||||
|
||||
一行操作按钮:分组、分隔线;宽度不足时,放不下的按钮从末尾起移入溢出菜单。基于 MyGo `ui.Toolbar`,保留其焦点组与溢出行为。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() {
|
||||
mujica.ToolbarGroup(c, func() {
|
||||
mujica.ToolbarButton(c, "Bold", mujica.ToolbarButtonOptions{Icon: icons.Must("bold")})
|
||||
mujica.ToolbarButton(c, "Italic", mujica.ToolbarButtonOptions{Icon: icons.Must("italic")})
|
||||
})
|
||||
mujica.ToolbarSeparator(c)
|
||||
if mujica.ToolbarButton(c, "Copy", mujica.ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() {
|
||||
copySelection()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
签名:
|
||||
|
||||
- `func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element`
|
||||
- `func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.Element`
|
||||
- `func ToolbarGroup(c *ui.Context, fn func()) *ui.Element`
|
||||
- `func ToolbarSeparator(c *ui.Context)`
|
||||
|
||||
## 参数
|
||||
|
||||
`ToolbarOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | "" | 工具栏的无障碍名称。 |
|
||||
|
||||
`ToolbarButton` 的 `label` 必填(为空时 panic):它是按钮名称、仅图标时的提示文字和溢出菜单中的文字。
|
||||
|
||||
`ToolbarButtonOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Icon` | `*ui.SVG` | nil | 16 DIP 图标。 |
|
||||
| `ShowLabel` | `bool` | false | 图标旁显示文字;没有图标时总是显示。 |
|
||||
| `Disabled` | `bool` | false | 禁用;溢出菜单中同样禁用。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 工具栏:Surface 背景、Border 边框、4 DIP 圆角。
|
||||
- 按钮:无底色,悬停 SurfaceHover,按下 Selection,焦点环 2 DIP。
|
||||
- 分隔线:旧金细竖线,不进入无障碍树。
|
||||
- 溢出:末尾显示 “»” 按钮,菜单中按原顺序列出被收起的操作,组之间以分隔线隔开。
|
||||
|
||||
## 事件
|
||||
|
||||
- `ToolbarButton(...).Clicked()`:点击、Enter / Space,或在溢出菜单中选择。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入工具栏(一个停靠点),再按 Tab 离开。
|
||||
- ←/→:在操作之间移动(跳过禁用项并循环);Home / End 到首末。
|
||||
- Enter / Space:执行。溢出按钮用 Enter、Space 或 ↓ 打开菜单。
|
||||
|
||||
## 限制
|
||||
|
||||
- 溢出菜单是 MyGo 打开的系统菜单(`ui/toolbar.go:45`),外观为系统样式,不是 MujicaUI 自绘。
|
||||
- 溢出按钮的无障碍名称固定为英文 “More”(`ui/toolbar.go:44`),按钮样式来自 MyGo 主题。
|
||||
@@ -0,0 +1,65 @@
|
||||
# 068 CommandPalette 命令面板
|
||||
|
||||
## 用途
|
||||
|
||||
用快捷键(默认 Cmd+K,非 macOS 为 Ctrl+K)打开的模态命令搜索框:输入过滤、分组显示、无结果提示、方向键选择,执行后关闭。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
res := mujica.CommandPalette(c, &open, []mujica.Command{
|
||||
{ID: "new", Label: "New document", Group: "File"},
|
||||
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}},
|
||||
}, mujica.CommandPaletteOptions{})
|
||||
if id, ok := res.Chosen(); ok {
|
||||
run(id)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandPaletteOptions) CommandPaletteResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`open`:是否显示;快捷键把它设为 true,执行、Escape 或点击遮罩设为 false。调用方也可直接设置。
|
||||
|
||||
`Command`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `string` | 必填 | `Chosen` 报告的标识;为空或重复时 panic。 |
|
||||
| `Label` | `string` | 必填 | 显示名称;为空时 panic。 |
|
||||
| `Group` | `string` | "" | 分组标题;分组按首次出现的顺序排列,组内保持原顺序。 |
|
||||
| `Keywords` | `[]string` | nil | 额外的搜索词。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前图标。 |
|
||||
|
||||
`CommandPaletteOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OpenMods` | `ui.Modifiers` | 0 | 打开快捷键的修饰键。 |
|
||||
| `OpenKey` | `ui.Key` | 0(即 `ui.Cmd`+K) | 打开快捷键;为 0 时忽略 `OpenMods`。 |
|
||||
|
||||
返回 `CommandPaletteResult`;关闭时其 `Element` 为 nil。
|
||||
|
||||
## 状态
|
||||
|
||||
- 打开:遮罩变暗,面板在窗口上部,焦点在搜索框;每次打开都清空搜索。
|
||||
- 搜索:不区分大小写,按空格分词,每个词须出现在名称、分组或关键词中。
|
||||
- 高亮项:Selection 背景、左侧 AccentText 边线、600 字重;键盘移动时滚入视野。
|
||||
- 无结果:显示“没有匹配的命令”,Enter 不执行任何命令。
|
||||
- 底部提示栏说明 ↑↓、Enter、Esc。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Chosen() (string, bool)`:本帧执行的命令 ID(Enter 或点击),同时关闭面板。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Cmd+K / Ctrl+K:打开(窗口级快捷键)。
|
||||
- 输入:过滤。↑/↓:移动高亮并循环。Enter:执行并关闭。Escape:关闭不执行,焦点回到打开前的元素。
|
||||
- Tab 限制在面板内(MyGo 模态对话框行为)。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
|
||||
- 搜索是子串匹配,不做模糊匹配或排序打分。
|
||||
@@ -0,0 +1,155 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func ExampleTabs() {
|
||||
tab := 0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Tabs(c, &tab, []mujica.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},
|
||||
}, mujica.TabsOptions{Label: "Settings"}).Changed() {
|
||||
fmt.Println("tab", tab)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSegmentedControl() {
|
||||
view := 0
|
||||
build := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.SegmentedControl(c, &view, []mujica.Segment{
|
||||
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
|
||||
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
|
||||
}, mujica.SegmentedControlOptions{Label: "View"}).Changed() {
|
||||
fmt.Println("view", view)
|
||||
}
|
||||
}
|
||||
ui.Render(build, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBreadcrumb() {
|
||||
path := []mujica.BreadcrumbItem{{Label: "Home", Icon: icons.Must("house")}, {Label: "Library"}, {Label: "Poems"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok {
|
||||
path = path[:i+1]
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePagination() {
|
||||
page, size := 1, 20
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Pagination(c, &page, &size, mujica.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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Steps(c, ¤t, []mujica.Step{
|
||||
{Title: "Account", Clickable: true},
|
||||
{Title: "Profile", Description: "Name and seal"},
|
||||
{Title: "Confirm"},
|
||||
}, mujica.StepsOptions{Label: "Sign-up"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSidebar() {
|
||||
selected := "inbox"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Sidebar(c, &selected, []mujica.SidebarSection{
|
||||
{Title: "Mail", Items: []mujica.SidebarItem{
|
||||
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3},
|
||||
{Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}},
|
||||
}},
|
||||
}, mujica.SidebarOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleNavigationMenu() {
|
||||
router := ui.NewRouter("/")
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.NavigationMenu(c, router, []mujica.NavigationLink{
|
||||
{Label: "Home", Path: "/"},
|
||||
{Label: "Library", Items: []mujica.NavigationLink{
|
||||
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
|
||||
}},
|
||||
}, mujica.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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
res := mujica.Menubar(c, []mujica.MenubarMenu{
|
||||
{Label: "File", Items: []mujica.MenubarItem{
|
||||
{ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
|
||||
{Label: "Export", Items: []mujica.MenubarItem{{ID: "pdf", Label: "PDF"}}},
|
||||
}},
|
||||
{Label: "View", Items: []mujica.MenubarItem{
|
||||
{ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: wrap},
|
||||
}},
|
||||
}, mujica.MenubarOptions{})
|
||||
if id, ok := res.Chosen(); ok && id == "wrap" {
|
||||
wrap = !wrap
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolbar() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() {
|
||||
mujica.ToolbarGroup(c, func() {
|
||||
mujica.ToolbarButton(c, "Bold", mujica.ToolbarButtonOptions{Icon: icons.Must("bold")})
|
||||
mujica.ToolbarButton(c, "Italic", mujica.ToolbarButtonOptions{Icon: icons.Must("italic")})
|
||||
})
|
||||
mujica.ToolbarSeparator(c)
|
||||
if mujica.ToolbarButton(c, "Copy", mujica.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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
res := mujica.CommandPalette(c, &open, []mujica.Command{
|
||||
{ID: "new", Label: "New document", Group: "File"},
|
||||
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}},
|
||||
}, mujica.CommandPaletteOptions{})
|
||||
if id, ok := res.Chosen(); ok {
|
||||
fmt.Println("run", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,462 @@
|
||||
package main
|
||||
|
||||
var navigationPages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
var navigationPages = []page{
|
||||
{ID: "059", Name: "Tabs", ZhName: "标签页", Category: "Navigation", Demo: navigationTabsDemo, Code: navigationTabsCode,
|
||||
Keys: "Tab enters the tab list at the chosen tab. Left/Right (Up/Down when vertical) choose the next enabled tab, skipping disabled ones and wrapping; Home and End choose the first and last."},
|
||||
{ID: "060", Name: "SegmentedControl", ZhName: "分段控件", Category: "Navigation", Demo: navigationSegmentedDemo, Code: navigationSegmentedCode,
|
||||
Keys: "One Tab stop. Arrow keys choose the previous or next segment and wrap; Home and End choose the ends."},
|
||||
{ID: "061", Name: "Breadcrumb", ZhName: "面包屑", Category: "Navigation", Demo: navigationBreadcrumbDemo, Code: navigationBreadcrumbCode,
|
||||
Keys: "Tab moves through the links and the fold button; Enter or Space activates. In the fold menu, Tab moves between items and Escape closes it, returning focus to the button."},
|
||||
{ID: "062", Name: "Pagination", ZhName: "分页", Category: "Navigation", Demo: navigationPaginationDemo, Code: navigationPaginationCode,
|
||||
Keys: "The page buttons are one Tab stop: Left/Right move, Home/End jump to the ends, Enter or Space goes to the page. The size select opens with Up/Down; the jump field goes on Enter."},
|
||||
{ID: "063", Name: "Steps", ZhName: "步骤条", Category: "Navigation", Demo: navigationStepsDemo, Code: navigationStepsCode,
|
||||
Keys: "Clickable steps are one Tab stop; arrow keys move between them and Enter or Space goes to the step."},
|
||||
{ID: "064", Name: "Sidebar", ZhName: "侧边栏", Category: "Navigation", Demo: navigationSidebarDemo, Code: navigationSidebarCode,
|
||||
Keys: "One Tab stop. Up/Down choose the previous or next item, Home/End the ends, typing the first letters of a label jumps to it. Section and group titles open and close with a click."},
|
||||
{ID: "065", Name: "NavigationMenu", ZhName: "导航菜单", Category: "Navigation", Demo: navigationMenuDemo, Code: navigationMenuCode,
|
||||
Keys: "Left/Right move between top links; Down or Enter opens a group with focus on its first link, Up/Down move inside, Escape closes. Back/Forward also answer Cmd+[ / Cmd+] (Alt+Left/Right off macOS)."},
|
||||
{ID: "066", Name: "Menubar", ZhName: "菜单栏", Category: "Navigation", Demo: navigationMenubarDemo, Code: navigationMenubarCode,
|
||||
Keys: "Tab reaches the bar; Left/Right move between menus, Down opens one. Up/Down/Home/End move through items, Right opens a submenu (or the next menu), Left closes it, Enter/Space chooses, Escape closes one level."},
|
||||
{ID: "067", Name: "Toolbar", ZhName: "工具栏", Category: "Navigation", Demo: navigationToolbarDemo, Code: navigationToolbarCode,
|
||||
Keys: "One Tab stop. Left/Right move between actions, Home/End to the ends, Enter or Space runs one. Actions that do not fit move to the » menu at the end."},
|
||||
{ID: "068", Name: "CommandPalette", ZhName: "命令面板", Category: "Navigation", Demo: navigationPaletteDemo, Code: navigationPaletteCode,
|
||||
Keys: "Cmd+K (Ctrl+K off macOS) opens it with focus in the search. Type to filter, Up/Down choose (wrapping), Enter runs and closes, Escape closes without running."},
|
||||
}
|
||||
|
||||
// navigationCheck is a check box that logs its changes.
|
||||
func navigationCheck(c *ui.Context, v *bool, label string, log func(string)) {
|
||||
was := *v
|
||||
ui.Checkbox(c, v, label)
|
||||
if *v != was {
|
||||
log(fmt.Sprintf("%s: %v", label, *v))
|
||||
}
|
||||
}
|
||||
|
||||
// navigationSlider is a labeled whole-number slider that logs its changes.
|
||||
func navigationSlider(c *ui.Context, v *float64, label string, lo, hi float64, log func(string)) {
|
||||
was := math.Round(*v)
|
||||
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("%s %.0f", label, math.Round(*v)))
|
||||
ui.StepSlider(c, v, lo, hi, 1).Width(160)
|
||||
})
|
||||
if math.Round(*v) != was {
|
||||
log(fmt.Sprintf("%s: %.0f", label, math.Round(*v)))
|
||||
}
|
||||
}
|
||||
|
||||
func navigationControls(c *ui.Context, fn func()) { ui.Row(c).Wrap().Gap(16).Children(fn) }
|
||||
|
||||
func navigationDemo(c *ui.Context, controls, body func()) {
|
||||
ui.Column(c).Gap(20).AlignItems(ui.Stretch).Children(func() {
|
||||
navigationControls(c, controls)
|
||||
body()
|
||||
})
|
||||
}
|
||||
|
||||
const navigationTabsCode = `mujica.Tabs(c, &tab, []mujica.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},
|
||||
}, mujica.TabsOptions{Label: "Settings"})`
|
||||
|
||||
func navigationTabsDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
tab int
|
||||
vertical, archive, many bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "059", func() state { return state{archive: true} })
|
||||
navigationDemo(c, func() {
|
||||
navigationCheck(c, &s.vertical, "Vertical", log)
|
||||
navigationCheck(c, &s.archive, "Disable Archive", log)
|
||||
navigationCheck(c, &s.many, "Many tabs (overflow)", log)
|
||||
}, func() {
|
||||
names := []string{"General", "Fonts", "Archive", "Advanced"}
|
||||
if s.many {
|
||||
names = append(names, "Seals", "Heraldry", "Ledgers", "Envoys", "Banquets", "Chapel")
|
||||
}
|
||||
var tabs []mujica.Tab
|
||||
for i, n := range names {
|
||||
tabs = append(tabs, mujica.Tab{Label: n, Disabled: n == "Archive" && s.archive, Panel: func() {
|
||||
ui.Text(c, fmt.Sprintf("The %s panel (tab %d).", n, i+1))
|
||||
}})
|
||||
}
|
||||
tabs[1].Icon = icons.Must("italic")
|
||||
s.tab = min(s.tab, len(tabs)-1)
|
||||
if tabs[s.tab].Disabled {
|
||||
s.tab = 0
|
||||
}
|
||||
ui.Column(c).Width(420).Children(func() {
|
||||
if mujica.Tabs(c, &s.tab, tabs, mujica.TabsOptions{Vertical: s.vertical, Label: "Settings"}).Changed() {
|
||||
log("Changed: tab " + names[s.tab])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigationSegmentedCode = `mujica.SegmentedControl(c, &view, []mujica.Segment{
|
||||
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
|
||||
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
|
||||
}, mujica.SegmentedControlOptions{Label: "View"})`
|
||||
|
||||
func navigationSegmentedDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
text, icon int
|
||||
equal, iconOnly, disab bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "060", func() state { return state{} })
|
||||
navigationDemo(c, func() {
|
||||
navigationCheck(c, &s.equal, "Equal widths", log)
|
||||
navigationCheck(c, &s.iconOnly, "Icons only", log)
|
||||
navigationCheck(c, &s.disab, "Disabled", log)
|
||||
}, func() {
|
||||
opts := mujica.SegmentedControlOptions{Equal: s.equal, Disabled: s.disab, Label: "Range"}
|
||||
ui.Row(c).Gap(24).AlignItems(ui.Center).Children(func() {
|
||||
if mujica.SegmentedControl(c, &s.text, []mujica.Segment{{Label: "Day"}, {Label: "Week"}, {Label: "Fortnight"}}, opts).Changed() {
|
||||
log(fmt.Sprintf("Changed: range %d", s.text))
|
||||
}
|
||||
opts.Label = "View"
|
||||
if mujica.SegmentedControl(c, &s.icon, []mujica.Segment{
|
||||
{Label: "List", Icon: icons.Must("list"), HideLabel: s.iconOnly},
|
||||
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: s.iconOnly},
|
||||
}, opts).Changed() {
|
||||
log(fmt.Sprintf("Changed: view %d", s.icon))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigationBreadcrumbCode = `if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok {
|
||||
path = path[:i+1]
|
||||
}`
|
||||
|
||||
var navigationBreadcrumbNames = []string{"Home", "Library", "Manuscripts", "Seventeenth century", "Correspondence of the court", "Letters to the Duchess", "Draft of the winter decree"}
|
||||
|
||||
func navigationBreadcrumbDemo(c *ui.Context, log func(string)) {
|
||||
type state struct{ depth, max float64 }
|
||||
s := ui.Local(c.Root(), "061", func() state { return state{depth: 6, max: 4} })
|
||||
navigationDemo(c, func() {
|
||||
navigationSlider(c, &s.depth, "Depth", 1, 7, log)
|
||||
navigationSlider(c, &s.max, "MaxItems", 3, 7, log)
|
||||
}, func() {
|
||||
var path []mujica.BreadcrumbItem
|
||||
for i, n := range navigationBreadcrumbNames[:int(s.depth)] {
|
||||
it := mujica.BreadcrumbItem{Label: n}
|
||||
if i == 0 {
|
||||
it.Icon = icons.Must("house")
|
||||
}
|
||||
path = append(path, it)
|
||||
}
|
||||
if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{MaxItems: int(s.max)}).Chosen(); ok {
|
||||
log("Chosen: " + path[i].Label)
|
||||
s.depth = float64(i + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const navigationPaginationCode = `if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() {
|
||||
load(page, size)
|
||||
}`
|
||||
|
||||
func navigationPaginationDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
page, size int
|
||||
total float64
|
||||
sizes, jump, disab bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "062", func() state { return state{page: 1, size: 20, total: 420, sizes: true, jump: true} })
|
||||
navigationDemo(c, func() {
|
||||
navigationSlider(c, &s.total, "Total", 0, 1000, log)
|
||||
navigationCheck(c, &s.sizes, "Page sizes", log)
|
||||
navigationCheck(c, &s.jump, "Jump", log)
|
||||
navigationCheck(c, &s.disab, "Disabled", log)
|
||||
}, func() {
|
||||
opts := mujica.PaginationOptions{Total: int(s.total), Jump: s.jump, Disabled: s.disab}
|
||||
if s.sizes {
|
||||
opts.PageSizes = []int{10, 20, 50}
|
||||
} else {
|
||||
s.size = 20
|
||||
}
|
||||
if mujica.Pagination(c, &s.page, &s.size, opts).Changed() {
|
||||
log(fmt.Sprintf("Changed: page %d, size %d", s.page, s.size))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const navigationStepsCode = `mujica.Steps(c, ¤t, []mujica.Step{
|
||||
{Title: "Account", Clickable: true},
|
||||
{Title: "Profile", Description: "Name and seal"},
|
||||
{Title: "Confirm"},
|
||||
}, mujica.StepsOptions{Label: "Sign-up"})`
|
||||
|
||||
func navigationStepsDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
current int
|
||||
vertical, failed, clickOK bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "063", func() state { return state{current: 1, clickOK: true} })
|
||||
steps := []mujica.Step{
|
||||
{Title: "Account", Description: "Name and seal"},
|
||||
{Title: "Profile", Description: "Titles and house"},
|
||||
{Title: "Payment", Description: "Treasury"},
|
||||
{Title: "Confirm"},
|
||||
}
|
||||
navigationDemo(c, func() {
|
||||
navigationCheck(c, &s.vertical, "Vertical", log)
|
||||
navigationCheck(c, &s.failed, "Payment failed", log)
|
||||
navigationCheck(c, &s.clickOK, "Done steps clickable", log)
|
||||
if mujica.Button(c, "Back", mujica.ButtonOptions{Variant: mujica.Secondary, Disabled: s.current == 0}).Clicked() {
|
||||
s.current--
|
||||
log(fmt.Sprintf("Back: step %d", s.current+1))
|
||||
}
|
||||
if mujica.Button(c, "Next", mujica.ButtonOptions{Disabled: s.current == len(steps)}).Clicked() {
|
||||
s.current++
|
||||
log(fmt.Sprintf("Next: step %d", s.current+1))
|
||||
}
|
||||
}, func() {
|
||||
for i := range steps {
|
||||
steps[i].Clickable = s.clickOK && i < s.current
|
||||
}
|
||||
steps[2].Error = s.failed
|
||||
if mujica.Steps(c, &s.current, steps, mujica.StepsOptions{Vertical: s.vertical, Label: "Sign-up"}).Changed() {
|
||||
log("Changed: step " + steps[s.current].Title)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const navigationSidebarCode = `mujica.Sidebar(c, &selected, []mujica.SidebarSection{
|
||||
{Title: "Mail", Items: []mujica.SidebarItem{
|
||||
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3},
|
||||
{Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}},
|
||||
}},
|
||||
}, mujica.SidebarOptions{})`
|
||||
|
||||
func navigationSidebarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
selected string
|
||||
compact bool
|
||||
unread float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "064", func() state { return state{selected: "inbox", unread: 12} })
|
||||
navigationDemo(c, func() {
|
||||
navigationCheck(c, &s.compact, "Compact", log)
|
||||
navigationSlider(c, &s.unread, "Unread", 0, 1200, log)
|
||||
}, func() {
|
||||
sections := []mujica.SidebarSection{
|
||||
{Title: "Library", Items: []mujica.SidebarItem{
|
||||
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: int(s.unread)},
|
||||
{ID: "starred", Label: "Starred", Icon: icons.Must("star")},
|
||||
{Label: "Projects", Items: []mujica.SidebarItem{
|
||||
{ID: "opera", Label: "Opera", Icon: icons.Must("folder")},
|
||||
{ID: "ballet", Label: "Ballet", Icon: icons.Must("folder")},
|
||||
}},
|
||||
}},
|
||||
{Title: "System", Items: []mujica.SidebarItem{
|
||||
{ID: "settings", Label: "Settings", Icon: icons.Must("settings")},
|
||||
{ID: "trash", Label: "Trash", Icon: icons.Must("trash-2")},
|
||||
}},
|
||||
}
|
||||
ui.Row(c).Height(360).Border(1, mujica.Tokens(c).Border).Radius(4).Clip().Children(func() {
|
||||
if mujica.Sidebar(c, &s.selected, sections, mujica.SidebarOptions{Compact: s.compact}).Changed() {
|
||||
log("Changed: " + s.selected)
|
||||
}
|
||||
ui.Text(c, "Showing "+s.selected).Padding(24)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigationMenuCode = `mujica.NavigationMenu(c, router, []mujica.NavigationLink{
|
||||
{Label: "Home", Path: "/"},
|
||||
{Label: "Library", Items: []mujica.NavigationLink{
|
||||
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
|
||||
}},
|
||||
}, mujica.NavigationMenuOptions{History: true})
|
||||
router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) })`
|
||||
|
||||
func navigationMenuDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
router *ui.Router
|
||||
history bool
|
||||
last string
|
||||
}
|
||||
s := ui.Local(c.Root(), "065", func() state { return state{router: ui.NewRouter("/"), history: true, last: "/"} })
|
||||
navigationDemo(c, func() {
|
||||
navigationCheck(c, &s.history, "Back and forward", log)
|
||||
}, func() {
|
||||
mujica.NavigationMenu(c, s.router, []mujica.NavigationLink{
|
||||
{Label: "Home", Path: "/", Icon: icons.Must("house")},
|
||||
{Label: "Library", Items: []mujica.NavigationLink{
|
||||
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
|
||||
{Label: "Letters", Path: "/library/letters", Description: "Correspondence"},
|
||||
{Label: "Archive", Items: []mujica.NavigationLink{
|
||||
{Label: "Older letters", Path: "/archive/letters"},
|
||||
}},
|
||||
}},
|
||||
{Label: "About", Path: "/about"},
|
||||
}, mujica.NavigationMenuOptions{History: s.history})
|
||||
if p := s.router.Path(); p != s.last {
|
||||
log(fmt.Sprintf("Path: %s → %s", s.last, p))
|
||||
s.last = p
|
||||
}
|
||||
ui.Column(c).Height(120).Children(func() {
|
||||
s.router.View(c, func(r *ui.Route) {
|
||||
r.Title(r.Path())
|
||||
ui.Text(c, "Page "+r.Path()).Padding(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigationMenubarCode = `res := mujica.Menubar(c, []mujica.MenubarMenu{
|
||||
{Label: "File", Items: []mujica.MenubarItem{
|
||||
{ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
|
||||
{Label: "Export", Items: []mujica.MenubarItem{{ID: "pdf", Label: "PDF"}}},
|
||||
}},
|
||||
{Label: "View", Items: []mujica.MenubarItem{
|
||||
{ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: wrap},
|
||||
}},
|
||||
}, mujica.MenubarOptions{})
|
||||
if id, ok := res.Chosen(); ok && id == "wrap" {
|
||||
wrap = !wrap
|
||||
}`
|
||||
|
||||
func navigationMenubarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
wrap, ruler, locked bool
|
||||
zoom string
|
||||
}
|
||||
s := ui.Local(c.Root(), "066", func() state { return state{wrap: true, zoom: "fit"} })
|
||||
navigationDemo(c, func() {
|
||||
navigationCheck(c, &s.locked, "Lock editing (disables Edit items)", log)
|
||||
}, func() {
|
||||
menus := []mujica.MenubarMenu{
|
||||
{Label: "File", Items: []mujica.MenubarItem{
|
||||
{ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
|
||||
{ID: "open", Label: "Open…", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyO}},
|
||||
{Label: "Export", Items: []mujica.MenubarItem{
|
||||
{ID: "pdf", Label: "PDF"},
|
||||
{ID: "png", Label: "Image"},
|
||||
{Label: "Archive", Items: []mujica.MenubarItem{{ID: "zip", Label: "Zip"}, {ID: "tar", Label: "Tarball"}}},
|
||||
}},
|
||||
{Kind: mujica.MenubarSeparator},
|
||||
{ID: "quit", Label: "Quit", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyQ}},
|
||||
}},
|
||||
{Label: "Edit", Items: []mujica.MenubarItem{
|
||||
{ID: "undo", Label: "Undo", Disabled: s.locked, Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyZ}},
|
||||
{ID: "redo", Label: "Redo", Disabled: s.locked, Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyZ}},
|
||||
}},
|
||||
{Label: "View", Items: []mujica.MenubarItem{
|
||||
{ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: s.wrap},
|
||||
{ID: "ruler", Label: "Ruler", Kind: mujica.MenubarCheck, Checked: s.ruler},
|
||||
{Kind: mujica.MenubarSeparator},
|
||||
{ID: "fit", Label: "Fit to window", Kind: mujica.MenubarRadio, Checked: s.zoom == "fit"},
|
||||
{ID: "actual", Label: "Actual size", Kind: mujica.MenubarRadio, Checked: s.zoom == "actual"},
|
||||
}},
|
||||
}
|
||||
ui.Column(c).Height(260).Border(1, mujica.Tokens(c).Border).Radius(4).Clip().Children(func() {
|
||||
if id, ok := mujica.Menubar(c, menus, mujica.MenubarOptions{}).Chosen(); ok {
|
||||
switch id {
|
||||
case "wrap":
|
||||
s.wrap = !s.wrap
|
||||
case "ruler":
|
||||
s.ruler = !s.ruler
|
||||
case "fit", "actual":
|
||||
s.zoom = id
|
||||
}
|
||||
log("Chosen: " + id)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("Word wrap %v · Ruler %v · Zoom %s", s.wrap, s.ruler, s.zoom)).Padding(16)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigationToolbarCode = `mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() {
|
||||
mujica.ToolbarGroup(c, func() {
|
||||
mujica.ToolbarButton(c, "Bold", mujica.ToolbarButtonOptions{Icon: icons.Must("bold")})
|
||||
mujica.ToolbarButton(c, "Italic", mujica.ToolbarButtonOptions{Icon: icons.Must("italic")})
|
||||
})
|
||||
mujica.ToolbarSeparator(c)
|
||||
if mujica.ToolbarButton(c, "Copy", mujica.ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() {
|
||||
copySelection()
|
||||
}
|
||||
})`
|
||||
|
||||
func navigationToolbarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
width float64
|
||||
labels bool
|
||||
noDeletes bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "067", func() state { return state{width: 520, noDeletes: true} })
|
||||
navigationDemo(c, func() {
|
||||
navigationSlider(c, &s.width, "Width", 120, 640, log)
|
||||
navigationCheck(c, &s.labels, "Show labels", log)
|
||||
navigationCheck(c, &s.noDeletes, "Disable Delete", log)
|
||||
}, func() {
|
||||
ui.Column(c).Width(float32(s.width)).Children(func() {
|
||||
mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() {
|
||||
button := func(label, icon string, disabled bool) {
|
||||
if mujica.ToolbarButton(c, label, mujica.ToolbarButtonOptions{Icon: icons.Must(icon), ShowLabel: s.labels, Disabled: disabled}).Clicked() {
|
||||
log("Clicked: " + label)
|
||||
}
|
||||
}
|
||||
mujica.ToolbarGroup(c, func() {
|
||||
button("Bold", "bold", false)
|
||||
button("Italic", "italic", false)
|
||||
button("Underline", "underline", false)
|
||||
})
|
||||
mujica.ToolbarSeparator(c)
|
||||
button("Copy", "copy", false)
|
||||
button("Image", "image", false)
|
||||
button("Delete", "trash-2", s.noDeletes)
|
||||
mujica.ToolbarSeparator(c)
|
||||
button("Settings", "settings", false)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigationPaletteCode = `res := mujica.CommandPalette(c, &open, []mujica.Command{
|
||||
{ID: "new", Label: "New document", Group: "File"},
|
||||
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}},
|
||||
}, mujica.CommandPaletteOptions{})
|
||||
if id, ok := res.Chosen(); ok {
|
||||
run(id)
|
||||
}`
|
||||
|
||||
func navigationPaletteDemo(c *ui.Context, log func(string)) {
|
||||
type state struct{ open, wasOpen bool }
|
||||
s := ui.Local(c.Root(), "068", func() state { return state{} })
|
||||
navigationDemo(c, func() {
|
||||
if mujica.Button(c, "Open command palette", mujica.ButtonOptions{Icon: icons.Must("command")}).Clicked() {
|
||||
s.open = true
|
||||
}
|
||||
ui.Text(c, "or press Cmd+K / Ctrl+K").TextColor(mujica.Tokens(c).TextMuted)
|
||||
}, func() {
|
||||
res := mujica.CommandPalette(c, &s.open, []mujica.Command{
|
||||
{ID: "new", Label: "New document", Group: "File", Icon: icons.Must("file")},
|
||||
{ID: "open", Label: "Open recent", Group: "File", Icon: icons.Must("folder")},
|
||||
{ID: "save", Label: "Save all", Group: "File", Icon: icons.Must("file-text")},
|
||||
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"theme", "night"}, Icon: icons.Must("moon")},
|
||||
{ID: "zoom-in", Label: "Zoom in", Group: "View", Icon: icons.Must("zoom-in")},
|
||||
{ID: "zoom-out", Label: "Zoom out", Group: "View", Icon: icons.Must("zoom-out")},
|
||||
{ID: "settings", Label: "Open settings", Group: "Application", Keywords: []string{"preferences"}, Icon: icons.Must("settings")},
|
||||
}, mujica.CommandPaletteOptions{})
|
||||
if s.open != s.wasOpen {
|
||||
log(fmt.Sprintf("Open: %v", s.open))
|
||||
s.wasOpen = s.open
|
||||
}
|
||||
if id, ok := res.Chosen(); ok {
|
||||
log("Run: " + id)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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 {
|
||||
*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
|
||||
}
|
||||
|
||||
// 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 := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = 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 {
|
||||
m.overlay(titles)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (m *menubarRun) title(i int, label string) *ui.Element {
|
||||
c, st := m.c, m.st
|
||||
k := Tokens(c)
|
||||
d := 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)
|
||||
}
|
||||
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 {
|
||||
m.keys(t)
|
||||
}
|
||||
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]
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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))
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"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
|
||||
}
|
||||
// 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.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-Density(c).Unit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// panel builds level l at (x, y), flipping left of parent when it does not fit.
|
||||
// It returns the panel and the row of its highlighted item.
|
||||
func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *ui.Element) {
|
||||
c, st := m.c, m.st
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
items := m.items(l)
|
||||
panel := ui.Column(c).Absolute().Padding(u).MinWidth(200).AlignItems(ui.Stretch).Role(ui.RolePopup).
|
||||
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Shadow(0, 6, 16, 0, ui.RGBA(0, 0, 0, 0.18))
|
||||
pb := panel.Bounds()
|
||||
ww, wh := c.Size()
|
||||
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)
|
||||
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 row.Clicked() {
|
||||
st.hl = append(st.hl[:l], j)
|
||||
m.activate(l, j)
|
||||
return
|
||||
}
|
||||
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 := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
name := it.Label
|
||||
if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked {
|
||||
name += ", " + msg(c, "menubar.checked")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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(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
|
||||
}
|
||||
|
||||
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), "+")
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type menubarApp struct {
|
||||
wrap bool
|
||||
zoom string
|
||||
chosen []string
|
||||
}
|
||||
|
||||
func (a *menubarApp) menus() []MenubarMenu {
|
||||
return []MenubarMenu{
|
||||
{Label: "File", Items: []MenubarItem{
|
||||
{ID: "new", Label: "New", Shortcut: MenubarShortcut{ui.Cmd, ui.KeyN}},
|
||||
{ID: "open", Label: "Open…", Shortcut: MenubarShortcut{ui.Cmd, 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"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *menubarApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Fill().Children(func() {
|
||||
if id, ok := Menubar(c, a.menus(), MenubarOptions{}).Chosen(); ok {
|
||||
a.chosen = append(a.chosen, id)
|
||||
switch id {
|
||||
case "wrap":
|
||||
a.wrap = !a.wrap
|
||||
case "zoom-fit", "zoom-100":
|
||||
a.zoom = id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMenubarPointer(t *testing.T) {
|
||||
a := &menubarApp{zoom: "zoom-fit"}
|
||||
tt := ui.NewTester(a.view, 700, 500)
|
||||
if err := tt.Click("View"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Word wrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !a.wrap || tt.HasText("Actual size") {
|
||||
t.Fatalf("check item: wrap %v, menu still open %v", a.wrap, tt.HasText("Actual size"))
|
||||
}
|
||||
_ = tt.Click("View")
|
||||
if _, ok := tt.Find("Word wrap, checked"); !ok {
|
||||
t.Fatal("checked state not named")
|
||||
}
|
||||
if err := tt.Click("Actual size"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.zoom != "zoom-100" {
|
||||
t.Fatalf("radio chose %q", a.zoom)
|
||||
}
|
||||
_ = tt.Click("File")
|
||||
r, _ := tt.Find("Export")
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Move(r.X+r.W/2+1, r.Y+r.H/2)
|
||||
if !tt.HasText("Vector") {
|
||||
t.Fatalf("hover did not open submenu: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Image")
|
||||
if err := tt.Click("Vector"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := a.chosen; len(got) != 3 || got[2] != "svg" {
|
||||
t.Fatalf("chosen %q", got)
|
||||
}
|
||||
_ = tt.Click("Help")
|
||||
tt.ClickAt(600, 400)
|
||||
if tt.HasText("About") {
|
||||
t.Fatal("click outside did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarHoverSwitchesMenus(t *testing.T) {
|
||||
a := &menubarApp{}
|
||||
tt := ui.NewTester(a.view, 700, 500)
|
||||
_ = tt.Click("File")
|
||||
r, _ := tt.Find("Help")
|
||||
tt.Move(r.X+2, r.Y+2)
|
||||
if tt.HasText("Quit") || !tt.HasText("About") {
|
||||
t.Fatalf("hover did not switch: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarKeyboard(t *testing.T) {
|
||||
a := &menubarApp{}
|
||||
tt := ui.NewTester(a.view, 700, 500)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("File") {
|
||||
t.Fatal("Tab did not reach the bar")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if !tt.Focused("File") {
|
||||
t.Fatal("arrows did not move between titles")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown) // opens File on New
|
||||
tt.Key(0, ui.KeyDown) // Open
|
||||
tt.Key(0, ui.KeyDown) // Export
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("Vector") {
|
||||
t.Fatal("Right did not open the submenu")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown) // skips disabled Image to Vector
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(a.chosen) != 1 || a.chosen[0] != "svg" {
|
||||
t.Fatalf("chosen %q", a.chosen)
|
||||
}
|
||||
if tt.HasText("Quit") || !tt.Focused("File") {
|
||||
t.Fatal("menu did not close back to its title")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyUp) // wraps past the separator to Quit
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("Word wrap") {
|
||||
t.Fatal("Right on a plain item did not move to the next menu")
|
||||
}
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !a.wrap {
|
||||
t.Fatal("Space did not toggle the check item")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Word wrap") {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarShortcutText(t *testing.T) {
|
||||
got := menubarShortcutText(MenubarShortcut{ui.Ctrl | ui.Shift, ui.KeyF5})
|
||||
want := "Ctrl+Shift+F5"
|
||||
if runtime.GOOS == "darwin" {
|
||||
want = "⌃⇧F5"
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarInvalid(t *testing.T) {
|
||||
for name, menus := range map[string][]MenubarMenu{
|
||||
"empty": nil,
|
||||
"title": {{Items: []MenubarItem{{ID: "a", Label: "A"}}}},
|
||||
"id": {{Label: "M", Items: []MenubarItem{{Label: "A"}}}},
|
||||
"submenu": {{Label: "M", Items: []MenubarItem{{Label: "A", Kind: MenubarCheck, Items: []MenubarItem{}}}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Menubar(c, menus, MenubarOptions{})
|
||||
}, 300, 100, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarSnapshot(t *testing.T) {
|
||||
a := &menubarApp{wrap: true, zoom: "zoom-fit"}
|
||||
snapshot(t, "menubar", 480, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Menubar(c, a.menus(), MenubarOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"breadcrumb.label": {"Breadcrumb", "路径导航"},
|
||||
"breadcrumb.current": {"current page", "当前页"},
|
||||
"breadcrumb.more": {"Show hidden path", "显示折叠的路径"},
|
||||
"pagination.label": {"Pagination", "分页"},
|
||||
"pagination.total": {"%d items", "共 %d 条"},
|
||||
"pagination.prev": {"Previous page", "上一页"},
|
||||
"pagination.next": {"Next page", "下一页"},
|
||||
"pagination.page": {"Page %d", "第 %d 页"},
|
||||
"pagination.current": {"current", "当前"},
|
||||
"pagination.size": {"Page size", "每页条数"},
|
||||
"pagination.per_page": {"%d / page", "%d 条/页"},
|
||||
"pagination.jump": {"Go to", "跳至"},
|
||||
"pagination.field": {"Page number", "页码"},
|
||||
"pagination.range": {"1–%d", "1–%d"},
|
||||
"steps.item": {"Step %d of %d: %s, %s", "第 %d 步,共 %d 步:%s,%s"},
|
||||
"steps.waiting": {"waiting", "等待"},
|
||||
"steps.current": {"current", "进行中"},
|
||||
"steps.done": {"done", "已完成"},
|
||||
"steps.error": {"failed", "出错"},
|
||||
"navigation_menu.label": {"Site navigation", "导航菜单"},
|
||||
"navigation_menu.back": {"Back", "后退"},
|
||||
"navigation_menu.forward": {"Forward", "前进"},
|
||||
"menubar.label": {"Menu bar", "菜单栏"},
|
||||
"menubar.checked": {"checked", "已勾选"},
|
||||
"command_palette.label": {"Command palette", "命令面板"},
|
||||
"command_palette.search": {"Search commands", "搜索命令"},
|
||||
"command_palette.empty": {"No matching commands", "没有匹配的命令"},
|
||||
"command_palette.hint_move": {"↑↓ choose", "↑↓ 选择"},
|
||||
"command_palette.hint_run": {"Enter run", "Enter 执行"},
|
||||
"command_palette.hint_close": {"Esc close", "Esc 关闭"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
path := router.Path()
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = 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), msg(c, "navigation_menu.back"))
|
||||
navigationMenuHistory(c, ui.ForwardButton(c, router), 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 := Tokens(c)
|
||||
d := 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 := Tokens(c)
|
||||
d := 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).TextColor(k.TextMuted).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)
|
||||
}
|
||||
if on {
|
||||
b.TextColor(k.Text).FontWeight(600)
|
||||
}
|
||||
if b.Hovered() || *open {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
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) {
|
||||
panel.Margin(u, 0, 0, 0).Padding(u*2).Gap(u).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(240).MaxWidth(360)
|
||||
panel.FocusGroup(ui.Vertical)
|
||||
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(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(l.Items)
|
||||
})
|
||||
}
|
||||
|
||||
func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := 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)
|
||||
}
|
||||
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(theme.CaptionSize).TextColor(k.TextMuted).FontWeight(400)
|
||||
}
|
||||
})
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"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
|
||||
}
|
||||
|
||||
// PaginationResult is the built Pagination.
|
||||
type PaginationResult struct {
|
||||
*ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
// Changed reports a new page or page size in this frame, from the user or a correction.
|
||||
func (r PaginationResult) Changed() bool { return r.changed }
|
||||
|
||||
// Pagination moves *page (from 1) through Total records, *pageSize per page.
|
||||
// A page past the end after Total shrinks is corrected to the last page.
|
||||
func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult {
|
||||
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 := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Wrap().Role(ui.RoleGroup).Label(msg(c, "pagination.label")).Disabled(opts.Disabled)
|
||||
res := PaginationResult{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.changed = p, true
|
||||
}
|
||||
set := func(p int) {
|
||||
if p != *page {
|
||||
slog.Debug("mujica: pagination page", "from", *page, "to", p)
|
||||
*page, res.changed = p, true
|
||||
}
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(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"), 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(msg(c, "pagination.page"), p)
|
||||
if p == *page {
|
||||
name += ", " + msg(c, "pagination.current")
|
||||
}
|
||||
if paginationButton(c, n, nil, name, p == *page, false).Clicked() {
|
||||
set(p)
|
||||
}
|
||||
}
|
||||
if paginationButton(c, "", icons.Must("chevron-right"), 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 := Tokens(c)
|
||||
h := Density(c).ControlHeight()
|
||||
b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, Density(c).Unit()*2).Radius(theme.ControlRadius).
|
||||
Label(name).Disabled(disabled)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case on:
|
||||
b.Background(k.Accent).TextColor(k.OnAccent).FontWeight(600)
|
||||
case b.Pressed():
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text).Border(theme.BorderWidth, k.ControlBorder)
|
||||
default:
|
||||
b.TextColor(k.Text).Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
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 *PaginationResult) {
|
||||
k := Tokens(c)
|
||||
d := 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(msg(c, "pagination.size"))
|
||||
internal.Ring(sel.Trigger, k.Focus, theme.ControlRadius)
|
||||
if sel.Trigger.Hovered() {
|
||||
sel.Trigger.Background(k.SurfaceHover)
|
||||
}
|
||||
sel.Trigger.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(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) {
|
||||
panel.Margin(u, 0, 0, 0).Padding(u).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16))
|
||||
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)
|
||||
}
|
||||
it.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(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.changed = true
|
||||
slog.Debug("mujica: pagination size", "from", old, "to", *pageSize, "page", *page)
|
||||
}
|
||||
}
|
||||
|
||||
func paginationJump(c *ui.Context, pages int, set func(int)) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, 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(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(msg(c, "pagination.range"), pages))
|
||||
} else if in.Focused() {
|
||||
border = k.Focus
|
||||
}
|
||||
box.Border(theme.BorderWidth, border)
|
||||
if *bad {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{Locale: 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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 := Tokens(c)
|
||||
d := 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
|
||||
}
|
||||
seg.TextColor(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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func TestSegmentedControlClickAndKeys(t *testing.T) {
|
||||
sel, changes := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// SidebarResult is the built Sidebar.
|
||||
type SidebarResult struct {
|
||||
*ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
// Changed reports a new choice in this frame.
|
||||
func (r SidebarResult) Changed() bool { return r.changed }
|
||||
|
||||
// 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) SidebarResult {
|
||||
k := 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 := SidebarResult{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")
|
||||
}
|
||||
ui.SidebarSection(c, s.Title, sidebarOpen(*closed, "s:"+s.Title), func() {
|
||||
sidebarItems(c, *selected, s.Items, *closed, 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.changed = 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, depth int) {
|
||||
u := Density(c).Unit()
|
||||
for _, it := range items {
|
||||
if it.Label == "" {
|
||||
panic("mujica: SidebarItem needs a Label")
|
||||
}
|
||||
if it.Items != nil {
|
||||
ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() {
|
||||
ui.SidebarSection(c, it.Label, sidebarOpen(closed, fmt.Sprintf("g%d:%s", depth, it.Label)), func() {
|
||||
sidebarItems(c, selected, it.Items, closed, depth+1)
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() {
|
||||
sidebarItem(c, selected, it, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Tokens(c)
|
||||
d := 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(theme.CaptionSize).FontFeatures("tnum").
|
||||
TextColor(k.TextMuted).Padding(0, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background)
|
||||
})
|
||||
}
|
||||
if on {
|
||||
e.Background(k.Selection).TextColor(k.Text).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(Density(c).Unit()*2, 4).Background(Tokens(c).Ornament).Role(ui.RoleNone)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).FillHeight().Children(func() {
|
||||
w = Sidebar(c, &sel, sidebarSample(), SidebarOptions{Compact: true}).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) {
|
||||
Use(c, Settings{})
|
||||
Sidebar(c, &sel, tc.s, tc.opts)
|
||||
}, 300, 300, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"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
|
||||
}
|
||||
|
||||
// StepsResult is the built Steps.
|
||||
type StepsResult struct {
|
||||
*ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
// Changed reports that the user chose a clickable step in this frame.
|
||||
func (r StepsResult) Changed() bool { return r.changed }
|
||||
|
||||
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) StepsResult {
|
||||
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 := 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 := StepsResult{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.changed = i, 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 := Tokens(c)
|
||||
u := 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)
|
||||
}
|
||||
} 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(msg(c, "steps.item"), i+1, n, s.Title, 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(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 := 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(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 := Tokens(c)
|
||||
ui.Box(c).Height(2).Grow(1).MinWidth(24).Margin(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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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
|
||||
}
|
||||
|
||||
// TabsResult is the built Tabs: the element holding tabs and panel.
|
||||
type TabsResult struct {
|
||||
*ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
// Changed reports a new choice made by the user in this frame.
|
||||
func (r TabsResult) Changed() bool { return r.changed }
|
||||
|
||||
// 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) TabsResult {
|
||||
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 := Tokens(c)
|
||||
d := 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 opts.Vertical {
|
||||
root.Row()
|
||||
}
|
||||
res := TabsResult{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.Changed() {
|
||||
prev := *selected
|
||||
*selected = enabled[at]
|
||||
res.changed = 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 := Tokens(c)
|
||||
d := 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
|
||||
}
|
||||
tab.TextColor(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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(240).Children(func() {
|
||||
w = Tabs(c, &sel, tabs, TabsOptions{}).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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{Mode: tabsMode(c), Density: d})
|
||||
tabsView(c)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tabsMode recovers the mode snapshot applied, to add a density.
|
||||
func tabsMode(c *ui.Context) Mode {
|
||||
if Tokens(c).Dark {
|
||||
return Dark
|
||||
}
|
||||
return Light
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ToolbarOptions configure a Toolbar.
|
||||
type ToolbarOptions struct {
|
||||
// Label names the toolbar for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Toolbar is a row of actions built by fn, one Tab stop with arrow keys inside.
|
||||
// Actions that do not fit move, from the last, into an overflow menu.
|
||||
func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element {
|
||||
k := Tokens(c)
|
||||
tb := ui.Toolbar(c, fn).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
|
||||
MinHeight(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 := Tokens(c)
|
||||
d := 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)
|
||||
}
|
||||
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(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, Density(c).ControlHeight()-12).Margin(0, Density(c).Unit()).Shrink(0).
|
||||
Background(Tokens(c).Ornament).Role(ui.RoleNone)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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) { Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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)
|
||||
})
|
||||
})
|
||||
}
|
||||