diff --git a/breadcrumb.go b/breadcrumb.go new file mode 100644 index 0000000..1bfd93b --- /dev/null +++ b/breadcrumb.go @@ -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) + } + } + }) +} diff --git a/breadcrumb_test.go b/breadcrumb_test.go new file mode 100644 index 0000000..326abd8 --- /dev/null +++ b/breadcrumb_test.go @@ -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}) + }) + }) +} diff --git a/command_palette.go b/command_palette.go new file mode 100644 index 0000000..e49f66f --- /dev/null +++ b/command_palette.go @@ -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 +} diff --git a/command_palette_test.go b/command_palette_test.go new file mode 100644 index 0000000..724250f --- /dev/null +++ b/command_palette_test.go @@ -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{}) + }) +} diff --git a/docs/components/059-tabs.md b/docs/components/059-tabs.md new file mode 100644 index 0000000..f99cb30 --- /dev/null +++ b/docs/components/059-tabs.md @@ -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` 呈现,名称取自所选标签。 +- 不提供标签关闭、拖动排序。 diff --git a/docs/components/060-segmented-control.md b/docs/components/060-segmented-control.md new file mode 100644 index 0000000..1a93ae4 --- /dev/null +++ b/docs/components/060-segmented-control.md @@ -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` 的焦点组在方向键移动时直接点击目标分段。 diff --git a/docs/components/061-breadcrumb.md b/docs/components/061-breadcrumb.md new file mode 100644 index 0000000..15240cd --- /dev/null +++ b/docs/components/061-breadcrumb.md @@ -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` 组合实现。 diff --git a/docs/components/062-pagination.md b/docs/components/062-pagination.md new file mode 100644 index 0000000..36943b7 --- /dev/null +++ b/docs/components/062-pagination.md @@ -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 绘制。 diff --git a/docs/components/063-steps.md b/docs/components/063-steps.md new file mode 100644 index 0000000..b9e7051 --- /dev/null +++ b/docs/components/063-steps.md @@ -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 角色;步骤以列表项(可点击时为按钮)呈现,状态写入名称。 diff --git a/docs/components/064-sidebar.md b/docs/components/064-sidebar.md new file mode 100644 index 0000000..f32e7b8 --- /dev/null +++ b/docs/components/064-sidebar.md @@ -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 不给标题注册按键)。 diff --git a/docs/components/065-navigation-menu.md b/docs/components/065-navigation-menu.md new file mode 100644 index 0000000..630ca64 --- /dev/null +++ b/docs/components/065-navigation-menu.md @@ -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 颜色)。 diff --git a/docs/components/066-menubar.md b/docs/components/066-menubar.md new file mode 100644 index 0000000..6f45bba --- /dev/null +++ b/docs/components/066-menubar.md @@ -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` 相同的方式)。 diff --git a/docs/components/067-toolbar.md b/docs/components/067-toolbar.md new file mode 100644 index 0000000..7d05381 --- /dev/null +++ b/docs/components/067-toolbar.md @@ -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 主题。 diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md new file mode 100644 index 0000000..a249aeb --- /dev/null +++ b/docs/components/068-command-palette.md @@ -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`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。 +- 搜索是子串匹配,不做模糊匹配或排序打分。 diff --git a/example_navigation_test.go b/example_navigation_test.go new file mode 100644 index 0000000..5b5a611 --- /dev/null +++ b/example_navigation_test.go @@ -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) +} diff --git a/examples/gallery/pages_navigation.go b/examples/gallery/pages_navigation.go index 1f53a12..3809106 100644 --- a/examples/gallery/pages_navigation.go +++ b/examples/gallery/pages_navigation.go @@ -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) + } + }) +} diff --git a/menubar.go b/menubar.go new file mode 100644 index 0000000..00f63f2 --- /dev/null +++ b/menubar.go @@ -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 +} diff --git a/menubar_panel.go b/menubar_panel.go new file mode 100644 index 0000000..11e7185 --- /dev/null +++ b/menubar_panel.go @@ -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), "+") +} diff --git a/menubar_test.go b/menubar_test.go new file mode 100644 index 0000000..1309072 --- /dev/null +++ b/menubar_test.go @@ -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{}) + }) + }) +} diff --git a/messages_navigation.go b/messages_navigation.go new file mode 100644 index 0000000..8474db7 --- /dev/null +++ b/messages_navigation.go @@ -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 关闭"}, + }) +} diff --git a/navigation_menu.go b/navigation_menu.go new file mode 100644 index 0000000..da0d3cc --- /dev/null +++ b/navigation_menu.go @@ -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 +} diff --git a/navigation_menu_test.go b/navigation_menu_test.go new file mode 100644 index 0000000..137e42f --- /dev/null +++ b/navigation_menu_test.go @@ -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}) + }) + }) +} diff --git a/pagination.go b/pagination.go new file mode 100644 index 0000000..05a6fa3 --- /dev/null +++ b/pagination.go @@ -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() + } + }) +} diff --git a/pagination_test.go b/pagination_test.go new file mode 100644 index 0000000..0e7d427 --- /dev/null +++ b/pagination_test.go @@ -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}) + }) + }) +} diff --git a/segmented_control.go b/segmented_control.go new file mode 100644 index 0000000..f20cfe9 --- /dev/null +++ b/segmented_control.go @@ -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 +} diff --git a/segmented_control_test.go b/segmented_control_test.go new file mode 100644 index 0000000..56ea8c2 --- /dev/null +++ b/segmented_control_test.go @@ -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}) + }) + }) +} diff --git a/sidebar.go b/sidebar.go new file mode 100644 index 0000000..339400e --- /dev/null +++ b/sidebar.go @@ -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) +} diff --git a/sidebar_test.go b/sidebar_test.go new file mode 100644 index 0000000..9eb9f63 --- /dev/null +++ b/sidebar_test.go @@ -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}) + }) + }) +} diff --git a/steps.go b/steps.go new file mode 100644 index 0000000..8971b9a --- /dev/null +++ b/steps.go @@ -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 +} diff --git a/steps_test.go b/steps_test.go new file mode 100644 index 0000000..201e346 --- /dev/null +++ b/steps_test.go @@ -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}) + }) + }) +} diff --git a/tabs.go b/tabs.go new file mode 100644 index 0000000..c3b022e --- /dev/null +++ b/tabs.go @@ -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() + }) +} diff --git a/tabs_test.go b/tabs_test.go new file mode 100644 index 0000000..f5ba7f2 --- /dev/null +++ b/tabs_test.go @@ -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 +} diff --git a/testdata/snapshots/darwin/breadcrumb-dark.png b/testdata/snapshots/darwin/breadcrumb-dark.png new file mode 100644 index 0000000..7ad11a0 Binary files /dev/null and b/testdata/snapshots/darwin/breadcrumb-dark.png differ diff --git a/testdata/snapshots/darwin/breadcrumb-light.png b/testdata/snapshots/darwin/breadcrumb-light.png new file mode 100644 index 0000000..f6d94e3 Binary files /dev/null and b/testdata/snapshots/darwin/breadcrumb-light.png differ diff --git a/testdata/snapshots/darwin/command_palette-dark.png b/testdata/snapshots/darwin/command_palette-dark.png new file mode 100644 index 0000000..7587260 Binary files /dev/null and b/testdata/snapshots/darwin/command_palette-dark.png differ diff --git a/testdata/snapshots/darwin/command_palette-light.png b/testdata/snapshots/darwin/command_palette-light.png new file mode 100644 index 0000000..f76ffd4 Binary files /dev/null and b/testdata/snapshots/darwin/command_palette-light.png differ diff --git a/testdata/snapshots/darwin/menubar-dark.png b/testdata/snapshots/darwin/menubar-dark.png new file mode 100644 index 0000000..f68dcae Binary files /dev/null and b/testdata/snapshots/darwin/menubar-dark.png differ diff --git a/testdata/snapshots/darwin/menubar-light.png b/testdata/snapshots/darwin/menubar-light.png new file mode 100644 index 0000000..d2e0343 Binary files /dev/null and b/testdata/snapshots/darwin/menubar-light.png differ diff --git a/testdata/snapshots/darwin/navigation_menu-dark.png b/testdata/snapshots/darwin/navigation_menu-dark.png new file mode 100644 index 0000000..a45b9ca Binary files /dev/null and b/testdata/snapshots/darwin/navigation_menu-dark.png differ diff --git a/testdata/snapshots/darwin/navigation_menu-light.png b/testdata/snapshots/darwin/navigation_menu-light.png new file mode 100644 index 0000000..bc992d2 Binary files /dev/null and b/testdata/snapshots/darwin/navigation_menu-light.png differ diff --git a/testdata/snapshots/darwin/pagination-dark.png b/testdata/snapshots/darwin/pagination-dark.png new file mode 100644 index 0000000..25c2935 Binary files /dev/null and b/testdata/snapshots/darwin/pagination-dark.png differ diff --git a/testdata/snapshots/darwin/pagination-light.png b/testdata/snapshots/darwin/pagination-light.png new file mode 100644 index 0000000..e8f1911 Binary files /dev/null and b/testdata/snapshots/darwin/pagination-light.png differ diff --git a/testdata/snapshots/darwin/segmented_control-dark.png b/testdata/snapshots/darwin/segmented_control-dark.png new file mode 100644 index 0000000..6d9110c Binary files /dev/null and b/testdata/snapshots/darwin/segmented_control-dark.png differ diff --git a/testdata/snapshots/darwin/segmented_control-light.png b/testdata/snapshots/darwin/segmented_control-light.png new file mode 100644 index 0000000..ebbd3df Binary files /dev/null and b/testdata/snapshots/darwin/segmented_control-light.png differ diff --git a/testdata/snapshots/darwin/sidebar-dark.png b/testdata/snapshots/darwin/sidebar-dark.png new file mode 100644 index 0000000..df7abfc Binary files /dev/null and b/testdata/snapshots/darwin/sidebar-dark.png differ diff --git a/testdata/snapshots/darwin/sidebar-light.png b/testdata/snapshots/darwin/sidebar-light.png new file mode 100644 index 0000000..3cd875d Binary files /dev/null and b/testdata/snapshots/darwin/sidebar-light.png differ diff --git a/testdata/snapshots/darwin/steps-dark.png b/testdata/snapshots/darwin/steps-dark.png new file mode 100644 index 0000000..cd38011 Binary files /dev/null and b/testdata/snapshots/darwin/steps-dark.png differ diff --git a/testdata/snapshots/darwin/steps-light.png b/testdata/snapshots/darwin/steps-light.png new file mode 100644 index 0000000..7381891 Binary files /dev/null and b/testdata/snapshots/darwin/steps-light.png differ diff --git a/testdata/snapshots/darwin/tabs-comfortable-dark.png b/testdata/snapshots/darwin/tabs-comfortable-dark.png new file mode 100644 index 0000000..8e995c0 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-comfortable-light.png b/testdata/snapshots/darwin/tabs-comfortable-light.png new file mode 100644 index 0000000..a366b76 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/tabs-compact-dark.png b/testdata/snapshots/darwin/tabs-compact-dark.png new file mode 100644 index 0000000..d2f037c Binary files /dev/null and b/testdata/snapshots/darwin/tabs-compact-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-compact-light.png b/testdata/snapshots/darwin/tabs-compact-light.png new file mode 100644 index 0000000..c69e387 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-compact-light.png differ diff --git a/testdata/snapshots/darwin/tabs-dark.png b/testdata/snapshots/darwin/tabs-dark.png new file mode 100644 index 0000000..41bd90d Binary files /dev/null and b/testdata/snapshots/darwin/tabs-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-light.png b/testdata/snapshots/darwin/tabs-light.png new file mode 100644 index 0000000..01b2495 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-light.png differ diff --git a/testdata/snapshots/darwin/toolbar-dark.png b/testdata/snapshots/darwin/toolbar-dark.png new file mode 100644 index 0000000..77b97e4 Binary files /dev/null and b/testdata/snapshots/darwin/toolbar-dark.png differ diff --git a/testdata/snapshots/darwin/toolbar-light.png b/testdata/snapshots/darwin/toolbar-light.png new file mode 100644 index 0000000..7472177 Binary files /dev/null and b/testdata/snapshots/darwin/toolbar-light.png differ diff --git a/toolbar.go b/toolbar.go new file mode 100644 index 0000000..712fefd --- /dev/null +++ b/toolbar.go @@ -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) +} diff --git a/toolbar_test.go b/toolbar_test.go new file mode 100644 index 0000000..125e65f --- /dev/null +++ b/toolbar_test.go @@ -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) + }) + }) +}