Merge branch 'main' into worktree-agent-ac0c9e7144c57cf07

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:26:17 -04:00
commit e538f88d5d
444 files changed
+30007 -38

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- { name: macOS, os: macos-latest, skip: "" }
# Snapshot goldens exist only for darwin; see docs/compat.md.
- { name: Linux, os: ubuntu-latest, skip: "-skip Snapshot" }
- { name: Windows, os: windows-latest, skip: "-skip Snapshot" }
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Format
if: runner.os == 'Linux'
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- name: Build and vet every platform
if: runner.os == 'Linux'
run: |
for os in darwin linux windows; do
GOOS=$os go build ./...
GOOS=$os go vet ./...
done
- name: Staticcheck
if: runner.os == 'Linux'
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
- name: Test
run: go test ${{ matrix.skip }} ./...
- name: Race
if: runner.os != 'Windows'
run: go test -race ${{ matrix.skip }} ./...
- name: Examples compile
run: go build ./examples/...
- name: Upload snapshot diffs
if: failure()
uses: actions/upload-artifact@v5
with:
name: snapshot-diffs-${{ matrix.name }}
path: testdata/snapshots/**/*.got.png
if-no-files-found: ignore
+1
View File
@@ -32,6 +32,7 @@ testdata/snapshots/ <os>/<name>-<light|dark>.png
- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) *ui.Element`. Composites may return a named struct.
- Zero `Options` gives the default look. No `map[string]any` config.
- Explicit font sizes: `FontSize(c, theme.CaptionSize)`; it applies the desktop text scale.
- Colors: `k := mujica.Tokens(c)`. Metrics: `theme.*` consts and `Density(c).Unit()` / `.ControlHeight()`.
- Build on MyGo Bases (`ui.ButtonBase`, `ui.CheckboxBase`, `ui.SelectBase`, ...) or MyGo widgets restyled through the theme `Use` sets. Never reach into MyGo internals.
- Focus: `internal.Ring(e, k.Focus, radius)` for the 2 DIP ring with 2 DIP gap.
+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Severity is the meaning of a message. The zero value is SeverityInfo.
type Severity int
const (
SeverityInfo Severity = iota
SeveritySuccess
SeverityWarning
SeverityError
)
func (s Severity) String() string {
switch s {
case SeverityInfo:
return "info"
case SeveritySuccess:
return "success"
case SeverityWarning:
return "warning"
case SeverityError:
return "error"
}
panic(fmt.Sprintf("mujica: unknown Severity %d", int(s)))
}
// severityLook returns the color and icon that, with text, mark s.
func severityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
switch s {
case SeveritySuccess:
return k.Success, icons.Must("circle-check")
case SeverityWarning:
return k.Warning, icons.Must("triangle-alert")
case SeverityError:
return k.Danger, icons.Must("circle-x")
}
return k.Info, icons.Must("info")
}
// AlertOptions configure an Alert.
type AlertOptions struct {
Severity Severity
// Title goes above the message in bold; empty shows none.
Title string
// Action labels a button at the end; empty shows none.
Action string
}
// AlertResult is what an Alert built and reported this frame.
type AlertResult struct {
// Element is nil while the alert is closed.
Element *ui.Element
// Action reports a click on the action button.
Action bool
// Closed reports the user dismissing the alert.
Closed bool
}
// Alert shows an inline message marked by color, icon and accessible name.
// A non-nil open adds a dismiss button that sets *open to false; nothing
// shows while *open is false.
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
if message == "" {
panic("mujica: Alert needs a message")
}
if open != nil && !*open {
return AlertResult{}
}
k := Tokens(c)
u := Density(c).Unit()
tone, icon := severityLook(k, opts.Severity)
name := msg(c, "alert."+opts.Severity.String())
var res AlertResult
box := ui.Row(c).FillWidth().AlignItems(ui.Start).Gap(u*3).Padding(u*3, u*3, u*3, u*4).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Role(ui.RoleGroup).Label(name).Clip()
// A bar in the severity color along the leading edge.
box.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0)
})
// Warnings and errors are read out when they appear or change.
said := ui.Local(box, "alert.said", func() string { return "" })
if news := name + ": " + message; opts.Severity >= SeverityWarning && *said != news {
*said = news
c.Announce(news)
}
box.Children(func() {
ui.Icon(c, icon).Size(20, 20).TextColor(tone).Margin(1, 0, 0, 0)
ui.Column(c).Grow(1).Shrink(1).Gap(u).Children(func() {
if opts.Title != "" {
ui.Text(c, opts.Title).Bold().TextColor(k.Text)
}
ui.Text(c, message).TextColor(k.Text).LineHeight(theme.LineHeight)
if opts.Action != "" {
ui.Row(c).Margin(u, 0, 0, 0).Children(func() {
if Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() {
slog.Debug("mujica: alert action", "severity", opts.Severity, "action", opts.Action)
res.Action = true
}
})
}
})
if open != nil {
dismiss := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Label: msg(c, "alert.close")})
dismiss.TextColor(k.TextMuted).Padding(0).MinHeight(theme.MinHitSize).Margin(-u, -u, -u, 0)
if dismiss.Clicked() {
slog.Debug("mujica: alert closed", "severity", opts.Severity)
*open, res.Closed = false, true
}
}
})
res.Element = box
return res
}
+110
View File
@@ -0,0 +1,110 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AlertDialogOptions configure an AlertDialog. Title is required.
type AlertDialogOptions struct {
Title string
Description string
// ConfirmLabel is "Delete" when empty; CancelLabel is "Cancel".
ConfirmLabel string
CancelLabel string
// ConfirmText, when set, must be typed before Confirm unlocks.
ConfirmText string
}
// AlertDialogResult reports the choice made in this frame.
type AlertDialogResult struct {
Confirmed bool
Cancelled bool
}
// alertDialogState is the typed confirmation, kept while the alert shows.
type alertDialogState struct{ typed string }
// AlertDialog asks to confirm a destructive action while *open is true.
// Cancel takes the focus, the backdrop ignores clicks, Escape cancels,
// and Enter never confirms before ConfirmText is typed.
func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult {
if opts.Title == "" {
panic("mujica: AlertDialog needs a Title")
}
var res AlertDialogResult
if !*open {
return res
}
k := Tokens(c)
u := Density(c).Unit()
confirmLabel, cancelLabel := opts.ConfirmLabel, opts.CancelLabel
if confirmLabel == "" {
confirmLabel = msg(c, "alert_dialog.confirm")
}
if cancelLabel == "" {
cancelLabel = msg(c, "alert_dialog.cancel")
}
shown, backClicked := true, false
ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
backClicked = back.Clicked()
st := ui.Local(panel, "mujica.alert", func() alertDialogState { return alertDialogState{} })
p := overlayEnter(c, panel)
back.Background(overlayScrim(k).Alpha(p)).Padding(16)
dialogPanel(c, panel, 420, false, p)
panel.Role(ui.RoleAlertDialog).Label(opts.Title).Description(opts.Description)
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Shrink(0).Children(func() {
ui.Icon(c, icons.Must("triangle-alert")).Size(22, 22).TextColor(k.Danger).Shrink(0).Margin(1, 0, 0, 0)
ui.Column(c).Gap(u * 2).Grow(1).Children(func() {
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600)
if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted)
}
})
})
submitted := opts.ConfirmText != "" && alertDialogTyped(c, st, opts.ConfirmText)
unlocked := opts.ConfirmText == "" || st.typed == opts.ConfirmText
res.Confirmed = submitted && unlocked
dialogActions(c, func() {
if Button(c, cancelLabel, ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
res.Cancelled = true
}
if Button(c, confirmLabel, ButtonOptions{Variant: Danger, Disabled: !unlocked}).Clicked() {
res.Confirmed = true
}
})
})
if !shown && !backClicked {
res.Cancelled = true // Escape
}
if res.Confirmed {
res.Cancelled = false
}
if res.Confirmed || res.Cancelled {
slog.Debug("mujica: alert_dialog close", "confirmed", res.Confirmed)
*open = false
}
return res
}
// alertDialogTyped builds the confirmation field and reports Enter in it.
func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) bool {
k := Tokens(c)
d := Density(c)
prompt := fmt.Sprintf(msg(c, "alert_dialog.type_to_unlock"), want)
submitted := false
ui.Column(c).Gap(d.Unit()).Shrink(0).Children(func() {
ui.Text(c, prompt).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
in := ui.TextInputBase(c, &st.typed).Label(prompt).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
internal.Ring(in, k.Focus, theme.ControlRadius)
submitted = in.Submitted()
})
return submitted
}
+102
View File
@@ -0,0 +1,102 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type alertApp struct {
open bool
opts AlertDialogOptions
confirmed, cancelled int
}
func (a *alertApp) view(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Remove", ButtonOptions{}).Clicked() {
a.open = true
}
r := AlertDialog(c, &a.open, a.opts)
if r.Confirmed {
a.confirmed++
}
if r.Cancelled {
a.cancelled++
}
}
func newAlertApp(opts AlertDialogOptions) (*alertApp, *ui.Tester) {
opts.Title = "Delete the archive?"
a := &alertApp{opts: opts}
tt := ui.NewTester(a.view, 600, 400)
_ = tt.Click("Remove")
return a, tt
}
func TestAlertDialogFocusesCancelAndEnterCancels(t *testing.T) {
a, tt := newAlertApp(AlertDialogOptions{Description: "This cannot be undone."})
if !a.open || !tt.Focused("Cancel") {
t.Fatalf("open %v, Cancel focused %v", a.open, tt.Focused("Cancel"))
}
tt.Key(0, ui.KeyEnter)
if a.confirmed != 0 || a.cancelled != 1 || a.open {
t.Fatalf("Enter: confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
}
if !tt.Focused("Remove") {
t.Fatal("focus not restored")
}
}
func TestAlertDialogIgnoresBackdropEscapeCancels(t *testing.T) {
a, tt := newAlertApp(AlertDialogOptions{})
tt.ClickAt(8, 392)
if !a.open || a.cancelled != 0 {
t.Fatalf("backdrop click: open %v cancelled %d", a.open, a.cancelled)
}
tt.Key(0, ui.KeyEscape)
if a.open || a.cancelled != 1 || a.confirmed != 0 {
t.Fatalf("Escape: open %v cancelled %d confirmed %d", a.open, a.cancelled, a.confirmed)
}
}
func TestAlertDialogConfirmOnce(t *testing.T) {
a, tt := newAlertApp(AlertDialogOptions{ConfirmLabel: "Erase"})
if err := tt.Click("Erase"); err != nil {
t.Fatal(err)
}
if a.confirmed != 1 || a.cancelled != 0 || a.open {
t.Fatalf("confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
}
}
func TestAlertDialogConfirmTextGatesEnter(t *testing.T) {
a, tt := newAlertApp(AlertDialogOptions{ConfirmText: "archive"})
_ = tt.Click("Delete") // locked
if a.confirmed != 0 || !a.open {
t.Fatal("locked Delete confirmed")
}
if err := tt.Click("Type “archive” to confirm"); err != nil {
t.Fatal(err)
}
tt.Type("archiv")
tt.Key(0, ui.KeyEnter)
if a.confirmed != 0 || !a.open {
t.Fatalf("before the target matched: confirmed %d open %v", a.confirmed, a.open)
}
tt.Type("e")
tt.Key(0, ui.KeyEnter)
if a.confirmed != 1 || a.open {
t.Fatalf("typed target: confirmed %d open %v", a.confirmed, a.open)
}
}
func TestAlertDialogSnapshot(t *testing.T) {
snapshot(t, "alert_dialog", 560, 320, func(c *ui.Context) {
open := true
AlertDialog(c, &open, AlertDialogOptions{
Title: "Burn the old letters?", Description: "Twelve letters will be destroyed. This cannot be undone.",
ConfirmText: "letters",
})
})
}
+113
View File
@@ -0,0 +1,113 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestAlertActionAndClose(t *testing.T) {
open := true
actions, closes := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := Alert(c, &open, "Disk almost full", AlertOptions{Severity: SeverityWarning, Title: "Storage", Action: "Free space"})
if r.Action {
actions++
}
if r.Closed {
closes++
}
}, 500, 300)
if err := tt.Click("Free space"); err != nil {
t.Fatal(err)
}
if actions != 1 || !open {
t.Fatalf("actions %d open %v", actions, open)
}
if err := tt.Click("Dismiss"); err != nil {
t.Fatal(err)
}
if open || closes != 1 {
t.Fatalf("after dismiss open %v closes %d", open, closes)
}
if tt.HasText("Disk almost full") {
t.Fatal("closed alert still shows")
}
}
func TestAlertKeyboard(t *testing.T) {
open := true
actions := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Alert(c, &open, "Sync failed", AlertOptions{Severity: SeverityError, Action: "Retry"}).Action {
actions++
}
}, 500, 300)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if actions != 1 {
t.Fatalf("Enter on action: %d", actions)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if open {
t.Fatal("Space on dismiss left it open")
}
}
func TestAlertWithoutOpenHasNoDismiss(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Alert(c, nil, "Read only", AlertOptions{})
}, 500, 300)
if _, ok := tt.Find("Dismiss"); ok {
t.Fatal("dismiss button without open")
}
if _, ok := tt.Find("Information"); !ok {
t.Fatal("alert lacks its severity name")
}
}
func TestAlertAnnouncesWarningsOnce(t *testing.T) {
sev := SeverityInfo
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
Alert(c, nil, "磁盘已满", AlertOptions{Severity: sev})
}, 500, 300)
if a := tt.Announcements(); len(a) != 0 {
t.Fatalf("info announced %q", a)
}
sev = SeverityError
tt.Frame()
tt.Frame()
if a := tt.Announcements(); !slices.Equal(a, []string{"错误: 磁盘已满"}) {
t.Fatalf("error announcements %q", a)
}
}
func TestAlertPanicsWithoutMessage(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Alert(c, nil, "", AlertOptions{})
}, 300, 200)
}
func TestAlertSnapshot(t *testing.T) {
open := true
snapshot(t, "alert", 560, 400, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).Children(func() {
Alert(c, nil, "A new version is ready to install.", AlertOptions{})
Alert(c, &open, "Your changes were saved.", AlertOptions{Severity: SeveritySuccess})
Alert(c, nil, "The certificate expires in 3 days.", AlertOptions{Severity: SeverityWarning, Title: "Certificate"})
Alert(c, &open, "The archive could not be read.", AlertOptions{Severity: SeverityError, Title: "Import failed", Action: "Retry"})
})
})
}
+120
View File
@@ -0,0 +1,120 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AppShellOptions are the slots of an AppShell. Content is required; the others are optional.
type AppShellOptions struct {
// TitleBar goes across the top, as a TitleBar.
TitleBar func()
// Sidebar scrolls on its own beside the content and collapses to a rail.
Sidebar func()
// Content scrolls on its own.
Content func()
// StatusBar goes across the bottom, as a StatusBar.
StatusBar func()
// SidebarWidth is the open sidebar's width in DIPs; zero is 240.
SidebarWidth float32
// ContentScroll keeps the content's offset; nil keeps it by the shell's place.
ContentScroll *ui.ScrollState
}
// AppShellResult is an AppShell and its event in this frame.
type AppShellResult struct {
*ui.Element
// Toggled reports the sidebar opened or collapsed.
Toggled bool
}
// AppShell lays out a window: title bar, sidebar, content and status bar.
// *collapsed folds the sidebar to a rail; its toggle button and Cmd/Ctrl+B flip it.
func AppShell(c *ui.Context, collapsed *bool, opts AppShellOptions) AppShellResult {
if collapsed == nil {
panic("mujica: AppShell needs a collapsed state")
}
if opts.Content == nil {
panic("mujica: AppShell needs Content")
}
if opts.SidebarWidth < 0 {
panic("mujica: AppShell SidebarWidth must not be negative")
}
k := Tokens(c)
var res AppShellResult
toggle := func() {
*collapsed = !*collapsed
res.Toggled = true
slog.Debug("mujica: app_shell toggled", "collapsed", *collapsed)
}
if opts.Sidebar != nil && c.Shortcut(ui.Cmd, ui.KeyB) {
toggle()
}
e := ui.Column(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
e.Children(func() {
if opts.TitleBar != nil {
opts.TitleBar()
}
ui.Row(c).Grow(1).MinHeight(0).AlignItems(ui.Stretch).Children(func() {
if opts.Sidebar != nil && appShellSidebar(c, *collapsed, opts) {
toggle()
}
ScrollArea(c, opts.ContentScroll, ScrollAreaOptions{}, opts.Content).Grow(1).MinWidth(0)
})
if opts.StatusBar != nil {
opts.StatusBar()
}
})
res.Element = e
return res
}
// appShellSidebar builds the sidebar or its rail and reports whether its toggle was pressed.
func appShellSidebar(c *ui.Context, collapsed bool, opts AppShellOptions) bool {
k := Tokens(c)
u := Density(c).Unit()
open := opts.SidebarWidth
if open == 0 {
open = 240
}
rail := theme.MinHitSize + 4*u
target := open
if collapsed {
target = rail
}
side := ui.Column(c).Shrink(0).Clip().AlignItems(ui.Stretch).Background(k.Surface).
BorderWidth(0, theme.BorderWidth, 0, 0).BorderColor(k.Border)
side.Width(side.AnimateWith("app_shell.width", target, Motion(c, theme.StateDuration), ui.EaseOut))
var pressed bool
side.Children(func() {
head := ui.Row(c).Shrink(0).Padding(u * 2).Justify(ui.End)
if collapsed {
head.Justify(ui.Center)
}
head.Children(func() {
label := msg(c, "app_shell.collapse")
if collapsed {
label = msg(c, "app_shell.expand")
}
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).
Label(label).Tooltip(label).TextColor(k.TextMuted)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Pressed() {
b.Background(k.Selection).TextColor(k.Text)
} else if b.Hovered() {
b.Background(k.SurfaceHover).TextColor(k.Text)
}
b.Children(func() { ui.Icon(c, icons.Must("panel-left")).Size(16, 16) })
pressed = b.Clicked()
})
if !collapsed {
ScrollArea(c, nil, ScrollAreaOptions{}, opts.Sidebar).Grow(1).MinHeight(0)
}
})
return pressed
}
+148
View File
@@ -0,0 +1,148 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
type appShellProbe struct {
collapsed bool
toggled int
content ui.ScrollState
}
func (p *appShellProbe) view(c *ui.Context) {
Use(c, Settings{})
r := AppShell(c, &p.collapsed, AppShellOptions{
TitleBar: func() { TitleBar(c, "Court Records", TitleBarOptions{}) },
Sidebar: func() {
for i := range 30 {
ui.Text(c, fmt.Sprintf("Nav %d", i)).Height(24)
}
},
Content: func() {
for i := range 40 {
ui.Text(c, fmt.Sprintf("Entry %d", i)).Height(24)
}
},
StatusBar: func() { StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "s", Text: "Ready"}}}) },
ContentScroll: &p.content,
})
if r.Toggled {
p.toggled++
}
}
func appShellTester(p *appShellProbe) *ui.Tester {
tt := ui.NewTester(p.view, 640, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
return tt
}
func TestAppShellSlots(t *testing.T) {
tt := appShellTester(&appShellProbe{})
title, _ := tt.Find("Court Records")
nav, _ := tt.Find("Nav 0")
entry, _ := tt.Find("Entry 0")
ready, _ := tt.Find("Ready")
if title.Y > nav.Y || nav.X >= entry.X || ready.Y < 360 {
t.Fatalf("slots out of place: title %+v nav %+v entry %+v status %+v", title, nav, entry, ready)
}
if entry.X < 240 {
t.Fatalf("content at %v overlaps the 240 DIP sidebar", entry.X)
}
}
func TestAppShellCollapseByButton(t *testing.T) {
p := &appShellProbe{}
tt := appShellTester(p)
if err := tt.Click("Collapse sidebar"); err != nil {
t.Fatal(err)
}
if !p.collapsed || p.toggled != 1 || tt.HasText("Nav 0") {
t.Fatalf("after collapse: collapsed=%v toggled=%d nav shown=%v", p.collapsed, p.toggled, tt.HasText("Nav 0"))
}
if entry, _ := tt.Find("Entry 0"); entry.X != 44 {
t.Fatalf("content at %v beside the rail, want 44", entry.X)
}
if err := tt.Click("Expand sidebar"); err != nil {
t.Fatal(err)
}
if p.collapsed || !tt.HasText("Nav 0") || p.toggled != 2 {
t.Fatal("Expand did not reopen the sidebar")
}
}
func TestAppShellCollapseByShortcut(t *testing.T) {
p := &appShellProbe{}
tt := appShellTester(p)
tt.Key(ui.Cmd, ui.KeyB)
if !p.collapsed {
t.Fatal("Cmd+B did not collapse")
}
tt.Key(ui.Cmd, ui.KeyB)
if p.collapsed || p.toggled != 2 {
t.Fatalf("Cmd+B twice: collapsed=%v toggled=%d", p.collapsed, p.toggled)
}
}
func TestAppShellContentScrollsAlone(t *testing.T) {
p := &appShellProbe{}
tt := appShellTester(p)
nav, _ := tt.Find("Nav 0")
tt.Scroll(400, 200, 0, 96)
if p.content.Y != 96 {
t.Fatalf("content offset %v, want 96", p.content.Y)
}
if r, _ := tt.Find("Nav 0"); r.Y != nav.Y {
t.Fatalf("sidebar moved from %v to %v with the content", nav.Y, r.Y)
}
if r, _ := tt.Find("Court Records"); r.Y > 20 {
t.Fatalf("title bar scrolled to %v", r.Y)
}
// Collapsing keeps the content's place.
tt.Key(ui.Cmd, ui.KeyB)
if p.content.Y != 96 {
t.Fatalf("collapse reset the content offset to %v", p.content.Y)
}
}
func TestAppShellNeedsContent(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("missing Content did not panic")
}
}()
var collapsed bool
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AppShell(c, &collapsed, AppShellOptions{})
}, 100, 100)
}
func TestAppShellSnapshot(t *testing.T) {
snapshot(t, "app_shell", 560, 260, func(c *ui.Context) {
collapsed := ui.Local(c.Root(), "snap", func() bool { return false })
AppShell(c, collapsed, AppShellOptions{
TitleBar: func() { TitleBar(c, "Court Records", TitleBarOptions{}) },
Sidebar: func() {
for _, s := range []string{"Chronicles", "Letters", "Decrees"} {
ui.Text(c, s).Padding(6, 12)
}
},
Content: func() {
Container(c, ContainerOptions{}, func() {
Stack(c, StackOptions{Gap: SpaceM}, func() {
ui.Text(c, "The Grand Hall").FontSize(18)
Divider(c, DividerOptions{Ornament: true})
ui.Text(c, "Content scrolls on its own, apart from the sidebar.")
})
})
},
StatusBar: func() { StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "s", Text: "Ready"}}}) },
})
})
}
+49
View File
@@ -0,0 +1,49 @@
package mujica
import "github.com/egoist/mygo/ui"
// AspectRatioOptions configure an AspectRatio.
type AspectRatioOptions struct {
// Ratio is width over height; zero is 1.
Ratio float32
}
// AspectRatio fills its parent's box and centers a frame of Ratio inside, as large as fits.
// The child is centered in the frame without stretching and clipped to it.
func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element {
if opts.Ratio < 0 {
panic("mujica: AspectRatio Ratio must be positive")
}
if child == nil {
panic("mujica: AspectRatio needs a child")
}
r := opts.Ratio
if r == 0 {
r = 1
}
outer := ui.Box(c).Fill().Center()
room := outer.Bounds()
return outer.Children(func() {
frame := ui.Box(c).AspectRatio(r).Shrink(0).Center().Clip()
was := frame.Bounds()
w := room.W
// A room whose height differs from the frame's, or a frame narrower than it, has a set height.
if room.H-was.H > 0.5 || was.H-room.H > 0.5 || was.W < room.W-0.5 {
w = min(w, room.H*r)
}
if w <= 0 {
// Not laid out yet: lay out once, then size from the room.
frame.WidthPercent(100)
if first := ui.Local(outer, "aspect_ratio.first", func() bool { return true }); *first {
*first = false
c.Invalidate()
}
} else {
frame.Width(w)
if w-was.W > 0.5 || was.W-w > 0.5 {
c.Invalidate()
}
}
frame.Children(child)
})
}
+113
View File
@@ -0,0 +1,113 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
// aspectRatioBounds returns the frame (via a filling child) and a fixed 40x20 child.
func aspectRatioBounds(t *testing.T, ratio float32, w, h int) (box, child ui.Rect) {
t.Helper()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Size(float32(w), float32(h)).Children(func() {
AspectRatio(c, AspectRatioOptions{Ratio: ratio}, func() {
box = ui.Box(c).Fill().Absolute().Bounds()
child = ui.Box(c).Size(40, 20).Bounds()
})
})
}, 600, 600).Frame()
return box, child
}
func TestAspectRatioWidthBound(t *testing.T) {
box, _ := aspectRatioBounds(t, 16.0/9, 320, 400)
if box.W != 320 || box.H != 180 {
t.Fatalf("box %vx%v, want 320x180", box.W, box.H)
}
}
func TestAspectRatioHeightBound(t *testing.T) {
box, _ := aspectRatioBounds(t, 2, 400, 100)
if box.W != 200 || box.H != 100 {
t.Fatalf("box %vx%v, want 200x100", box.W, box.H)
}
}
func TestAspectRatioDefaultSquareAndUnstretchedChild(t *testing.T) {
box, child := aspectRatioBounds(t, 0, 200, 400)
if box.W != 200 || box.H != 200 {
t.Fatalf("box %vx%v, want 200x200", box.W, box.H)
}
if child.W != 40 || child.H != 20 {
t.Fatalf("child stretched to %vx%v", child.W, child.H)
}
if child.X != box.X+80 || child.Y != box.Y+90 {
t.Fatalf("child at %v,%v not centered in %+v", child.X, child.Y, box)
}
}
func TestAspectRatioFollowsWindowResize(t *testing.T) {
var box ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Fill().Children(func() {
AspectRatio(c, AspectRatioOptions{Ratio: 2}, func() {
box = ui.Box(c).Fill().Absolute().Bounds()
})
})
}, 400, 100)
if box.W != 200 || box.H != 100 {
t.Fatalf("short window: %vx%v, want 200x100", box.W, box.H)
}
tt.SetSize(400, 400)
tt.Frame()
if box.W != 400 || box.H != 200 {
t.Fatalf("tall window: %vx%v, want 400x200", box.W, box.H)
}
tt.SetSize(300, 400)
tt.Frame()
if box.W != 300 || box.H != 150 {
t.Fatalf("narrow window: %vx%v, want 300x150", box.W, box.H)
}
}
func TestAspectRatioRejectsNegative(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("negative ratio did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AspectRatio(c, AspectRatioOptions{Ratio: -1}, func() {})
}, 100, 100)
}
func TestAspectRatioSnapshot(t *testing.T) {
snapshot(t, "aspect_ratio", 420, 160, func(c *ui.Context) {
k := Tokens(c)
ui.Row(c).Padding(16).Gap(16).Height(160).AlignItems(ui.Stretch).Children(func() {
for _, r := range []float32{1, 16.0 / 9, 3.0 / 4} {
ui.Box(c).Width(120).Children(func() {
AspectRatio(c, AspectRatioOptions{Ratio: r}, func() {
ui.Box(c).Fill().Center().Background(k.Surface).Border(1, k.Border).Radius(4).Children(func() {
ui.Text(c, aspectRatioLabel(r)).TextColor(k.TextMuted)
})
})
})
}
})
})
}
func aspectRatioLabel(r float32) string {
switch r {
case 1:
return "1 : 1"
case 3.0 / 4:
return "3 : 4"
}
return "16 : 9"
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"unicode"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// AvatarOptions configure an Avatar.
type AvatarOptions struct {
// Image is the picture; nil falls back to initials, then a person icon.
Image *ui.Bitmap
// Size is the diameter in DIPs; zero means 32.
Size float32
}
// Avatar shows a person as a round picture, their initials or an icon.
// Assistive technology reads it as an image named name.
func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element {
size := opts.Size
if size < 0 {
panic("mujica: Avatar Size must not be negative")
}
if size == 0 {
size = 32
}
k := Tokens(c)
label := name
if label == "" {
label = msg(c, "avatar.unnamed")
}
e := ui.Box(c).Size(size, size).Radius(size / 2).Clip().Shrink(0).Center().Role(ui.RoleImage).Label(label)
initials := avatarInitials(name)
e.Children(func() {
switch {
case opts.Image != nil:
ui.Image(c, opts.Image).Fit(ui.Cover).Size(size, size)
case initials != "":
e.Background(k.Selection).Border(theme.BorderWidth, k.Border)
ui.Text(c, initials).Font(theme.SerifFont).FontSize(size * avatarInitialsScale(initials)).FontWeight(600).
TextColor(k.AccentText).SingleLine()
default:
e.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
Icon(c, "user", IconOptions{Size: size * 0.5, Tone: IconMuted})
}
})
return e
}
// avatarInitials returns the first letters of the first and last words.
func avatarInitials(name string) string {
var first, last rune
inWord := false
for _, r := range name {
letter := unicode.IsLetter(r) || unicode.IsDigit(r)
if letter && !inWord {
if first == 0 {
first = unicode.ToUpper(r)
} else {
last = unicode.ToUpper(r)
}
}
inWord = letter
}
switch {
case first == 0:
return ""
case last == 0:
return string(first)
}
return string([]rune{first, last})
}
// avatarInitialsScale fits two letters inside the circle.
func avatarInitialsScale(initials string) float32 {
if len([]rune(initials)) > 1 {
return 0.34
}
return 0.42
}
+128
View File
@@ -0,0 +1,128 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AvatarItem is one person of an AvatarGroup.
type AvatarItem struct {
Name string
Image *ui.Bitmap
}
// AvatarGroupOptions configure an AvatarGroup.
type AvatarGroupOptions struct {
// Max is how many avatars show before the "+N" button; zero means 4.
Max int
// Size is each avatar's diameter; zero means 32.
Size float32
}
type avatarGroupKey struct{}
// AvatarGroupView is a shown AvatarGroup and its full list's state.
type AvatarGroupView struct {
Element *ui.Element
// Open reports whether the full list shows.
Open bool
// Toggled reports the full list opened or closed this frame.
Toggled bool
}
// AvatarGroup shows overlapping avatars. Past Max, a "+N" button opens a
// popover with the full list of names.
func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) AvatarGroupView {
limit := opts.Max
if limit < 0 {
panic("mujica: AvatarGroup Max must not be negative")
}
if limit == 0 {
limit = 4
}
size := opts.Size
if size < 0 {
panic("mujica: AvatarGroup Size must not be negative")
}
if size == 0 {
size = 32
}
k := Tokens(c)
shown := min(limit, len(items))
ring := float32(2)
row := ui.Row(c).AlignItems(ui.Center).Role(ui.RoleGroup)
open := ui.Local(row, avatarGroupKey{}, func() bool { return false })
was := *open
row.Children(func() {
for i, it := range items[:shown] {
cell := ui.Box(c).Key(i).Padding(ring).Radius(size/2 + ring).Background(k.Background).Shrink(0)
if i > 0 {
cell.Margin(0, 0, 0, -size/4)
}
cell.Children(func() { Avatar(c, it.Name, AvatarOptions{Image: it.Image, Size: size}) })
}
rest := len(items) - shown
if rest == 0 {
*open = false
return
}
more := avatarGroupMore(c, k, size, ring, rest, len(items))
if more.Clicked() {
*open = !*open
}
ui.PopoverBase(c, more, open, func(panel *ui.Element) {
avatarGroupList(c, k, panel, items)
})
})
v := AvatarGroupView{Element: row, Open: *open, Toggled: *open != was}
if v.Toggled {
slog.Debug("mujica: avatargroup toggle", "open", v.Open)
}
return v
}
func avatarGroupMore(c *ui.Context, k theme.Tokens, size, ring float32, rest, total int) *ui.Element {
cell := ui.Box(c).Key("more").Padding(ring).Radius(size/2+ring).Background(k.Background).
Margin(0, 0, 0, -size/4).Shrink(0)
var b *ui.Element
cell.Children(func() {
b = ui.ButtonBase(c).Size(size, size).Radius(size / 2).Label(fmt.Sprintf(msg(c, "avatar_group.more"), total))
bg := k.Surface
if b.Pressed() {
bg = k.Selection
} else if b.Hovered() {
bg = k.SurfaceHover
}
b.Background(bg).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Text)
internal.Ring(b, k.Focus, size/2)
internal.Fade(b, Motion(c, theme.StateDuration))
b.Children(func() {
ui.Text(c, fmt.Sprintf("+%d", rest)).FontSize(size * 0.36).FontWeight(600).SingleLine()
})
})
return b
}
func avatarGroupList(c *ui.Context, k theme.Tokens, panel *ui.Element, items []AvatarItem) {
u := Density(c).Unit()
panel.Margin(u, 0, 0, 0).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).MinWidth(200)
if d := Motion(c, theme.PopupDuration); d > 0 {
panel.Transition(ui.ElementTransition{Duration: d, Enter: &ui.Motion{Y: -4}})
}
ui.Scroll(c).MaxHeight(240).Children(func() {
ui.Column(c).Gap(u * 2).Role(ui.RoleList).Children(func() {
for i, it := range items {
ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
Avatar(c, it.Name, AvatarOptions{Image: it.Image, Size: 24})
ui.Text(c, it.Name).SingleLine()
})
}
})
})
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var avatarGroupPeople = []AvatarItem{
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"},
}
func TestAvatarGroupMaxAndRest(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 3})
}, 400, 300)
tt.Frame()
if !tt.HasText("+3") {
t.Fatalf("texts %v", tt.Texts())
}
if _, ok := tt.Find("Umiri Yahata"); ok {
t.Fatal("fourth avatar shown past Max")
}
}
func TestAvatarGroupNoOverflowNoButton(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AvatarGroup(c, avatarGroupPeople[:2], AvatarGroupOptions{})
}, 400, 300)
tt.Frame()
if tt.HasText("+") {
t.Fatalf("overflow button with 2 of 4: %v", tt.Texts())
}
}
func TestAvatarGroupExpandByKeyboard(t *testing.T) {
var toggles []bool
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if v := AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 2}); v.Toggled {
toggles = append(toggles, v.Open)
}
}, 400, 400)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Show all 6 people") {
t.Fatal("overflow button not focused")
}
tt.Key(0, ui.KeyEnter)
for _, p := range avatarGroupPeople {
if !tt.HasText(p.Name) {
t.Fatalf("list lacks %q: %v", p.Name, tt.Texts())
}
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Tomori Takamatsu") {
t.Fatal("list still open after Escape")
}
if len(toggles) != 2 || !toggles[0] || toggles[1] {
t.Fatalf("toggles %v, want [true false]", toggles)
}
}
func TestAvatarGroupExpandByClick(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 4})
}, 400, 400)
if err := tt.Click("+2"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Tomori Takamatsu") {
t.Fatal("list not open")
}
tt.ClickAt(390, 390)
if tt.HasText("Tomori Takamatsu") {
t.Fatal("list open after outside click")
}
}
func TestAvatarGroupSnapshot(t *testing.T) {
snapshot(t, "avatar_group", 320, 260, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).AlignItems(ui.Start).Children(func() {
AvatarGroup(c, avatarGroupPeople[:3], AvatarGroupOptions{})
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 3, Size: 40})
})
})
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestAvatarInitials(t *testing.T) {
for name, want := range map[string]string{
"Sakiko Togawa": "ST",
"uika": "U",
"Anon Chihaya Mortis": "AM",
" ": "",
"张伟": "张",
"mutsumi (wakaba) 若叶睦": "M若",
} {
if got := avatarInitials(name); got != want {
t.Errorf("%q: %q, want %q", name, got, want)
}
}
}
func TestAvatarFallbacks(t *testing.T) {
var size ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).AlignItems(ui.Start).Children(func() {
size = Avatar(c, "Sakiko Togawa", AvatarOptions{Size: 48}).Bounds()
Avatar(c, "", AvatarOptions{})
Avatar(c, "Photo Person", AvatarOptions{Image: imageTestBitmap()})
})
}, 300, 100)
tt.Frame()
if size.W != 48 || size.H != 48 {
t.Fatalf("size %v", size)
}
if !tt.HasText("ST") {
t.Fatal("initials missing")
}
if _, ok := tt.Find("Unnamed"); !ok {
t.Fatal("unnamed avatar not labeled")
}
if _, ok := tt.Find("Photo Person"); !ok {
t.Fatal("image avatar not labeled")
}
if tt.HasText("PP") {
t.Fatal("initials shown over image")
}
}
func TestAvatarSnapshot(t *testing.T) {
snapshot(t, "avatar", 320, 90, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Center).Children(func() {
Avatar(c, "Sakiko Togawa", AvatarOptions{Size: 48})
Avatar(c, "Uika", AvatarOptions{})
Avatar(c, "", AvatarOptions{Size: 40})
Avatar(c, "Photo", AvatarOptions{Image: imageTestBitmap(), Size: 40})
Avatar(c, "Mutsumi Wakaba", AvatarOptions{Size: 24})
})
})
}
+144
View File
@@ -0,0 +1,144 @@
package mujica
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// BadgeTone is a badge's emphasis. The zero value is BadgeAccent.
type BadgeTone int
const (
BadgeAccent BadgeTone = iota
BadgeNeutral
BadgeSuccess
BadgeWarning
BadgeDanger
BadgeInfo
)
// BadgePosition places a badge. The zero value lays it out inline.
type BadgePosition int
const (
BadgeInline BadgePosition = iota
BadgeTopRight
BadgeTopLeft
BadgeBottomRight
BadgeBottomLeft
)
// BadgeOptions configure a Badge.
type BadgeOptions struct {
// Text shows as is; when empty, Count shows instead.
Text string
// Count must not be negative; zero with no Text hides the badge.
Count int
// Max caps the shown count as "Max+"; zero means 99.
Max int
Tone BadgeTone
Position BadgePosition
// Label says what the badge counts; it is read before the full value.
Label string
}
// Badge shows a short text or count. Inline badges take no content; corner
// positions pin the badge on content's corner.
func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element {
if opts.Count < 0 || opts.Max < 0 {
panic("mujica: Badge Count and Max must not be negative")
}
if (opts.Position == BadgeInline) != (content == nil) {
panic("mujica: Badge needs content exactly when Position is not BadgeInline")
}
shown, full := badgeText(opts)
if opts.Position == BadgeInline {
if shown == "" {
return ui.Box(c).Size(0, 0)
}
return badgePill(c, opts, shown, full)
}
at := badgeAnchor(opts.Position)
wrap := ui.Box(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Start)
wrap.Children(func() {
content()
if shown != "" {
badgePill(c, opts, shown, full).Attach(at, ui.AnchorCenter)
}
})
return wrap
}
// badgeText returns what the badge shows and what it reads.
func badgeText(opts BadgeOptions) (shown, full string) {
if opts.Text != "" {
return opts.Text, opts.Text
}
if opts.Count == 0 {
return "", ""
}
limit := opts.Max
if limit == 0 {
limit = 99
}
full = strconv.Itoa(opts.Count)
if opts.Count > limit {
return strconv.Itoa(limit) + "+", full
}
return full, full
}
func badgePill(c *ui.Context, opts BadgeOptions, shown, full string) *ui.Element {
k := Tokens(c)
bg, fg, border := badgeColors(k, opts.Tone)
label := full
if opts.Label != "" {
label = opts.Label + " " + full
}
p := ui.Box(c).Row().Center().MinWidth(18).Height(18).Padding(0, 5).Radius(theme.CardRadius).
Background(bg).Shrink(0).Role(ui.RoleStatus).Label(label)
if border.A > 0 {
p.Border(theme.BorderWidth, border)
}
p.Children(func() {
ui.Text(c, shown).FontSize(theme.CaptionSize - 1).FontWeight(600).TextColor(fg).
FontFeatures("tnum").SingleLine()
})
return p
}
func badgeColors(k theme.Tokens, t BadgeTone) (bg, fg, border ui.Color) {
switch t {
case BadgeAccent:
return k.Accent, k.OnAccent, ui.Color{}
case BadgeNeutral:
return k.SurfaceHover, k.Text, k.Border
case BadgeSuccess:
return k.Surface, k.Success, k.Success
case BadgeWarning:
return k.Surface, k.Warning, k.Warning
case BadgeDanger:
return k.DangerFill, k.OnDanger, ui.Color{}
case BadgeInfo:
return k.Surface, k.Info, k.Info
}
panic(fmt.Sprintf("mujica: unknown BadgeTone %d", t))
}
func badgeAnchor(p BadgePosition) ui.Anchor {
switch p {
case BadgeTopRight:
return ui.AnchorTopRight
case BadgeTopLeft:
return ui.AnchorTopLeft
case BadgeBottomRight:
return ui.AnchorBottomRight
case BadgeBottomLeft:
return ui.AnchorBottomLeft
}
panic(fmt.Sprintf("mujica: unknown BadgePosition %d", p))
}
+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBadgeText(t *testing.T) {
for _, tc := range []struct {
opts BadgeOptions
shown, full string
}{
{BadgeOptions{Count: 7}, "7", "7"},
{BadgeOptions{Count: 99}, "99", "99"},
{BadgeOptions{Count: 100}, "99+", "100"},
{BadgeOptions{Count: 12, Max: 9}, "9+", "12"},
{BadgeOptions{Count: 0}, "", ""},
{BadgeOptions{Text: "New", Count: 5}, "New", "New"},
} {
shown, full := badgeText(tc.opts)
if shown != tc.shown || full != tc.full {
t.Errorf("%+v: %q %q", tc.opts, shown, full)
}
}
}
func TestBadgeReadsFullValue(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Badge(c, BadgeOptions{Count: 1204, Label: "Unread"}, nil)
}, 300, 100)
tt.Frame()
if !tt.HasText("99+") {
t.Fatalf("texts %v", tt.Texts())
}
if _, ok := tt.Find("Unread 1204"); !ok {
t.Fatal("full value not read")
}
}
func TestBadgeCornerPosition(t *testing.T) {
for _, tc := range []struct {
pos BadgePosition
right, down bool
}{{BadgeTopRight, true, false}, {BadgeTopLeft, false, false}, {BadgeBottomRight, true, true}, {BadgeBottomLeft, false, true}} {
var host ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(40).Children(func() {
Badge(c, BadgeOptions{Count: 3, Position: tc.pos}, func() {
host = ui.Box(c).Size(60, 60).Bounds()
})
})
}, 300, 300)
tt.Frame()
r, ok := tt.Find("3")
if !ok {
t.Fatalf("%v: badge missing", tc.pos)
}
cx, cy := r.X+r.W/2, r.Y+r.H/2
wantX, wantY := host.X, host.Y
if tc.right {
wantX += host.W
}
if tc.down {
wantY += host.H
}
if d := cx - wantX; d < -2 || d > 2 {
t.Errorf("%v: x %v, want %v", tc.pos, cx, wantX)
}
if d := cy - wantY; d < -2 || d > 2 {
t.Errorf("%v: y %v, want %v", tc.pos, cy, wantY)
}
}
}
func TestBadgeBadOptionsPanic(t *testing.T) {
for i, build := range []func(c *ui.Context){
func(c *ui.Context) { Badge(c, BadgeOptions{Count: -1}, nil) },
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1, Position: BadgeTopRight}, nil) },
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1}, func() {}) },
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1, Tone: BadgeTone(42)}, nil) },
} {
func() {
defer func() {
if recover() == nil {
t.Fatalf("case %d: no panic", i)
}
}()
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
build(c)
}, 100, 100)
tt.Frame()
}()
}
}
func TestBadgeSnapshot(t *testing.T) {
snapshot(t, "badge", 460, 100, func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(14).AlignItems(ui.Center).Children(func() {
Badge(c, BadgeOptions{Count: 7}, nil)
Badge(c, BadgeOptions{Count: 250}, nil)
Badge(c, BadgeOptions{Text: "Draft", Tone: BadgeNeutral}, nil)
Badge(c, BadgeOptions{Text: "Live", Tone: BadgeSuccess}, nil)
Badge(c, BadgeOptions{Text: "Late", Tone: BadgeWarning}, nil)
Badge(c, BadgeOptions{Text: "Info", Tone: BadgeInfo}, nil)
Badge(c, BadgeOptions{Count: 3, Tone: BadgeDanger, Position: BadgeTopRight}, func() {
Avatar(c, "Sakiko Togawa", AvatarOptions{})
})
})
})
}
+157
View File
@@ -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)
}
}
})
}
+110
View File
@@ -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})
})
})
}
+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ButtonGroupItem is one button of a ButtonGroup. Give Label, Icon or both.
type ButtonGroupItem struct {
Label string
Icon *ui.SVG
// Name names an icon-only item for assistive technology.
Name string
Disabled bool
}
// ButtonGroupOptions configure a ButtonGroup.
type ButtonGroupOptions struct {
Vertical bool
// Label names the group for assistive technology.
Label string
Disabled bool
}
// ButtonGroupResult is the group element and the item clicked this frame.
type ButtonGroupResult struct {
*ui.Element
clicked int
}
// ClickedItem returns the index of the item clicked this frame, or -1.
func (r ButtonGroupResult) ClickedItem() int { return r.clicked }
// ButtonGroup joins buttons in one bordered strip; arrows move between them.
func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions) ButtonGroupResult {
if len(items) == 0 {
panic("mujica: ButtonGroup needs items")
}
k := Tokens(c)
d := Density(c)
orient, last := ui.Horizontal, len(items)-1
g := ui.Row(c).AlignItems(ui.Stretch).Shrink(0)
if opts.Vertical {
orient = ui.Vertical
g.Column()
}
g.FocusGroup(orient).Role(ui.RoleToolbar).Label(opts.Label).Disabled(opts.Disabled)
r := ButtonGroupResult{Element: g, clicked: -1}
g.Children(func() {
for i, it := range items {
if it.Label == "" && it.Icon == nil {
panic("mujica: ButtonGroupItem needs a Label or an Icon")
}
b := ui.ButtonBase(c).Key(i).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).
Padding(0, d.Unit()*3).Gap(d.Unit() * 2).Disabled(it.Disabled)
internal.Ring(b, k.Focus, theme.ControlRadius)
if it.Name != "" {
b.Label(it.Name)
}
bg := k.Surface
if b.Pressed() {
bg = k.Selection
} else if b.Hovered() {
bg = k.SurfaceHover
}
b.Background(bg).TextColor(k.Text).Transition(textInputFade)
b.Radius(buttonGroupCorners(i, last, opts.Vertical)...)
b.Border(theme.BorderWidth, k.ControlBorder).BorderWidth(buttonGroupEdges(i, opts.Vertical)...)
b.Children(func() {
if it.Icon != nil {
ui.Icon(c, it.Icon).Size(16, 16)
}
if it.Label != "" {
ui.Text(c, it.Label).SingleLine()
}
})
if b.Clicked() {
r.clicked = i
slog.Debug("mujica: button_group click", "item", i)
}
}
})
return r
}
// buttonGroupCorners rounds only the outer corners of item i of 0..last.
func buttonGroupCorners(i, last int, vertical bool) []float32 {
var tl, tr, br, bl float32
r := theme.ControlRadius
first, end := i == 0, i == last
if vertical {
if first {
tl, tr = r, r
}
if end {
br, bl = r, r
}
} else {
if first {
tl, bl = r, r
}
if end {
tr, br = r, r
}
}
return []float32{tl, tr, br, bl}
}
// buttonGroupEdges draws each shared edge once: items after the first skip the leading one.
func buttonGroupEdges(i int, vertical bool) []float32 {
w := theme.BorderWidth
switch {
case i == 0:
return []float32{w}
case vertical:
return []float32{0, w, w, w}
default:
return []float32{w, w, w, 0}
}
}
+84
View File
@@ -0,0 +1,84 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
var buttonGroupTestItems = []ButtonGroupItem{{Label: "Left"}, {Label: "Center", Disabled: true}, {Label: "Right"}}
func TestButtonGroupClickAndArrows(t *testing.T) {
var got []int
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if i := ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Label: "Align"}).ClickedItem(); i >= 0 {
got = append(got, i)
}
}, 400, 200)
_ = tt.Click("Center")
_ = tt.Click("Left")
tt.Key(0, ui.KeyRight)
if !tt.Focused("Right") {
t.Fatal("Right arrow did not skip the disabled item to Right")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyRight)
if !tt.Focused("Left") {
t.Fatal("arrows do not wrap round")
}
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
t.Fatalf("clicked %v, want [0 2]", got)
}
}
func TestButtonGroupOneTabStop(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Vertical: true})
Button(c, "After", ButtonOptions{})
}, 400, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Left") {
t.Fatal("Tab did not enter the group")
}
tt.Key(0, ui.KeyDown)
if !tt.Focused("Right") {
t.Fatal("Down did not move in a vertical group")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("After") {
t.Fatal("Tab did not leave the group")
}
}
func TestButtonGroupDisabledGroup(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Disabled: true}).ClickedItem() >= 0 {
n++
}
}, 400, 200)
_ = tt.Click("Left")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if n != 0 {
t.Fatalf("disabled group clicked %d", n)
}
}
func TestButtonGroupSnapshot(t *testing.T) {
snapshot(t, "button_group", 420, 200, func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(24).AlignItems(ui.Start).Children(func() {
ButtonGroup(c, []ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month", Disabled: true}}, ButtonGroupOptions{})
ButtonGroup(c, []ButtonGroupItem{
{Icon: icons.Must("bold"), Name: "Bold"},
{Icon: icons.Must("italic"), Name: "Italic"},
{Icon: icons.Must("underline"), Name: "Underline"},
}, ButtonGroupOptions{Vertical: true})
})
})
}
+47 -10
View File
@@ -1,10 +1,12 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
@@ -80,16 +82,51 @@ func TestButtonMinHeight(t *testing.T) {
}
}
func TestButtonSnapshot(t *testing.T) {
snapshot(t, "button", 640, 140, func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(12).Wrap().Children(func() {
Button(c, "Primary", ButtonOptions{})
Button(c, "Secondary", ButtonOptions{Variant: Secondary})
Button(c, "Outline", ButtonOptions{Variant: Outline})
Button(c, "Ghost", ButtonOptions{Variant: Ghost})
Button(c, "Delete", ButtonOptions{Variant: Danger})
Button(c, "Saving", ButtonOptions{Loading: true})
Button(c, "Disabled", ButtonOptions{Disabled: true})
func TestButtonIconAndDanger(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "", ButtonOptions{Variant: Danger, Icon: icons.Must("trash-2"), Label: "Delete"}).Clicked() {
n++
}
}, 300, 200)
if err := tt.Click("Delete"); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("icon button clicked %d times", n)
}
}
// buttonDensitySnapshots snapshots view at Compact and Comfortable as name-<density>.
func buttonDensitySnapshots(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
for _, d := range []theme.Density{theme.Compact, theme.Comfortable} {
snapshot(t, name+"-"+strings.ToLower(d.String()), w, h, func(c *ui.Context) {
m := Light
if Tokens(c).Dark {
m = Dark
}
Use(c, Settings{Mode: m, Density: d})
view(c)
})
}
}
func TestButtonSnapshot(t *testing.T) {
snapshot(t, "button", 640, 140, buttonSnapshotView)
buttonDensitySnapshots(t, "button", 640, 160, buttonSnapshotView)
}
func buttonSnapshotView(c *ui.Context) {
ui.Row(c).Padding(24).Gap(12).Wrap().Children(func() {
Button(c, "Primary", ButtonOptions{})
Button(c, "Secondary", ButtonOptions{Variant: Secondary})
Button(c, "Outline", ButtonOptions{Variant: Outline})
Button(c, "Ghost", ButtonOptions{Variant: Ghost})
Button(c, "Delete", ButtonOptions{Variant: Danger})
Button(c, "Saving", ButtonOptions{Loading: true})
Button(c, "Disabled", ButtonOptions{Disabled: true})
Button(c, "Add", ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")})
})
}
+267
View File
@@ -0,0 +1,267 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// WeekStart is the first column of a month. The zero value follows the locale:
// Monday for zh-CN, Sunday for en-US.
type WeekStart int
const (
WeekStartLocale WeekStart = iota
WeekStartSunday
WeekStartMonday
WeekStartSaturday
)
// CalendarOptions configure a Calendar.
type CalendarOptions struct {
// Min and Max bound the days that can be chosen; zero leaves a side open.
Min, Max Date
// DisableDate marks single days that cannot be chosen.
DisableDate func(Date) bool
WeekStart WeekStart
// Today is marked; zero uses the system clock.
Today Date
Disabled bool
// Label names the calendar for assistive technology.
Label string
}
// Calendar shows one month and chooses a day into *value.
// Arrows move by day and week, PageUp/PageDown by month, Shift with them by year.
func Calendar(c *ui.Context, value *Date, opts CalendarOptions) DateTimeResult {
datetimeCheckDate("Calendar value", *value)
datetimeCheckBounds("Calendar", opts.Min, opts.Max)
box := ui.Column(c)
cursor := ui.Local(box, "calendar-cursor", func() Date { return Date{} })
seen := ui.Local(box, "calendar-seen", func() Date { return Date{} })
today := datetimeToday(c, opts.Today)
if *seen != *value || cursor.IsZero() {
*seen = *value
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
changed := false
box.Disabled(opts.Disabled).Children(func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(d Date) bool { return d == *value },
enabled: calendarEnabled(opts.Min, opts.Max, opts.DisableDate),
today: today,
weekStart: opts.WeekStart,
label: opts.Label,
})
if !picked.IsZero() && picked != *value {
slog.Debug("mujica: calendar change", "from", *value, "to", picked)
*value, *seen = picked, picked
changed = true
}
})
return DateTimeResult{Element: box, changed: changed}
}
// calendarConfig is how calendarMonth marks and limits its days.
type calendarConfig struct {
chosen func(Date) bool
inRange func(Date) bool
enabled func(Date) bool
today Date
weekStart WeekStart
autoFocus bool
label string
}
// calendarEnabled combines bounds and a day filter.
func calendarEnabled(lo, hi Date, disable func(Date) bool) func(Date) bool {
return func(d Date) bool { return d.within(lo, hi) && (disable == nil || !disable(d)) }
}
// calendarFirstWeekday resolves the first column for the window's locale.
func calendarFirstWeekday(c *ui.Context, w WeekStart) time.Weekday {
switch w {
case WeekStartSunday:
return time.Sunday
case WeekStartMonday:
return time.Monday
case WeekStartSaturday:
return time.Saturday
case WeekStartLocale:
if CurrentLocale(c) == ZhCN {
return time.Monday
}
return time.Sunday
}
panic(fmt.Sprintf("mujica: unknown WeekStart %d", w))
}
// calendarSeek returns the first enabled day from d stepping by dir, or d.
func calendarSeek(d Date, dir int, enabled func(Date) bool) Date {
for i, x := 0, d; i < 31; i, x = i+1, x.AddDays(dir) {
if enabled(x) {
return x
}
}
return d
}
// calendarMonth draws the month of *cursor and returns the day picked and the day hovered.
func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hovered Date) {
k := Tokens(c)
d := Density(c)
cur := *cursor
title := datetimeMonthTitle(c, cur.Year, cur.Month)
label := cfg.label
if label == "" {
label = msg(c, "calendar.label")
}
cell := d.ControlHeight()
root := ui.Column(c).Gap(d.Unit() * 2).Shrink(0).Width(cell * 7).Role(ui.RoleGroup).Label(label)
keyed := ui.Local(root, "calendar-keys", func() bool { return false })
move := func(to Date, dir int, keys bool) {
*cursor = calendarSeek(to, dir, cfg.enabled)
*keyed = keys
}
root.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).Children(func() {
if datetimeIconButton(c, "chevrons-left", msg(c, "calendar.prev_year")).Clicked() {
move(cur.AddMonths(-12), -1, false)
}
if datetimeIconButton(c, "chevron-left", msg(c, "calendar.prev_month")).Clicked() {
move(cur.AddMonths(-1), -1, false)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if datetimeIconButton(c, "chevron-right", msg(c, "calendar.next_month")).Clicked() {
move(cur.AddMonths(1), 1, false)
}
if datetimeIconButton(c, "chevrons-right", msg(c, "calendar.next_year")).Clicked() {
move(cur.AddMonths(12), 1, false)
}
})
first := calendarFirstWeekday(c, cfg.weekStart)
ui.Row(c).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).PaddingY(d.Unit()).Children(func() {
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center).
FontSize(theme.CaptionSize).TextColor(k.TextMuted).Role(ui.RoleColumnHeader).
Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
}
})
days := ui.Column(c).Role(ui.RoleTable).Label(title).FocusGroup(ui.Horizontal | ui.Vertical)
keysMoved := true
switch {
case days.Shortcut(0, ui.KeyLeft):
move(cur.AddDays(-1), -1, true)
case days.Shortcut(0, ui.KeyRight):
move(cur.AddDays(1), 1, true)
case days.Shortcut(0, ui.KeyUp):
move(cur.AddDays(-7), -1, true)
case days.Shortcut(0, ui.KeyDown):
move(cur.AddDays(7), 1, true)
case days.Shortcut(0, ui.KeyPageUp):
move(cur.AddMonths(-1), -1, true)
case days.Shortcut(0, ui.KeyPageDown):
move(cur.AddMonths(1), 1, true)
case days.Shortcut(ui.Shift, ui.KeyPageUp):
move(cur.AddMonths(-12), -1, true)
case days.Shortcut(ui.Shift, ui.KeyPageDown):
move(cur.AddMonths(12), 1, true)
case days.Shortcut(0, ui.KeyHome):
move(Date{cur.Year, cur.Month, 1}, 1, true)
case days.Shortcut(0, ui.KeyEnd):
move(Date{cur.Year, cur.Month, 1}.AddMonths(1).AddDays(-1), -1, true)
default:
keysMoved = false
}
cur = *cursor
start := Date{cur.Year, cur.Month, 1}
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
days.Children(func() {
for w := range 6 {
ui.Row(c).Role(ui.RoleRow).Children(func() {
for i := range 7 {
day := start.AddDays(w*7 + i)
clicked, hover := calendarDay(c, day, cur, cfg, cell, keysMoved, *keyed)
if clicked {
picked = day
move(day, 1, false)
}
if hover {
hovered = day
}
}
})
}
})
})
return picked, hovered
}
// calendarDay draws one day cell and reports whether it was picked or hovered.
func calendarDay(c *ui.Context, day, cur Date, cfg calendarConfig, size float32, focus, keyed bool) (picked, hovered bool) {
k := Tokens(c)
enabled := cfg.enabled(day)
chosen := cfg.chosen(day)
sel := Date{}
if chosen {
sel = day
}
cell := ui.RadioBase(c, &sel, day).Size(size, size-4).Center().Radius(theme.ControlRadius).
Label(datetimeDayLabel(c, day)).Disabled(!enabled).FocusRing(false)
if day == cur && enabled {
if focus {
cell.Focus()
} else if cfg.autoFocus {
cell.AutoFocus()
}
}
band := cfg.inRange != nil && cfg.inRange(day)
fg := k.Text
if day.Month != cur.Month {
fg = k.TextMuted
}
switch {
case chosen:
cell.Background(k.Accent)
fg = k.OnAccent
if cell.Hovered() {
cell.Background(k.AccentHover)
}
case band:
cell.Background(k.Selection).Radius(0)
case cell.Pressed():
cell.Background(k.Selection)
case cell.Hovered():
cell.Background(k.SurfaceHover)
}
ring := cell.FocusVisible() || cell.Focused() && keyed
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if day == cfg.today {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-5, 5), fg)
}
if ring {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
cell.Children(func() {
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(fg).FontFeatures("tnum")
if day == cfg.today {
t.Bold()
}
if !enabled {
t.Strikethrough()
}
})
return cell.Clicked() && enabled, cell.Hovered()
}
+165
View File
@@ -0,0 +1,165 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var calendarToday = Date{2026, time.October, 5}
// datetimeTestLabel is the en-US spoken name of a day cell.
func datetimeTestLabel(d Date) string {
return fmt.Sprintf("%v, %v %d, %d", d.Weekday(), d.Month, d.Day, d.Year)
}
func calendarTester(value *Date, opts CalendarOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
if Calendar(c, value, opts).Changed() {
*changes++
}
}, 400, 400)
}
func TestCalendarClickChoosesDay(t *testing.T) {
v, n := Date{}, 0
tt := calendarTester(&v, CalendarOptions{}, &n)
if !tt.HasText("October 2026") {
t.Fatalf("empty value should show today's month, got %v", tt.Texts())
}
if err := tt.Click("Monday, October 12, 2026"); err != nil {
t.Fatal(err)
}
if v != (Date{2026, time.October, 12}) || n != 1 {
t.Fatalf("value %v changes %d", v, n)
}
if err := tt.Click("Monday, October 12, 2026"); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("same day reported change again: %d", n)
}
}
func TestCalendarKeyboard(t *testing.T) {
v, n := Date{2026, time.October, 15}, 0
tt := calendarTester(&v, CalendarOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Thursday, October 15, 2026") {
t.Fatal("Tab should land on the chosen day")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Friday, October 23, 2026") {
t.Fatal("arrows should move the focus by day and week")
}
tt.Key(0, ui.KeyPageDown)
if !tt.HasText("November 2026") || !tt.Focused("Monday, November 23, 2026") {
t.Fatalf("PageDown should move a month: %v", tt.Texts()[:3])
}
tt.Key(ui.Shift, ui.KeyPageUp)
if !tt.HasText("November 2025") {
t.Fatal("Shift+PageUp should move a year")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if v != (Date{2025, time.November, 30}) || n != 1 {
t.Fatalf("Enter chose %v (%d changes)", v, n)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeySpace)
if v != (Date{2025, time.November, 1}) || n != 2 {
t.Fatalf("Space chose %v", v)
}
}
func TestCalendarLimits(t *testing.T) {
v, n := Date{2026, time.October, 10}, 0
opts := CalendarOptions{
Min: Date{2026, time.October, 5},
Max: Date{2026, time.October, 20},
DisableDate: func(d Date) bool { return d.Weekday() == time.Sunday },
}
tt := calendarTester(&v, opts, &n)
for _, label := range []string{"Thursday, October 1, 2026", "Sunday, October 11, 2026", "Wednesday, October 21, 2026"} {
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
if v != (Date{2026, time.October, 10}) || n != 0 {
t.Fatalf("disabled days changed the value to %v", v)
}
if err := tt.Click("Saturday, October 10, 2026"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyRight)
if !tt.Focused("Monday, October 12, 2026") {
t.Fatal("arrows should skip the disabled Sunday")
}
}
func TestCalendarWeekStart(t *testing.T) {
for _, tc := range []struct {
ws WeekStart
loc Locale
first string
}{{WeekStartLocale, EnUS, "Sunday"}, {WeekStartLocale, ZhCN, "星期一"}, {WeekStartMonday, EnUS, "Monday"}, {WeekStartSaturday, EnUS, "Saturday"}} {
v := Date{2026, time.October, 5}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: tc.loc})
Calendar(c, &v, CalendarOptions{WeekStart: tc.ws, Today: calendarToday})
}, 400, 400)
texts := tt.Texts()
var first string
for i, s := range texts {
if s == "2026年10月" || s == "October 2026" {
first = texts[i+3]
break
}
}
if first != tc.first {
t.Errorf("%v %v: first column %q, want %q", tc.ws, tc.loc, first, tc.first)
}
}
}
func TestCalendarDisabled(t *testing.T) {
v, n := Date{2026, time.October, 10}, 0
tt := calendarTester(&v, CalendarOptions{Disabled: true}, &n)
_ = tt.Click("Monday, October 12, 2026")
_ = tt.Click("Next month")
if v != (Date{2026, time.October, 10}) || n != 0 || !tt.HasText("October 2026") {
t.Fatalf("disabled calendar changed: %v", v)
}
}
func TestCalendarRejectsInvalidValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic for February 30")
}
}()
v := Date{2026, time.February, 30}
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Calendar(c, &v, CalendarOptions{})
}, 400, 400)
}
func TestCalendarSnapshot(t *testing.T) {
v := Date{2026, time.October, 14}
snapshot(t, "calendar", 320, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
Calendar(c, &v, CalendarOptions{Today: calendarToday, Max: Date{2026, time.October, 28},
DisableDate: func(d Date) bool { return d.Day == 20 }})
})
})
}
+134
View File
@@ -0,0 +1,134 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CheckboxOptions configure a Checkbox.
type CheckboxOptions struct {
// Indeterminate shows the mixed mark; a click then checks the box.
Indeterminate bool
Disabled bool
}
// checkboxSize is the box edge in DIPs.
const checkboxSize float32 = 16
// Checkbox toggles *value; a click on the box or its label toggles, as does Space.
func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element {
if value == nil {
panic("mujica: Checkbox needs a value")
}
k := Tokens(c)
d := Density(c)
row := ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false).Disabled(opts.Disabled)
if row.Changed() && opts.Indeterminate && !*value {
// From mixed, a click always checks.
*value = true
}
if opts.Indeterminate {
row.Description(msg(c, "checkbox.mixed"))
}
on, mixed := *value, opts.Indeterminate
hover, pressed := row.Hovered(), row.Pressed()
row.Children(func() {
box := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(theme.ControlRadius)
switch {
case on || mixed:
box.Background(k.Accent).Border(theme.BorderWidth, k.AccentText)
case pressed:
box.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
case hover:
box.Background(k.SurfaceHover).Border(theme.BorderWidth, k.AccentText)
default:
box.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
}
box.Transition(textInputFade)
box.DrawOver(func(p *ui.Painter, r ui.Rect) {
switch {
case mixed:
p.Fill(ui.Rect{X: r.X + r.W*0.25, Y: r.Y + r.H/2 - 1, W: r.W * 0.5, H: 2}, k.OnAccent, 1)
case on:
var path ui.Path
path.MoveTo(r.X+r.W*0.24, r.Y+r.H*0.52).LineTo(r.X+r.W*0.42, r.Y+r.H*0.7).LineTo(r.X+r.W*0.77, r.Y+r.H*0.3)
p.StrokePath(&path, 2, k.OnAccent)
}
if row.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
if label != "" {
ui.Text(c, label)
}
})
if row.Changed() {
slog.Debug("mujica: checkbox toggle", "label", label, "checked", *value)
}
return row
}
// CheckboxItem is one box of a CheckboxGroup.
type CheckboxItem struct {
Label string
Value *bool
Disabled bool
}
// CheckboxGroupOptions configure a CheckboxGroup.
type CheckboxGroupOptions struct {
Disabled bool
}
// CheckboxGroup shows a select-all box over items: checked, mixed or clear.
func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts CheckboxGroupOptions) InputResult {
if len(items) == 0 {
panic("mujica: CheckboxGroup needs items")
}
d := Density(c)
on, enabled, enabledOn := 0, 0, 0
for _, it := range items {
if it.Value == nil {
panic("mujica: CheckboxItem needs a Value")
}
if *it.Value {
on++
}
if !it.Disabled {
enabled++
if *it.Value {
enabledOn++
}
}
}
var r InputResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled || enabled == 0)
r.Element.Children(func() {
all := on == len(items)
head := Checkbox(c, &all, label, CheckboxOptions{Indeterminate: on > 0 && !all})
r.Input = head
if head.Changed() {
// Select-all leaves disabled items alone.
target := enabledOn < enabled
for _, it := range items {
if !it.Disabled {
*it.Value = target
}
}
r.changed = true
slog.Debug("mujica: checkbox_group select_all", "label", label, "checked", target)
}
ui.Column(c).Padding(0, 0, 0, checkboxSize+d.Unit()*2).Role(ui.RoleGroup).Label(label).Children(func() {
for _, it := range items {
if Checkbox(c, it.Value, it.Label, CheckboxOptions{Disabled: it.Disabled}).Changed() {
r.changed = true
}
}
})
})
return r
}
+104
View File
@@ -0,0 +1,104 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestCheckboxLabelClickAndSpace(t *testing.T) {
on, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Checkbox(c, &on, "Remember me", CheckboxOptions{}).Changed() {
changes++
}
}, 300, 200)
_ = tt.Click("Remember me")
if !on || changes != 1 {
t.Fatalf("label click: %v changes %d", on, changes)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if on || changes != 2 {
t.Fatalf("Space: %v changes %d", on, changes)
}
}
func TestCheckboxIndeterminateChecks(t *testing.T) {
for _, start := range []bool{false, true} {
on := start
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Checkbox(c, &on, "Some", CheckboxOptions{Indeterminate: true})
}, 300, 200)
_ = tt.Click("Some")
if !on {
t.Fatalf("from mixed with %v, click left the box clear", start)
}
}
}
func TestCheckboxDisabled(t *testing.T) {
on := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Checkbox(c, &on, "Off", CheckboxOptions{Disabled: true})
}, 300, 200)
_ = tt.Click("Off")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if on || tt.Focused("Off") {
t.Fatalf("disabled box changed %v or took focus", on)
}
}
func TestCheckboxGroupSelectAll(t *testing.T) {
mail, cal, locked := true, false, false
changes := 0
items := []CheckboxItem{{Label: "Mail", Value: &mail}, {Label: "Calendar", Value: &cal}, {Label: "Admin", Value: &locked, Disabled: true}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if CheckboxGroup(c, "Notifications", items, CheckboxGroupOptions{}).Changed() {
changes++
}
}, 300, 300)
_ = tt.Click("Notifications")
if !mail || !cal || locked || changes != 1 {
t.Fatalf("select all: mail %v cal %v admin %v changes %d", mail, cal, locked, changes)
}
_ = tt.Click("Notifications")
if mail || cal || locked {
t.Fatalf("clear all: mail %v cal %v admin %v", mail, cal, locked)
}
_ = tt.Click("Calendar")
if !cal || changes != 3 {
t.Fatalf("item click: cal %v changes %d", cal, changes)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Notifications") {
t.Fatal("Tab did not reach the select-all box")
}
tt.Key(0, ui.KeySpace)
if !mail || !cal {
t.Fatalf("Space on select-all: mail %v cal %v", mail, cal)
}
}
func checkboxSnapshotView(c *ui.Context) {
a, b, m, x, y := false, true, false, true, false
ui.Row(c).Padding(24).Gap(32).AlignItems(ui.Start).Children(func() {
ui.Column(c).Children(func() {
Checkbox(c, &a, "Unchecked", CheckboxOptions{})
Checkbox(c, &b, "Checked", CheckboxOptions{})
Checkbox(c, &m, "Indeterminate", CheckboxOptions{Indeterminate: true})
Checkbox(c, &b, "Disabled", CheckboxOptions{Disabled: true})
})
CheckboxGroup(c, "Notifications", []CheckboxItem{{Label: "Mail", Value: &x}, {Label: "Calendar", Value: &y}}, CheckboxGroupOptions{})
})
}
func TestCheckboxSnapshot(t *testing.T) {
snapshot(t, "checkbox", 420, 220, checkboxSnapshotView)
buttonDensitySnapshots(t, "checkbox", 420, 260, checkboxSnapshotView)
}
+396
View File
@@ -0,0 +1,396 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ColorPickerOptions configure a ColorPicker.
type ColorPickerOptions struct {
// Presets are offered as swatches; nil uses the built-in court palette.
Presets []ui.Color
// Label names the trigger; empty uses the built-in "Color".
Label string
Disabled bool
}
// ColorPickerParts are the parts of a ColorPicker.
type ColorPickerParts struct {
Trigger *ui.Element
changed bool
}
// Changed reports whether the user picked a new color this frame.
func (p ColorPickerParts) Changed() bool { return p.changed }
// colorPickerPresets are the default swatches: court tones and neutrals.
var colorPickerPresets = []ui.Color{
ui.Hex("#7D2034"), ui.Hex("#A12635"), ui.Hex("#AD9164"), ui.Hex("#805714"),
ui.Hex("#326247"), ui.Hex("#355E80"), ui.Hex("#4B3A5A"), ui.Hex("#211C1D"),
ui.Hex("#6A5B55"), ui.Hex("#D2C3AF"), ui.Hex("#F2EBDD"), ui.Hex("#FFFFFF"),
}
// colorPickerHSVA keeps hue for grays, which RGB loses.
type colorPickerHSVA struct{ h, s, v, a float64 }
type colorPickerState struct {
open, wasOpen bool
hsv colorPickerHSVA
last ui.Color
hex, r, g, b string
alpha string
bad string
}
// ColorPicker is a swatch button opening a panel to pick *color: a saturation and
// brightness square, hue and opacity sliders, HEX, RGB and opacity fields, and presets.
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "color_picker.label")
}
presets := opts.Presets
if presets == nil {
presets = colorPickerPresets
}
parts := ColorPickerParts{}
trigger := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*1.5).Gap(d.Unit() * 2).
Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled).Label(label)
parts.Trigger = trigger
internal.Ring(trigger, k.Focus, theme.ControlRadius)
st := ui.Local(trigger, "picker", func() colorPickerState {
s := colorPickerState{last: *color, hsv: colorPickerToHSVA(*color)}
s.sync(*color)
return s
})
if *color != st.last {
st.hsv, st.last = colorPickerToHSVA(*color), *color
st.sync(*color)
}
set := func(x colorPickerHSVA, fields bool) {
x.s, x.v, x.a = colorPickerClamp(x.s), colorPickerClamp(x.v), colorPickerClamp(x.a)
st.hsv = x
next := x.color()
if next != *color {
slog.Debug("mujica: color picker changed", "color", colorPickerHex(next))
*color = next
parts.changed = true
}
st.last = *color
if fields {
st.sync(*color)
}
}
if trigger.Clicked() {
st.open = !st.open
}
border := k.ControlBorder
if trigger.Hovered() && !opts.Disabled {
border = k.ControlBorder.Mix(k.Text, 0.25)
trigger.Background(k.SurfaceHover)
}
trigger.Border(theme.BorderWidth, border).Description(colorPickerHex(*color))
now := *color
trigger.Children(func() {
ui.Box(c).Size(d.Unit()*6, d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Clip().Shrink(0).
Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.Fill(r, now, 0)
})
ui.Text(c, strings.ToUpper(colorPickerHex(now))).Font(theme.MonoFont).SingleLine()
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted)
})
opened := st.open && !st.wasOpen
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()*3).Gap(d.Unit()*3).Width(260).Radius(theme.PopupRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.18))
colorPickerSquare(c, st, set, opened)
x := st.hsv
h := x.h
if colorPickerSlider(c, &h, 360, msg(c, "color_picker.hue"), func(p *ui.Painter, r ui.Rect) {
hues := []ui.Color{ui.RGB(255, 0, 0), ui.RGB(255, 255, 0), ui.RGB(0, 255, 0), ui.RGB(0, 255, 255), ui.RGB(0, 0, 255), ui.RGB(255, 0, 255), ui.RGB(255, 0, 0)}
w := r.W / 6
for i := range 6 {
p.FillGradient(ui.Rect{X: r.X + w*float32(i), Y: r.Y, W: w + 0.5, H: r.H}, ui.LinearGradient{From: hues[i], To: hues[i+1], Angle: 90}, 0)
}
}).Changed() {
set(colorPickerHSVA{h, x.s, x.v, x.a}, true)
}
a := x.a
opaque := colorPickerHSVA{x.h, x.s, x.v, 1}.color()
if colorPickerSlider(c, &a, 1, msg(c, "color_picker.alpha"), func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.FillGradient(r, ui.LinearGradient{From: opaque.Alpha(0), To: opaque, Angle: 90}, 0)
}).Changed() {
set(colorPickerHSVA{x.h, x.s, x.v, a}, true)
}
colorPickerFields(c, st, color, set)
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, sw := range presets {
hex := strings.ToUpper(colorPickerHex(sw))
b := ui.ButtonBase(c).Height(d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
internal.Ring(b, k.Focus, theme.ControlRadius)
chosen := *color == sw
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.Fill(r, sw, 0)
})
if chosen {
mark := ui.RGB(255, 255, 255)
if colorPickerLuma(sw) > 0.55 {
mark = ui.RGB(0, 0, 0)
}
b.Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(mark) })
}
if b.Clicked() {
set(colorPickerToHSVA(sw), true)
}
}
})
})
if st.wasOpen && !st.open {
trigger.Focus()
}
st.wasOpen = st.open
return parts
}
// colorPickerSquare picks saturation across and brightness up.
func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPickerHSVA, bool), opened bool) {
k := Tokens(c)
x := st.hsv
sq := ui.Box(c).Height(150).Radius(theme.ControlRadius).Focusable().Role(ui.RoleSlider).Cursor(ui.CursorCrosshair).
Label(msg(c, "color_picker.square"))
sq.FocusRing(false)
if opened {
sq.Focus()
}
if _, _, ok := sq.Dragged(); ok {
px, py, _ := sq.PointerPosition()
if b := sq.Bounds(); b.W > 0 && b.H > 0 {
set(colorPickerHSVA{x.h, float64(px / b.W), 1 - float64(py/b.H), x.a}, true)
}
}
switch {
case sq.Shortcut(0, ui.KeyRight):
set(colorPickerHSVA{x.h, x.s + 0.01, x.v, x.a}, true)
case sq.Shortcut(0, ui.KeyLeft):
set(colorPickerHSVA{x.h, x.s - 0.01, x.v, x.a}, true)
case sq.Shortcut(0, ui.KeyUp):
set(colorPickerHSVA{x.h, x.s, x.v + 0.01, x.a}, true)
case sq.Shortcut(0, ui.KeyDown):
set(colorPickerHSVA{x.h, x.s, x.v - 0.01, x.a}, true)
}
x = st.hsv
sq.Description(fmt.Sprintf(msg(c, "color_picker.sv"), x.s*100, x.v*100))
hue := colorPickerHSVA{x.h, 1, 1, 1}.color()
sq.Draw(func(p *ui.Painter, r ui.Rect) {
p.FillGradient(r, ui.LinearGradient{From: ui.RGB(255, 255, 255), To: hue, Angle: 90}, theme.ControlRadius)
p.FillGradient(r, ui.LinearGradient{From: ui.RGBA(0, 0, 0, 0), To: ui.RGB(0, 0, 0), Angle: 180}, theme.ControlRadius)
cx, cy := internal.Snap(p, r.X+r.W*float32(x.s)), internal.Snap(p, r.Y+r.H*float32(1-x.v))
p.FillPath(internal.Diamond(cx, cy, 14), ui.RGB(0, 0, 0).Alpha(0.5))
p.FillPath(internal.Diamond(cx, cy, 11), ui.RGB(255, 255, 255))
p.FillPath(internal.Diamond(cx, cy, 6), x.color().Alpha(1))
if sq.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
}
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
k := Tokens(c)
const knob = 10
s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name)
frac := float32(*v / hi)
s.Draw(func(p *ui.Painter, r ui.Rect) {
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
x := r.X + knob/2 + (r.W-knob)*frac
thumb := ui.Rect{X: x - knob/2, Y: r.Y, W: knob, H: r.H}
p.Fill(thumb, k.Surface, theme.ControlRadius)
p.Stroke(thumb, k.Text, theme.ControlRadius, 1.5)
if s.FocusVisible() {
internal.FocusRing(p, thumb, theme.ControlRadius, k.Focus)
}
})
return s
}
// sync shows col in every field.
func (st *colorPickerState) sync(col ui.Color) {
st.hex = strings.TrimPrefix(strings.ToUpper(colorPickerHex(col)), "#")
st.r, st.g, st.b = strconv.Itoa(int(col.R)), strconv.Itoa(int(col.G)), strconv.Itoa(int(col.B))
st.alpha = strconv.Itoa(int(math.Round(float64(col.A) / 255 * 100)))
st.bad = ""
}
// colorPickerFields edits the color as HEX, RGB and opacity text.
func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set func(colorPickerHSVA, bool)) {
k := Tokens(c)
d := Density(c)
apply := func(field string, text *string, col ui.Color, ok bool) {
if !ok {
st.bad = field
return
}
kept := *text
set(colorPickerToHSVA(col), true)
*text = kept
}
editing := false
channel := func(s string) (uint8, bool) {
n, err := strconv.Atoi(strings.TrimSpace(s))
return uint8(n), err == nil && n >= 0 && n <= 255
}
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
hex := InputGroup(c, &st.hex, InputGroupOptions{Prefix: "#", Label: "HEX", Invalid: st.bad == "hex"})
editing = hex.Input.Focused()
if hex.Changed() {
col, err := colorPickerParseHex(st.hex)
apply("hex", &st.hex, col, err == nil)
}
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, f := range []struct {
name, label string
text *string
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
ui.Column(c).Gap(d.Unit()).Children(func() {
ui.Text(c, f.name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
editing = editing || in.Input.Focused()
if !in.Changed() {
return
}
col := *color
v, ok := channel(*f.text)
switch f.name {
case "R":
col.R = v
case "G":
col.G = v
case "B":
col.B = v
default:
ok = ok && v <= 100
col.A = uint8(math.Round(float64(v) / 100 * 255))
}
apply(f.name, f.text, col, ok)
})
}
})
if !editing {
st.sync(*color)
}
if st.bad != "" {
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(theme.CaptionSize).TextColor(k.Danger).Role(ui.RoleStatus)
}
})
}
// colorPickerHex returns col as #rrggbb, or #rrggbbaa when translucent.
func colorPickerHex(col ui.Color) string {
if col.A == 255 {
return fmt.Sprintf("#%02x%02x%02x", col.R, col.G, col.B)
}
return fmt.Sprintf("#%02x%02x%02x%02x", col.R, col.G, col.B, col.A)
}
// colorPickerParseHex parses rgb, rrggbb or rrggbbaa, with or without "#".
func colorPickerParseHex(s string) (ui.Color, error) {
h := strings.TrimPrefix(strings.TrimSpace(s), "#")
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
if len(h) == 6 {
h += "ff"
}
if len(h) != 8 {
return ui.Color{}, fmt.Errorf("mujica: color %q is not hex", s)
}
v, err := strconv.ParseUint(h, 16, 32)
if err != nil {
return ui.Color{}, fmt.Errorf("mujica: color %q: %w", s, err)
}
return ui.Color{R: uint8(v >> 24), G: uint8(v >> 16), B: uint8(v >> 8), A: uint8(v)}, nil
}
func colorPickerToHSVA(col ui.Color) colorPickerHSVA {
r, g, b := float64(col.R)/255, float64(col.G)/255, float64(col.B)/255
hi, lo := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
d := hi - lo
var h float64
switch {
case d == 0:
case hi == r:
h = 60 * math.Mod((g-b)/d, 6)
case hi == g:
h = 60 * ((b-r)/d + 2)
default:
h = 60 * ((r-g)/d + 4)
}
if h < 0 {
h += 360
}
s := 0.0
if hi > 0 {
s = d / hi
}
return colorPickerHSVA{h, s, hi, float64(col.A) / 255}
}
func (x colorPickerHSVA) color() ui.Color {
ch := x.v * x.s
h := math.Mod(x.h, 360) / 60
y := ch * (1 - math.Abs(math.Mod(h, 2)-1))
var r, g, b float64
switch {
case h < 1:
r, g = ch, y
case h < 2:
r, g = y, ch
case h < 3:
g, b = ch, y
case h < 4:
g, b = y, ch
case h < 5:
r, b = y, ch
default:
r, b = ch, y
}
m := x.v - ch
byte8 := func(v float64) uint8 { return uint8(math.Round(colorPickerClamp(v) * 255)) }
return ui.Color{R: byte8(r + m), G: byte8(g + m), B: byte8(b + m), A: byte8(x.a)}
}
func colorPickerClamp(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// colorPickerLuma is the relative brightness of col, 0 to 1.
func colorPickerLuma(col ui.Color) float64 {
return (0.2126*float64(col.R) + 0.7152*float64(col.G) + 0.0722*float64(col.B)) / 255
}
// colorPickerCheckers paints the grid that shows transparency.
func colorPickerCheckers(p *ui.Painter, r ui.Rect, size float32) {
p.Fill(r, ui.RGB(255, 255, 255), 0)
for y, row := r.Y, 0; y < r.Y+r.H; y, row = y+size, row+1 {
for x, col := r.X, 0; x < r.X+r.W; x, col = x+size, col+1 {
if (row+col)%2 == 0 {
p.Fill(ui.Rect{X: x, Y: y, W: min(size, r.X+r.W-x), H: min(size, r.Y+r.H-y)}, ui.RGB(204, 204, 204), 0)
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
if ColorPicker(c, col, opts).Changed() {
*changes++
}
})
}, 400, 560)
}
func TestColorPickerPresetAndHex(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
if err := tt.Click("Accent"); err != nil {
t.Fatal(err)
}
if err := tt.Click("#355E80"); err != nil {
t.Fatal(err)
}
if col != ui.Hex("#355E80") || n != 1 {
t.Fatalf("preset: %v changes %d", col, n)
}
if err := tt.Click("HEX"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("ff000080")
if col != (ui.Color{R: 255, A: 128}) {
t.Fatalf("hex: %v", col)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("zz")
if col != (ui.Color{R: 255, A: 128}) || !tt.HasText(colorPickerInvalidEN) {
t.Fatalf("invalid hex changed the color or showed no error: %v", col)
}
}
const colorPickerInvalidEN = "Use HEX RGB, RRGGBB or RRGGBBAA; channels 0–255; opacity 0–100."
func TestColorPickerRGBAndAlphaFields(t *testing.T) {
col, n := ui.RGB(0, 0, 0), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Ink"}, &n)
_ = tt.Click("Ink")
_ = tt.Click("Green")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("200")
_ = tt.Click("Opacity percent")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("50")
if col != (ui.Color{G: 200, A: 128}) {
t.Fatalf("rgb: %v", col)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("300")
if col.A != 128 || !tt.HasText(colorPickerInvalidEN) {
t.Fatalf("opacity 300 accepted: %v", col)
}
}
func TestColorPickerKeyboardAndEscape(t *testing.T) {
col, n := ui.Hex("#808080"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Shade"}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.Focused("Saturation and brightness") {
t.Fatal("square not focused on open")
}
tt.Key(0, ui.KeyUp)
if col == ui.Hex("#808080") || col.R != col.G {
t.Fatalf("up arrow: %v", col)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Hue") {
t.Fatal("hue slider not next")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEscape)
if tt.HasText("HEX") || !tt.Focused("Shade") {
t.Fatal("escape did not close and return focus")
}
}
func TestColorPickerDisabledAndParse(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent", Disabled: true}, &n)
_ = tt.Click("Accent")
if tt.HasText("HEX") {
t.Fatal("disabled picker opened")
}
for in, want := range map[string]ui.Color{"#abc": ui.Hex("#aabbcc"), "7D2034": ui.Hex("#7D2034"), "#00000000": {}} {
got, err := colorPickerParseHex(in)
if err != nil || got != want {
t.Errorf("%q: %v %v", in, got, err)
}
}
for _, in := range []string{"", "#12", "#12345g", "#1234567"} {
if _, err := colorPickerParseHex(in); err == nil {
t.Errorf("%q parsed", in)
}
}
}
func TestColorPickerSnapshot(t *testing.T) {
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
snapshot(t, "color_picker", 340, 520, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
p := ColorPicker(c, &a, ColorPickerOptions{})
st := ui.Local(p.Trigger, "snap", func() bool { return false })
if !*st {
*st = true
ui.Local(p.Trigger, "picker", func() colorPickerState { return colorPickerState{} }).open = true
c.Invalidate()
}
})
})
}
+212
View File
@@ -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
}
+142
View File
@@ -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{})
})
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// Space is a spacing step scaled by the window's density. Zero picks the component's default.
type Space int
const (
SpaceDefault Space = iota
SpaceNone
SpaceXS // 1 unit, 4 DIPs at default density
SpaceS // 2 units
SpaceM // 3 units
SpaceL // 4 units
SpaceXL // 6 units
SpaceXXL // 8 units
)
// containerSpace returns s in DIPs for density d, using def when s is SpaceDefault.
func containerSpace(d theme.Density, s, def Space) float32 {
if s == SpaceDefault {
s = def
}
var u float32
switch s {
case SpaceNone:
case SpaceXS:
u = 1
case SpaceS:
u = 2
case SpaceM:
u = 3
case SpaceL:
u = 4
case SpaceXL:
u = 6
case SpaceXXL:
u = 8
default:
panic("mujica: invalid Space value")
}
return u * d.Unit()
}
// ContainerOptions configure a Container.
type ContainerOptions struct {
// Padding defaults to SpaceL.
Padding Space
// MaxWidth caps the width in DIPs; zero leaves it unbounded.
MaxWidth float32
// Center places the container in the middle of its parent's width.
Center bool
// Fill takes the parent's whole content box.
Fill bool
// Clip cuts content that overflows the padding box.
Clip bool
}
// Container pads its children, caps their width and can center, fill and clip.
func Container(c *ui.Context, opts ContainerOptions, children func()) *ui.Element {
if opts.MaxWidth < 0 {
panic("mujica: Container MaxWidth must not be negative")
}
if children == nil {
panic("mujica: Container needs children")
}
e := ui.Column(c).Padding(containerSpace(Density(c), opts.Padding, SpaceL))
if opts.MaxWidth > 0 {
e.MaxWidth(opts.MaxWidth)
}
if opts.Fill {
e.Fill()
}
if opts.Center {
e.FillWidth().Margin(0, ui.Auto)
}
if opts.Clip {
e.Clip()
}
return e.Children(children)
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"image/color"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestContainerPaddingByDensity(t *testing.T) {
for _, tc := range []struct {
density theme.Density
pad Space
want float32
}{{theme.Default, SpaceDefault, 16}, {theme.Compact, SpaceDefault, 12}, {theme.Comfortable, SpaceS, 10}, {theme.Default, SpaceNone, 0}} {
var outer, inner ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Density: tc.density})
outer = Container(c, ContainerOptions{Padding: tc.pad}, func() {
inner = ui.Box(c).Size(10, 10).Bounds()
}).Bounds()
}, 400, 300).Frame()
if inner.X-outer.X != tc.want || inner.Y-outer.Y != tc.want {
t.Errorf("%v %v: inset %v,%v, want %v", tc.density, tc.pad, inner.X-outer.X, inner.Y-outer.Y, tc.want)
}
}
}
func TestContainerMaxWidthCentered(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(600).Children(func() {
r = Container(c, ContainerOptions{MaxWidth: 300, Center: true}, func() { ui.Text(c, "Body") }).Bounds()
})
}, 600, 300).Frame()
if r.X != 150 || r.W != 300 {
t.Fatalf("container at x=%v w=%v, want 150 and 300", r.X, r.W)
}
}
func TestContainerFill(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Size(320, 200).Children(func() {
r = Container(c, ContainerOptions{Fill: true}, func() { ui.Text(c, "x") }).Bounds()
})
}, 400, 300).Frame()
if r.W != 320 || r.H != 200 {
t.Fatalf("filled %vx%v, want 320x200", r.W, r.H)
}
}
func TestContainerClip(t *testing.T) {
for _, clip := range []bool{false, true} {
var bg ui.Color
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
bg = Tokens(c).Background
Container(c, ContainerOptions{Padding: SpaceNone, Clip: clip}, func() {
ui.Box(c).Size(200, 200).Background(ui.RGB(255, 0, 0))
}).Size(100, 100)
}, 300, 300)
got := tt.Image().At(150, 150)
clipped := got == color.RGBA{bg.R, bg.G, bg.B, 255}
if clipped != clip {
t.Errorf("Clip %v: pixel outside %v", clip, got)
}
}
}
func TestContainerInvalidSpacePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("invalid Space did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Container(c, ContainerOptions{Padding: Space(99)}, func() {})
}, 100, 100)
}
func TestContainerSnapshot(t *testing.T) {
snapshot(t, "container", 420, 150, func(c *ui.Context) {
k := Tokens(c)
ui.Column(c).Padding(16).Children(func() {
Container(c, ContainerOptions{MaxWidth: 280, Center: true}, func() {
ui.Text(c, "Of the Court").Font(theme.SerifFont).FontSize(theme.H3Size)
ui.Text(c, "A centered container, 280 DIPs at most, padded by four units.").TextColor(k.TextMuted)
}).Background(k.Surface).Border(1, k.Border).Radius(theme.CardRadius)
})
})
}
+58
View File
@@ -0,0 +1,58 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
)
// ContextMenuOptions configure a ContextMenu.
type ContextMenuOptions struct {
Disabled bool
}
// contextMenuState is where the menu opened, relative to the target.
type contextMenuState struct {
open bool
x, y float32
}
// ContextMenu gives target an in-window menu that build fills. A secondary
// click opens it at the pointer; the menu key or Shift+F10, while the focus
// is in target, opens it below target. It flips to stay in the window, and
// the focus returns on close. target must be a container: the menu adds an
// anchor child to it, and takes its HandleInput. It returns target.
func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element {
if target == nil {
panic("mujica: ContextMenu needs a target")
}
if build == nil {
panic("mujica: ContextMenu needs a build function")
}
st := ui.Local(target, "mujica.contextmenu", func() contextMenuState { return contextMenuState{} })
if opts.Disabled {
st.open = false
return target
}
target.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputPointerDown || ev.Button != 1 {
return false
}
st.open, st.x, st.y = true, ev.X, ev.Y
slog.Debug("mujica: context_menu open", "by", "pointer")
return true
})
if target.Shortcut(0, ui.KeyContextMenu) || target.Shortcut(ui.Shift, ui.KeyF10) {
st.open, st.x, st.y = true, 0, target.Bounds().H
slog.Debug("mujica: context_menu open", "by", "keyboard")
}
if !st.open {
return target
}
var anchor *ui.Element
target.Children(func() {
anchor = ui.Box(c).Key("mujica.contextmenu.anchor").Absolute().Left(st.x).Top(st.y).Size(1, 1).PassThrough()
})
popupMenuOpen(c, anchor, &st.open, build)
return target
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func contextMenuTester(a *menuApp, disabled bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Gap(8).Children(func() {
row := ui.Row(c).Height(80).FillWidth().Padding(12).Background(Tokens(c).Surface)
row.Children(func() {
Button(c, "Letter row", ButtonOptions{Variant: Ghost})
})
ContextMenu(c, row, ContextMenuOptions{Disabled: disabled}, a.build)
})
}, 500, 320)
}
func TestContextMenuOpensAtPointer(t *testing.T) {
a := &menuApp{}
tt := contextMenuTester(a, false)
tt.RightClickAt(200, 60)
tt.Frame()
r, ok := tt.Find("Rename")
// The label sits past the menu padding and the check column.
if !ok || r.X < 230 || r.X > 250 || r.Y < 60 || r.Y > 110 {
t.Fatalf("menu item %+v not at the pointer (200, 60)", r)
}
if tt.Menu() != nil {
t.Fatal("the system menu opened")
}
_ = tt.Click("Rename")
if len(a.chosen) != 1 || tt.HasText("Rename") {
t.Fatalf("chosen %q", a.chosen)
}
}
func TestContextMenuOnInnerButtonAndEdge(t *testing.T) {
a := &menuApp{}
tt := contextMenuTester(a, false)
b, _ := tt.Find("Letter row")
tt.RightClickAt(b.X+4, b.Y+4)
if !tt.HasText("Rename") {
t.Fatal("right click on an inner button did not open the menu")
}
tt.Key(0, ui.KeyEscape)
tt.RightClickAt(480, 95)
tt.Frame()
last, _ := tt.Find("Burn")
first, _ := tt.Find("Rename")
if last.Y+last.H > 320 || first.Y > 95 || last.X+last.W > 500 {
t.Fatalf("menu not kept in the window: first %+v last %+v", first, last)
}
}
func TestContextMenuKeyboard(t *testing.T) {
for _, key := range []struct {
mods ui.Modifiers
key ui.Key
}{{0, ui.KeyContextMenu}, {ui.Shift, ui.KeyF10}} {
a := &menuApp{}
tt := contextMenuTester(a, false)
tt.Key(0, ui.KeyTab)
tt.Key(key.mods, key.key)
tt.Frame()
if !tt.Focused("Rename") {
t.Fatalf("%v: menu did not open with focus", key)
}
item, _ := tt.Find("Rename")
if item.Y < 100 {
t.Fatalf("%v: keyboard menu %+v not below the focused target", key, item)
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Rename") || !tt.Focused("Letter row") {
t.Fatalf("%v: Escape did not close and restore", key)
}
}
}
func TestContextMenuDisabled(t *testing.T) {
a := &menuApp{}
tt := contextMenuTester(a, true)
tt.RightClickAt(200, 60)
tt.Key(0, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyF10)
if tt.HasText("Rename") {
t.Fatal("disabled context menu opened")
}
}
func TestContextMenuSnapshot(t *testing.T) {
snapshot(t, "context_menu", 460, 320, func(c *ui.Context) {
a := &menuApp{}
ui.Column(c).Padding(20).Children(func() {
row := ui.Row(c).Height(36).FillWidth().Padding(0, 12).AlignItems(ui.Center).Background(Tokens(c).Selection)
row.Children(func() { ui.Text(c, "Letter to the Countess") })
anchor := ui.Box(c).Absolute().Left(140).Top(48).Size(1, 1)
open := true
popupMenuOpen(c, anchor, &open, a.build)
})
})
}
+155
View File
@@ -0,0 +1,155 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
)
// DatePickerOptions configure a DatePicker.
type DatePickerOptions struct {
// Layout is the Go time layout typed and shown; empty uses 2006-01-02.
Layout string
// Min and Max bound the dates accepted; zero leaves a side open.
Min, Max Date
DisableDate func(Date) bool
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today Date
// Clearable shows a clear button and accepts empty text as no date.
Clearable bool
// Placeholder shows in the empty field; empty uses the built-in copy.
Placeholder string
Disabled bool
// Label names the field for assistive technology.
Label string
}
// DatePicker is a typed date field with a calendar popup editing *value.
// Typed text commits on Enter or when the field loses focus; invalid text stays and is reported.
func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult {
datetimeCheckDate("DatePicker value", *value)
datetimeCheckBounds("DatePicker", opts.Min, opts.Max)
layout := opts.Layout
if layout == "" {
layout = "2006-01-02"
}
format := func(d Date) string {
if d.IsZero() {
return ""
}
return d.Format(layout)
}
d := Density(c)
today := datetimeToday(c, opts.Today)
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
col := ui.Column(c).Width(d.ControlHeight() * 7).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
text := ui.Local(col, "date-picker-text", func() string { return format(*value) })
seen := ui.Local(col, "date-picker-seen", func() Date { return *value })
problem := ui.Local(col, "date-picker-error", func() string { return "" })
open := ui.Local(col, "date-picker-open", func() bool { return false })
cursor := ui.Local(col, "date-picker-cursor", func() Date { return Date{} })
wasFocused := ui.Local(col, "date-picker-focused", func() bool { return false })
if *seen != *value {
*seen, *text, *problem = *value, format(*value), ""
}
changed := false
commit := func(to Date, how string) {
if to != *value {
slog.Debug("mujica: date_picker change", "from", *value, "to", to, "by", how)
*value = to
changed = true
}
*seen, *text, *problem = to, format(to), ""
}
submit := func() {
s := strings.TrimSpace(*text)
if s == "" && opts.Clearable {
commit(Date{}, "text")
return
}
t, err := time.Parse(layout, s)
if err != nil {
*problem = fmt.Sprintf(msg(c, "date_picker.invalid"), format(today))
slog.Debug("mujica: date_picker invalid", "text", s)
return
}
if p := datePickerProblem(c, DateOf(t), opts.Min, opts.Max, enabled, format); p != "" {
*problem = p
return
}
commit(DateOf(t), "text")
}
openAt := func() {
*open = true
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
placeholder := opts.Placeholder
if placeholder == "" {
placeholder = msg(c, "date_picker.placeholder")
}
var field *ui.Element
col.Children(func() {
field = datetimeField(c, ui.Row(c), *problem != "")
field.Children(func() {
in := ui.TextInputBase(c, text).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
label := opts.Label
if label == "" {
label = placeholder
}
in.Label(label).Error(*problem)
focused := in.Focused()
if in.Submitted() || *wasFocused && !focused && *text != format(*value) {
submit()
}
*wasFocused = focused
if opts.Clearable && !value.IsZero() && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
commit(Date{}, "clear")
}
if datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Clicked() {
if *open {
*open = false
} else {
openAt()
}
}
})
if !*open && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
openAt()
}
datetimePopup(c, field, open, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == *value },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if !picked.IsZero() {
commit(picked, "calendar")
*open = false
}
})
datetimeError(c, *problem)
})
return DateTimeResult{Element: col, changed: changed}
}
// datePickerProblem explains why an existing day d cannot be chosen, or returns "".
func datePickerProblem(c *ui.Context, d Date, lo, hi Date, enabled func(Date) bool, format func(Date) string) string {
switch {
case !lo.IsZero() && d.Compare(lo) < 0:
return fmt.Sprintf(msg(c, "calendar.before_min"), format(lo))
case !hi.IsZero() && d.Compare(hi) > 0:
return fmt.Sprintf(msg(c, "calendar.after_max"), format(hi))
case !enabled(d):
return fmt.Sprintf(msg(c, "calendar.unavailable"), format(d))
}
return ""
}
+152
View File
@@ -0,0 +1,152 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func datePickerTester(value *Date, opts DatePickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Gap(8).Children(func() {
if DatePicker(c, value, opts).Changed() {
*changes++
}
Button(c, "Elsewhere", ButtonOptions{})
})
}, 420, 460)
}
func datePickerType(tt *ui.Tester, label, text string) {
_ = tt.Click(label)
tt.Command("selectAll")
tt.Type(text)
}
func TestDatePickerTyping(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due"}, &n)
datePickerType(tt, "Due", "2026-11-03")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.November, 3}) || n != 1 {
t.Fatalf("Enter committed %v (%d)", v, n)
}
datePickerType(tt, "Due", "2026-12-24")
if err := tt.Click("Elsewhere"); err != nil {
t.Fatal(err)
}
if v != (Date{2026, time.December, 24}) || n != 2 {
t.Fatalf("blur committed %v (%d)", v, n)
}
}
func TestDatePickerInvalidInputKept(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Max: Date{2026, time.December, 31}}, &n)
datePickerType(tt, "Due", "2026-02-30")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.October, 5}) || n != 0 {
t.Fatalf("invalid day was corrected into %v", v)
}
if !tt.HasText("Enter a date like 2026-10-05.") {
t.Fatalf("expected an error: %v", tt.Texts())
}
// The typed text stays: fixing its day makes it valid.
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
tt.Type("28")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.February, 28}) || n != 1 || tt.HasText("Enter a date like") {
t.Fatalf("edited text committed %v (%d)", v, n)
}
datePickerType(tt, "Due", "2027-01-02")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.February, 28}) || !tt.HasText("Choose 2026-12-31 or earlier.") {
t.Fatalf("out of range accepted: %v %v", v, tt.Texts())
}
datePickerType(tt, "Due", "")
tt.Key(0, ui.KeyEnter)
if v.IsZero() {
t.Fatal("empty text cleared a non-clearable picker")
}
}
func TestDatePickerCalendarPopup(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due"}, &n)
if err := tt.Click("Open calendar"); err != nil {
t.Fatal(err)
}
if !tt.Focused("Monday, October 5, 2026") {
t.Fatal("the popup should focus the chosen day")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.October, 6}) || n != 1 || tt.HasText("October 2026") {
t.Fatalf("keyboard pick %v, popup open %v", v, tt.HasText("October 2026"))
}
_ = tt.Click("Due")
tt.Key(0, ui.KeyDown)
if !tt.HasText("October 2026") {
t.Fatal("Down should open the calendar")
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("October 2026") || v != (Date{2026, time.October, 6}) {
t.Fatal("Escape should close without change")
}
_ = tt.Click("Open calendar")
if err := tt.Click("Thursday, October 22, 2026"); err != nil {
t.Fatal(err)
}
if v != (Date{2026, time.October, 22}) || n != 2 {
t.Fatalf("click pick %v", v)
}
}
func TestDatePickerClearAndLayout(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Clearable: true, Layout: "02/01/2006"}, &n)
_ = tt.Click("Due")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.October, 5}) || n != 0 || tt.HasText("Enter a date like") {
t.Fatalf("shown text did not parse back with the layout: %v", tt.Texts())
}
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if !v.IsZero() || n != 1 {
t.Fatalf("clear left %v", v)
}
datePickerType(tt, "Due", "31/12/2026")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.December, 31}) {
t.Fatalf("custom layout parsed %v", v)
}
}
func TestDatePickerDisabled(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n)
_ = tt.Click("Open calendar")
if tt.HasText("October 2026") {
t.Fatal("disabled picker opened")
}
}
func TestDatePickerSnapshot(t *testing.T) {
a, b, e := Date{2026, time.October, 14}, Date{}, Date{2026, time.October, 5}
snapshot(t, "date_picker", 640, 130, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).AlignItems(ui.Start).Children(func() {
ui.Column(c).Gap(12).Width(260).Children(func() {
DatePicker(c, &b, DatePickerOptions{Today: calendarToday})
DatePicker(c, &e, DatePickerOptions{Today: calendarToday, Disabled: true})
})
ui.Column(c).Width(300).Children(func() {
DatePicker(c, &a, DatePickerOptions{Today: calendarToday, Clearable: true, Label: "Due"})
})
})
})
}
+174
View File
@@ -0,0 +1,174 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// DateRange is an inclusive span of days. The zero value means no range.
type DateRange struct {
Start, End Date
}
// IsZero reports whether r is the empty range.
func (r DateRange) IsZero() bool { return r == DateRange{} }
// Days returns how many days r covers, counting both ends.
func (r DateRange) Days() int {
if r.Start.IsZero() || r.End.IsZero() {
return 0
}
return int(r.End.civil().Sub(r.Start.civil()).Hours()/24) + 1
}
// DateRangePickerOptions configure a DateRangePicker.
type DateRangePickerOptions struct {
// Layout is the Go time layout shown; empty uses 2006-01-02.
Layout string
// Min and Max bound both ends; zero leaves a side open.
Min, Max Date
// DisableDate marks days that neither end nor the span may include.
DisableDate func(Date) bool
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today Date
Disabled bool
// Label names the field for assistive technology.
Label string
}
// DateRangePicker edits *value in a popup: pick a start, preview the span, pick an end, then confirm.
// Clear empties the value; Escape, Cancel or a click outside discards the draft.
func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult {
datetimeCheckDate("DateRangePicker Start", value.Start)
datetimeCheckDate("DateRangePicker End", value.End)
if value.Start.IsZero() != value.End.IsZero() || !value.IsZero() && value.End.Compare(value.Start) < 0 {
panic(fmt.Sprintf("mujica: DateRangePicker value %v..%v is not a range", value.Start, value.End))
}
datetimeCheckBounds("DateRangePicker", opts.Min, opts.Max)
layout := opts.Layout
if layout == "" {
layout = "2006-01-02"
}
d := Density(c)
k := Tokens(c)
today := datetimeToday(c, opts.Today)
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "date-range-open", func() bool { return false })
wasOpen := ui.Local(col, "date-range-was-open", func() bool { return false })
draft := ui.Local(col, "date-range-draft", func() DateRange { return DateRange{} })
cursor := ui.Local(col, "date-range-cursor", func() Date { return Date{} })
hover := ui.Local(col, "date-range-hover", func() Date { return Date{} })
problem := ui.Local(col, "date-range-error", func() string { return "" })
changed, cancelled, done := false, false, false
finish := func(to DateRange, how string) {
if to != *value {
slog.Debug("mujica: date_range_picker change", "from", *value, "to", to, "by", how)
*value = to
changed = true
}
*open, done = false, true
}
label := opts.Label
if label == "" {
label = msg(c, "date_range_picker.label")
}
text := ""
if !value.IsZero() {
text = value.Start.Format(layout) + " – " + value.End.Format(layout)
}
var field *ui.Element
col.Children(func() {
field = datetimeField(c, ui.Row(c), false)
field.Children(func() {
t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
*open = !*open
if *open {
*draft, *problem, *hover = *value, "", Date{}
*cursor = value.Start
if cursor.IsZero() {
*cursor = today
}
slog.Debug("mujica: date_range_picker open")
}
}
})
datetimePopup(c, field, open, func() {
ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted).
FontSize(theme.CaptionSize).FontFeatures("tnum").Role(ui.RoleStatus)
end := draft.End
if end.IsZero() && !draft.Start.IsZero() {
end = *hover
if end.IsZero() {
end = *cursor
}
}
picked, hovered := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == draft.Start || x == draft.End },
inRange: func(x Date) bool { return !draft.Start.IsZero() && x.within(draft.Start, end) },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if hovered != *hover {
*hover = hovered
c.Invalidate()
}
if !picked.IsZero() {
*problem = dateRangePick(c, draft, picked, enabled, layout)
}
datetimeError(c, *problem)
datetimeFooter(c, func() {
if Button(c, msg(c, "calendar.clear"), ButtonOptions{Variant: Ghost, Disabled: value.IsZero() && draft.IsZero()}).Clicked() {
finish(DateRange{}, "clear")
}
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
*open = false
}
ready := !draft.Start.IsZero() && !draft.End.IsZero()
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{Disabled: !ready}).Clicked() {
finish(*draft, "confirm")
}
})
})
})
if *wasOpen && !*open && !done {
slog.Debug("mujica: date_range_picker cancel")
cancelled = true
}
*wasOpen = *open
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
// dateRangePick applies a picked day to the draft and returns any problem.
func dateRangePick(c *ui.Context, draft *DateRange, day Date, enabled func(Date) bool, layout string) string {
if draft.Start.IsZero() || !draft.End.IsZero() || day.Compare(draft.Start) < 0 {
*draft = DateRange{Start: day}
return ""
}
for x := draft.Start; x.Compare(day) <= 0; x = x.AddDays(1) {
if !enabled(x) {
return fmt.Sprintf(msg(c, "date_range_picker.blocked"), x.Format(layout))
}
}
draft.End = day
return ""
}
// dateRangeSummary describes the draft: what to pick next, or the span chosen.
func dateRangeSummary(c *ui.Context, r DateRange, layout string) string {
switch {
case r.Start.IsZero():
return msg(c, "date_range_picker.pick_start")
case r.End.IsZero():
return fmt.Sprintf(msg(c, "date_range_picker.pick_end"), r.Start.Format(layout))
}
return fmt.Sprintf(msg(c, "date_range_picker.summary"), r.Start.Format(layout), r.End.Format(layout), r.Days())
}
+138
View File
@@ -0,0 +1,138 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func dateRangeTester(value *DateRange, opts DateRangePickerOptions, changes, cancels *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Stay"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
r := DateRangePicker(c, value, opts)
if r.Changed() {
*changes++
}
if r.Cancelled() {
*cancels++
}
})
}, 420, 520)
}
func oct(day int) Date { return Date{2026, time.October, day} }
func TestDateRangePickerPickAndConfirm(t *testing.T) {
v, n, x := DateRange{}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
if err := tt.Click("Stay"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Choose the start date.") {
t.Fatalf("popup missing: %v", tt.Texts())
}
_ = tt.Click(datetimeTestLabel(oct(12)))
if !tt.HasText("From 2026-10-12, choose the end date.") {
t.Fatal("start not taken")
}
_ = tt.Click(datetimeTestLabel(oct(8)))
if !tt.HasText("From 2026-10-08, choose the end date.") {
t.Fatal("a day before the start should restart the range")
}
_ = tt.Click(datetimeTestLabel(oct(14)))
if !tt.HasText("2026-10-08 – 2026-10-14 · 7 days") || n != 0 {
t.Fatalf("draft should wait for Confirm: %v %d", v, n)
}
if err := tt.Click("Confirm"); err != nil {
t.Fatal(err)
}
if v != (DateRange{oct(8), oct(14)}) || n != 1 || x != 0 || tt.HasText("Confirm") {
t.Fatalf("confirm gave %v, n=%d x=%d", v, n, x)
}
}
func TestDateRangePickerCancelKeepsValue(t *testing.T) {
v, n, x := DateRange{oct(1), oct(3)}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(20)))
tt.Key(0, ui.KeyEscape)
if v != (DateRange{oct(1), oct(3)}) || n != 0 || x != 1 {
t.Fatalf("Escape: %v n=%d x=%d", v, n, x)
}
_ = tt.Click("Stay")
_ = tt.Click("Cancel")
if x != 2 || tt.HasText("Confirm") {
t.Fatalf("Cancel: x=%d", x)
}
_ = tt.Click("Stay")
_ = tt.Click("Clear")
if !v.IsZero() || n != 1 || x != 2 {
t.Fatalf("Clear: %v n=%d x=%d", v, n, x)
}
}
func TestDateRangePickerLimits(t *testing.T) {
v, n, x := DateRange{}, 0, 0
opts := DateRangePickerOptions{Min: oct(5), DisableDate: func(d Date) bool { return d == oct(16) }}
tt := dateRangeTester(&v, opts, &n, &x)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(2)))
if !tt.HasText("Choose the start date.") {
t.Fatal("a day before Min was taken")
}
_ = tt.Click(datetimeTestLabel(oct(14)))
_ = tt.Click(datetimeTestLabel(oct(18)))
if !tt.HasText("The range cannot include 2026-10-16.") {
t.Fatalf("span over a disabled day accepted: %v", tt.Texts())
}
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() {
t.Fatal("confirm without an end changed the value")
}
_ = tt.Click(datetimeTestLabel(oct(15)))
_ = tt.Click("Confirm")
if v != (DateRange{oct(14), oct(15)}) {
t.Fatalf("got %v", v)
}
}
func TestDateRangePickerKeyboard(t *testing.T) {
v, n, x := DateRange{}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused(datetimeTestLabel(calendarToday)) {
t.Fatal("the popup should focus today")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if !tt.HasText("From 2026-10-05, choose the end date.") {
t.Fatal("Enter should pick the start")
}
tt.Key(0, ui.KeySpace)
if !tt.HasText("2026-10-05 – 2026-10-12 · 8 days") {
t.Fatalf("Space should pick the end: %v", tt.Texts())
}
}
func TestDateRangeDays(t *testing.T) {
r := DateRange{Date{2026, time.March, 28}, Date{2026, time.April, 2}}
if r.Days() != 6 || (DateRange{}).Days() != 0 {
t.Fatalf("days %d", r.Days())
}
}
func TestDateRangePickerSnapshot(t *testing.T) {
a, b := DateRange{oct(8), oct(14)}, DateRange{}
snapshot(t, "date_range_picker", 640, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
DateRangePicker(c, &b, DateRangePickerOptions{Today: calendarToday})
DateRangePicker(c, &a, DateRangePickerOptions{Today: calendarToday})
})
})
}
+181
View File
@@ -0,0 +1,181 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// DateTimePickerOptions configure a DateTimePicker.
type DateTimePickerOptions struct {
// Location is the time zone the user edits in. Required.
Location *time.Location
// Min and Max bound the instant; zero leaves a side open.
Min, Max time.Time
Seconds bool
Hour12 bool
MinuteStep int
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today Date
Disabled bool
// Label names the field for assistive technology.
Label string
}
// DateTimePicker edits *value as a date and a wall-clock time in opts.Location, confirmed together.
// Times a DST change skips or repeats are reported, never shifted.
func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult {
loc := opts.Location
if loc == nil {
panic("mujica: DateTimePicker needs a Location")
}
if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.After(opts.Max) {
panic(fmt.Sprintf("mujica: DateTimePicker Min %v is after Max %v", opts.Min, opts.Max))
}
cfg := timePickerConfig("DateTimePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12, MinuteStep: opts.MinuteStep})
cfg.label = msg(c, "date_time_picker.time")
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
var lo, hi Date
if !opts.Min.IsZero() {
lo = DateOf(opts.Min.In(loc))
}
if !opts.Max.IsZero() {
hi = DateOf(opts.Max.In(loc))
}
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "date-time-open", func() bool { return false })
wasOpen := ui.Local(col, "date-time-was-open", func() bool { return false })
day := ui.Local(col, "date-time-day", func() Date { return Date{} })
clock := ui.Local(col, "date-time-clock", func() TimeOfDay { return TimeOfDay{} })
cursor := ui.Local(col, "date-time-cursor", func() Date { return Date{} })
problem := ui.Local(col, "date-time-error", func() string { return "" })
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), cfg) }
changed, cancelled, done := false, false, false
label := opts.Label
if label == "" {
label = msg(c, "date_time_picker.placeholder")
}
text := ""
if !value.IsZero() {
text = format(*value)
}
col.Children(func() {
field := datetimeField(c, ui.Row(c), false)
field.Children(func() {
t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
*open = !*open
if *open {
*day, *clock, *problem = today, TimeOfDay{}, ""
if !value.IsZero() {
w := value.In(loc)
*day, *clock = DateOf(w), TimeOfDay{w.Hour(), w.Minute(), w.Second()}
}
*cursor = *day
slog.Debug("mujica: date_time_picker open")
}
}
})
datetimePopup(c, field, open, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == *day },
enabled: calendarEnabled(lo, hi, nil),
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if !picked.IsZero() {
*day, *problem = picked, ""
}
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
if _, edited := timeEditor(c, clock, cfg); edited {
*problem = ""
}
ui.Text(c, loc.String()).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
})
datetimeError(c, *problem)
datetimeFooter(c, func() {
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
*open = false
}
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{}).Clicked() {
at, p := dateTimeConfirm(c, *day, *clock, loc, opts, cfg)
*problem = p
if p == "" {
if !at.Equal(*value) {
slog.Debug("mujica: date_time_picker change", "from", *value, "to", at)
*value, changed = at, true
}
*open, done = false, true
}
}
})
})
})
if *wasOpen && !*open && !done {
slog.Debug("mujica: date_time_picker cancel")
cancelled = true
}
*wasOpen = *open
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
// dateTimeConfirm turns the draft into an instant, or explains why it cannot.
func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opts DateTimePickerOptions, cfg timeConfig) (time.Time, string) {
if p := timePickerProblem(c, t, cfg); p != "" {
return time.Time{}, p
}
wall := fmt.Sprintf("%v %s", d, timeText(c, t, cfg))
at, n := dateTimeResolve(d, t, loc)
switch {
case n == 0:
slog.Debug("mujica: date_time_picker gap", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(msg(c, "date_time_picker.gap"), wall, loc.String())
case n > 1:
slog.Debug("mujica: date_time_picker ambiguous", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(msg(c, "date_time_picker.ambiguous"), wall, loc.String())
case !opts.Min.IsZero() && at.Before(opts.Min):
return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), cfg))
case !opts.Max.IsZero() && at.After(opts.Max):
return at, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), cfg))
}
return at, ""
}
// dateTimeResolve finds the instants showing wall time d t in loc: none in a DST gap, two in an overlap.
func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
wall := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, time.UTC)
guess := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, loc)
var hits []time.Time
for _, probe := range []time.Time{guess.Add(-24 * time.Hour), guess, guess.Add(24 * time.Hour)} {
_, off := probe.Zone()
u := wall.Add(-time.Duration(off) * time.Second).In(loc)
y, m, dd := u.Date()
if (Date{y, m, dd}) != d || u.Hour() != t.Hour || u.Minute() != t.Minute || u.Second() != t.Second {
continue
}
seen := false
for _, h := range hits {
seen = seen || h.Equal(u)
}
if !seen {
hits = append(hits, u)
}
}
if len(hits) == 0 {
return time.Time{}, 0
}
return hits[0], len(hits)
}
// dateTimeText formats a wall time with the picker's clock style.
func dateTimeText(c *ui.Context, w time.Time, cfg timeConfig) string {
return DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
}
+152
View File
@@ -0,0 +1,152 @@
package mujica
import (
"testing"
"time"
_ "time/tzdata"
"github.com/egoist/mygo/ui"
)
func dateTimeTester(t *testing.T, value *time.Time, opts DateTimePickerOptions, changes, cancels *int) *ui.Tester {
t.Helper()
opts.Today = calendarToday
opts.Label = "Meeting"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
r := DateTimePicker(c, value, opts)
if r.Changed() {
*changes++
}
if r.Cancelled() {
*cancels++
}
})
}, 480, 640)
}
func newYork(t *testing.T) *time.Location {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
return loc
}
func TestDateTimeResolve(t *testing.T) {
ny := newYork(t)
if _, n := dateTimeResolve(Date{2026, time.March, 8}, TimeOfDay{2, 30, 0}, ny); n != 0 {
t.Fatalf("spring-forward gap gave %d instants", n)
}
if _, n := dateTimeResolve(Date{2026, time.November, 1}, TimeOfDay{1, 30, 0}, ny); n != 2 {
t.Fatalf("fall-back overlap gave %d instants", n)
}
at, n := dateTimeResolve(Date{2026, time.July, 4}, TimeOfDay{12, 0, 0}, ny)
if n != 1 || !at.Equal(time.Date(2026, time.July, 4, 16, 0, 0, 0, time.UTC)) {
t.Fatalf("plain time %v (%d)", at, n)
}
}
func TestDateTimePickerConfirmInLocation(t *testing.T) {
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
t.Fatal(err)
}
v := time.Date(2026, time.October, 5, 23, 30, 0, 0, time.UTC) // 08:30 on Oct 6 in Tokyo
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: tokyo}, &n, &x)
if !tt.HasText("2026-10-06 08:30 JST") {
t.Fatalf("value should show in the caller's zone: %v", tt.Texts())
}
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(9)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.KeyUp)
if n != 0 {
t.Fatal("the draft should wait for Confirm")
}
_ = tt.Click("Confirm")
want := time.Date(2026, time.October, 9, 9, 30, 0, 0, tokyo)
if !v.Equal(want) || n != 1 || x != 0 || v.Location() != tokyo {
t.Fatalf("confirmed %v, want %v", v, want)
}
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(20)))
_ = tt.Click("Cancel")
if !v.Equal(want) || x != 1 {
t.Fatalf("cancel changed %v", v)
}
}
func TestDateTimePickerReportsDSTGap(t *testing.T) {
ny := newYork(t)
v := time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny, Today: Date{2026, time.March, 1}}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Sunday, March 8, 2026")
_ = tt.Click("Confirm")
if n != 0 || !v.Equal(time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)) {
t.Fatalf("gap time was shifted into %v", v)
}
if !tt.HasText("2026-03-08 02:30 does not exist in America/New_York: the clocks skip it.") {
t.Fatalf("missing gap error: %v", tt.Texts())
}
_ = tt.Click("Time: Hours")
tt.Key(0, ui.KeyUp)
_ = tt.Click("Confirm")
if n != 1 || !v.Equal(time.Date(2026, time.March, 8, 3, 30, 0, 0, ny)) {
t.Fatalf("after fixing: %v", v)
}
}
func TestDateTimePickerReportsAmbiguous(t *testing.T) {
ny := newYork(t)
v := time.Date(2026, time.October, 31, 1, 30, 0, 0, ny)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Sunday, November 1, 2026")
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("2026-11-01 01:30 happens twice in America/New_York: the clocks repeat it.") {
t.Fatalf("ambiguous time accepted: %v %v", v, tt.Texts())
}
}
func TestDateTimePickerRange(t *testing.T) {
v := time.Time{}
n, x := 0, 0
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(11)))
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key3)
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() || !tt.HasText("Choose 2026-10-10 12:00 UTC or earlier.") {
t.Fatalf("past Max accepted: %v %v", v, tt.Texts())
}
}
func TestDateTimePickerNeedsLocation(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic without Location")
}
}()
v := time.Time{}
dateTimeTester(t, &v, DateTimePickerOptions{}, new(int), new(int))
}
func TestDateTimePickerSnapshot(t *testing.T) {
a, b := time.Date(2026, time.October, 14, 18, 45, 0, 0, time.UTC), time.Time{}
snapshot(t, "date_time_picker", 720, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
DateTimePicker(c, &b, DateTimePickerOptions{Location: time.UTC, Today: calendarToday})
DateTimePicker(c, &a, DateTimePickerOptions{Location: time.UTC, Today: calendarToday, Hour12: true})
})
})
}
+201
View File
@@ -0,0 +1,201 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Date is a calendar day with no time of day and no location.
// The zero value means no date.
type Date struct {
Year int
Month time.Month
Day int
}
// DateOf returns the day t falls on in t's own location, without converting it.
func DateOf(t time.Time) Date {
y, m, d := t.Date()
return Date{y, m, d}
}
// IsZero reports whether d is the empty date.
func (d Date) IsZero() bool { return d == Date{} }
// Valid reports whether d names a real day.
func (d Date) Valid() bool { return !d.IsZero() && DateOf(d.civil()) == d }
// String formats d as 2006-01-02, or "" when empty.
func (d Date) String() string {
if d.IsZero() {
return ""
}
return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)
}
// Format formats d with a Go time layout.
func (d Date) Format(layout string) string { return d.civil().Format(layout) }
// Compare returns -1, 0 or 1 as d is before, equal to or after o.
func (d Date) Compare(o Date) int { return d.civil().Compare(o.civil()) }
// AddDays returns d moved by n days.
func (d Date) AddDays(n int) Date { return DateOf(d.civil().AddDate(0, 0, n)) }
// AddMonths returns d moved by n months, keeping the day within the month.
func (d Date) AddMonths(n int) Date {
first := time.Date(d.Year, d.Month+time.Month(n), 1, 0, 0, 0, 0, time.UTC)
last := first.AddDate(0, 1, -1).Day()
return Date{first.Year(), first.Month(), min(d.Day, last)}
}
// Weekday returns the day of the week of d.
func (d Date) Weekday() time.Weekday { return d.civil().Weekday() }
// civil is d at midnight on a fixed clock, only for arithmetic.
func (d Date) civil() time.Time { return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC) }
// within reports whether d lies inside the optional bounds lo and hi.
func (d Date) within(lo, hi Date) bool {
return (lo.IsZero() || d.Compare(lo) >= 0) && (hi.IsZero() || d.Compare(hi) <= 0)
}
// DateTimeResult is a date or time component and what happened to it this frame.
type DateTimeResult struct {
*ui.Element
changed bool
cancelled bool
}
// Changed reports whether the component wrote a new value this frame.
func (r DateTimeResult) Changed() bool { return r.changed }
// Cancelled reports whether the user discarded a pending edit this frame.
func (r DateTimeResult) Cancelled() bool { return r.cancelled }
// datetimeCheckDate panics when a non-empty date is not a real day.
func datetimeCheckDate(name string, d Date) {
if !d.IsZero() && !d.Valid() {
panic(fmt.Sprintf("mujica: %s %v is not a calendar date", name, d))
}
}
// datetimeCheckBounds panics on invalid or inverted Min and Max.
func datetimeCheckBounds(name string, lo, hi Date) {
datetimeCheckDate(name+" Min", lo)
datetimeCheckDate(name+" Max", hi)
if !lo.IsZero() && !hi.IsZero() && lo.Compare(hi) > 0 {
panic(fmt.Sprintf("mujica: %s Min %v is after Max %v", name, lo, hi))
}
}
// datetimeToday returns today, or the caller's fixed day when set.
func datetimeToday(c *ui.Context, fixed Date) Date {
if !fixed.IsZero() {
datetimeCheckDate("Today", fixed)
return fixed
}
return DateOf(c.Now())
}
// datetimeMonthName returns the localized name of m.
func datetimeMonthName(c *ui.Context, m time.Month) string {
return msg(c, fmt.Sprintf("calendar.month_%d", int(m)))
}
// datetimeMonthTitle returns the localized title of a month, as "October 2026".
func datetimeMonthTitle(c *ui.Context, y int, m time.Month) string {
return fmt.Sprintf(msg(c, "calendar.title"), datetimeMonthName(c, m), y)
}
// datetimeDayLabel returns the localized spoken name of d.
func datetimeDayLabel(c *ui.Context, d Date) string {
wd := msg(c, fmt.Sprintf("calendar.weekday_long_%d", int(d.Weekday())))
return fmt.Sprintf(msg(c, "calendar.day_label"), wd, datetimeMonthName(c, d.Month), d.Day, d.Year)
}
// datetimeIconButton is a square, borderless button showing icon, named label.
func datetimeIconButton(c *ui.Context, icon, label string) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case b.Pressed():
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
b.TextColor(k.TextMuted).Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
return b
}
// datetimeField styles row as a single-line field: solid fill and fine border.
func datetimeField(c *ui.Context, row *ui.Element, invalid bool) *ui.Element {
k := Tokens(c)
d := Density(c)
border := k.ControlBorder
switch {
case invalid:
border = k.Danger
case row.FocusWithin():
border = k.Focus
case row.Hovered():
border = k.Text
}
return row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()).PaddingX(d.Unit()*3).Gap(d.Unit()).
AlignItems(ui.Center).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border)
}
// datetimeTrigger is the button inside a field that shows text and opens a popup.
func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).Gap(Density(c).Unit() * 2).
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius)
internal.Ring(b, k.Focus, theme.ControlRadius)
b.Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(k.TextMuted)
if text == "" {
ui.Text(c, placeholder).TextColor(k.TextMuted).SingleLine()
} else {
ui.Text(c, text).TextColor(k.Text).SingleLine().FontFeatures("tnum")
}
})
return b
}
// datetimePopup shows fn in a popup panel below anchor while *open is set.
func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, fn func()) *ui.Element {
k := Tokens(c)
d := Density(c)
return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
born := ui.Local(panel, "datetime-born", func() bool { return true })
if *born {
panel.Animate("datetime-fade", 0, 0)
*born = false
}
fade := panel.Animate("datetime-fade", 1, Motion(c, theme.PopupDuration))
panel.MaxWidth(d.ControlHeight()*7+d.Unit()*6+2*theme.BorderWidth).Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()*3).Gap(d.Unit()*3).Radius(theme.PopupRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).
Shadow(0, 6, 18, 0, ui.RGBA(0, 0, 0, 0.16)).Opacity(fade)
fn()
})
}
// datetimeError shows a field error in Danger beneath a control.
func datetimeError(c *ui.Context, text string) {
if text == "" {
return
}
ui.Text(c, text).FontSize(theme.CaptionSize).TextColor(Tokens(c).Danger).Role(ui.RoleStatus)
}
// datetimeFooter lays out popup actions at the end of a row.
func datetimeFooter(c *ui.Context, fn func()) {
ui.Row(c).Gap(Density(c).Unit() * 2).Justify(ui.End).AlignItems(ui.Center).FillWidth().Children(fn)
}
+182
View File
@@ -0,0 +1,182 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// datetimeGridConfig describes a page of month or year cells, addressed by ordinal.
type datetimeGridConfig struct {
// pageOf returns the first ordinal and count of the page holding o.
pageOf func(o int) (start, n int)
cols int
page int
title func(start int) string
prev string
next string
text func(o int) string
label func(o int) string
chosen func(o int) bool
current func(o int) bool
enabled func(o int) bool
// muted greys cells of the page that belong to a neighbor page.
muted func(o, start int) bool
autoFocus bool
}
// datetimeGrid draws the page of *cursor and returns the ordinal picked, if any.
func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked int, ok bool) {
k := Tokens(c)
d := Density(c)
root := ui.Column(c).Gap(d.Unit() * 2).Shrink(0).Width(d.ControlHeight() * 7)
keyed := ui.Local(root, "datetime-grid-keys", func() bool { return false })
root.Children(func() {
start, _ := cfg.pageOf(*cursor)
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).
PaddingY(d.Unit()).Children(func() {
if datetimeIconButton(c, "chevron-left", cfg.prev).Clicked() {
*cursor, *keyed = *cursor-cfg.page, false
}
ui.Text(c, cfg.title(start)).Font(theme.SerifFont).FontSize(theme.BodySize + 2).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if datetimeIconButton(c, "chevron-right", cfg.next).Clicked() {
*cursor, *keyed = *cursor+cfg.page, false
}
})
cells := ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Horizontal | ui.Vertical)
moved := true
switch {
case cells.Shortcut(0, ui.KeyLeft):
*cursor--
case cells.Shortcut(0, ui.KeyRight):
*cursor++
case cells.Shortcut(0, ui.KeyUp):
*cursor -= cfg.cols
case cells.Shortcut(0, ui.KeyDown):
*cursor += cfg.cols
case cells.Shortcut(0, ui.KeyPageUp):
*cursor -= cfg.page
case cells.Shortcut(0, ui.KeyPageDown):
*cursor += cfg.page
case cells.Shortcut(0, ui.KeyHome):
*cursor, _ = cfg.pageOf(*cursor)
case cells.Shortcut(0, ui.KeyEnd):
s, n := cfg.pageOf(*cursor)
*cursor = s + n - 1
default:
moved = false
}
if moved {
*keyed = true
}
start, n := cfg.pageOf(*cursor)
w := d.ControlHeight() * 7 / float32(cfg.cols)
cells.Children(func() {
for r := 0; r < n; r += cfg.cols {
ui.Row(c).Children(func() {
for o := start + r; o < start+min(r+cfg.cols, n); o++ {
if datetimeGridCell(c, o, start, *cursor, cfg, w, moved, *keyed) {
picked, ok = o, true
*cursor, *keyed = o, false
}
}
})
}
})
})
return picked, ok
}
// datetimeGridCell draws one month or year cell and reports whether it was picked.
func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfig, w float32, focus, keyed bool) bool {
k := Tokens(c)
d := Density(c)
enabled, chosen := cfg.enabled(o), cfg.chosen(o)
sel := o - 1
if chosen {
sel = o
}
cell := ui.RadioBase(c, &sel, o).Size(w, d.ControlHeight()).Center().Radius(theme.ControlRadius).
Label(cfg.label(o)).Disabled(!enabled).FocusRing(false)
if o == cursor && enabled {
if focus {
cell.Focus()
} else if cfg.autoFocus {
cell.AutoFocus()
}
}
fg := k.Text
if cfg.muted(o, start) {
fg = k.TextMuted
}
switch {
case chosen:
cell.Background(k.Accent)
fg = k.OnAccent
if cell.Hovered() {
cell.Background(k.AccentHover)
}
case cell.Pressed():
cell.Background(k.Selection)
case cell.Hovered():
cell.Background(k.SurfaceHover)
}
current := cfg.current(o)
ring := cell.FocusVisible() || cell.Focused() && keyed
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if current {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-6, 5), fg)
}
if ring {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
cell.Children(func() {
t := ui.Text(c, cfg.text(o)).TextColor(fg).FontFeatures("tnum").SingleLine()
if current {
t.Bold()
}
if !enabled {
t.Strikethrough()
}
})
return cell.Clicked() && enabled
}
// datetimeGridField is the field and popup shared by MonthPicker and YearPicker.
type datetimeGridField struct {
text, placeholder, label string
clearable, hasValue bool
disabled bool
// home is the ordinal the popup opens on.
home int
}
// datetimeGridPicker draws f and its popup grid; it returns the column, a picked ordinal and whether Clear was pressed.
func datetimeGridPicker(c *ui.Context, f datetimeGridField, cfg datetimeGridConfig) (col *ui.Element, picked int, ok, cleared bool) {
d := Density(c)
col = ui.Column(c).Width(d.ControlHeight() * 6).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(f.disabled)
open := ui.Local(col, "datetime-grid-open", func() bool { return false })
cursor := ui.Local(col, "datetime-grid-cursor", func() int { return 0 })
col.Children(func() {
field := datetimeField(c, ui.Row(c), false)
field.Children(func() {
t := datetimeTrigger(c, f.text, f.placeholder, "calendar").Label(f.label).Description(f.text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
*open, *cursor = !*open, f.home
}
if f.clearable && f.hasValue && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
cleared = true
}
})
datetimePopup(c, field, open, func() {
cfg.autoFocus = true
if picked, ok = datetimeGrid(c, cursor, cfg); ok {
*open = false
}
})
})
return col, picked, ok, cleared
}
+153
View File
@@ -0,0 +1,153 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// DialogOptions configure a Dialog. Title is required.
type DialogOptions struct {
Title string
Description string
// Width is 440 DIPs when zero; the window always bounds it.
Width float32
// Decorated draws the double border, corner ornaments and title rule.
Decorated bool
// Actions builds the footer, usually buttons, aligned to the end.
Actions func()
// KeepOnBackdrop ignores clicks on the backdrop.
KeepOnBackdrop bool
// KeepOnEscape ignores Escape.
KeepOnEscape bool
// HideClose removes the close button from the header.
HideClose bool
}
// Dialog shows a modal dialog while *open is true. It traps the focus,
// makes the window inert and gives the focus back to the opener on close.
func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element {
if opts.Title == "" {
panic("mujica: Dialog needs a Title")
}
if !*open {
return nil
}
k := Tokens(c)
shown, backClicked, closed := true, false, false
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
backClicked = back.Clicked()
p := overlayEnter(c, panel)
back.Background(overlayScrim(k).Alpha(p)).Padding(16)
dialogPanel(c, panel, opts.Width, opts.Decorated, p)
panel.Label(opts.Title)
if opts.Description != "" {
panel.Description(opts.Description)
}
closed = dialogHeader(c, opts.Title, opts.Description, opts.Decorated, !opts.HideClose, "dialog.close")
if content != nil {
ui.Scroll(c).Shrink(1).Children(content)
}
if opts.Actions != nil {
dialogActions(c, opts.Actions)
}
})
switch {
case closed:
slog.Debug("mujica: dialog close", "by", "button")
*open = false
case !shown && backClicked && !opts.KeepOnBackdrop:
slog.Debug("mujica: dialog close", "by", "backdrop")
*open = false
case !shown && !backClicked && !opts.KeepOnEscape:
slog.Debug("mujica: dialog close", "by", "escape")
*open = false
}
return panel
}
// dialogPanel styles a centered dialog panel at entrance progress p.
func dialogPanel(c *ui.Context, panel *ui.Element, width float32, decorated bool, p float32) {
k := Tokens(c)
u := Density(c).Unit()
if width == 0 {
width = 440
}
w, h := c.Size()
overlayStyle(c, panel, p)
panel.Width(width).MaxWidth(w-32).MaxHeight(h-32).Padding(u*6).Gap(u*3).Margin(8*(1-p), 0, 0, 0)
if decorated {
panel.Border(theme.BorderWidth, k.Ornament).Padding(u*6 + 6).DrawOver(func(pt *ui.Painter, r ui.Rect) {
dialogOrnaments(pt, r, k.Ornament, k.Surface)
})
}
}
// dialogOrnaments draws the inner frame and the four corner diamonds.
func dialogOrnaments(p *ui.Painter, r ui.Rect, ink, ground ui.Color) {
const in = 5
f := ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}
p.Stroke(f, ink, 1, 1)
for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} {
x, y := internal.Snap(p, pt[0]), internal.Snap(p, pt[1])
p.FillPath(internal.Diamond(x, y, 11), ground)
p.FillPath(internal.Diamond(x, y, 7), ink)
}
}
// dialogHeader builds the title, description and close button; it reports a close click.
func dialogHeader(c *ui.Context, title, description string, decorated, closable bool, closeKey string) bool {
k := Tokens(c)
u := Density(c).Unit()
clicked := false
ui.Column(c).Gap(u).Shrink(0).Children(func() {
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).Grow(1)
if closable {
clicked = dialogCloseButton(c, msg(c, closeKey)).Clicked()
}
})
if decorated {
dialogRule(c, k.Ornament)
}
if description != "" {
ui.Text(c, description).TextColor(k.TextMuted)
}
})
return clicked
}
// dialogRule is the ornament line under a decorated title.
func dialogRule(c *ui.Context, ink ui.Color) {
ui.Box(c).FillWidth().Height(9).Margin(2, 0, 2, 0).Draw(func(p *ui.Painter, r ui.Rect) {
y, cx := r.Y+r.H/2, r.X+r.W/2
p.Line(r.X, y, cx-10, y, 1, ink)
p.Line(cx+10, y, r.X+r.W, y, 1, ink)
p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, y), 7), ink)
})
}
// dialogCloseButton is the 28 DIP icon button closing a panel.
func dialogCloseButton(c *ui.Context, label string) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).Margin(-4, -4, 0, 0)
internal.Ring(b, k.Focus, theme.ControlRadius)
b.TextColor(k.TextMuted)
if b.Pressed() {
b.Background(k.Selection).TextColor(k.Text)
} else if b.Hovered() {
b.Background(k.SurfaceHover).TextColor(k.Text)
}
b.Children(func() { ui.Icon(c, icons.Must("x")).Size(16, 16) })
return b
}
// dialogActions lays out a dialog footer.
func dialogActions(c *ui.Context, actions func()) {
u := Density(c).Unit()
ui.Row(c).Gap(u*2).Justify(ui.End).Wrap().Shrink(0).Margin(u*2, 0, 0, 0).Children(actions)
}
+180
View File
@@ -0,0 +1,180 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
type dialogApp struct {
open bool
opts DialogOptions
saved int
name string
densities theme.Density
}
func (a *dialogApp) view(c *ui.Context) {
Use(c, Settings{Mode: Light, Density: a.densities})
if Button(c, "Rename", ButtonOptions{}).Clicked() {
a.open = true
}
a.opts.Actions = func() {
if Button(c, "Cancel", ButtonOptions{Variant: Secondary}).Clicked() {
a.open = false
}
if Button(c, "Save", ButtonOptions{}).Clicked() {
a.saved++
a.open = false
}
}
Dialog(c, &a.open, a.opts, func() {
ui.TextInput(c, &a.name)
})
}
func newDialogApp(opts DialogOptions) (*dialogApp, *ui.Tester) {
opts.Title = "Rename"
a := &dialogApp{opts: opts}
tt := ui.NewTester(a.view, 600, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
return a, tt
}
func TestDialogOpenFocusTrapRestore(t *testing.T) {
a, tt := newDialogApp(DialogOptions{Description: "Pick a new name."})
if err := tt.Click("Rename"); err != nil {
t.Fatal(err)
}
if !a.open || !tt.HasText("Pick a new name.") {
t.Fatalf("open %v, texts %q", a.open, tt.Texts())
}
if !tt.Focused("Close") {
t.Fatalf("focus did not move into the dialog")
}
// Tab cycles Close, input, Cancel, Save and back to Close.
for range 3 {
tt.Key(0, ui.KeyTab)
}
if !tt.Focused("Save") {
t.Fatalf("tab order left the dialog")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Close") {
t.Fatalf("tab did not wrap inside the dialog")
}
tt.Key(0, ui.KeyEscape)
if a.open {
t.Fatal("Escape did not close")
}
if !tt.Focused("Rename") {
t.Fatal("focus not restored to the opener")
}
}
func TestDialogActionsAndClose(t *testing.T) {
a, tt := newDialogApp(DialogOptions{})
_ = tt.Click("Rename")
if err := tt.Click("Save"); err != nil {
t.Fatal(err)
}
if a.saved != 1 || a.open {
t.Fatalf("saved %d open %v", a.saved, a.open)
}
_ = tt.Click("Rename")
if err := tt.Click("Close"); err != nil {
t.Fatal(err)
}
if a.open || a.saved != 1 {
t.Fatalf("close button: open %v saved %d", a.open, a.saved)
}
}
func TestDialogCloseRules(t *testing.T) {
a, tt := newDialogApp(DialogOptions{})
_ = tt.Click("Rename")
tt.ClickAt(10, 390)
if a.open {
t.Fatal("backdrop click did not close")
}
a, tt = newDialogApp(DialogOptions{KeepOnBackdrop: true, KeepOnEscape: true, HideClose: true})
_ = tt.Click("Rename")
tt.ClickAt(10, 390)
tt.Key(0, ui.KeyEscape)
if !a.open {
t.Fatal("kept dialog closed")
}
if tt.HasText("Close") {
t.Fatal("close button shown with HideClose")
}
if _, ok := tt.Find("Close"); ok {
t.Fatal("close button present with HideClose")
}
}
func TestDialogEscapeGoesToNestedPopoverFirst(t *testing.T) {
open, inner := true, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Dialog(c, &open, DialogOptions{Title: "Outer"}, func() {
b := Button(c, "More", ButtonOptions{})
if b.Clicked() {
inner = true
}
Popover(c, b, &inner, PopoverOptions{}, func() { ui.Text(c, "Inner") })
})
}, 600, 400)
_ = tt.Click("More")
if !inner {
t.Fatal("popover did not open")
}
tt.Key(0, ui.KeyEscape)
if inner || !open {
t.Fatalf("after one Escape inner %v outer %v", inner, open)
}
tt.Key(0, ui.KeyEscape)
if open {
t.Fatal("second Escape did not close the dialog")
}
}
func TestDialogNeedsTitle(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
open := true
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Dialog(c, &open, DialogOptions{}, nil)
}, 300, 200)
}
func dialogSnapshotView(d theme.Density, decorated bool) func(c *ui.Context) {
return func(c *ui.Context) {
mode := Light
if Tokens(c).Dark {
mode = Dark
}
Use(c, Settings{Mode: mode, Density: d})
open := true
name := "Ophelia"
ui.Column(c).Padding(24).Children(func() { ui.Text(c, "Behind the dialog") })
Dialog(c, &open, DialogOptions{
Title: "Rename the chamber", Description: "The new name shows in every hall.", Decorated: decorated,
Actions: func() {
Button(c, "Cancel", ButtonOptions{Variant: Secondary})
Button(c, "Rename", ButtonOptions{})
},
}, func() { ui.TextInput(c, &name) })
}
}
func TestDialogSnapshot(t *testing.T) {
snapshot(t, "dialog", 560, 360, dialogSnapshotView(theme.Default, true))
snapshot(t, "dialog-compact", 560, 360, dialogSnapshotView(theme.Compact, false))
snapshot(t, "dialog-comfortable", 560, 400, dialogSnapshotView(theme.Comfortable, true))
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// dividerDiamond is the ornament's size in DIPs.
const dividerDiamond float32 = 7
// DividerOptions configure a Divider.
type DividerOptions struct {
// Vertical draws a line down a row instead of across a column.
Vertical bool
// Label sits in the middle of a horizontal divider.
Label string
// Ornament draws the line in the ornament color with a single diamond in the middle; a Label replaces the diamond.
Ornament bool
}
// Divider is a thin separating line. It never takes focus and its ornament is not read out.
func Divider(c *ui.Context, opts DividerOptions) *ui.Element {
if opts.Vertical && opts.Label != "" {
panic("mujica: a vertical Divider cannot have a Label")
}
k := Tokens(c)
line := k.Border
if opts.Ornament {
line = k.Ornament
}
if opts.Label == "" && !opts.Ornament {
e := ui.Box(c).Background(line).Shrink(0).AlignSelf(ui.Stretch)
if opts.Vertical {
return e.Width(theme.BorderWidth)
}
return e.Height(theme.BorderWidth)
}
e := ui.Row(c).Gap(Density(c).Unit() * 2).Shrink(0).AlignSelf(ui.Stretch)
if opts.Vertical {
e.Column().AlignItems(ui.Center)
}
segment := func() {
s := ui.Box(c).Grow(1).Background(line)
if opts.Vertical {
s.Width(theme.BorderWidth)
} else {
s.Height(theme.BorderWidth)
}
}
return e.Children(func() {
segment()
if opts.Label != "" {
ui.Text(c, opts.Label).Font(theme.SerifFont).FontSize(theme.CaptionSize).
TextColor(k.TextMuted).SingleLine().Shrink(0)
} else {
ui.Box(c).Size(dividerDiamond, dividerDiamond).Shrink(0).Role(ui.RoleNone).
Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.Ornament)
})
}
segment()
})
}
+107
View File
@@ -0,0 +1,107 @@
package mujica
import (
"image/color"
"testing"
"github.com/egoist/mygo/ui"
)
func TestDividerSizes(t *testing.T) {
var h, v ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(300).Children(func() {
h = Divider(c, DividerOptions{}).Bounds()
ui.Row(c).Height(80).Children(func() {
v = Divider(c, DividerOptions{Vertical: true}).Bounds()
})
})
}, 400, 300).Frame()
if h.W != 300 || h.H != 1 {
t.Fatalf("horizontal %vx%v, want 300x1", h.W, h.H)
}
if v.W != 1 || v.H != 80 {
t.Fatalf("vertical %vx%v, want 1x80", v.W, v.H)
}
}
func TestDividerNeverFocused(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Children(func() {
Divider(c, DividerOptions{Label: "Section", Ornament: true})
Divider(c, DividerOptions{Ornament: true})
Button(c, "OK", ButtonOptions{})
})
}, 400, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("OK") {
t.Fatal("first Tab did not skip the dividers to the button")
}
if tt.Focused("Section") {
t.Fatal("divider label took the focus")
}
}
func TestDividerLabelCentered(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(400).Children(func() {
Divider(c, DividerOptions{Label: "Chapter II"})
})
}, 400, 100)
r, ok := tt.Find("Chapter II")
if !ok {
t.Fatal("label missing")
}
if mid := r.X + r.W/2; mid < 199 || mid > 201 {
t.Fatalf("label centered at %v, want 200", mid)
}
}
func TestDividerOrnamentColor(t *testing.T) {
var want ui.Color
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
want = Tokens(c).Ornament
ui.Column(c).Width(201).Padding(10, 0).Children(func() {
Divider(c, DividerOptions{Ornament: true})
})
}, 201, 40)
// The diamond's center sits on the line's middle.
got := tt.Image().At(100, 13)
if got != (color.RGBA{want.R, want.G, want.B, 255}) {
t.Fatalf("diamond center %v, want ornament %v", got, want)
}
}
func TestDividerVerticalLabelPanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("vertical label did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Divider(c, DividerOptions{Vertical: true, Label: "x"})
}, 100, 100)
}
func TestDividerSnapshot(t *testing.T) {
snapshot(t, "divider", 420, 170, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(20).Children(func() {
Divider(c, DividerOptions{})
Divider(c, DividerOptions{Ornament: true})
Divider(c, DividerOptions{Label: "Chapter II"})
Divider(c, DividerOptions{Label: "第二章", Ornament: true})
ui.Row(c).Height(28).Gap(12).Children(func() {
ui.Text(c, "Left")
Divider(c, DividerOptions{Vertical: true})
ui.Text(c, "Middle")
Divider(c, DividerOptions{Vertical: true, Ornament: true})
ui.Text(c, "Right")
})
})
})
}
+53
View File
@@ -0,0 +1,53 @@
# 001 AppShell 应用外壳
## 用途
搭建桌面窗口的整体骨架:顶部标题栏、左侧侧栏、主内容区和底部状态栏四个插槽。侧栏可折叠为只含切换按钮的窄条;侧栏与主内容各自独立滚动。
## 最小示例
```go
var collapsed bool
mujica.AppShell(c, &collapsed, mujica.AppShellOptions{
TitleBar: func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) },
Sidebar: func() { ui.Text(c, "Chronicles") },
Content: func() { ui.Text(c, "The Grand Hall") },
})
```
签名:`func AppShell(c *ui.Context, collapsed *bool, opts AppShellOptions) AppShellResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `collapsed` | `*bool` | 必填 | 侧栏是否折叠,由应用持有;为 nil 时 panic。 |
| `TitleBar` | `func()` | nil | 顶部插槽,通常放 `TitleBar`。 |
| `Sidebar` | `func()` | nil | 侧栏内容,自带独立滚动;为 nil 时没有侧栏与折叠快捷键。 |
| `Content` | `func()` | 必填 | 主内容,自带独立滚动;为 nil 时 panic。 |
| `StatusBar` | `func()` | nil | 底部插槽,通常放 `StatusBar`。 |
| `SidebarWidth` | `float32` | 240 | 展开时侧栏宽度(DIP);负数 panic。 |
| `ContentScroll` | `*ui.ScrollState` | nil | 保存主内容滚动位置;nil 时按位置保留。 |
返回 `AppShellResult`,内嵌 `*ui.Element`。
## 状态
- 展开:侧栏宽 `SidebarWidth`,顶部右侧为收起按钮。
- 折叠:侧栏收为 `28 + 4 单位` 的窄条,只保留展开按钮。宽度变化 120 ms,减少动画时直接切换。
- 切换按钮有悬停、按下和键盘焦点环;折叠不会重置主内容滚动位置。
## 事件
- `Toggled`:本帧侧栏被展开或折叠(按钮或快捷键),只触发一次。
## 键盘操作
- `Cmd+B`(macOS)/ `Ctrl+B`(其他平台):切换侧栏。焦点控件先处理该键,富文本等若占用此键则不会触发。
- Tab 依次到达切换按钮、侧栏与内容中的控件;滚动区域内容溢出时可获得焦点,方向键、Page Up/Down、Home、End 滚动。
## 限制
- 窄窗口不会自动折叠,由应用根据窗口宽度设置 `*collapsed`。
- 折叠动画期间侧栏内容立即隐藏,只有宽度过渡。
- 快捷键固定为 Cmd/Ctrl+B,不可配置。
+43
View File
@@ -0,0 +1,43 @@
# 002 TitleBar 标题栏
## 用途
窗口顶部的标题行:衬线体应用标题、前后工具插槽。空白处可拖动窗口,并为系统窗口按钮留出空间。配合 `mygo.TitleBarHidden` 使用。
## 最小示例
```go
mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{
Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) },
})
```
签名:`func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `title` | `string` | 必填 | 标题文字,过长时单行省略。 |
| `Leading` | `func()` | nil | 标题前的工具,如侧栏开关。 |
| `Trailing` | `func()` | nil | 标题后的工具。 |
## 状态
- 高度取 `c.TitleBar().Height` 与 `控件高度 + 2 单位` 中较大者(默认密度 44 DIP)。
- 左右内边距在 `c.TitleBar().Left/Right` 之外再加 3 单位,不覆盖系统按钮;全屏时预留为 0。
- Surface 底色,底部 1 DIP Border 分隔线。
## 事件
TitleBar 本身无事件;插槽中控件照常报告 `Clicked` 等事件。
## 键盘操作
插槽控件照常接受 Tab、Enter、Space。标题栏本身不获取焦点。
## 限制
- 拖动、双击缩放由 MyGo `DragWindow` 实现,只作用于非交互区域;需真机验证,无窗口测试无法覆盖。
- 系统窗口按钮外观由系统决定,不换肤。
- 普通有标题栏的窗口中 `c.TitleBar()` 为零,组件只是一行普通标题。
+63
View File
@@ -0,0 +1,63 @@
# 003 StatusBar 状态栏
## 用途
窗口底部的状态条,分左右两组条目:状态文字、图标、进度与可点击操作。宽度不足时自动隐藏次要条目,并显示剩余数量。
## 最小示例
```go
bar := mujica.StatusBar(c, mujica.StatusBarOptions{
Left: []mujica.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
Right: []mujica.StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
})
if bar.Clicked == "bell" {
opened++
}
```
签名:`func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult`
## 参数
`StatusBarOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Left` | `[]StatusItem` | nil | 左侧条目,从左到右。 |
| `Right` | `[]StatusItem` | nil | 右侧条目,从左到右。 |
`StatusItem`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `ID` | `string` | 必填 | 稳定键,也是 `Clicked` 的值;为空或重复 panic。 |
| `Text` | `string` | "" | 状态文字(12 DIP)。 |
| `Icon` | `*ui.SVG` | nil | 前置图标(14 DIP)。 |
| `Tone` | `StatusItemTone` | `StatusItemNeutral` | 语义色:Neutral、Success、Warning、Danger、Info。 |
| `ShowProgress` | `bool` | false | 显示 64 DIP 进度条。 |
| `Progress` | `float32` | 0 | 进度 0–1,超出 panic。 |
| `Action` | `bool` | false | 条目成为按钮。 |
| `Label` | `string` | "" | 无文字条目的无障碍名称;`Text` 与 `Label` 都为空时 panic。 |
| `Secondary` | `bool` | false | 空间不足时优先隐藏。 |
## 状态
- 默认:Surface 底色,顶部 Border 线,组内条目以细线分隔。
- 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。
- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下;显示 `+N`,悬停提示列出被隐藏条目,无障碍名称为“另有 N 项”。主要条目永不隐藏。
- 进度:AccentText 填充,Border 轨道,描述为百分比。
## 事件
- `Clicked`:本帧被点击的操作条目 ID,没有则为空。一次点击只报告一次。
## 键盘操作
Tab 到达操作条目,Enter 或 Space 触发。纯文字条目不进入 Tab 序列。
## 限制
- 折叠依据上一帧的宽度和文字测量估算,窗口缩放后下一帧生效。
- 被隐藏条目只在 `+N` 的提示中列出,提示仅悬停可见,键盘用户通过无障碍名称得知数量。
- 不提供不确定进度动画;需要时在 `Text` 中说明。
+45
View File
@@ -0,0 +1,45 @@
# 004 Container 容器
## 用途
为一组内容提供内边距、最大宽度、水平居中、填充父级和裁剪,适合正文、设置页等需要可读行宽的区域。
## 最小示例
```go
mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() {
ui.Text(c, "A centered column of prose.")
})
```
签名:`func Container(c *ui.Context, opts ContainerOptions, children func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Padding` | `Space` | `SpaceL`(4 单位,默认密度 16 DIP) | 四周内边距,随密度缩放。 |
| `MaxWidth` | `float32` | 0(不限) | 最大宽度 DIP;负数 panic。 |
| `Center` | `bool` | false | 占满父级宽度后受 `MaxWidth` 限制并水平居中。 |
| `Fill` | `bool` | false | 占满父级内容区(宽和高)。 |
| `Clip` | `bool` | false | 裁剪超出的内容。 |
| `children` | `func()` | 必填 | 子内容,纵向排列;nil 时 panic。 |
`Space` 取值:`SpaceDefault`(组件默认)、`SpaceNone`、`SpaceXS`(1 单位)、`SpaceS`(2)、`SpaceM`(3)、`SpaceL`(4)、`SpaceXL`(6)、`SpaceXXL`(8)。单位为 Compact 3、Default 4、Comfortable 5 DIP。非法值 panic。
## 状态
布局组件,无交互状态。不绘制背景,可在返回的元素上调用 `Background`、`Border`。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 只做水平居中;垂直居中请用 `Stack` 的对齐或 MyGo `Center()`。
- 子内容固定纵向排列,需要横向时在内部放 `Stack`。
+49
View File
@@ -0,0 +1,49 @@
# 005 Stack 堆叠
## 用途
按横向或纵向排列子元素,统一间距、对齐与换行。用 `StackItem` 包裹可隐藏的子项,隐藏时不占位置也不留间距。
## 最小示例
```go
mujica.Stack(c, mujica.StackOptions{Horizontal: true, Gap: mujica.SpaceM}, func() {
mujica.Button(c, "Save", mujica.ButtonOptions{})
mujica.StackItem(!showDraft, func() { mujica.Button(c, "Draft", mujica.ButtonOptions{Variant: mujica.Secondary}) })
mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Ghost})
})
```
签名:
- `func Stack(c *ui.Context, opts StackOptions, children func()) *ui.Element`
- `func StackItem(hidden bool, build func())`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Horizontal` | `bool` | false | 横向排列;否则纵向。 |
| `Gap` | `Space` | `SpaceS`(2 单位) | 子项间距,随密度缩放。 |
| `Align` | `StackAlign` | `StackAlignAuto` | 交叉轴对齐:Auto(纵向拉伸、横向居中)、Start、Center、End、Stretch;非法值 panic。 |
| `Justify` | `ui.Align` | `ui.Start` | 主轴分布:Start、Center、End、SpaceBetween、SpaceAround、SpaceEvenly。 |
| `Wrap` | `bool` | false | 横向换行;纵向时设置会 panic。 |
`StackItem`:`hidden` 为 true 时不构建 `build`,因此既不占空间也不产生间距。`build` 为 nil 时 panic。
## 状态
布局组件,无交互状态。
## 事件
无。
## 键盘操作
不获取焦点;Tab 顺序即子项构建顺序。
## 限制
- 隐藏项不会被构建,其内部界面状态(如未提交的输入)随之丢弃;需要保留时把状态放在应用中。
- 只有横向支持换行。
+57
View File
@@ -0,0 +1,57 @@
# 006 Grid 网格
## 用途
按行列排布子元素:列宽可为固定尺寸、按内容或按比例分配剩余空间;子项可指定位置与跨行跨列。基于 MyGo 网格布局。
## 最小示例
```go
mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { ui.Text(c, "Header") })
ui.Text(c, "Name")
ui.Text(c, "Ada")
})
```
签名:
- `func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element`
- `func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element`
## 参数
`GridOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Columns` | `[]ui.Track` | 一列 `Fr(1)` | 各列尺寸:`ui.Fixed(dip)`、`ui.Fr(n)`(分配剩余空间)、`ui.FitContent()`。 |
| `Rows` | `[]ui.Track` | nil | 前几行尺寸;之后自动添加的行按内容高度。 |
| `Gap` | `Space` | `SpaceM`(3 单位) | 列间距,也是默认行间距。 |
| `RowGap` | `Space` | 同 `Gap` | 行间距。 |
`GridCellOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Column`, `Row` | `int` | 0(自动放置) | 起始列、行,从 1 计;负数 panic。 |
| `ColumnSpan`, `RowSpan` | `int` | 1 | 跨越轨道数;-1 跨到最后一条;小于 -1 panic。 |
`GridCell` 内的单个子元素会拉伸铺满整个单元格,与网格直接子项一致。
## 状态
布局组件,无交互状态。
## 事件
无。
## 键盘操作
不获取焦点;Tab 顺序为构建顺序。
## 限制
- 网格会构建全部子项;大量条目请使用虚拟化列表。
- 不校验跨度是否超出列数,超出时由 MyGo 按其规则扩展。
+49
View File
@@ -0,0 +1,49 @@
# 007 ScrollArea 滚动区域
## 用途
纵向、横向或双向滚动内容。支持滚轮、触控板、滚动条拖动与键盘;可读取、设置并保留滚动位置。基于 MyGo `ui.Scroll`。
## 最小示例
```go
var offset ui.ScrollState
mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() {
for range 50 {
ui.Text(c, "A line of the log")
}
}).Height(200)
```
签名:`func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `state` | `*ui.ScrollState` | nil | 滚动位置。设置 `X`/`Y` 即滚动到该处,`math.MaxFloat32` 表示末尾;组件写回 `MaxX`/`MaxY`。nil 时按区域在视图中的位置保留。 |
| `Direction` | `ScrollDirection` | `ScrollVertical` | `ScrollVertical`、`ScrollHorizontal`、`ScrollBoth`;非法值 panic。 |
| `Label` | `string` | "" | 区域的无障碍名称。 |
| `children` | `func()` | 必填 | 内容;nil 时 panic。 |
需给区域尺寸(`Height`、`Grow` 等)。
## 状态
- 内容溢出时区域可获得焦点,键盘焦点环画在区域内侧(2 DIP,Focus 色),避免被父级裁剪。
- 不溢出时不进入 Tab 序列。
- 滚动条为 MyGo 叠加式滑块,颜色取主题 Scrollbar(TextMuted 50%),指针悬停时显示,不占布局空间。
- 切换主题、折叠周围布局都不重置位置。
## 事件
无独立事件;比较 `state.X`/`state.Y` 的变化即可得知滚动。
## 键盘操作
焦点在区域或其内容上时:方向键滚动 40 DIP,Page Up/Down 与 Space/Shift+Space 翻页,Home/End 到顶部/底部(macOS 另有 Cmd+↑/↓)。文本输入等自行处理这些键的控件优先。
## 限制
- 滚动条只在悬停时显示,不提供常驻滚动条(MyGo 滚动条不占空间的设计)。
- 大量行请使用虚拟化列表,ScrollArea 会构建全部子项。
+64
View File
@@ -0,0 +1,64 @@
# 008 SplitPane 分栏
## 用途
将区域分为左右(或上下)两栏,中间分隔条可拖动、可用键盘调整;两栏有各自最小尺寸,可指定一栏可折叠。
## 最小示例
```go
split := mujica.SplitPaneState{Size: 160}
mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst},
func() { ui.Text(c, "Files") },
func() { ui.Text(c, "Editor") },
).Fill()
```
签名:`func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, first, second func()) SplitPaneResult`
## 参数
`SplitPaneState`(应用持有,nil 时 panic)
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Size` | `float32` | 0(首次布局后取一半) | 第一栏宽度(纵向时为高度),DIP。每帧被限制在最小尺寸之间。 |
| `Collapsed` | `bool` | false | 可折叠栏是否折叠;折叠时保留 `Size` 以便恢复。未设置 `Collapsible` 却为 true 时 panic。 |
`SplitPaneOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Vertical` | `bool` | false | 第一栏在上。 |
| `MinFirst`, `MinSecond` | `float32` | 10 单位(默认 40 DIP) | 两栏最小尺寸;负数 panic。 |
| `Collapsible` | `SplitPaneSide` | `SplitPaneNeither` | 可折叠的栏:`SplitPaneFirst` 或 `SplitPaneSecond`。 |
返回 `SplitPaneResult`,内嵌 `*ui.Element`。
## 状态
- 分隔条为 1 DIP ControlBorder 线,命中区域 1.5 单位宽;悬停或拖动时变为 AccentText;键盘焦点时在命中区域外画焦点环。
- 折叠时该栏不构建,分隔条贴边并显示一段短标记,提示可从此处拉开。
- 无障碍:角色为 splitter,名称“分隔条”,描述为第一栏所占百分比或“已折叠”。
## 事件
- `Resized`:本帧尺寸改变(拖动或键盘)。
- `Toggled`:本帧折叠或展开。
## 键盘操作
Tab 聚焦分隔条后:
- ←/→(纵向为 ↑/↓):移动 4 单位,到达最小尺寸后停止。
- 可折叠栏已在最小尺寸时继续朝它按方向键:折叠;折叠后按反方向键:展开。
- Home / End:移到最小 / 最大位置。
- Enter:切换可折叠栏(未设置 `Collapsible` 时不占用 Enter)。
指针:拖动分隔条;拖到可折叠栏最小尺寸的一半以内即折叠,拖回超过一半即展开;双击切换折叠。
## 限制
- 未直接使用 MyGo `ui.Split`:它的最小栏宽固定为 10 个间距单位(`ui/split.go:20`),没有折叠能力,且分隔条元素不对外暴露,无法挂接 Enter、Home、End。本组件用 MyGo 公开的 `Focusable`、`Dragged`、`Shortcut`、`RoleSplitter` 重新组合,行为与 MyGo 分隔条一致并补齐上述功能。
- MyGo 没有公开的无障碍数值范围接口,当前位置通过描述文字提供。
- 键盘或拖动产生的尺寸在下一帧生效(组件会立即请求下一帧)。
+41
View File
@@ -0,0 +1,41 @@
# 009 AspectRatio 宽高比
## 用途
在父容器的宽高约束内放置一个保持指定宽高比的框,尽可能大并居中;子内容在框内居中,不被拉伸,超出部分裁剪。适合预览、视频占位、封面图。
## 最小示例
```go
ui.Box(c).Size(320, 240).Children(func() {
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
ui.Text(c, "16 : 9")
})
})
```
签名:`func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Ratio` | `float32` | 1 | 宽 / 高;负数 panic。 |
| `child` | `func()` | 必填 | 框内内容;nil 时 panic。需要铺满框时让子元素 `Fill()`。 |
## 状态
布局组件,无交互状态。返回的元素占满父级内容区,比例框位于其中央。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 父级高度不确定(随内容增长)时只按宽度计算。
- 框尺寸依据上一帧的父级尺寸计算,窗口缩放后组件请求下一帧完成调整(MyGo 布局的 `AspectRatio` 只能由宽推高,不能同时受宽高约束)。
+43
View File
@@ -0,0 +1,43 @@
# 010 Divider 分隔线
## 用途
横向或纵向的细分隔线;横向可在中间放文字;可选旧金色单个菱形装饰。永不获取焦点,装饰不进入无障碍树。
## 最小示例
```go
ui.Column(c).Padding(16).Gap(12).Children(func() {
mujica.Divider(c, mujica.DividerOptions{Ornament: true})
mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"})
})
```
签名:`func Divider(c *ui.Context, opts DividerOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Vertical` | `bool` | false | 纵向,放在横向行中,高度随行拉伸。 |
| `Label` | `string` | "" | 横向分隔线中间的文字(衬线 12 DIP,TextMuted)。纵向带文字时 panic。 |
| `Ornament` | `bool` | false | 线条改用 Ornament 色,并在中间画一个 7 DIP 菱形;有 `Label` 时文字取代菱形。 |
## 状态
- 默认:1 DIP Border 色细线。
- 装饰:Ornament 色线与菱形。
- 无悬停、焦点等交互状态。
## 事件
无。
## 键盘操作
不获取焦点,Tab 直接跳过。
## 限制
- 方向不会根据父级自动判断,需要显式设置 `Vertical`。
- 纵向不支持文字。
+69
View File
@@ -0,0 +1,69 @@
# 011 Text 文本
## 用途
正文文本:自动换行、按行数省略、可选择复制。`EditableText` 是 Text 的可编辑模式,就地修改一段短文本(如文件名),并展示调用方的保存进度与保存结果。
## 最小示例
```go
name := "Coronation.md"
saveErr := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2})
if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() {
saveErr = "" // save name here; set saveErr on failure
}
}
```
## 参数
`func Text(c *ui.Context, s string, opts TextOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `MaxLines` | `int` | `0` | 超过该行数时以省略号截断;`0` 不截断;`1` 为单行。负数 panic。 |
| `Selectable` | `bool` | `false` | 允许用指针或键盘选择并复制文本。 |
`func EditableText(c *ui.Context, value *string, opts EditableTextOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Saving` | `bool` | `false` | 调用方正在保存:显示加载标记,并禁止再次编辑。 |
| `Error` | `string` | `""` | 调用方保存失败的说明,显示在文本下方并报告给辅助技术。 |
| `Label` | `string` | `""` | 辅助技术读出的名称。 |
## 状态
- 默认:可编辑文本带虚线下边,提示可以编辑。
- 悬停:背景变为 `SurfaceHover`,尺寸不变。
- 聚焦:2 DIP 焦点环。
- 编辑中:显示 MyGo 文本输入框,酒红边框,选中扩展名之前的部分。
- 保存中(`Saving`):显示加载标记,阻止进入编辑。
- 错误(`Error`):虚线变为 `Danger`,下方显示错误说明,保留已确认的值。
## 事件
- `EditableText(...).Changed()`:用户确认了一个新值,`*value` 已更新。调用方在此保存,并通过 `Saving` 与 `Error` 回传结果。
- 取消编辑不产生事件,`*value` 不变。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦可编辑文本 |
| Enter / 双击 | 开始编辑 |
| Enter | 确认 |
| Escape | 取消并恢复原值 |
| 焦点移走 | 视为确认 |
| Cmd/Ctrl+A、Cmd/Ctrl+C | 在 `Selectable` 文本上全选、复制 |
## 限制
- 编辑模式基于 `ui.EditableText`。MyGo 不报告“取消”事件,组件只能保证取消后值不变,不能通知调用方。
- 编辑模式只支持单行文本。
- 值为空字符串时文本区域没有可见文字;组件保留最小宽度和虚线,保证仍可点击。
- MyGo 无法检测文本是否真的被截断,所以截断时不会自动显示完整内容的提示。
+43
View File
@@ -0,0 +1,43 @@
# 012 Heading 标题
## 用途
页面与区块的三级衬线标题,可带副标题与旧金色装饰分隔线(对称细线加中央菱形)。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})
}
```
## 参数
`func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Level` | `int` | `0`(即 1) | 1、2、3 级,对应 28、22、18 DIP。其他值 panic。 |
| `Subtitle` | `string` | `""` | 标题下方的说明文字,使用 `TextMuted`。 |
| `Divider` | `bool` | `false` | 在下方绘制装饰分隔线,颜色为 `Ornament`。 |
## 状态
纯展示,仅默认状态。字号按系统文本缩放(`Preferences().TextScale`)放大。
## 事件
无。
## 键盘操作
不获取焦点。装饰线不进入无障碍树。
## 限制
- 标题字体为 `theme.SerifFont`(Georgia、宋体类回退);系统缺字时由系统回退字体显示。
- MyGo v0.2.10 没有 Heading 角色(`ui/access.go` 的 Role 列表中没有),辅助技术把标题读作普通文本,无法按标题层级导航。
- 分隔线只在 Heading 内绘制,不依赖 Divider 组件。
+47
View File
@@ -0,0 +1,47 @@
# 013 Icon 图标
## 用途
按名称显示 `icons` 包内置的 Lucide SVG 图标,可设尺寸与语义颜色。名称不存在时显示可见的回退标记并写警告日志。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Row(c).Gap(8).Children(func() {
mujica.Icon(c, "star", mujica.IconOptions{})
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
})
}
```
## 参数
`func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element`
`name` 必填,空字符串 panic。可用名称见 `icons.Names()`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Size` | `float32` | `0`(即 16) | 宽高(DIP),建议 16、20、24。负数 panic。 |
| `Tone` | `IconTone` | `IconInherit` | `IconInherit` 沿用文字颜色;另有 `IconMuted`、`IconAccent`、`IconSuccess`、`IconWarning`、`IconDanger`、`IconInfo`。未知值 panic。 |
| `Label` | `string` | `""` | 有含义的图标的无障碍名称;为空时图标视为装饰,不进入无障碍树。 |
## 状态
- 默认:按语义颜色显示图标。
- 加载失败(名称不存在):在同尺寸方框中用 `Danger` 色绘制虚线框与斜线,提示文字为“缺少图标:名称”,无障碍名称相同。每个元素对每个缺失名称记录一次 `slog.Warn("mujica: icon missing", "name", ...)`。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 只能使用 `icons` 包内置的图标。要显示任意 SVG,可直接用 `ui.Icon`。
- 回退标记带悬停提示,但图标本身不获取焦点,键盘用户看不到这个提示,只能依赖无障碍名称。
+60
View File
@@ -0,0 +1,60 @@
# 014 Image 图片
## 用途
显示位图(`*ui.Bitmap`)或 SVG(`*ui.SVG`),可选包含或裁切填充;提供占位、加载中、加载失败三种状态和替代说明。
## 最小示例
```go
var photo *ui.Bitmap // decode once with ui.DecodeBitmap, outside the view
loading, loadErr := false, errors.New("timeout")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
img := mujica.Image(c, photo, mujica.ImageOptions{
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
Loading: loading, Err: loadErr,
})
if img.Retry {
loading, loadErr = true, nil // start loading again here
}
}
```
## 参数
`func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView`
`src` 为 `nil`(未赋值的接口)时显示占位。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时图片使用自身尺寸;占位、加载、错误状态为 160×120。负数 panic。 |
| `Crop` | `bool` | `false` | `true` 覆盖并裁切(`ui.Cover`);`false` 完整放入(`ui.Contain`)。 |
| `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 |
| `Loading` | `bool` | `false` | 调用方正在加载。 |
| `Err` | `error` | `nil` | 调用方加载失败;优先于 `Loading`。 |
返回值 `ImageView`:`Element` 为外层元素;`Retry` 表示本帧点击了重试。
## 状态
优先级:错误 > 加载中 > 占位 > 图片。
- 占位:浅色框内显示图片图标,名称“暂无图片”。
- 加载中:框内显示 Spinner,名称“正在加载图片”。
- 错误:显示错误图标、“图片加载失败”和“重试”按钮,`Err.Error()` 作为无障碍错误说明。
## 事件
- `ImageView.Retry`:用户点击了重试,或在按钮上按 Enter/Space。
## 键盘操作
只有错误状态的“重试”按钮可以聚焦(Tab),用 Enter 或 Space 触发。
## 限制
- 组件不读取文件,也不访问网络。解码(`ui.DecodeBitmap`、`ui.ParseSVG`)和异步加载由调用方完成,结果通过 `Window.Update` 回到界面线程,再设置 `Loading` 与 `Err`。
- SVG 的绘制范围以 MyGo 支持的部分为准:不支持文本、嵌入图片、pattern、marker 和 filter。
- 接口里装着 nil 指针(如 `(*ui.Bitmap)(nil)`)时不算 `nil`,不会显示占位。
+47
View File
@@ -0,0 +1,47 @@
# 015 Avatar 头像
## 用途
圆形人物头像。依次回退:图片 → 姓名首字母 → 默认人物图标。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Row(c).Gap(8).Children(func() {
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "", mujica.AvatarOptions{})
})
}
```
## 参数
`func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Image` | `*ui.Bitmap` | `nil` | 头像图片,按圆形裁切填充。 |
| `Size` | `float32` | `0`(即 32) | 直径(DIP)。负数 panic。 |
`name` 用作无障碍名称和首字母来源;为空时无障碍名称是“未命名”。
## 状态
- 图片:圆形裁切。
- 首字母:取第一个和最后一个词的首字母(大写),衬线字体,`AccentText` 文字配 `Selection` 底色。
- 无可用首字母:`SurfaceHover` 底色加人物图标。
## 事件
无。
## 键盘操作
不获取焦点。辅助技术把头像读作以 `name` 命名的图片。
## 限制
- 中文姓名没有空格时按一个词处理,只取第一个字,例如“张伟”显示为“张”。
- 头像不使用 MyGo `ui.Avatar`,因为它的尺寸固定为字号的 2.25 倍,无法配置(`ui/indicators.go:383`)。
+54
View File
@@ -0,0 +1,54 @@
# 016 AvatarGroup 头像组
## 用途
把多个头像重叠排列。超过最大数量时显示“+N”按钮,点开后在弹层中列出完整名单。
## 最小示例
```go
people := []mujica.AvatarItem{
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})
}
```
## 参数
`func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) AvatarGroupView`
`AvatarItem`:`Name string`、`Image *ui.Bitmap`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Max` | `int` | `0`(即 4) | 最多直接显示的头像数。负数 panic。 |
| `Size` | `float32` | `0`(即 32) | 每个头像的直径。负数 panic。 |
返回值 `AvatarGroupView`:`Element`;`Open` 表示完整名单正在显示;`Toggled` 表示本帧打开或关闭了名单。
## 状态
- 默认:头像之间重叠四分之一直径,用 2 DIP 窗口背景色描边分隔。
- “+N”按钮:悬停、按下改变背景;聚焦时显示 2 DIP 焦点环。
- 展开:按钮下方弹出 6 DIP 圆角面板,列出全部头像与姓名,超过 240 DIP 时滚动;遵循减少动画设置。
## 事件
- `AvatarGroupView.Toggled` / `Open`:名单展开或关闭(点击按钮、Enter、Space、Escape、点击外部)。同时记录 `slog.Debug("mujica: avatargroup toggle")`。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦“+N”按钮(读作“显示全部 N 人”) |
| Enter / Space | 展开或收起完整名单 |
| Escape | 关闭名单 |
## 限制
- 分隔描边使用窗口 `Background` 颜色,放在 `Surface` 卡片上时描边颜色会与卡片背景不同。
- 名单中的条目只用于展示,不可选择。
+50
View File
@@ -0,0 +1,50 @@
# 017 Badge 徽标
## 用途
显示短文字或计数。计数超过上限时显示为“上限+”。徽标可以内联排列,也可以钉在内容的某个角上。辅助技术朗读完整数值。
## 最小示例
```go
unread := 120
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
mujica.Badge(c, mujica.BadgeOptions{Count: unread, Label: "Unread", Position: mujica.BadgeTopRight}, func() {
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{})
})
}
```
## 参数
`func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element`
`Position` 为 `BadgeInline` 时 `content` 必须为 `nil`;为角落位置时 `content` 必须不为 `nil`。否则 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Text` | `string` | `""` | 显示的文字;非空时忽略 `Count`。 |
| `Count` | `int` | `0` | 计数。`Text` 为空且计数为 0 时不显示徽标。负数 panic。 |
| `Max` | `int` | `0`(即 99) | 显示上限,超过时显示 `Max+`。负数 panic。 |
| `Tone` | `BadgeTone` | `BadgeAccent` | `BadgeAccent`、`BadgeNeutral`、`BadgeSuccess`、`BadgeWarning`、`BadgeDanger`、`BadgeInfo`。 |
| `Position` | `BadgePosition` | `BadgeInline` | `BadgeTopRight`、`BadgeTopLeft`、`BadgeBottomRight`、`BadgeBottomLeft`:徽标中心对准内容的对应角。 |
| `Label` | `string` | `""` | 计数的含义,朗读时放在完整数值前,如“Unread 1204”。 |
## 状态
纯展示。强调和危险色调使用实色填充(`OnAccent` 配 `Accent`,`OnDanger` 配 `DangerFill`);成功、警告、信息色调只把语义色用在文字和边框上。
## 事件
无。
## 键盘操作
不获取焦点。徽标的无障碍名称为 `Label` 加完整数值。
## 限制
- 角落徽标的位置相对于 `content` 所在的盒子。内容本身有外边距时,徽标不会跟着偏移。
- 徽标使用 `RoleStatus`,部分屏幕阅读器可能在数值变化时主动播报。
+63
View File
@@ -0,0 +1,63 @@
# 018 Tag 标签
## 用途
短标签,可带图标。可以点击,也可以关闭,点击标签和关闭标签是两个独立的操作,各有自己的焦点停靠点。
## 最小示例
```go
tags := []string{"Baroque", "Gothic"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Row(c).Gap(8).Children(func() {
for i, t := range tags {
v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true})
v.Element.Key(t)
if v.Closed {
tags = append(tags[:i:i], tags[i+1:]...)
}
}
})
}
```
## 参数
`func Tag(c *ui.Context, label string, opts TagOptions) TagView`
`label` 必填,空字符串 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Icon` | `string` | `""` | 图标名称,见 Icon 组件。 |
| `Clickable` | `bool` | `false` | 标签文字部分作为按钮,可以聚焦和点击。 |
| `Closable` | `bool` | `false` | 显示 28×28 DIP 的关闭按钮。 |
| `Disabled` | `bool` | `false` | 禁用点击和关闭,并移出 Tab 顺序。 |
返回值 `TagView`:`Element`;`Clicked` 表示标签被激活;`Closed` 表示请求关闭。
## 状态
- 默认:`Surface` 底色、细边框、2 DIP 圆角。
- 悬停 / 按下:可点击部分和关闭按钮分别变为 `SurfaceHover` / `Selection`。
- 聚焦:对应部分显示 2 DIP 焦点环。
- 禁用:由 MyGo 统一调暗,不响应输入。
## 事件
- `TagView.Clicked`:点击标签文字,或在聚焦时按 Enter/Space。点击关闭按钮不会触发它。
- `TagView.Closed`:点击关闭按钮,或在关闭按钮上按 Enter/Space,或在聚焦的可点击标签上按 Delete/Backspace。组件不会自行移除标签,由调用方删除数据。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 先聚焦标签文字(仅 `Clickable`),再聚焦关闭按钮 |
| Enter / Space | 激活当前聚焦的部分 |
| Delete / Backspace | 在可点击标签上请求关闭(仅 `Closable`) |
## 限制
- 不可点击的标签不获取焦点,只能通过关闭按钮操作。
- 标签不提供“选中”状态;需要可选标签时请使用 Toggle 类组件。
+53
View File
@@ -0,0 +1,53 @@
# 019 Link 链接
## 用途
带下划线的链接文本:打开外部地址、在 MyGo Router 中跳转路径,或作为应用内动作。可以显示打开失败的反馈。
## 最小示例
```go
details := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() {
details = !details
}
}
```
## 参数
`func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element`
`label` 必填,空字符串 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `URL` | `string` | `""` | 有协议的地址用系统默认应用打开。没有协议的路径(如 `/notes/42`、`?tab=info`)在 MyGo Router 中跳转。为空时是应用内动作。 |
| `Error` | `string` | `""` | 调用方提供的打开失败说明,显示在链接下方,也报告给辅助技术。 |
## 状态
- 默认:`AccentText` 颜色,始终带 1 DIP 下划线。外部地址后面带“外部链接”小图标。
- 悬停 / 按下:文字与下划线变为 `Text` 颜色,布局不变。
- 聚焦:2 DIP 焦点环。
- 错误:格式不正确的地址(无法解析,或 http/https 缺少主机名)不会打开,下方显示“无法打开此链接:地址”;`Error` 非空时优先显示 `Error`。
## 事件
- `Clicked()`:每次点击或按 Enter 都会报告,无论是否打开了地址。同时记录 `slog.Debug("mujica: link activate")`。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦链接 |
| Enter | 激活 |
## 限制
- MyGo v0.2.10 不报告打开失败。`ui.Link` 和 `Context.OpenURL` 不返回结果(`ui/context.go:270`、`ui/window.go:583`)。窗口宿主在 goroutine 中调用 `Shell.OpenExternal` 并丢弃它的错误(`content.go:55`)。因此组件只能自行检查地址格式,系统打开失败无法自动显示。需要这种反馈时,传空 `URL`,在 `Clicked()` 后由调用方在 goroutine 中调用 `mygo.Shell.OpenExternal(url)`。它会返回 error,把结果通过 `Window.Update` 写回 `Error`。
- 路径是否交给 Router 由 MyGo 决定。没有 Router 时,路径会被当作 URL 交给系统打开。
- 链接不能嵌入 `ui.RichText` 句子中作为行内元素,因为外部图标和错误说明需要额外的行布局。行内链接请直接使用 `ui.Link`。
+55
View File
@@ -0,0 +1,55 @@
# 020 Kbd 快捷键
## 用途
把单键或组合键显示为键帽。按平台显示:macOS 用 ⌘ ⇧ ⌥ ⌃ 等符号且不加连接符;其他平台显示 Ctrl、Shift、Alt,键之间用“+”连接。只做展示,不注册快捷键。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Row(c).Gap(6).Children(func() {
mujica.Text(c, "Command palette", mujica.TextOptions{})
mujica.Kbd(c, "Mod", "Shift", "P")
})
}
```
## 参数
`func Kbd(c *ui.Context, keys ...string) *ui.Element`
至少要传一个键,键名不能为空,否则 panic。键名不区分大小写:
| 键名 | macOS | 其他平台 |
| --- | --- | --- |
| `Mod` | ⌘(Command) | Ctrl |
| `Ctrl` | ⌃ | Ctrl |
| `Alt` | ⌥(Option) | Alt |
| `Shift` | ⇧ | Shift |
| `Enter` | ↩ | Enter |
| `Esc` | esc | Esc |
| `Tab` | ⇥ | Tab |
| `Backspace` | ⌫ | Backspace |
| `Delete` | ⌦ | Delete |
| `Up` `Down` `Left` `Right` | ↑ ↓ ← → | ↑ ↓ ← → |
其他键名按原样显示;单个字符转为大写。
## 状态
纯展示。键帽使用等宽字体、`Surface` 底色、`ControlBorder` 边框,底边 2 DIP。
## 事件
无。
## 键盘操作
不获取焦点,也不注册任何快捷键。辅助技术读出完整的组合键文字,例如“Command+Shift+P”或“Ctrl+Shift+P”。
## 限制
- 平台在编译时由 `runtime.GOOS` 决定。Gallery 的网页镜像显示的是运行 Go 进程那台机器的平台。
- `Mod` 与 MyGo 的 `ui.Cmd` 一致:macOS 上是 Command,其他平台是 Control。
+43
View File
@@ -0,0 +1,43 @@
# 021 Button 按钮
## 用途
触发一个操作。提供主按钮、次按钮、描边、无底色和危险五种变体,可带图标,可显示加载与禁用。基于 MyGo `ui.ButtonBase`。
## 最小示例
```go
if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() {
fmt.Println("saved")
}
```
签名:`func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `label` | `string` | 必填(仅图标时可为空) | 按钮文字。 |
| `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 |
| `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 |
| `Loading` | `bool` | `false` | 以菱形加载标记替换图标,保留尺寸与文字,阻止点击。 |
| `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 |
| `Label` | `string` | `""` | 无文字按钮的无障碍名称。 |
## 状态
默认、悬停(底色加深或出现浅底)、按下、键盘聚焦(2 DIP 酒红焦点环,外留 2 DIP)、加载、禁用(MyGo 统一减淡)。高度不低于当前密度的控件高度(28/36/44 DIP)。
## 事件
`Clicked()`:鼠标在按钮内释放、Enter 或 Space 时为真,一次操作只报告一次。加载与禁用时始终为假。
## 键盘操作
Tab / Shift+Tab 聚焦;Enter 或 Space 触发。
## 限制
- 加载标记在减少动画模式下静止显示。
- 危险变体的悬停与按下色由 `DangerFill` 混合黑色得到,未单独定义变量。
+42
View File
@@ -0,0 +1,42 @@
# 022 IconButton 图标按钮
## 用途
只显示图标的方形按钮,用于工具栏与紧凑操作。操作名称必填,供辅助技术朗读,并作为默认提示文字。建立在 Button 之上。
## 最小示例
```go
if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() {
fmt.Println("open settings")
}
```
签名:`func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `icon` | `*ui.SVG` | 必填 | 图标;为 nil 时 panic。 |
| `label` | `string` | 必填 | 操作名称;为空时 panic。 |
| `Variant` | `ButtonVariant` | `Primary` | 与 Button 相同的五种变体;工具栏通常用 `Ghost`。 |
| `Tooltip` | `string` | 操作名称 | 悬停约 600 毫秒后显示的提示。 |
| `Loading` | `bool` | `false` | 显示加载标记并阻止点击。 |
| `Disabled` | `bool` | `false` | 禁用。 |
## 状态
同 Button:悬停、按下、聚焦、加载、禁用。边长等于当前密度的控件高度,不小于 28 DIP。
## 事件
`Clicked()`,一次操作一次。
## 键盘操作
Tab 聚焦;Enter 或 Space 触发。
## 限制
- 禁用时不显示提示:MyGo 的 `Hovered` 对禁用元素返回 false,提示随之不出现;名称仍作为无障碍描述保留。
+53
View File
@@ -0,0 +1,53 @@
# 023 ButtonGroup 按钮组
## 用途
把若干按钮拼成一条,共用边框与尺寸,只有外侧圆角。整个组是一个 Tab 停靠点,组内用方向键移动。基于 `ui.ButtonBase` 与 MyGo 焦点组 `FocusGroup`。
## 最小示例
```go
items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}
if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}).ClickedItem(); i >= 0 {
fmt.Println("chose", items[i].Label)
}
```
签名:`func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions) ButtonGroupResult`
## 参数
`ButtonGroupItem`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | `""` | 文字;与 `Icon` 至少给一个,否则 panic。 |
| `Icon` | `*ui.SVG` | `nil` | 图标。 |
| `Name` | `string` | `""` | 仅图标项的无障碍名称。 |
| `Disabled` | `bool` | `false` | 单项禁用;方向键跳过该项。 |
`ButtonGroupOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Vertical` | `bool` | `false` | 纵向排列,使用上下方向键。 |
| `Label` | `string` | `""` | 组的无障碍名称(工具栏角色)。 |
| `Disabled` | `bool` | `false` | 禁用整组。 |
`items` 为空时 panic。
## 状态
每项有悬停、按下、聚焦、禁用;相邻项共用一条 1 DIP 分隔边。
## 事件
`ClickedItem()` 返回本帧被点击项的下标,无点击时为 -1。
## 键盘操作
Tab 进入组(回到上次聚焦项);横向组 Left/Right、纵向组 Up/Down 移动并循环;Home、End 到首末项;Enter 或 Space 触发。
## 限制
- 组内没有选中概念;需要保持选中请使用 ToggleGroup(033)。
+49
View File
@@ -0,0 +1,49 @@
# 024 TextInput 文本输入框
## 用途
单行文本编辑。支持占位、前后插槽、清空、只读与错误。编辑核心是 MyGo `ui.TextInputBase`,因此保留选择、撤销重做、剪贴板、右键菜单与输入法组合。
## 最小示例
```go
r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true})
if r.Submitted() {
fmt.Println("hello", name)
}
```
签名:`func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult`
`InputResult` 内嵌外层 `*ui.Element`,`Input` 字段是内部编辑元素,`Changed()`、`Submitted()` 报告事件。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 编辑的字符串;nil 时 panic。 |
| `Placeholder` | `string` | `""` | 空值时的占位文字(TextMuted 色)。 |
| `Label` | `string` | `""` | 无障碍名称,也供测试查找。 |
| `Leading` / `Trailing` | `func()` | `nil` | 在框内左侧、右侧构建内容,如图标或单位。 |
| `Clearable` | `bool` | `false` | 有内容时显示清空按钮;Escape 也会清空。 |
| `Disabled` | `bool` | `false` | 禁用,不可聚焦。 |
| `ReadOnly` | `bool` | `false` | 可聚焦、选择、复制,不可修改。 |
| `Error` | `string` | `""` | 非空时边框变为 Danger,并在下方显示该文字;辅助技术同时朗读。 |
## 状态
默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。颜色变化 120 毫秒过渡,减少动画时直接切换。
## 事件
- `Changed()`:输入、粘贴、撤销或清空改变了值。只读时不会报告。
- `Submitted()`:在框内按 Enter。
## 键盘操作
Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;输入法组合期间不提交。
## 限制
- MyGo 没有公开的只读输入接口(只读模式仅用于 `Text.Selectable`,`editor.go:77`)。`ReadOnly` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。
- 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。
+49
View File
@@ -0,0 +1,49 @@
# 025 TextArea 多行文本框
## 用途
多行文本编辑,可设最小与最大高度,超出最大高度后在框内滚动;可显示字数;提交快捷键可配置。编辑核心是 MyGo `ui.TextAreaBase`,只排版可见段落,适合长文本。
## 最小示例
```go
r := mujica.TextArea(c, &notes, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200})
if r.Submitted() {
fmt.Println("send", notes)
}
```
签名:`func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 编辑的字符串。 |
| `Placeholder` | `string` | `""` | 占位文字。 |
| `Label` | `string` | `""` | 无障碍名称。 |
| `MinHeight` | `float32` | `0`(三行) | 编辑区最小高度,DIP。 |
| `MaxHeight` | `float32` | `0`(随内容增长) | 编辑区最大高度,超出后滚动;小于 `MinHeight` 时 panic。 |
| `MaxLength` | `int` | `0`(不显示) | 显示“当前 / 上限”字数(按 Unicode 字符计);超出时变红并提示超出数量。 |
| `SubmitKey` / `SubmitMods` | `ui.Key` / `ui.Modifiers` | Cmd+Enter(非 macOS 为 Ctrl+Enter) | 提交快捷键;`SubmitKey` 为零时使用默认值。 |
| `Disabled` | `bool` | `false` | 禁用。 |
| `ReadOnly` | `bool` | `false` | 只读,可选择复制。 |
| `Error` | `string` | `""` | 错误文字;为空且超出 `MaxLength` 时自动显示超出提示。 |
## 状态
默认、悬停、聚焦、只读、错误、禁用,表现同 TextInput。
## 事件
`Changed()`:内容被用户改变。`Submitted()`:按下提交快捷键。
## 键盘操作
Enter 与 Shift+Enter 换行;提交快捷键提交;方向键、PageUp/PageDown 移动光标并滚动;撤销、剪贴板与输入法同 TextInput。
## 限制
- `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。
- 提交快捷键不能是不带修饰键的 Enter:MyGo 编辑器先接收 Enter 并插入换行(`editor.go:161`)。
- 只读实现方式同 TextInput。
+49
View File
@@ -0,0 +1,49 @@
# 026 NumberInput 数字输入框
## 用途
输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式)。
## 最小示例
```go
if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() {
fmt.Println("copies", copies)
}
```
签名:`func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*float64` | 必填 | 数值;空值用 `math.NaN()` 表示,仅在 `AllowEmpty` 时允许。 |
| `Min` / `Max` | `float64` | 都为 0 | 范围;相等(含零值)表示不限范围;`Min > Max` 时 panic。 |
| `Step` | `float64` | `1` | 方向键与按钮的步长;负数 panic。 |
| `Precision` | `int` | 取 `Step` 的小数位 | 保留与显示的小数位。 |
| `AllowEmpty` | `bool` | `false` | 允许清空,值变为 NaN;从空值步进时从 0(夹在范围内)开始。 |
| `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 |
| `Placeholder` | `string` | `""` | 空值时的占位。 |
| `Label` | `string` | `""` | 无障碍名称。 |
| `Disabled` | `bool` | `false` | 禁用。 |
| `Error` | `string` | `""` | 调用方给出的错误。 |
## 状态
输入模式同 TextInput;输入非数字或超出范围时显示内置错误(“请输入数字”“请输入 a 到 b 之间的数字”),值保持上一次有效值;失去焦点后文本恢复为当前值并清除该错误。到达边界时对应按钮禁用。
## 事件
`Changed()`:值因输入、方向键、按钮或步进器改变。
## 键盘操作
输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到最小、End 到最大;按住箭头持续步进。
## 限制
- 输入模式下的加减按钮不进入 Tab 序列,键盘用 Up/Down。
- 仅步进模式复用 MyGo `ui.Stepper`,箭头高度来自主题间距,不随密度的控件高度变化。
- MyGo `ui.Stepper` 的箭头不检查禁用(`indicators.go:216`),禁用时组件向它传入副本,保证值不被修改。
- MyGo 的步进器吸附依赖有限边界(`widgets.go` 中 `snap`),所以仅步进模式要求有界。
+42
View File
@@ -0,0 +1,42 @@
# 027 PasswordInput 密码输入框
## 用途
遮蔽显示的密码输入,带显示/隐藏切换。允许粘贴;遮蔽时不允许复制与剪切。基于 `ui.TextInputBase(...).Password()`,辅助技术读不到遮蔽中的值。组件从不记录密码内容。
## 最小示例
```go
if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() {
fmt.Println("signing in")
}
```
签名:`func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) InputResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 密码。 |
| `Placeholder` | `string` | `""` | 占位文字。 |
| `Label` | `string` | `""` | 无障碍名称。 |
| `Disabled` | `bool` | `false` | 禁用(切换按钮同时禁用)。 |
| `Error` | `string` | `""` | 错误文字。 |
## 状态
默认、悬停、聚焦、错误、禁用;遮蔽 / 显示两种模式,切换按钮的图标与名称(“显示密码”“隐藏密码”)随之变化。
## 事件
`Changed()`:内容改变。`Submitted()`:Enter。日志只写切换事件(`mujica: password_input reveal`,含是否显示),不写内容。
## 键盘操作
Tab 依次到输入框、切换按钮;切换按钮上 Space 或 Enter 切换;输入框中 Enter 提交,Cmd/Ctrl+V 粘贴。
## 限制
- MyGo 的 `Password()` 一旦设置无法撤销(`editor.go:791`),因此遮蔽与显示两种模式各用一个编辑器,切换时撤销历史和光标位置不保留,焦点留在切换按钮上。
- 显示模式下内容是普通文本,可复制,辅助技术可朗读——这是用户主动选择的结果。
+51
View File
@@ -0,0 +1,51 @@
# 028 SearchInput 搜索框
## 用途
搜索模式:放大镜、输入、清空与提交。查找栏模式(`Find`):在此基础上显示匹配数量、上一项与下一项,对应 MyGo 的 Search field 与 Find bar。匹配由调用方计算。
## 最小示例
```go
matches := 0
if query != "" {
matches = strings.Count(text, query)
}
r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: &current})
if r.Moved() {
fmt.Println("show match", current)
}
```
签名:`func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchInputResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `query` | `*string` | 必填 | 查询文字。 |
| `Placeholder` | `string` | “搜索”或“查找” | 占位文字。 |
| `Label` | `string` | 占位文字 | 无障碍名称。 |
| `Disabled` | `bool` | `false` | 禁用(查找快捷键也停用)。 |
| `Find` | `bool` | `false` | 开启查找栏模式。 |
| `Matches` | `int` | `0` | 匹配数量;负数 panic。 |
| `Current` | `*int` | `nil` | 当前匹配下标;`Find` 时必填,组件会把它夹在有效范围内。 |
## 状态
同 TextInput;查找栏模式下显示“第 n 项,共 m 项”或“无匹配”(状态角色,变化时可被朗读),无匹配时上一项/下一项禁用。
## 事件
- `Changed()`:查询改变(含清空)。
- `Submitted()`:搜索模式下 Enter。
- `Moved()`:查找栏模式下当前匹配改变。
## 键盘操作
搜索模式:Enter 提交,Escape 清空。查找栏模式:Enter 下一项,Shift+Enter 上一项,macOS 上 Cmd+G / Shift+Cmd+G、其他平台 F3 / Shift+F3(窗口级),循环到首尾;Escape 清空。
## 限制
- MyGo 单行编辑器把 Shift+Enter 当作普通提交且不公开修饰键(`editor.go:489`,`submitMods` 未导出),组件用 `HandleInput` 先截获 Shift+Enter。
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
+51
View File
@@ -0,0 +1,51 @@
# 029 Checkbox 复选框
## 用途
切换布尔值,支持选中、未选、半选;点击方框或标签都会切换。`CheckboxGroup` 提供组内全选(对应 MyGo CheckboxGroup)。基于 `ui.CheckboxBase`。
## 最小示例
```go
mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{
{Label: "Mail", Value: &mail},
{Label: "Calendar", Value: &calendar},
}, mujica.CheckboxGroupOptions{})
```
签名:
```go
func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element
func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts CheckboxGroupOptions) InputResult
```
## 参数
`CheckboxOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Indeterminate` | `bool` | `false` | 显示半选横线;此时点击总是变为选中。是否半选由调用方决定。 |
| `Disabled` | `bool` | `false` | 禁用。 |
`CheckboxItem`:`Label string`、`Value *bool`(必填)、`Disabled bool`。`CheckboxGroupOptions`:`Disabled bool`。`items` 为空时 panic。
## 状态
未选(Surface 底、ControlBorder 边)、悬停与按下(AccentText 边)、选中(酒红底、象牙白勾)、半选(酒红底、象牙白横线)、键盘聚焦(方框外焦点环)、禁用。行高不低于密度控件高度。
全选框:全部选中为选中,部分选中为半选,全不选为未选。点击时若可用项未全选则全部勾选,否则全部清除;禁用项保持不变。
## 事件
- `Checkbox(...).Changed()`:用户切换了值。
- `CheckboxGroup(...).Changed()`:全选或任一项被切换。
## 键盘操作
Tab 聚焦(每个复选框一个停靠点);Space 切换。
## 限制
- MyGo 只有内部字段能把复选框报告为“mixed”(`access.go:307`,`checked` 未导出)。半选时组件在无障碍描述中加入“部分选中”,但角色状态仍报告为未选中。
+52
View File
@@ -0,0 +1,52 @@
# 030 RadioGroup 单选组
## 用途
在一组选项中选一个,横向或纵向排列;选项值是任意可比较类型,作为稳定标识。基于 `ui.RadioGroup` 与 `ui.RadioBase`,整组是一个 Tab 停靠点。
## 最小示例
```go
items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}}
if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).Changed() {
fmt.Println("size", size)
}
```
签名:`func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opts RadioGroupOptions) InputResult`
## 参数
`RadioItem[T]`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Value` | `T` | 必填 | 稳定选项值;重复时 panic。 |
| `Label` | `string` | `""` | 标签,点击标签也会选择。 |
| `Disabled` | `bool` | `false` | 单项禁用,方向键跳过。 |
`RadioGroupOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Horizontal` | `bool` | `false` | 横向排列。 |
| `Label` | `string` | `""` | 组的无障碍名称。 |
| `Disabled` | `bool` | `false` | 禁用整组。 |
`value` 为 nil 或 `items` 为空时 panic。`*value` 不在选项中时没有选中项。
## 状态
未选(空心圆)、悬停与按下(AccentText 边)、选中(酒红实心圆与象牙白圆点)、键盘聚焦、禁用。
## 事件
`Changed()`:用户选择了另一个值(点击、Space 或方向键)。
## 键盘操作
Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循环,跳过禁用项;Home、End 选择首末可用项。
## 限制
- 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。
+59
View File
@@ -0,0 +1,59 @@
# 041 Form
表单。
## 用途
组合多个字段:注册字段、同步与调用方提供的异步校验、提交与重置、提交中防重入、提交失败时定位第一个错误字段。Form 不保存业务模型,不调用远程接口,也不做持久化;值始终由应用持有。
## 最小示例
```go
var state mujica.FormState
name := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
})
})
if res.Submitted() {
state.EndSubmit("") // after saving name
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐(MyGo `ui.Form` 布局)。 |
| `FormOptions.SubmitLabel` | `string` | 内置“Submit/提交” | 提交按钮文字。 |
| `FormOptions.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 |
| `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 |
| `FormItemOptions[T].Required` | `bool` | false | 标记必填;零值、空字符串、空切片或空映射视为缺失。 |
| `FormItemOptions[T].Description` | `string` | "" | 说明文字;异步校验进行中显示“Checking…”。 |
| `FormItemOptions[T].Initial` | `T` | 零值 | 重置时写回的初始值。 |
| `FormItemOptions[T].Validate` | `func(T) string` | nil | 同步校验,返回错误文字或 "";跨字段规则在此读取其他值。 |
| `FormItemOptions[T].Async` | `func(v T, version uint64)` | nil | 启动异步校验;完成后在界面线程调用 `FormState.Resolve(name, version, msg)`。 |
## 状态
默认;字段错误(触碰后或首次提交后才显示);异步校验中(说明显示 Checking…);提交中(提交按钮显示加载并阻止重复提交,重置禁用);提交失败(表单级错误显示在按钮上方,保留全部值)。
## 事件
`FormParts.Submitted()`:全部校验通过的一次提交,处理完毕后调用 `FormState.EndSubmit(err)`。`FormParts.Reset()`:已恢复初始值并清除校验与提交状态。`FormState.Submit()` 可在 Enter 等处请求提交。`FormState.Error(name)` 返回当前应显示的错误,用于给控件设置 Invalid。
## 键盘操作
Tab / Shift+Tab 在字段间移动,离开字段即校验。提交失败后焦点移到第一个错误字段并滚动到可见处,同时向屏幕阅读器播报。点击标签聚焦控件。
## 限制
- 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。
- 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。
- 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。
- 必填标记以“ *”附在标签文字后,屏幕阅读器会一起读出;MyGo 的 Field 标签不支持富文本,无法单独着色。
+49
View File
@@ -0,0 +1,49 @@
# 042 FormField
表单字段。
## 用途
为单个控件提供标签、必填标记、说明与错误文字。标签通过 MyGo `ui.Field` 关联控件:屏幕阅读器以标签命名控件,并读出说明与错误;点击标签聚焦控件。
## 最小示例
```go
email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `label` | `string` | 必填 | 标签文字;空值 panic。 |
| `FormFieldOptions.Required` | `bool` | false | 在标签后加“ *”。 |
| `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 |
| `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 |
| `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 |
| `control` | `func()` | 必填 | 构建控件;其中第一个可获取焦点的元素即被关联的控件。 |
## 状态
默认、必填、带说明、错误;禁用由控件自身决定,标签随之变淡。
## 事件
无自身事件;事件来自内部控件。
## 键盘操作
Tab 直接到达控件;点击标签聚焦控件,或切换复选框、开关。
## 限制
- 标签、说明与错误文字由 MyGo `ui.Field` 绘制,使用主题颜色(文字、TextMuted、Danger);字号与间距跟随 MyGo。
- 错误只把边框染红的效果对 MyGo 自带输入框生效;MujicaUI 的 InputGroup 等需同时传入 `Invalid: true`。
- 左右布局时多个字段需放在同一个 `ui.Form` 或 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
+54
View File
@@ -0,0 +1,54 @@
# 043 InputGroup
输入组合。
## 用途
把单行输入框与前后缀文字、按钮放进同一个边框,例如网址前缀、单位、清空或搜索按钮。每个按钮保留独立焦点。
## 最小示例
```go
site := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
g := mujica.InputGroup(c, &site, mujica.InputGroupOptions{
Prefix: "https://", Label: "Site",
Buttons: []mujica.InputGroupButton{{Label: "Open"}},
})
if g.Clicked == 0 {
c.OpenURL("https://" + site)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 输入框的值。 |
| `Prefix / Suffix` | `string` | "" | 输入框前后的固定文字。 |
| `Buttons` | `[]InputGroupButton` | nil | 按钮:Label(必填,文字或无障碍名称)、Icon、IconOnly、Leading(放在输入框前)、Disabled。 |
| `Placeholder` | `string` | "" | 占位文字。 |
| `Label` | `string` | "" | 不在 FormField 内时为输入框命名。 |
| `Disabled` | `bool` | false | 禁用整个组合。 |
| `ReadOnly` | `bool` | false | 值显示为可选择、可复制、可聚焦的文字,不能修改;按钮仍可用。 |
| `Invalid` | `bool` | false | 边框显示危险色。 |
## 状态
默认、悬停(边框加深)、聚焦(强调色边框与焦点环)、禁用、只读、错误。
## 事件
`InputGroupParts.Changed()` 值被编辑;`Submitted()` 在输入框中按 Enter;`Clicked` 为被点击按钮在 Buttons 中的下标,未点击为 -1。`Input` 为可聚焦的输入元素。
## 键盘操作
输入框与每个按钮都是独立的 Tab 停靠点;按钮响应 Enter 和 Space;输入框保留选择、剪贴板、撤销与输入法。
## 限制
- MyGo 没有公开的只读输入框,ReadOnly 改用可选择文字实现,宽度与输入框一致。
- IconOnly 按钮必须提供 Icon,Label 作为无障碍名称;缺失时 panic。
+48
View File
@@ -0,0 +1,48 @@
# 044 PinInput
验证码输入。
## 用途
输入固定位数的数字验证码。值是字符串,保留前导零。
## 最小示例
```go
code := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PinInput(c, &code, mujica.PinInputOptions{Length: 6}).Completed() {
fmt.Println("verify", code)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 当前数字串;含非数字或超出位数时 panic。 |
| `Length` | `int` | 6 | 位数。 |
| `Label` | `string` | 内置“Verification code/验证码” | 无障碍名称。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Invalid` | `bool` | false | 格子边框显示危险色。 |
## 状态
默认、聚焦(当前格强调边框、焦点环与光标)、输入法组合中(当前格显示带下划线的组合文字)、禁用、错误。
## 事件
`PinInputParts.Changed()` 值改变;`Completed()` 在用户填满最后一格的那一帧为 true。
## 键盘操作
数字键依次填格;非数字被忽略;Backspace 清除最后一位;Cmd/Ctrl+V 或编辑菜单粘贴整段,取其中的数字填满各格。
## 限制
- 只支持数字。编辑按顺序进行:只能在末尾追加或删除,不能单独改中间某一格。
- 输入法组合期间不提交、不跳格;组合结束后才写入值。
- 整个组件是一个焦点停靠点,屏幕阅读器以文本框读出名称与“已输入 n / N 位”的说明。
+48
View File
@@ -0,0 +1,48 @@
# 045 TagsInput
标签输入。
## 用途
编辑一组不重复的标签,对应 MyGo 的 Token field。支持 Enter 或分隔符添加、整段粘贴拆分、去重、数量上限与添加前校验。
## 最小示例
```go
tags := []string{"gothic"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TagsInput(c, &tags, mujica.TagsInputOptions{Max: 5, Placeholder: "Add a tag", Label: "Tags"})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `tags` | `*[]string` | 必填 | 标签列表。 |
| `Max` | `int` | 0(不限) | 最多标签数;负数 panic。 |
| `Separators` | `string` | ",;,;" 与换行、制表符 | 输入或粘贴时用于拆分的字符。 |
| `Validate` | `func(string) string` | nil | 添加前校验,返回错误文字或 ""。 |
| `Placeholder` | `string` | "" | 占位文字。 |
| `Label` | `string` | "" | 不在 FormField 内时为输入框命名。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Invalid` | `bool` | false | 边框显示危险色。 |
## 状态
默认、聚焦、错误(重复、超出上限、校验失败时显示具体原因并保留输入文字)、禁用。
## 事件
`TagsInputParts.Changed()` 添加或移除了标签;`Input` 为输入框元素。
## 键盘操作
Enter 或分隔符添加;输入框为空时 Backspace 移除最后一个标签;Tab 可到达每个标签的移除按钮,Enter/Space 移除。
## 限制
- 粘贴含分隔符的文本时,未通过检查的片段保留在输入框中,供用户修改。
- MyGo 单行输入框会处理粘贴文本中的换行,换行能否作为分隔符取决于平台粘贴行为;逗号、分号总是有效。
+46
View File
@@ -0,0 +1,46 @@
# 046 Rating
评分。
## 用途
以星形显示和设置评分,支持半星等步长、只读展示和数值说明。
## 最小示例
```go
stars := 3.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Rating(c, &stars, mujica.RatingOptions{Step: 0.5, ShowValue: true})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*float64` | 必填 | 评分,0 到 Max。 |
| `Max` | `int` | 5 | 星数;负数 panic。 |
| `Step` | `float64` | 1 | 最小变化量,须整除 1(如 0.5、0.25);否则 panic。 |
| `ReadOnly` | `bool` | false | 只显示,可聚焦以便读出数值。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“Rating/评分” | 无障碍名称。 |
| `ShowValue` | `bool` | false | 在星形右侧显示“3.5 / 5”。 |
## 状态
默认、悬停(预览指针处的数值)、按下拖动、聚焦(焦点环)、只读、禁用。
## 事件
`RatingParts.Changed()` 用户设置了新值;同值点击不触发。
## 键盘操作
→/↑ 增加一个步长,←/↓ 减少,Home 清零,End 满分。
## 限制
- 屏幕阅读器以滑块角色读出名称,数值通过说明“3.5 of 5”提供;MyGo 未公开为自定义元素设置数值范围的接口。
+45
View File
@@ -0,0 +1,45 @@
# 047 ColorPicker
颜色选择器。
## 用途
选择带透明度的颜色:色板按钮弹出面板,包含饱和度/亮度方块、色相与透明度滑块、HEX 与 RGB、透明度输入以及预设色。透明部分显示棋盘格。
## 最小示例
```go
tint := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ColorPicker(c, &tint, mujica.ColorPickerOptions{Label: "Tint"})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `color` | `*ui.Color` | 必填 | 当前颜色。 |
| `Presets` | `[]ui.Color` | 内置 12 色宫廷色板 | 预设色。 |
| `Label` | `string` | 内置“Color/颜色” | 按钮的无障碍名称。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、悬停、聚焦、展开、输入无效(对应输入框边框变红并显示格式说明,颜色不变)、预设选中(边框加勾选标记)、禁用。
## 事件
`ColorPickerParts.Changed()` 用户选择了新颜色;`Trigger` 为按钮元素。
## 键盘操作
Enter/Space 打开;打开后焦点在方块上,方向键调整饱和度与亮度;Tab 依次到达色相、透明度滑块(方向键、Home、End)、HEX、R、G、B、A 输入框与预设色;Escape 或点击外部关闭并把焦点还给按钮。
## 限制
- HEX 接受 RGB、RRGGBB、RRGGBBAA,可带或不带 #;RGB 为 0–255 整数,透明度为 0–100。无效输入保留在输入框中直到失焦,不修改颜色。
- 色谱渐变是 PRD 允许的例外。
- 弹层位置由 MyGo PopoverBase 决定,空间不足时显示在按钮上方。
+52
View File
@@ -0,0 +1,52 @@
# 048 FilePicker
文件选择。
## 用途
调用 MyGo 的系统文件或文件夹选择对话框(`mygo.Dialog.Open`),支持单选、多选与扩展名筛选,只返回所选路径。
## 最小示例
```go
var paths []string
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FilePicker(c, &paths, mujica.FilePickerOptions{
Multiple: true,
Filters: []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}},
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `paths` | `*[]string` | 必填 | 所选路径;取消时不变。 |
| `Directory` | `bool` | false | 选择文件夹。 |
| `Multiple` | `bool` | false | 允许多选。 |
| `Filters` | `[]mygo.FileFilter` | nil | 扩展名筛选,扩展名不带点;带点时 panic。 |
| `Title` | `string` | "" | 对话框标题。 |
| `Label` | `string` | 内置“Choose file…/选择文件…” | 按钮文字。 |
| `Parent` | `*mygo.Window` | nil | 附着的窗口(macOS 显示为表单)。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、对话框打开中(按钮显示加载并阻止重复打开)、已选择(列出文件名与所在目录)、对话框失败(危险色说明)、禁用。
## 事件
`FilePickerParts.Changed()` 选中了新路径;`Canceled()` 用户取消,不视为错误。
## 键盘操作
Enter/Space 打开系统对话框;对话框内的键盘操作由系统提供。
## 限制
- 对话框为系统样式,不换肤。
- 对话框在后台 goroutine 中等待,结果经 `Context.Invalidate` 回到界面线程;需要应用已通过 `mygo.App.Run` 启动,无窗口测试中无法打开真实对话框。
- 不读取文件内容。
+49
View File
@@ -0,0 +1,49 @@
# 049 FileDropZone
文件拖放区。
## 用途
接收从系统拖入或通过“浏览…”选择的文件,检查类型、数量和大小,显示文件列表与错误,可逐项移除。不上传,不读取内容。
## 最小示例
```go
var files []mujica.DroppedFile
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FileDropZone(c, &files, mujica.FileDropZoneOptions{
Extensions: []string{"png", "jpg"}, MaxFiles: 3, MaxSize: 5 << 20,
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `files` | `*[]DroppedFile` | 必填 | 已接受的文件(Path、Size)。 |
| `Extensions` | `[]string` | nil(不限) | 允许的扩展名,不带点,不区分大小写。 |
| `MaxFiles` | `int` | 0(不限) | 列表最多文件数。 |
| `MaxSize` | `int64` | 0(不限) | 单个文件的最大字节数。 |
| `Hint` | `string` | 内置“Drop files here/将文件拖到此处” | 提示文字。 |
| `Disabled` | `bool` | false | 禁用拖放、浏览与移除。 |
## 状态
默认、文件拖到上方(选中底色与强调色虚线边框)、检查中(列表显示“Checking…”)、已接受、被拒绝(危险色图标与具体原因)、禁用。
## 事件
`FileDropZoneParts.Changed()` 添加或移除了文件。被拒绝的条目只显示在组件内,可单独关闭。
## 键盘操作
Tab 到达“浏览…”与每个移除按钮;拖放需要指针,键盘用户通过“浏览…”添加文件。
## 限制
- 文件大小通过后台 goroutine 中的 `os.Stat` 获取,不阻塞界面线程;文件夹、无法读取的路径会被拒绝。
- 重复路径静默忽略。
- MyGo 的无窗口测试器不能模拟系统拖放,自动测试通过“浏览…”路径覆盖同一检查流程。
+46
View File
@@ -0,0 +1,46 @@
# 050 Transfer
穿梭框。
## 用途
在左右两个列表间移动条目,选出一个子集。两侧各有搜索、复选与全选,中间按钮批量移动。以稳定 ID 保存状态。
## 最小示例
```go
chosen := []string{"uika"}
items := []mujica.TransferItem{{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "nyamu", Label: "Nyamu", Disabled: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Transfer(c, &chosen, items, mujica.TransferOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `target` | `*[]string` | 必填 | 右侧已选条目的 ID,按加入顺序;含未知 ID 时 panic。 |
| `items` | `[]TransferItem` | 必填 | 全部条目:ID(唯一、非空)、Label、Disabled。 |
| `SourceTitle / TargetTitle` | `string` | 内置“Available/可选”“Chosen/已选” | 两侧标题。 |
| `Height` | `float32` | 240 | 列表高度(DIP)。 |
| `Disabled` | `bool` | false | 禁用整个组件。 |
## 状态
默认、悬停、勾选(底色加勾选标记)、半选(全选框显示减号)、禁用条目(不可勾选、不随全选移动)、空列表、无搜索结果、禁用。
## 事件
`TransferParts.Changed()` 有条目移动。
## 键盘操作
Tab 进入列表,↑/↓ 在条目间移动,Space 勾选;Tab 到达全选框、搜索框与移动按钮。
## 限制
- 条目逐项创建,适合数百条以内;大数据量请使用虚拟化列表组合。
- 全选只作用于当前搜索结果中的可用条目。
+55
View File
@@ -0,0 +1,55 @@
# 051 Calendar · 日历
## 用途
在页面中直接显示一个月份,用于选择单个日期。支持今日标记、不可选日期与周起始日配置。
## 最小示例
```go
day := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Calendar(c, &day, mujica.CalendarOptions{WeekStart: mujica.WeekStartMonday}).Changed() {
fmt.Println("chosen", day)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Calendar(c *ui.Context, value *Date, opts CalendarOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Min` | `Date` | 零值 | 最早可选日期;零值不限制。 |
| `Max` | `Date` | 零值 | 最晚可选日期;零值不限制。 |
| `DisableDate` | `func(Date) bool` | nil | 返回 true 的日期不可选,显示删除线。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 首列星期:跟随语言(zh-CN 周一、en-US 周日),或 `WeekStartSunday`/`WeekStartMonday`/`WeekStartSaturday`。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期:加粗并在下方绘制菱形。 |
| `Disabled` | `bool` | false | 禁用整个日历:不能翻页、不能选择,退出 Tab 序列。 |
| `Label` | `string` | 内置“日历” | 辅助技术读出的名称。 |
## 状态
默认、悬停、按下、聚焦(键盘焦点环)、选中(酒红填充 + 单选语义)、今日(加粗 + 菱形)、不可选(删除线并由 MyGo 置灰)、非本月(弱化文字)、禁用。
## 事件
`Calendar(...).Changed()` 在用户选择了与原值不同的日期的那一帧返回 true,`*value` 已更新。翻月不触发事件。
## 键盘操作
- Tab:进入日期格,落在已选日期或上次聚焦的日期。
- ←/→:前后一天;↑/↓:前后一周(自动跳过不可选日期,最多 31 天)。
- PageUp/PageDown:上/下一月;Shift+PageUp/PageDown:上/下一年。
- Home/End:本月第一天/最后一天。
- Enter/Space:选择当前日期。
## 限制
- 不可选日期不能获得焦点;若方向键 31 天内找不到可选日期,视图会翻页但焦点停在原处。
- MyGo 的 `ui.Calendar` 固定周一起始、只有英文且不支持禁用日期(ui/date.go:85-160),因此本组件基于 `RadioBase` 自绘网格,不复用 `ui.Calendar`。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+59
View File
@@ -0,0 +1,59 @@
# 052 DatePicker · 日期选择器
## 用途
可键入的日期输入框,带日历弹层。用于表单中录入单个日期。
## 最小示例
```go
due := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DatePickerOptions{Label: "Due date", Clearable: true,
Min: mujica.Date{Year: 2026, Month: time.January, Day: 1}}
if mujica.DatePicker(c, &due, opts).Changed() {
fmt.Println("due", due)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Layout` | `string` | `2006-01-02` | Go 时间布局,用于显示与解析输入。 |
| `Min` | `Date` | 零值 | 最早可接受日期。 |
| `Max` | `Date` | 零值 | 最晚可接受日期。 |
| `DisableDate` | `func(Date) bool` | nil | 不可选日期;键入此类日期会报告错误。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 弹层日历的周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 弹层日历中的今日标记;空值时弹层打开到此月份。 |
| `Clearable` | `bool` | false | 显示清空按钮;清空输入后提交表示“无日期”。 |
| `Placeholder` | `string` | 内置“选择日期” | 空输入框的占位文字。 |
| `Disabled` | `bool` | false | 禁用输入与弹层。 |
| `Label` | `string` | 占位文字 | 辅助技术名称。 |
## 状态
默认、悬停(边框加深)、聚焦(焦点色边框)、错误(危险色边框 + 下方说明,输入保留)、禁用。弹层内日历状态同 Calendar。
## 事件
`Changed()`:键入后按 Enter 或离开输入框并解析成功、在日历中选择、或点击清空导致值变化时为 true。
## 键盘操作
- 键入日期后 Enter 提交;Tab 离开时也会提交。
- ↓ 或 Alt+↓:打开日历;日历内按键同 Calendar,Enter 选择后关闭并写入。
- Escape:关闭日历,不改值。
## 限制
- 无效或越界的输入(如 2026-02-30)显示具体错误并保留原文,不会被修正为相邻日期。
- MyGo 的 `ui.DateInput` 固定格式且不可键入(ui/date.go:13),因此本组件组合 `TextInputBase` 与自绘日历弹层(`PopoverBase`)。
- 暂不提供只读模式:MyGo v0.2.10 未公开 TextInputBase 的只读开关。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+57
View File
@@ -0,0 +1,57 @@
# 053 DateRangePicker · 日期范围选择器
## 用途
在弹层中先选开始日期、再选结束日期,悬停时预览范围,确认后写入。
## 最小示例
```go
var stay mujica.DateRange
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.DateRangePicker(c, &stay, mujica.DateRangePickerOptions{Label: "Stay"})
if r.Changed() {
fmt.Println("stay", stay.Start, stay.End, stay.Days())
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Layout` | `string` | `2006-01-02` | 触发器与摘要中的日期格式。 |
| `Min` | `Date` | 零值 | 两端最早日期。 |
| `Max` | `Date` | 零值 | 两端最晚日期。 |
| `DisableDate` | `func(Date) bool` | nil | 不可选日期;范围内包含此类日期时拒绝并提示。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的起始月份。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“日期范围” | 辅助技术名称。 |
## 状态
触发器:默认、悬停、聚焦、禁用。弹层:起止日期酒红填充,中间日期浅色带;选择结束前随指针或焦点预览范围;错误说明;“确定”在两端选齐前禁用。
## 事件
`Changed()`:点击“确定”或“清空”使值改变时为 true。`Cancelled()`:按 Escape、点击“取消”或点击弹层外关闭、草稿被丢弃时为 true。
## 键盘操作
- Enter/Space/↓:打开弹层。
- 日历内方向键移动,Enter 依次选择开始与结束。
- 早于开始日期的选择会重新开始范围。
- Tab 到“清空”“取消”“确定”。
- Escape:放弃草稿。
## 限制
- 弹层一次显示一个月;跨月范围通过翻页完成。
- `DateRange` 为闭区间,`Days()` 计入两端。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+59
View File
@@ -0,0 +1,59 @@
# 054 TimePicker · 时间选择器
## 用途
分段编辑时、分、秒的时间输入,可在弹层列表中选择;支持 12/24 小时制、步长和范围。
## 最小示例
```go
alarm := mujica.TimeOfDay{Hour: 7, Minute: 30}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimePickerOptions{Label: "Alarm", Hour12: true, MinuteStep: 5}
if mujica.TimePicker(c, &alarm, opts).Changed() {
fmt.Println("alarm", alarm)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Seconds` | `bool` | false | 显示并编辑秒。 |
| `Hour12` | `bool` | false | 12 小时制,附加上午/下午段。 |
| `MinuteStep` | `int` | 0(即 1) | 分钟步长,必须整除 60。 |
| `SecondStep` | `int` | 0(即 1) | 秒步长,必须整除 60。 |
| `Min` | `TimeOfDay` | 00:00:00 | 最早时间。 |
| `Max` | `TimeOfDay` | 零值(即 23:59:59) | 最晚时间。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 空 | 组名;各段名称为“Label: 时/分/秒”。 |
## 状态
默认、悬停、聚焦段(酒红填充)、错误(危险色边框 + 说明,草稿保留)、禁用。弹层列表中超出范围的项禁用。
## 事件
`Changed()`:编辑后的时间合法(在范围内且符合步长)并与原值不同时为 true。不合法时不写入,显示错误。
## 键盘操作
- Tab:进入时间段(整组一个 Tab 停靠点)。
- ←/→:切换时、分、秒、上午/下午。
- ↑/↓:按步长增减,整圈循环。
- 数字键:直接键入,时完成后自动跳到分。
- A/P:设置上午/下午。
- Alt+↓ 或时钟按钮:打开列表弹层;Escape 关闭。
## 限制
- 超出范围或不符合步长的时间会提示,不会被自动夹取或取整。
- 段顺序固定为“时:分:秒 上午/下午”,zh-CN 下上午/下午也位于末尾。
- MyGo 的 `ui.TimeInput` 只有时分且无 12 小时制、步长和范围(ui/timeinput.go:18),因此本组件自绘分段。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+55
View File
@@ -0,0 +1,55 @@
# 055 TimeRangePicker · 时间范围选择器
## 用途
编辑开始与结束时间,校验顺序;跨午夜必须显式开启。
## 最小示例
```go
shift := mujica.TimeRange{Start: mujica.TimeOfDay{Hour: 22}, End: mujica.TimeOfDay{Hour: 6}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimeRangePickerOptions{Label: "Night shift", AllowOvernight: true}
if mujica.TimeRangePicker(c, &shift, opts).Changed() {
fmt.Println("shift", shift.Start, shift.End, shift.Overnight())
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Seconds` | `bool` | false | 显示秒。 |
| `Hour12` | `bool` | false | 12 小时制。 |
| `MinuteStep` | `int` | 0(即 1) | 分钟步长。 |
| `SecondStep` | `int` | 0(即 1) | 秒步长。 |
| `Min` | `TimeOfDay` | 00:00:00 | 两端最早时间。 |
| `Max` | `TimeOfDay` | 零值(即 23:59:59) | 两端最晚时间。 |
| `AllowOvernight` | `bool` | false | 允许结束早于开始,表示次日结束,并显示“次日”。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 空 | 整组名称。 |
## 状态
同 TimePicker;范围不合法时两端都显示错误边框;跨午夜时显示“+1 day / 次日”。
## 事件
`Changed()`:两端都合法且顺序正确、与原值不同时为 true。
## 键盘操作
- 两端各自按 TimePicker 操作;Tab 从开始移到结束。
## 限制
- 未开启 `AllowOvernight` 时传入跨午夜的值会 panic(编程错误)。
- 开始等于结束视为错误。
- `TimeRange.Overnight()` 报告是否跨午夜。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+62
View File
@@ -0,0 +1,62 @@
# 056 DateTimePicker · 日期时间选择器
## 用途
在调用方指定的时区中编辑日期和时间,确认后写入 `time.Time`。
## 最小示例
```go
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
var meeting time.Time
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DateTimePickerOptions{Label: "Meeting", Location: tokyo, MinuteStep: 15}
if mujica.DateTimePicker(c, &meeting, opts).Changed() {
fmt.Println("meeting", meeting)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 用户编辑所用时区;为 nil 时 panic。 |
| `Min` | `time.Time` | 零值 | 最早时刻。 |
| `Max` | `time.Time` | 零值 | 最晚时刻。 |
| `Seconds` | `bool` | false | 显示秒。 |
| `Hour12` | `bool` | false | 12 小时制。 |
| `MinuteStep` | `int` | 0(即 1) | 分钟步长。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的默认日期。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置占位文字 | 辅助技术名称。 |
## 状态
触发器显示该时区下的时间与时区缩写。弹层:日历、时间段、时区名、错误说明、“取消/确定”。
## 事件
`Changed()`:确认且时刻改变时为 true,写入的值位于 `Location`。`Cancelled()`:取消、Escape 或点击外部关闭时为 true。
## 键盘操作
- Enter/Space/↓:打开。
- 日历与时间段按键同 DatePicker、TimePicker。
- Tab 到“取消”“确定”。
- Escape:取消。
## 限制
- 夏令时跳过的时间(如纽约 2026-03-08 02:30)和重复的时间(如 2026-11-01 01:30)都会提示,不会自动移到相邻时刻或选择其中之一。
- 空值(零 `time.Time`)打开时默认今天 00:00。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+55
View File
@@ -0,0 +1,55 @@
# 057 MonthPicker · 月份选择器
## 用途
按年份分页选择月份。
## 最小示例
```go
var month mujica.YearMonth
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.MonthPickerOptions{Label: "Billing month", Clearable: true}
if mujica.MonthPicker(c, &month, opts).Changed() {
fmt.Println("month", month)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Min` | `YearMonth` | 零值 | 最早月份。 |
| `Max` | `YearMonth` | 零值 | 最晚月份。 |
| `DisableMonth` | `func(YearMonth) bool` | nil | 不可选月份。 |
| `Clearable` | `bool` | false | 有值时显示清空按钮。 |
| `Today` | `Date` | 零值(系统时钟) | 标记当前月份。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“选择月份” | 辅助技术名称。 |
## 状态
默认、悬停、聚焦、选中(酒红填充)、当前月(加粗 + 菱形)、不可选(删除线)、禁用。
## 事件
`Changed()`:选择或清空导致值变化时为 true。
## 键盘操作
- Enter/Space/↓:打开。
- ←/→:前后一月;↑/↓:前后三月(一行)。
- PageUp/PageDown:上/下一年。
- Home/End:一月/十二月。
- Enter:选择并关闭;Escape:关闭。
## 限制
- 键盘移到不可选月份时焦点保持在原处,可继续移动。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+55
View File
@@ -0,0 +1,55 @@
# 058 YearPicker · 年份选择器
## 用途
按十年分页浏览并选择年份。
## 最小示例
```go
year := 2026
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.YearPickerOptions{Label: "Vintage", Min: 1990, Max: 2030}
if mujica.YearPicker(c, &year, opts).Changed() {
fmt.Println("year", year)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Min` | `int` | 0 | 最早年份;0 不限制。 |
| `Max` | `int` | 0 | 最晚年份;0 不限制。 |
| `DisableYear` | `func(int) bool` | nil | 不可选年份。 |
| `Clearable` | `bool` | false | 有值时显示清空按钮。 |
| `Today` | `Date` | 零值(系统时钟) | 标记当前年份。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“选择年份” | 辅助技术名称。 |
## 状态
默认、悬停、聚焦、选中、当前年、不可选、相邻十年(弱化)、禁用。
## 事件
`Changed()`:选择或清空导致值变化时为 true。`*value` 为 0 表示未选择。
## 键盘操作
- Enter/Space/↓:打开。
- ←/→:前后一年;↑/↓:前后三年。
- PageUp/PageDown:上/下一个十年。
- Home/End:页首/页尾。
- Enter:选择并关闭;Escape:关闭。
## 限制
- 只支持公元 1 年及以后;值为负数时 panic。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+63
View File
@@ -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` 呈现,名称取自所选标签。
- 不提供标签关闭、拖动排序。
+55
View File
@@ -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` 的焦点组在方向键移动时直接点击目标分段。
+57
View File
@@ -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` 组合实现。
+53
View File
@@ -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 绘制。
+59
View File
@@ -0,0 +1,59 @@
# 063 Steps 步骤条
## 用途
显示多步流程的进度:已完成、当前、等待和出错四种状态;横向或纵向排列;每一步是否可点击由调用方决定。
## 最小示例
```go
mujica.Steps(c, &current, []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 角色;步骤以列表项(可点击时为按钮)呈现,状态写入名称。
+71
View File
@@ -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 不给标题注册按键)。
+61
View File
@@ -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 颜色)。
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more