diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..93a881a --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 27aa41b..4b10753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ testdata/snapshots/ /-.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. diff --git a/alert.go b/alert.go new file mode 100644 index 0000000..2b5f116 --- /dev/null +++ b/alert.go @@ -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 +} diff --git a/alert_dialog.go b/alert_dialog.go new file mode 100644 index 0000000..1f619d1 --- /dev/null +++ b/alert_dialog.go @@ -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 +} diff --git a/alert_dialog_test.go b/alert_dialog_test.go new file mode 100644 index 0000000..0d23111 --- /dev/null +++ b/alert_dialog_test.go @@ -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", + }) + }) +} diff --git a/alert_test.go b/alert_test.go new file mode 100644 index 0000000..af93006 --- /dev/null +++ b/alert_test.go @@ -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"}) + }) + }) +} diff --git a/app_shell.go b/app_shell.go new file mode 100644 index 0000000..ee6850e --- /dev/null +++ b/app_shell.go @@ -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 +} diff --git a/app_shell_test.go b/app_shell_test.go new file mode 100644 index 0000000..3d53652 --- /dev/null +++ b/app_shell_test.go @@ -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"}}}) }, + }) + }) +} diff --git a/aspect_ratio.go b/aspect_ratio.go new file mode 100644 index 0000000..d886a64 --- /dev/null +++ b/aspect_ratio.go @@ -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) + }) +} diff --git a/aspect_ratio_test.go b/aspect_ratio_test.go new file mode 100644 index 0000000..e2f485f --- /dev/null +++ b/aspect_ratio_test.go @@ -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" +} diff --git a/avatar.go b/avatar.go new file mode 100644 index 0000000..fdc2848 --- /dev/null +++ b/avatar.go @@ -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 +} diff --git a/avatar_group.go b/avatar_group.go new file mode 100644 index 0000000..0a755f0 --- /dev/null +++ b/avatar_group.go @@ -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() + }) + } + }) + }) +} diff --git a/avatar_group_test.go b/avatar_group_test.go new file mode 100644 index 0000000..229add8 --- /dev/null +++ b/avatar_group_test.go @@ -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}) + }) + }) +} diff --git a/avatar_test.go b/avatar_test.go new file mode 100644 index 0000000..ff44c7d --- /dev/null +++ b/avatar_test.go @@ -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}) + }) + }) +} diff --git a/badge.go b/badge.go new file mode 100644 index 0000000..8697378 --- /dev/null +++ b/badge.go @@ -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)) +} diff --git a/badge_test.go b/badge_test.go new file mode 100644 index 0000000..399de54 --- /dev/null +++ b/badge_test.go @@ -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{}) + }) + }) + }) +} diff --git a/breadcrumb.go b/breadcrumb.go new file mode 100644 index 0000000..1bfd93b --- /dev/null +++ b/breadcrumb.go @@ -0,0 +1,157 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// BreadcrumbItem is one level of a Breadcrumb path. +type BreadcrumbItem struct { + Label string + Icon *ui.SVG +} + +// BreadcrumbOptions configure a Breadcrumb. +type BreadcrumbOptions struct { + // MaxItems shown before middle items fold into a menu; zero means 4, minimum 3. + MaxItems int + // MaxItemWidth cuts longer labels with an ellipsis; zero means 160 DIPs. + MaxItemWidth float32 + // Label names the path for assistive technology; empty uses the built-in name. + Label string +} + +// BreadcrumbResult is the built Breadcrumb. +type BreadcrumbResult struct { + *ui.Element + chosen int +} + +// Chosen returns the index of the item the user activated in this frame. +func (r BreadcrumbResult) Chosen() (int, bool) { return r.chosen, r.chosen >= 0 } + +// Breadcrumb shows a path whose last item is the current one; earlier items are links. +func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) BreadcrumbResult { + if len(items) == 0 { + panic("mujica: Breadcrumb needs at least one item") + } + limit := opts.MaxItems + if limit == 0 { + limit = 4 + } + if limit < 3 { + panic("mujica: Breadcrumb MaxItems must be at least 3") + } + width := opts.MaxItemWidth + if width == 0 { + width = 160 + } + label := opts.Label + if label == "" { + label = msg(c, "breadcrumb.label") + } + k := Tokens(c) + u := Density(c).Unit() + row := ui.Row(c).AlignItems(ui.Center).Gap(u).MinWidth(0).Role(ui.RoleGroup).Label(label) + res := BreadcrumbResult{Element: row, chosen: -1} + choose := func(i int) { + res.chosen = i + slog.Debug("mujica: breadcrumb choose", "index", i, "label", items[i].Label) + } + // Folded items are those between the first and the last limit-2. + fold := len(items) > limit + hideFrom, hideTo := 1, len(items)-(limit-2) + row.Children(func() { + for i, it := range items { + if it.Label == "" { + panic("mujica: BreadcrumbItem needs a Label") + } + if fold && i >= hideFrom && i < hideTo { + if i == hideFrom { + breadcrumbSeparator(c) + breadcrumbMore(c, items[hideFrom:hideTo], func(j int) { choose(hideFrom + j) }) + } + continue + } + if i > 0 { + breadcrumbSeparator(c) + } + if i == len(items)-1 { + cur := ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(u, u*1.5).MinWidth(0). + FontWeight(600).TextColor(k.Text).Tooltip(it.Label).Label(it.Label + ", " + msg(c, "breadcrumb.current")) + cur.Children(func() { breadcrumbContent(c, it, width) }) + continue + } + link := ui.ButtonBase(c).Role(ui.RoleLink).Gap(u).Padding(u, u*1.5).Radius(theme.ControlRadius). + Shrink(1).MinWidth(0).Cursor(ui.CursorPointer).Tooltip(it.Label).Label(it.Label) + internal.Ring(link, k.Focus, theme.ControlRadius) + link.TextColor(k.TextMuted) + if link.Hovered() { + link.Background(k.SurfaceHover).TextColor(k.Text) + } + link.Children(func() { breadcrumbContent(c, it, width) }) + if link.Clicked() { + choose(i) + } + } + }) + return res +} + +func breadcrumbContent(c *ui.Context, it BreadcrumbItem, width float32) { + if it.Icon != nil { + ui.Icon(c, it.Icon).Size(14, 14).Shrink(0) + } + ui.Text(c, it.Label).SingleLine().MaxWidth(width).MinWidth(0) +} + +func breadcrumbSeparator(c *ui.Context) { + ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).TextColor(Tokens(c).Ornament).Role(ui.RoleNone) +} + +// breadcrumbMore is the ellipsis button listing the folded items. +func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) { + k := Tokens(c) + u := Density(c).Unit() + b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize). + Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).TextColor(k.TextMuted).Cursor(ui.CursorPointer) + internal.Ring(b, k.Focus, theme.ControlRadius) + open := ui.Local(b, "open", func() bool { return false }) + if b.Hovered() || *open { + b.Background(k.SurfaceHover).TextColor(k.Text) + } + if b.Clicked() { + *open = !*open + } + b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) }) + ui.PopoverBase(c, b, open, func(panel *ui.Element) { + panel.Margin(u, 0, 0, 0).Padding(u).Gap(u*0.5).Radius(theme.PopupRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(160).MaxWidth(320) + for j, it := range hidden { + item := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).Gap(u*2).Padding(u*1.5, u*2). + Radius(theme.ControlRadius).MinHeight(Density(c).ControlHeight() - u*2).Label(it.Label).TextColor(k.Text) + if j == 0 { + item.AutoFocus() + } + internal.Ring(item, k.Focus, theme.ControlRadius) + if item.Hovered() { + item.Background(k.SurfaceHover) + } + item.Children(func() { + if it.Icon != nil { + ui.Icon(c, it.Icon).Size(14, 14).Shrink(0) + } + ui.Text(c, it.Label).SingleLine().MinWidth(0) + }) + if item.Clicked() { + *open = false + choose(j) + } + } + }) +} diff --git a/breadcrumb_test.go b/breadcrumb_test.go new file mode 100644 index 0000000..326abd8 --- /dev/null +++ b/breadcrumb_test.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func breadcrumbPath(names ...string) []BreadcrumbItem { + var items []BreadcrumbItem + for _, n := range names { + items = append(items, BreadcrumbItem{Label: n}) + } + return items +} + +func TestBreadcrumbChoose(t *testing.T) { + got := -1 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if i, ok := Breadcrumb(c, breadcrumbPath("Home", "Library", "Poems"), BreadcrumbOptions{}).Chosen(); ok { + got = i + } + }, 500, 100) + if err := tt.Click("Library"); err != nil { + t.Fatal(err) + } + if got != 1 { + t.Fatalf("chose %d, want 1", got) + } + got = -1 + _ = tt.Click("Poems") + if got != -1 { + t.Fatalf("current item acted as a link: %d", got) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if got != 0 { + t.Fatalf("Enter on first link chose %d", got) + } +} + +func TestBreadcrumbFolds(t *testing.T) { + got := -1 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if i, ok := Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{}).Chosen(); ok { + got = i + } + }, 600, 300) + for _, hidden := range []string{"Alpha", "Beta", "Gamma"} { + if tt.HasText(hidden) { + t.Fatalf("%s should be folded: %q", hidden, tt.Texts()) + } + } + if !tt.HasText("Omega") || !tt.HasText("Root") { + t.Fatalf("ends missing: %q", tt.Texts()) + } + if err := tt.Click("Show hidden path"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Beta") || !tt.Focused("Alpha") { + t.Fatalf("menu did not open with focus on first: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Beta") { + t.Fatal("Escape did not close the menu") + } + _ = tt.Click("Show hidden path") + if err := tt.Click("Gamma"); err != nil { + t.Fatal(err) + } + if got != 3 || tt.HasText("Beta") { + t.Fatalf("chose %d, want 3, menu closed", got) + } +} + +func TestBreadcrumbInvalid(t *testing.T) { + for name, tc := range map[string]struct { + items []BreadcrumbItem + opts BreadcrumbOptions + }{ + "empty": {nil, BreadcrumbOptions{}}, + "max": {breadcrumbPath("a", "b"), BreadcrumbOptions{MaxItems: 2}}, + "label": {[]BreadcrumbItem{{Icon: icons.Must("house")}}, BreadcrumbOptions{}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Breadcrumb(c, tc.items, tc.opts) + }, 300, 100, 1) + }) + } +} + +func TestBreadcrumbSnapshot(t *testing.T) { + snapshot(t, "breadcrumb", 560, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() { + Breadcrumb(c, []BreadcrumbItem{{Label: "Home", Icon: icons.Must("house")}, {Label: "Library"}, {Label: "Collected poems"}}, BreadcrumbOptions{}) + Breadcrumb(c, breadcrumbPath("Archive", "2026", "October", "Correspondence of the court", "Letter to the Duchess of Mujica"), BreadcrumbOptions{MaxItemWidth: 140}) + }) + }) +} diff --git a/button_group.go b/button_group.go new file mode 100644 index 0000000..b3de134 --- /dev/null +++ b/button_group.go @@ -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} + } +} diff --git a/button_group_test.go b/button_group_test.go new file mode 100644 index 0000000..ead0aa7 --- /dev/null +++ b/button_group_test.go @@ -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}) + }) + }) +} diff --git a/button_test.go b/button_test.go index de99f0d..8a1bc96 100644 --- a/button_test.go +++ b/button_test.go @@ -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-. +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")}) }) } diff --git a/calendar.go b/calendar.go new file mode 100644 index 0000000..b3ebeb2 --- /dev/null +++ b/calendar.go @@ -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() +} diff --git a/calendar_test.go b/calendar_test.go new file mode 100644 index 0000000..d1ce6ca --- /dev/null +++ b/calendar_test.go @@ -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 }}) + }) + }) +} diff --git a/checkbox.go b/checkbox.go new file mode 100644 index 0000000..de9b5a4 --- /dev/null +++ b/checkbox.go @@ -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 +} diff --git a/checkbox_test.go b/checkbox_test.go new file mode 100644 index 0000000..d8befba --- /dev/null +++ b/checkbox_test.go @@ -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) +} diff --git a/color_picker.go b/color_picker.go new file mode 100644 index 0000000..a4f6724 --- /dev/null +++ b/color_picker.go @@ -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) + } + } + } +} diff --git a/color_picker_test.go b/color_picker_test.go new file mode 100644 index 0000000..b5dc1a5 --- /dev/null +++ b/color_picker_test.go @@ -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() + } + }) + }) +} diff --git a/command_palette.go b/command_palette.go new file mode 100644 index 0000000..e49f66f --- /dev/null +++ b/command_palette.go @@ -0,0 +1,212 @@ +package mujica + +import ( + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Command is an action of a CommandPalette. +type Command struct { + ID string + Label string + // Group heads the command in the list; commands keep their order within groups. + Group string + // Keywords also match the search. + Keywords []string + Icon *ui.SVG +} + +// CommandPaletteOptions configure a CommandPalette. +type CommandPaletteOptions struct { + // OpenMods and OpenKey open the palette; zero key means Cmd+K (Ctrl+K off macOS). + OpenMods ui.Modifiers + OpenKey ui.Key +} + +// CommandPaletteResult is the built CommandPalette; Element is nil while closed. +type CommandPaletteResult struct { + *ui.Element + chosen string +} + +// Chosen returns the ID of the command run in this frame; running closes the palette. +func (r CommandPaletteResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" } + +type commandPaletteKey struct{ open *bool } + +type commandPaletteState struct { + query string + hl int + ptr [2]float32 + wasOpen bool + reveal bool +} + +// CommandPalette is a searchable command dialog opened by a shortcut or by setting *open. +func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandPaletteOptions) CommandPaletteResult { + seen := map[string]bool{} + for _, cmd := range commands { + if cmd.ID == "" || cmd.Label == "" { + panic("mujica: Command needs an ID and a Label") + } + if seen[cmd.ID] { + panic("mujica: duplicate Command ID " + cmd.ID) + } + seen[cmd.ID] = true + } + mods, key := opts.OpenMods, opts.OpenKey + if key == 0 { + mods, key = ui.Cmd, ui.KeyK + } + st := ui.Local(c.Root(), commandPaletteKey{open}, func() commandPaletteState { return commandPaletteState{} }) + if c.Shortcut(mods, key) && !*open { + *open = true + } + if *open && !st.wasOpen { + st.query, st.hl = "", 0 + slog.Debug("mujica: command palette open") + } + st.wasOpen = *open + res := CommandPaletteResult{} + k := Tokens(c) + d := Density(c) + u := d.Unit() + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + ww, wh := c.Size() + back.Background(ui.RGBA(0, 0, 0, 0.4)).Justify(ui.Start) + panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww-32).MaxHeight(wh*0.76). + Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Shadow(0, 12, 32, 0, ui.RGBA(0, 0, 0, 0.3)).AlignItems(ui.Stretch).Label(msg(c, "command_palette.label")) + found := commandPaletteFilter(commands, st.query) + var in *ui.Element + ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*3, u*4).Children(func() { + ui.Icon(c, icons.Must("search")).Size(16, 16).TextColor(k.TextMuted) + in = ui.TextInputBase(c, &st.query).FillWidth().AutoFocus().Label(msg(c, "command_palette.search")) + }) + ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone) + if in.Changed() { + st.hl = 0 + } + n := len(found) + switch { + case n == 0: + case in.Shortcut(0, ui.KeyDown): + st.hl, st.reveal = (st.hl+1)%n, true + case in.Shortcut(0, ui.KeyUp): + st.hl, st.reveal = (st.hl-1+n)%n, true + } + st.hl = max(0, min(st.hl, n-1)) + run := -1 + if in.Submitted() && n > 0 { + run = st.hl + } + list := ui.Scroll(c).Padding(u * 2).Gap(2).MaxHeight(360).Role(ui.RoleList) + list.Children(func() { + if n == 0 { + ui.Text(c, msg(c, "command_palette.empty")).TextColor(k.TextMuted).Padding(u*6, u*2).TextAlign(ui.Center).FillWidth() + return + } + gutter := slices.ContainsFunc(found, func(cmd Command) bool { return cmd.Icon != nil }) + group := "\x00" + for i, cmd := range found { + if cmd.Group != group { + group = cmd.Group + if group != "" { + ui.Text(c, group).Font(theme.SerifFont).FontSize(theme.CaptionSize). + TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u*2, u*2, u, u*2).SingleLine() + } + } + if commandPaletteRow(c, cmd, i, st, gutter) { + run = i + } + } + }) + ui.Row(c).Gap(u*4).Padding(u*2, u*4).Background(k.Background).Children(func() { + for _, hint := range []string{"command_palette.hint_move", "command_palette.hint_run", "command_palette.hint_close"} { + ui.Text(c, msg(c, hint)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine() + } + }) + if run >= 0 { + res.chosen = found[run].ID + *open = false + slog.Debug("mujica: command palette run", "id", res.chosen) + } + }) + if !*open { + st.wasOpen = false + } + return res +} + +// commandPaletteRow builds row i and reports a click on it. +func commandPaletteRow(c *ui.Context, cmd Command, i int, st *commandPaletteState, gutter bool) bool { + k := Tokens(c) + d := Density(c) + u := d.Unit() + row := ui.Row(c).AlignItems(ui.Center).Gap(u*3).MinHeight(d.ControlHeight()).Padding(0, u*3). + Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(cmd.Label).TextColor(k.Text).Cursor(ui.CursorPointer) + on := i == st.hl + if on { + row.Background(k.Selection).FontWeight(600) + row.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5) + }) + if st.reveal { + row.ScrollIntoView() + st.reveal = false + } + } + px, py, over := row.PointerPosition() + b := row.Bounds() + if at := [2]float32{b.X + px, b.Y + py}; over && at != st.ptr { + st.ptr = at + if !on { + st.hl = i + c.Invalidate() + } + } + row.Children(func() { + if cmd.Icon != nil { + ui.Icon(c, cmd.Icon).Size(16, 16).TextColor(k.TextMuted) + } else if gutter { + ui.Box(c).Size(16, 16).Role(ui.RoleNone) + } + ui.Text(c, cmd.Label).SingleLine() + }) + return row.Clicked() +} + +// commandPaletteFilter keeps commands matching every word of query, grouped by first appearance. +func commandPaletteFilter(commands []Command, query string) []Command { + words := strings.Fields(strings.ToLower(query)) + var groups []string + byGroup := map[string][]Command{} + for _, cmd := range commands { + hay := strings.ToLower(cmd.Label + " " + cmd.Group + " " + strings.Join(cmd.Keywords, " ")) + ok := true + for _, w := range words { + if !strings.Contains(hay, w) { + ok = false + break + } + } + if !ok { + continue + } + if _, seen := byGroup[cmd.Group]; !seen { + groups = append(groups, cmd.Group) + } + byGroup[cmd.Group] = append(byGroup[cmd.Group], cmd) + } + var out []Command + for _, g := range groups { + out = append(out, byGroup[g]...) + } + return out +} diff --git a/command_palette_test.go b/command_palette_test.go new file mode 100644 index 0000000..724250f --- /dev/null +++ b/command_palette_test.go @@ -0,0 +1,142 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func commandPaletteSample() []Command { + return []Command{ + {ID: "new", Label: "New document", Group: "File", Icon: icons.Must("file")}, + {ID: "open", Label: "Open recent", Group: "File", Icon: icons.Must("folder")}, + {ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"theme", "night"}, Icon: icons.Must("moon")}, + {ID: "zoom", Label: "Zoom in", Group: "View", Icon: icons.Must("zoom-in")}, + {ID: "save", Label: "Save all", Group: "File"}, + } +} + +type paletteApp struct { + open bool + ran []string +} + +func (a *paletteApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Fill().Children(func() { + ui.Text(c, "Editor") + if id, ok := CommandPalette(c, &a.open, commandPaletteSample(), CommandPaletteOptions{}).Chosen(); ok { + a.ran = append(a.ran, id) + } + }) +} + +func TestCommandPaletteFilter(t *testing.T) { + ids := func(cs []Command) (out []string) { + for _, c := range cs { + out = append(out, c.ID) + } + return + } + got := ids(commandPaletteFilter(commandPaletteSample(), "")) + // Groups keep first-appearance order: File's Save joins its group. + want := []string{"new", "open", "save", "theme", "zoom"} + if len(got) != len(want) { + t.Fatalf("got %v", got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } + if got := ids(commandPaletteFilter(commandPaletteSample(), "NIGHT")); len(got) != 1 || got[0] != "theme" { + t.Fatalf("keyword search %v", got) + } + if got := ids(commandPaletteFilter(commandPaletteSample(), "file save")); len(got) != 1 || got[0] != "save" { + t.Fatalf("multi-word search %v", got) + } +} + +func TestCommandPaletteShortcutSearchRun(t *testing.T) { + a := &paletteApp{} + tt := ui.NewTester(a.view, 800, 600) + tt.Key(ui.Cmd, ui.KeyK) + if !a.open || !tt.HasText("Zoom in") { + t.Fatalf("Cmd+K did not open: %q", tt.Texts()) + } + tt.Type("zo") + if tt.HasText("New document") || !tt.HasText("Zoom in") { + t.Fatalf("search did not filter: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if a.open || len(a.ran) != 1 || a.ran[0] != "zoom" { + t.Fatalf("Enter: open %v ran %q", a.open, a.ran) + } + tt.Key(ui.Cmd, ui.KeyK) + if !tt.HasText("New document") { + t.Fatal("query not reset on reopen") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) // wraps to the last + tt.Key(0, ui.KeyEnter) + if len(a.ran) != 2 || a.ran[1] != "zoom" { + t.Fatalf("arrows ran %q", a.ran) + } +} + +func TestCommandPaletteEmptyClickEscape(t *testing.T) { + a := &paletteApp{open: true} + tt := ui.NewTester(a.view, 800, 600) + tt.Type("qqq") + if !tt.HasText("No matching commands") { + t.Fatalf("no empty state: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if !a.open || len(a.ran) != 0 { + t.Fatal("Enter with no results ran something") + } + tt.Key(0, ui.KeyEscape) + if a.open { + t.Fatal("Escape did not close") + } + a.open = true + tt.Frame() + if err := tt.Click("Open recent"); err != nil { + t.Fatal(err) + } + if a.open || len(a.ran) != 1 || a.ran[0] != "open" { + t.Fatalf("click: open %v ran %q", a.open, a.ran) + } +} + +func TestCommandPaletteInvalid(t *testing.T) { + for name, cmds := range map[string][]Command{ + "id": {{Label: "A"}}, + "dup": {{ID: "a", Label: "A"}, {ID: "a", Label: "B"}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := true + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + CommandPalette(c, &open, cmds, CommandPaletteOptions{}) + }, 300, 100, 1) + }) + } +} + +func TestCommandPaletteSnapshot(t *testing.T) { + open := true + snapshot(t, "command_palette", 640, 460, func(c *ui.Context) { + CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{}) + }) +} diff --git a/container.go b/container.go new file mode 100644 index 0000000..7007853 --- /dev/null +++ b/container.go @@ -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) +} diff --git a/container_test.go b/container_test.go new file mode 100644 index 0000000..b3bceaa --- /dev/null +++ b/container_test.go @@ -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) + }) + }) +} diff --git a/context_menu.go b/context_menu.go new file mode 100644 index 0000000..468029c --- /dev/null +++ b/context_menu.go @@ -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 +} diff --git a/context_menu_test.go b/context_menu_test.go new file mode 100644 index 0000000..c14c3c1 --- /dev/null +++ b/context_menu_test.go @@ -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) + }) + }) +} diff --git a/date_picker.go b/date_picker.go new file mode 100644 index 0000000..c17fe93 --- /dev/null +++ b/date_picker.go @@ -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 "" +} diff --git a/date_picker_test.go b/date_picker_test.go new file mode 100644 index 0000000..45a97a8 --- /dev/null +++ b/date_picker_test.go @@ -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"}) + }) + }) + }) +} diff --git a/date_range_picker.go b/date_range_picker.go new file mode 100644 index 0000000..0ce0323 --- /dev/null +++ b/date_range_picker.go @@ -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()) +} diff --git a/date_range_picker_test.go b/date_range_picker_test.go new file mode 100644 index 0000000..0de6cae --- /dev/null +++ b/date_range_picker_test.go @@ -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}) + }) + }) +} diff --git a/date_time_picker.go b/date_time_picker.go new file mode 100644 index 0000000..b5c60ce --- /dev/null +++ b/date_time_picker.go @@ -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") +} diff --git a/date_time_picker_test.go b/date_time_picker_test.go new file mode 100644 index 0000000..d10c0dd --- /dev/null +++ b/date_time_picker_test.go @@ -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}) + }) + }) +} diff --git a/datetime.go b/datetime.go new file mode 100644 index 0000000..9da7642 --- /dev/null +++ b/datetime.go @@ -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) +} diff --git a/datetime_grid.go b/datetime_grid.go new file mode 100644 index 0000000..7cb7c48 --- /dev/null +++ b/datetime_grid.go @@ -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 +} diff --git a/dialog.go b/dialog.go new file mode 100644 index 0000000..486e4f1 --- /dev/null +++ b/dialog.go @@ -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) +} diff --git a/dialog_test.go b/dialog_test.go new file mode 100644 index 0000000..3706ea7 --- /dev/null +++ b/dialog_test.go @@ -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)) +} diff --git a/divider.go b/divider.go new file mode 100644 index 0000000..3e4bdda --- /dev/null +++ b/divider.go @@ -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() + }) +} diff --git a/divider_test.go b/divider_test.go new file mode 100644 index 0000000..ab3db0c --- /dev/null +++ b/divider_test.go @@ -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") + }) + }) + }) +} diff --git a/docs/components/001-app-shell.md b/docs/components/001-app-shell.md new file mode 100644 index 0000000..f3ccaca --- /dev/null +++ b/docs/components/001-app-shell.md @@ -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,不可配置。 diff --git a/docs/components/002-title-bar.md b/docs/components/002-title-bar.md new file mode 100644 index 0000000..f7fc838 --- /dev/null +++ b/docs/components/002-title-bar.md @@ -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()` 为零,组件只是一行普通标题。 diff --git a/docs/components/003-status-bar.md b/docs/components/003-status-bar.md new file mode 100644 index 0000000..8e85639 --- /dev/null +++ b/docs/components/003-status-bar.md @@ -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` 中说明。 diff --git a/docs/components/004-container.md b/docs/components/004-container.md new file mode 100644 index 0000000..abe01ee --- /dev/null +++ b/docs/components/004-container.md @@ -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`。 diff --git a/docs/components/005-stack.md b/docs/components/005-stack.md new file mode 100644 index 0000000..bb2e7c9 --- /dev/null +++ b/docs/components/005-stack.md @@ -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 顺序即子项构建顺序。 + +## 限制 + +- 隐藏项不会被构建,其内部界面状态(如未提交的输入)随之丢弃;需要保留时把状态放在应用中。 +- 只有横向支持换行。 diff --git a/docs/components/006-grid.md b/docs/components/006-grid.md new file mode 100644 index 0000000..c22bac8 --- /dev/null +++ b/docs/components/006-grid.md @@ -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 按其规则扩展。 diff --git a/docs/components/007-scroll-area.md b/docs/components/007-scroll-area.md new file mode 100644 index 0000000..947ab19 --- /dev/null +++ b/docs/components/007-scroll-area.md @@ -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 会构建全部子项。 diff --git a/docs/components/008-split-pane.md b/docs/components/008-split-pane.md new file mode 100644 index 0000000..ea1e9b6 --- /dev/null +++ b/docs/components/008-split-pane.md @@ -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 没有公开的无障碍数值范围接口,当前位置通过描述文字提供。 +- 键盘或拖动产生的尺寸在下一帧生效(组件会立即请求下一帧)。 diff --git a/docs/components/009-aspect-ratio.md b/docs/components/009-aspect-ratio.md new file mode 100644 index 0000000..5992bd9 --- /dev/null +++ b/docs/components/009-aspect-ratio.md @@ -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` 只能由宽推高,不能同时受宽高约束)。 diff --git a/docs/components/010-divider.md b/docs/components/010-divider.md new file mode 100644 index 0000000..bc938df --- /dev/null +++ b/docs/components/010-divider.md @@ -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`。 +- 纵向不支持文字。 diff --git a/docs/components/011-text.md b/docs/components/011-text.md new file mode 100644 index 0000000..8bb00a7 --- /dev/null +++ b/docs/components/011-text.md @@ -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 无法检测文本是否真的被截断,所以截断时不会自动显示完整内容的提示。 diff --git a/docs/components/012-heading.md b/docs/components/012-heading.md new file mode 100644 index 0000000..17425b4 --- /dev/null +++ b/docs/components/012-heading.md @@ -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 组件。 diff --git a/docs/components/013-icon.md b/docs/components/013-icon.md new file mode 100644 index 0000000..886485f --- /dev/null +++ b/docs/components/013-icon.md @@ -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`。 +- 回退标记带悬停提示,但图标本身不获取焦点,键盘用户看不到这个提示,只能依赖无障碍名称。 diff --git a/docs/components/014-image.md b/docs/components/014-image.md new file mode 100644 index 0000000..b19325b --- /dev/null +++ b/docs/components/014-image.md @@ -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`,不会显示占位。 diff --git a/docs/components/015-avatar.md b/docs/components/015-avatar.md new file mode 100644 index 0000000..fedac25 --- /dev/null +++ b/docs/components/015-avatar.md @@ -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`)。 diff --git a/docs/components/016-avatar-group.md b/docs/components/016-avatar-group.md new file mode 100644 index 0000000..4924c2f --- /dev/null +++ b/docs/components/016-avatar-group.md @@ -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` 卡片上时描边颜色会与卡片背景不同。 +- 名单中的条目只用于展示,不可选择。 diff --git a/docs/components/017-badge.md b/docs/components/017-badge.md new file mode 100644 index 0000000..ebba68b --- /dev/null +++ b/docs/components/017-badge.md @@ -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`,部分屏幕阅读器可能在数值变化时主动播报。 diff --git a/docs/components/018-tag.md b/docs/components/018-tag.md new file mode 100644 index 0000000..f551e6c --- /dev/null +++ b/docs/components/018-tag.md @@ -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 类组件。 diff --git a/docs/components/019-link.md b/docs/components/019-link.md new file mode 100644 index 0000000..a6a760f --- /dev/null +++ b/docs/components/019-link.md @@ -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`。 diff --git a/docs/components/020-kbd.md b/docs/components/020-kbd.md new file mode 100644 index 0000000..cda7863 --- /dev/null +++ b/docs/components/020-kbd.md @@ -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。 diff --git a/docs/components/021-button.md b/docs/components/021-button.md new file mode 100644 index 0000000..02824ad --- /dev/null +++ b/docs/components/021-button.md @@ -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` 混合黑色得到,未单独定义变量。 diff --git a/docs/components/022-icon-button.md b/docs/components/022-icon-button.md new file mode 100644 index 0000000..9efa694 --- /dev/null +++ b/docs/components/022-icon-button.md @@ -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,提示随之不出现;名称仍作为无障碍描述保留。 diff --git a/docs/components/023-button-group.md b/docs/components/023-button-group.md new file mode 100644 index 0000000..c394325 --- /dev/null +++ b/docs/components/023-button-group.md @@ -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)。 diff --git a/docs/components/024-text-input.md b/docs/components/024-text-input.md new file mode 100644 index 0000000..88649a9 --- /dev/null +++ b/docs/components/024-text-input.md @@ -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 清空。 diff --git a/docs/components/025-text-area.md b/docs/components/025-text-area.md new file mode 100644 index 0000000..32205d8 --- /dev/null +++ b/docs/components/025-text-area.md @@ -0,0 +1,49 @@ +# 025 TextArea 多行文本框 + +## 用途 + +多行文本编辑,可设最小与最大高度,超出最大高度后在框内滚动;可显示字数;提交快捷键可配置。编辑核心是 MyGo `ui.TextAreaBase`,只排版可见段落,适合长文本。 + +## 最小示例 + +```go +r := mujica.TextArea(c, ¬es, 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。 diff --git a/docs/components/026-number-input.md b/docs/components/026-number-input.md new file mode 100644 index 0000000..82865f7 --- /dev/null +++ b/docs/components/026-number-input.md @@ -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`),所以仅步进模式要求有界。 diff --git a/docs/components/027-password-input.md b/docs/components/027-password-input.md new file mode 100644 index 0000000..20d4cab --- /dev/null +++ b/docs/components/027-password-input.md @@ -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`),因此遮蔽与显示两种模式各用一个编辑器,切换时撤销历史和光标位置不保留,焦点留在切换按钮上。 +- 显示模式下内容是普通文本,可复制,辅助技术可朗读——这是用户主动选择的结果。 diff --git a/docs/components/028-search-input.md b/docs/components/028-search-input.md new file mode 100644 index 0000000..8d074bc --- /dev/null +++ b/docs/components/028-search-input.md @@ -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: ¤t}) +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。 +- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。 diff --git a/docs/components/029-checkbox.md b/docs/components/029-checkbox.md new file mode 100644 index 0000000..2e88b84 --- /dev/null +++ b/docs/components/029-checkbox.md @@ -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` 未导出)。半选时组件在无障碍描述中加入“部分选中”,但角色状态仍报告为未选中。 diff --git a/docs/components/030-radio-group.md b/docs/components/030-radio-group.md new file mode 100644 index 0000000..f421a8e --- /dev/null +++ b/docs/components/030-radio-group.md @@ -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`)。 diff --git a/docs/components/041-form.md b/docs/components/041-form.md new file mode 100644 index 0000000..fa36aa6 --- /dev/null +++ b/docs/components/041-form.md @@ -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 标签不支持富文本,无法单独着色。 diff --git a/docs/components/042-form-field.md b/docs/components/042-form-field.md new file mode 100644 index 0000000..9e3f6e0 --- /dev/null +++ b/docs/components/042-form-field.md @@ -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)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。 diff --git a/docs/components/043-input-group.md b/docs/components/043-input-group.md new file mode 100644 index 0000000..a9a86ba --- /dev/null +++ b/docs/components/043-input-group.md @@ -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。 diff --git a/docs/components/044-pin-input.md b/docs/components/044-pin-input.md new file mode 100644 index 0000000..0954adb --- /dev/null +++ b/docs/components/044-pin-input.md @@ -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 位”的说明。 diff --git a/docs/components/045-tags-input.md b/docs/components/045-tags-input.md new file mode 100644 index 0000000..61ff072 --- /dev/null +++ b/docs/components/045-tags-input.md @@ -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 单行输入框会处理粘贴文本中的换行,换行能否作为分隔符取决于平台粘贴行为;逗号、分号总是有效。 diff --git a/docs/components/046-rating.md b/docs/components/046-rating.md new file mode 100644 index 0000000..6e7dd1c --- /dev/null +++ b/docs/components/046-rating.md @@ -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 未公开为自定义元素设置数值范围的接口。 diff --git a/docs/components/047-color-picker.md b/docs/components/047-color-picker.md new file mode 100644 index 0000000..1929278 --- /dev/null +++ b/docs/components/047-color-picker.md @@ -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 决定,空间不足时显示在按钮上方。 diff --git a/docs/components/048-file-picker.md b/docs/components/048-file-picker.md new file mode 100644 index 0000000..f974065 --- /dev/null +++ b/docs/components/048-file-picker.md @@ -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` 启动,无窗口测试中无法打开真实对话框。 +- 不读取文件内容。 diff --git a/docs/components/049-file-drop-zone.md b/docs/components/049-file-drop-zone.md new file mode 100644 index 0000000..f285161 --- /dev/null +++ b/docs/components/049-file-drop-zone.md @@ -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 的无窗口测试器不能模拟系统拖放,自动测试通过“浏览…”路径覆盖同一检查流程。 diff --git a/docs/components/050-transfer.md b/docs/components/050-transfer.md new file mode 100644 index 0000000..7ace8e6 --- /dev/null +++ b/docs/components/050-transfer.md @@ -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 到达全选框、搜索框与移动按钮。 + +## 限制 + +- 条目逐项创建,适合数百条以内;大数据量请使用虚拟化列表组合。 +- 全选只作用于当前搜索结果中的可用条目。 diff --git a/docs/components/051-calendar.md b/docs/components/051-calendar.md new file mode 100644 index 0000000..72d7a72 --- /dev/null +++ b/docs/components/051-calendar.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/052-date-picker.md b/docs/components/052-date-picker.md new file mode 100644 index 0000000..4a7b7a4 --- /dev/null +++ b/docs/components/052-date-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/053-date-range-picker.md b/docs/components/053-date-range-picker.md new file mode 100644 index 0000000..f400753 --- /dev/null +++ b/docs/components/053-date-range-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/054-time-picker.md b/docs/components/054-time-picker.md new file mode 100644 index 0000000..3740297 --- /dev/null +++ b/docs/components/054-time-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/055-time-range-picker.md b/docs/components/055-time-range-picker.md new file mode 100644 index 0000000..377fe2e --- /dev/null +++ b/docs/components/055-time-range-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/056-date-time-picker.md b/docs/components/056-date-time-picker.md new file mode 100644 index 0000000..b6dcb14 --- /dev/null +++ b/docs/components/056-date-time-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/057-month-picker.md b/docs/components/057-month-picker.md new file mode 100644 index 0000000..c0eb9e5 --- /dev/null +++ b/docs/components/057-month-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/058-year-picker.md b/docs/components/058-year-picker.md new file mode 100644 index 0000000..2ff6c6f --- /dev/null +++ b/docs/components/058-year-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/059-tabs.md b/docs/components/059-tabs.md new file mode 100644 index 0000000..f99cb30 --- /dev/null +++ b/docs/components/059-tabs.md @@ -0,0 +1,63 @@ +# 059 Tabs 标签页 + +## 用途 + +在同一区域内切换若干面板。标签列表与当前标签的面板一起构建;支持横向与纵向布局、禁用项和溢出滚动。Tabs 是视觉回归基准组件,快照覆盖三种密度。 + +## 最小示例 + +```go +mujica.Tabs(c, &tab, []mujica.Tab{ + {Label: "General", Panel: func() { ui.Text(c, "General settings") }}, + {Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }}, + {Label: "Archive", Disabled: true}, +}, mujica.TabsOptions{Label: "Settings"}) +``` + +签名:`func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) TabsResult` + +## 参数 + +`selected`:当前标签下标(从 0 开始)。越界或指向禁用标签时 panic。`tabs` 为空时 panic。 + +`Tab` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | 必填 | 标签文字,也是面板的无障碍名称;为空时 panic。 | +| `Icon` | `*ui.SVG` | nil | 标签文字前的 16 DIP 图标。 | +| `Disabled` | `bool` | false | 禁用:不可点击、不进入方向键序列。 | +| `Panel` | `func()` | nil | 选中时构建的面板内容;nil 显示空面板。 | + +`TabsOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Vertical` | `bool` | false | 标签竖排在左侧,面板在右侧。 | +| `Label` | `string` | "" | 标签列表的无障碍名称。 | + +返回 `TabsResult`,内嵌包含标签与面板的 `*ui.Element`。 + +## 状态 + +- 选中:Selection 背景、2 DIP AccentText 边线(横向在底边,纵向在右边)、600 字重,三者同时标记。 +- 悬停:SurfaceHover 背景;按下:Selection 背景。 +- 焦点:2 DIP Focus 焦点环,外留 2 DIP。 +- 禁用:MyGo 统一降低不透明度,不响应指针与键盘。 +- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。 + +## 事件 + +- `Changed()`:本帧用户选择了新标签(点击或方向键)。 + +## 键盘操作 + +- Tab:进入标签列表,落在当前标签;再按 Tab 离开。 +- ←/→、↑/↓:选择上一个/下一个可用标签,跳过禁用项,首尾循环;焦点随选择移动。 +- Home / End:第一个 / 最后一个可用标签。 + +## 限制 + +- MyGo `TabsBase` 的方向键不知道禁用项(`ui/base.go:192` 直接选择 i±1)。本组件只把可用标签交给 `TabsBase`,禁用标签以普通 `RoleTab` 行显示,因此禁用标签不在 MyGo 的标签计数内。 +- MyGo 没有 tabpanel 角色与 aria-controls 式关联;面板以 `RoleGroup` 呈现,名称取自所选标签。 +- 不提供标签关闭、拖动排序。 diff --git a/docs/components/060-segmented-control.md b/docs/components/060-segmented-control.md new file mode 100644 index 0000000..1a93ae4 --- /dev/null +++ b/docs/components/060-segmented-control.md @@ -0,0 +1,55 @@ +# 060 SegmentedControl 分段控件 + +## 用途 + +在少量互斥选项之间切换视图或范围,如“日 / 周 / 月”或“列表 / 网格”。基于 MyGo `SegmentedBase`,辅助技术看到单选组。 + +## 最小示例 + +```go +mujica.SegmentedControl(c, &view, []mujica.Segment{ + {Label: "List", Icon: icons.Must("list"), HideLabel: true}, + {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true}, +}, mujica.SegmentedControlOptions{Label: "View"}) +``` + +签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element` + +## 参数 + +`selected`:当前分段下标;越界时 panic。`segments` 为空时 panic。 + +`Segment` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | 必填 | 分段文字与无障碍名称;为空时 panic。 | +| `Icon` | `*ui.SVG` | nil | 文字前的图标。 | +| `HideLabel` | `bool` | false | 只显示图标,`Label` 作为名称与提示文字;没有 `Icon` 时 panic。 | + +`SegmentedControlOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Equal` | `bool` | false | 所有分段等宽(取最宽者);否则按内容自适应。 | +| `Label` | `string` | "" | 单选组的无障碍名称。 | +| `Disabled` | `bool` | false | 整组禁用。 | + +## 状态 + +- 选中:Accent 填充、OnAccent 文字、600 字重。 +- 悬停 SurfaceHover,按下 Selection;焦点环 2 DIP。 +- 禁用:整组降低不透明度,点击与方向键不改变值。 + +## 事件 + +- 返回元素的 `Changed()`:本帧用户选择了新分段。 + +## 键盘操作 + +- Tab:进入控件,落在当前分段(一个 Tab 停靠点)。 +- 方向键:选择上一个/下一个分段并循环;Home / End 到两端。 + +## 限制 + +- 不支持单个分段禁用:MyGo `SegmentedBase` 的焦点组在方向键移动时直接点击目标分段。 diff --git a/docs/components/061-breadcrumb.md b/docs/components/061-breadcrumb.md new file mode 100644 index 0000000..15240cd --- /dev/null +++ b/docs/components/061-breadcrumb.md @@ -0,0 +1,57 @@ +# 061 Breadcrumb 面包屑 + +## 用途 + +显示当前位置的层级路径。最后一项为当前页(非链接),前面各项可点击返回;层级过多时中间项折叠到“…”菜单;过长名称以省略号截断,悬停显示完整名称。 + +## 最小示例 + +```go +if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok { + path = path[:i+1] +} +``` + +签名:`func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) BreadcrumbResult` + +## 参数 + +`items` 为空时 panic。 + +`BreadcrumbItem` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | 必填 | 名称;为空时 panic。 | +| `Icon` | `*ui.SVG` | nil | 名称前的 14 DIP 图标。 | + +`BreadcrumbOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `MaxItems` | `int` | 0(即 4) | 最多显示的项数;超出时保留第一项和最后 `MaxItems-2` 项,其余折叠。小于 3 时 panic。 | +| `MaxItemWidth` | `float32` | 0(即 160 DIP) | 单项文字最大宽度,超出以省略号截断。 | +| `Label` | `string` | 内置“路径导航” | 整组的无障碍名称。 | + +返回 `BreadcrumbResult`,内嵌 `*ui.Element`。 + +## 状态 + +- 链接:TextMuted 文字,悬停 SurfaceHover 背景与 Text 文字,焦点环 2 DIP。 +- 当前项:Text 文字、600 字重,无障碍名称附“当前页”。 +- 分隔:旧金(Ornament)小箭头,不进入无障碍树。 +- 折叠按钮“…”:打开弹层列出被折叠的项,弹层打开时焦点在第一项。 + +## 事件 + +- `Chosen() (int, bool)`:本帧被激活的项下标(含折叠菜单中的项)。组件不修改路径,由调用方处理。 + +## 键盘操作 + +- Tab / Shift+Tab:在各链接与折叠按钮之间移动;Enter 或 Space 激活。 +- 折叠菜单:Tab 在项之间移动,Enter 选择,Escape 关闭并把焦点还给“…”按钮。 + +## 限制 + +- 完整名称提示对每一项都提供,MyGo 不报告文字是否被截断。 +- 未复用 MyGo `ui.Breadcrumbs`:它不支持折叠与提示,本组件用 `ButtonBase`、`PopoverBase` 组合实现。 diff --git a/docs/components/062-pagination.md b/docs/components/062-pagination.md new file mode 100644 index 0000000..36943b7 --- /dev/null +++ b/docs/components/062-pagination.md @@ -0,0 +1,53 @@ +# 062 Pagination 分页 + +## 用途 + +在分页数据之间移动:页码、上一页、下一页、页容量选择与跳页。总数变化后,超出范围的页码自动修正到最后一页并报告变化。 + +## 最小示例 + +```go +if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() { + load(page, size) +} +``` + +签名:`func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult` + +## 参数 + +`page`:当前页,从 1 开始;每帧被限制在 `[1, 总页数]`。`pageSize`:每页条数,必须为正,否则 panic。 + +`PaginationOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Total` | `int` | 0 | 记录总数;为负 panic。为 0 时仍显示 1 页。 | +| `PageSizes` | `[]int` | nil | 可选的页容量;nil 隐藏选择器。非 nil 时须包含 `*pageSize`,否则 panic。 | +| `Jump` | `bool` | false | 显示跳页输入框。 | +| `Siblings` | `int` | 0(即 1) | 当前页两侧显示的页码数;为负 panic。 | +| `Disabled` | `bool` | false | 整体禁用。 | + +返回 `PaginationResult`,内嵌 `*ui.Element`。 + +## 状态 + +- 当前页:Accent 填充、OnAccent 文字、600 字重,名称附“当前”。 +- 页码按钮:Border 细边框,悬停 SurfaceHover 与 ControlBorder 边框;首页时“上一页”禁用,末页时“下一页”禁用。 +- 页码多时首尾保留,中间以“…”省略,按钮数固定为 `2×Siblings+5`,位置稳定。 +- 跳页错误:输入非数字或超出范围时边框变为 Danger,并在旁显示有效范围;输入保留,可直接修改。 +- 修改页容量时,保持原页第一条记录可见。 + +## 事件 + +- `Changed()`:本帧页码或页容量改变(用户操作或总数变化导致的修正)。 + +## 键盘操作 + +- 页码按钮组是一个 Tab 停靠点:←/→ 移动,Home / End 到两端,Enter 或 Space 跳到该页。 +- 页容量:↑/↓ 打开并移动,Enter 选择,Escape 关闭。 +- 跳页框:输入页码后按 Enter。 + +## 限制 + +- 页容量选择基于 MyGo `SelectBase`,弹层由 MujicaUI 绘制。 diff --git a/docs/components/063-steps.md b/docs/components/063-steps.md new file mode 100644 index 0000000..b9e7051 --- /dev/null +++ b/docs/components/063-steps.md @@ -0,0 +1,59 @@ +# 063 Steps 步骤条 + +## 用途 + +显示多步流程的进度:已完成、当前、等待和出错四种状态;横向或纵向排列;每一步是否可点击由调用方决定。 + +## 最小示例 + +```go +mujica.Steps(c, ¤t, []mujica.Step{ + {Title: "Account", Clickable: true}, + {Title: "Profile", Description: "Name and seal"}, + {Title: "Confirm"}, +}, mujica.StepsOptions{Label: "Sign-up"}) +``` + +签名:`func Steps(c *ui.Context, current *int, steps []Step, opts StepsOptions) StepsResult` + +## 参数 + +`current`:进行中的步骤下标;之前的步骤视为已完成。等于 `len(steps)` 表示全部完成;超出范围 panic。`steps` 为空时 panic。 + +`Step` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 步骤标题;为空时 panic。 | +| `Description` | `string` | "" | 标题下的说明文字。 | +| `Error` | `bool` | false | 标为出错,优先于位置推导的状态。 | +| `Clickable` | `bool` | false | 允许用户点击跳到该步。 | + +`StepsOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Vertical` | `bool` | false | 纵向排列,连接线在标记下方。 | +| `Label` | `string` | "" | 整组的无障碍名称。 | + +返回 `StepsResult`,内嵌 `*ui.Element`。 + +## 状态 + +- 已完成:Accent 圆形标记内 OnAccent 勾号;之后的连接线为 AccentText。 +- 当前:AccentText 圆环与序号,标题 600 字重。 +- 等待:ControlBorder 圆环与 TextMuted 序号、标题。 +- 出错:Danger 圆环与叉号,标题 Danger 色。 +- 每步的无障碍名称为“第 n 步,共 m 步:标题,状态”,状态不只靠颜色表达。 + +## 事件 + +- `Changed()`:本帧用户点击了可点击的步骤,`*current` 已更新。 + +## 键盘操作 + +- 可点击的步骤组成一个 Tab 停靠点,方向键在其间移动,Enter 或 Space 跳到该步。不可点击的步骤不获取焦点。 + +## 限制 + +- MyGo 没有 step 或 aria-current 角色;步骤以列表项(可点击时为按钮)呈现,状态写入名称。 diff --git a/docs/components/064-sidebar.md b/docs/components/064-sidebar.md new file mode 100644 index 0000000..f32e7b8 --- /dev/null +++ b/docs/components/064-sidebar.md @@ -0,0 +1,71 @@ +# 064 Sidebar 侧边栏 + +## 用途 + +应用的源列表导航:分组、嵌套、图标、计数和选中项,分组与嵌套组可展开折叠;紧凑模式只显示图标。基于 MyGo `ui.Sidebar`,保留其键盘、首字母跳转与树形无障碍结构。 + +## 最小示例 + +```go +mujica.Sidebar(c, &selected, []mujica.SidebarSection{ + {Title: "Mail", Items: []mujica.SidebarItem{ + {ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3}, + {Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}}, + }}, +}, mujica.SidebarOptions{}) +``` + +签名:`func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts SidebarOptions) SidebarResult` + +## 参数 + +`selected`:选中项的 ID。 + +`SidebarSection` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 分组标题,点击展开折叠;为空时 panic(紧凑模式不显示)。 | +| `Items` | `[]SidebarItem` | nil | 分组内的项。 | + +`SidebarItem` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ID` | `string` | 叶子项必填 | 选择时写入 `*selected`;叶子项为空时 panic。 | +| `Label` | `string` | 必填 | 名称;为空时 panic。 | +| `Icon` | `*ui.SVG` | nil | 名称前图标;紧凑模式下必填。 | +| `Count` | `int` | 0 | 大于 0 时在右侧显示计数,超过 999 显示 “999+”。 | +| `Items` | `[]SidebarItem` | nil | 非 nil 时该项成为可展开折叠的嵌套组,子项缩进。 | + +`SidebarOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Compact` | `bool` | false | 图标栏:隐藏文字与组标题,组之间以旧金细线分隔,名称作为悬停提示。 | +| `Width` | `float32` | 0(240,紧凑时 56) | 宽度,DIP。高度填满父容器。 | + +返回 `SidebarResult`,内嵌 `*ui.Element`。展开折叠状态由组件保存。 + +## 状态 + +- 选中:Selection 背景、左侧 3 DIP AccentText 边线、600 字重,三者同时标记。 +- 悬停:SurfaceHover 背景。 +- 焦点:侧边栏获得焦点时,在选中项上画焦点环。 +- 计数:等宽数字,名称附计数(如 “Inbox (12)”)。 + +## 事件 + +- `Changed()`:本帧用户选择了新项。 + +## 键盘操作 + +- Tab:进入侧边栏(一个停靠点)。 +- ↑/↓:上一项/下一项;Home / End:首项/末项;输入名称开头字母跳到匹配项。 +- 分组与嵌套组标题用指针点击展开折叠。 + +## 限制 + +- 复用 MyGo `ui.SidebarItem`,它用主题 Accent 绘制图标(`ui/sidebar.go:182`);深色主题 Accent 对比度不足,本组件在构建侧边栏时临时把 MyGo 主题的 Accent 换成 AccentText。 +- MyGo `SidebarItem` 总是追加名称文字(`ui/sidebar.go:212`);紧凑模式传入空名称,名称改由 `Label` 与提示提供,因此紧凑模式下首字母跳转不可用。 +- 嵌套组用 MyGo `SidebarSection` 实现,组标题本身不能被选中,且只能用指针展开折叠(MyGo 不给标题注册按键)。 diff --git a/docs/components/065-navigation-menu.md b/docs/components/065-navigation-menu.md new file mode 100644 index 0000000..630ca64 --- /dev/null +++ b/docs/components/065-navigation-menu.md @@ -0,0 +1,61 @@ +# 065 NavigationMenu 导航菜单 + +## 用途 + +站点式的顶层导航:直接链接与下拉分组(分组内可再分小节),用应用已有的 `*ui.Router` 标记当前路径并跳转;可选显示 MyGo 的后退、前进按钮。 + +## 最小示例 + +```go +mujica.NavigationMenu(c, router, []mujica.NavigationLink{ + {Label: "Home", Path: "/"}, + {Label: "Library", Items: []mujica.NavigationLink{ + {Label: "Poems", Path: "/library/poems", Description: "Verse of the court"}, + }}, +}, mujica.NavigationMenuOptions{History: true}) +router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) }) +``` + +签名:`func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element` + +## 参数 + +`router` 为 nil 或 `links` 为空时 panic。路由状态由应用持有,组件只读 `Path()` 并调用 `Push`。 + +`NavigationLink` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | 必填 | 名称;为空时 panic。 | +| `Path` | `string` | 叶子必填 | 点击后 `router.Push(Path)`;既无 `Path` 也无 `Items` 时 panic。 | +| `Icon` | `*ui.SVG` | nil | 名称前图标。 | +| `Description` | `string` | "" | 下拉面板中名称下方的说明。 | +| `Items` | `[]NavigationLink` | nil | 非 nil 时为分组:顶层显示为下拉按钮,面板内显示为小节标题。 | + +`NavigationMenuOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `History` | `bool` | false | 在前面显示后退、前进按钮(MyGo `BackButton` / `ForwardButton`)。 | +| `Label` | `string` | 内置“导航菜单” | 链接组的无障碍名称。 | + +## 状态 + +- 当前路径:路径等于链接,或位于其下(`/library` 覆盖 `/library/poems`;`/` 只匹配自身)。顶层项以 600 字重与底部 AccentText 线标记;面板中的当前项为 Selection 背景、左侧边线与 600 字重。 +- 悬停或展开:SurfaceHover 背景。焦点环 2 DIP。 +- 历史按钮在历史两端禁用;右键列出可前往的页面(MyGo 行为)。 + +## 事件 + +- 无返回事件:跳转直接作用于 `router`,调用方读取 `router.Path()`。 + +## 键盘操作 + +- Tab:进入顶层链接组;←/→ 在顶层项之间移动。 +- 在分组上按 ↓、Enter 或 Space 打开面板,焦点在第一个链接;↑/↓ 在面板内移动,Enter 跳转并关闭,Escape 关闭并把焦点还给分组按钮。 +- 历史:Cmd+[ / Cmd+](macOS),Alt+←/→(其他平台),以及鼠标侧键,由 MyGo 路由处理。 + +## 限制 + +- MyGo 历史按钮的提示文字固定为英文 “Back” / “Forward”(`ui/router.go:713`);本组件覆盖了无障碍名称,但提示仍为英文。 +- 历史按钮的外观来自 MyGo 主题按钮样式(经 `mujica.Use` 映射为 MujicaUI 颜色)。 diff --git a/docs/components/066-menubar.md b/docs/components/066-menubar.md new file mode 100644 index 0000000..6f45bba --- /dev/null +++ b/docs/components/066-menubar.md @@ -0,0 +1,74 @@ +# 066 Menubar 菜单栏 + +## 用途 + +应用窗口内由 MujicaUI 自绘的菜单栏(不是系统菜单):多级子菜单、勾选项、单选项、分隔线、快捷键提示和完整键盘导航。 + +## 最小示例 + +```go +res := mujica.Menubar(c, []mujica.MenubarMenu{ + {Label: "File", Items: []mujica.MenubarItem{ + {ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}}, + {Label: "Export", Items: []mujica.MenubarItem{{ID: "pdf", Label: "PDF"}}}, + }}, + {Label: "View", Items: []mujica.MenubarItem{ + {ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: wrap}, + }}, +}, mujica.MenubarOptions{}) +if id, ok := res.Chosen(); ok && id == "wrap" { + wrap = !wrap +} +``` + +签名:`func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarResult` + +## 参数 + +`menus` 为空或标题为空时 panic。 + +`MenubarItem` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ID` | `string` | 必填(子菜单与分隔线除外) | `Chosen` 报告的标识。 | +| `Label` | `string` | 必填(分隔线除外) | 名称。 | +| `Kind` | `MenubarItemKind` | `MenubarAction` | `MenubarAction`、`MenubarCheck`、`MenubarRadio`、`MenubarSeparator`。子菜单必须为 `MenubarAction`。 | +| `Checked` | `bool` | false | 勾选项与单选项的当前值,由调用方持有。 | +| `Disabled` | `bool` | false | 禁用:不可选择,方向键跳过。 | +| `Shortcut` | `MenubarShortcut` | 无 | 仅显示的快捷键提示:macOS 显示 ⌘⇧ 等符号,其他平台显示 Ctrl+Shift+。键名无法表示时 panic。 | +| `Items` | `[]MenubarItem` | nil | 非 nil 时为子菜单。 | + +`MenubarOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | 内置“菜单栏” | 菜单栏的无障碍名称。 | + +返回 `MenubarResult`,内嵌菜单栏 `*ui.Element`。 + +## 状态 + +- 打开的菜单标题:Selection 背景;高亮项:Selection 背景。 +- 勾选项:AccentText 勾号;单选项:AccentText 菱形标记;名称附“已勾选”。 +- 禁用项:降低不透明度,指针与键盘均跳过。 +- 弹层:6 DIP 圆角、Border 边框;靠近窗口边缘时左移,子菜单放不下时翻到父菜单左侧,过高时上移。 + +## 事件 + +- `Chosen() (string, bool)`:本帧被选择的项 ID。选择后菜单关闭;勾选与单选由调用方更新 `Checked`。 + +## 键盘操作 + +- Tab:进入菜单栏(一个停靠点);←/→ 在标题间移动;↓、↑、Enter 或 Space 打开菜单。 +- 菜单中:↑/↓ 移动(跳过分隔线与禁用项并循环),Home / End 到首末项。 +- →:打开子菜单;在普通项上则打开右侧下一个菜单。←:关闭子菜单;在顶层则打开左侧菜单。 +- Enter / Space:选择或打开子菜单。Escape:关闭一级。 +- 指针:点击标题打开;菜单打开时移到其他标题即切换;悬停子菜单项展开;点击外部关闭。 + +## 限制 + +- 快捷键只做提示,不注册;用 `c.Shortcut` 处理。 +- MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。 +- 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。 +- 菜单打开期间,焦点保持在标题上,高亮项由组件管理(与 MyGo `SelectBase` 相同的方式)。 diff --git a/docs/components/067-toolbar.md b/docs/components/067-toolbar.md new file mode 100644 index 0000000..7d05381 --- /dev/null +++ b/docs/components/067-toolbar.md @@ -0,0 +1,67 @@ +# 067 Toolbar 工具栏 + +## 用途 + +一行操作按钮:分组、分隔线;宽度不足时,放不下的按钮从末尾起移入溢出菜单。基于 MyGo `ui.Toolbar`,保留其焦点组与溢出行为。 + +## 最小示例 + +```go +mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() { + mujica.ToolbarGroup(c, func() { + mujica.ToolbarButton(c, "Bold", mujica.ToolbarButtonOptions{Icon: icons.Must("bold")}) + mujica.ToolbarButton(c, "Italic", mujica.ToolbarButtonOptions{Icon: icons.Must("italic")}) + }) + mujica.ToolbarSeparator(c) + if mujica.ToolbarButton(c, "Copy", mujica.ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() { + copySelection() + } +}) +``` + +签名: + +- `func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element` +- `func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.Element` +- `func ToolbarGroup(c *ui.Context, fn func()) *ui.Element` +- `func ToolbarSeparator(c *ui.Context)` + +## 参数 + +`ToolbarOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | "" | 工具栏的无障碍名称。 | + +`ToolbarButton` 的 `label` 必填(为空时 panic):它是按钮名称、仅图标时的提示文字和溢出菜单中的文字。 + +`ToolbarButtonOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Icon` | `*ui.SVG` | nil | 16 DIP 图标。 | +| `ShowLabel` | `bool` | false | 图标旁显示文字;没有图标时总是显示。 | +| `Disabled` | `bool` | false | 禁用;溢出菜单中同样禁用。 | + +## 状态 + +- 工具栏:Surface 背景、Border 边框、4 DIP 圆角。 +- 按钮:无底色,悬停 SurfaceHover,按下 Selection,焦点环 2 DIP。 +- 分隔线:旧金细竖线,不进入无障碍树。 +- 溢出:末尾显示 “»” 按钮,菜单中按原顺序列出被收起的操作,组之间以分隔线隔开。 + +## 事件 + +- `ToolbarButton(...).Clicked()`:点击、Enter / Space,或在溢出菜单中选择。 + +## 键盘操作 + +- Tab:进入工具栏(一个停靠点),再按 Tab 离开。 +- ←/→:在操作之间移动(跳过禁用项并循环);Home / End 到首末。 +- Enter / Space:执行。溢出按钮用 Enter、Space 或 ↓ 打开菜单。 + +## 限制 + +- 溢出菜单是 MyGo 打开的系统菜单(`ui/toolbar.go:45`),外观为系统样式,不是 MujicaUI 自绘。 +- 溢出按钮的无障碍名称固定为英文 “More”(`ui/toolbar.go:44`),按钮样式来自 MyGo 主题。 diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md new file mode 100644 index 0000000..a249aeb --- /dev/null +++ b/docs/components/068-command-palette.md @@ -0,0 +1,65 @@ +# 068 CommandPalette 命令面板 + +## 用途 + +用快捷键(默认 Cmd+K,非 macOS 为 Ctrl+K)打开的模态命令搜索框:输入过滤、分组显示、无结果提示、方向键选择,执行后关闭。 + +## 最小示例 + +```go +res := mujica.CommandPalette(c, &open, []mujica.Command{ + {ID: "new", Label: "New document", Group: "File"}, + {ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}}, +}, mujica.CommandPaletteOptions{}) +if id, ok := res.Chosen(); ok { + run(id) +} +``` + +签名:`func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandPaletteOptions) CommandPaletteResult` + +## 参数 + +`open`:是否显示;快捷键把它设为 true,执行、Escape 或点击遮罩设为 false。调用方也可直接设置。 + +`Command` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ID` | `string` | 必填 | `Chosen` 报告的标识;为空或重复时 panic。 | +| `Label` | `string` | 必填 | 显示名称;为空时 panic。 | +| `Group` | `string` | "" | 分组标题;分组按首次出现的顺序排列,组内保持原顺序。 | +| `Keywords` | `[]string` | nil | 额外的搜索词。 | +| `Icon` | `*ui.SVG` | nil | 名称前图标。 | + +`CommandPaletteOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OpenMods` | `ui.Modifiers` | 0 | 打开快捷键的修饰键。 | +| `OpenKey` | `ui.Key` | 0(即 `ui.Cmd`+K) | 打开快捷键;为 0 时忽略 `OpenMods`。 | + +返回 `CommandPaletteResult`;关闭时其 `Element` 为 nil。 + +## 状态 + +- 打开:遮罩变暗,面板在窗口上部,焦点在搜索框;每次打开都清空搜索。 +- 搜索:不区分大小写,按空格分词,每个词须出现在名称、分组或关键词中。 +- 高亮项:Selection 背景、左侧 AccentText 边线、600 字重;键盘移动时滚入视野。 +- 无结果:显示“没有匹配的命令”,Enter 不执行任何命令。 +- 底部提示栏说明 ↑↓、Enter、Esc。 + +## 事件 + +- `Chosen() (string, bool)`:本帧执行的命令 ID(Enter 或点击),同时关闭面板。 + +## 键盘操作 + +- Cmd+K / Ctrl+K:打开(窗口级快捷键)。 +- 输入:过滤。↑/↓:移动高亮并循环。Enter:执行并关闭。Escape:关闭不执行,焦点回到打开前的元素。 +- Tab 限制在面板内(MyGo 模态对话框行为)。 + +## 限制 + +- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。 +- 搜索是子串匹配,不做模糊匹配或排序打分。 diff --git a/docs/components/081-dialog.md b/docs/components/081-dialog.md new file mode 100644 index 0000000..679d06d --- /dev/null +++ b/docs/components/081-dialog.md @@ -0,0 +1,75 @@ +# 081 Dialog 对话框 + +## 用途 + +在窗口上方显示模态对话框:标题、说明、内容与操作区。打开时限制焦点并屏蔽背景,关闭后把焦点还给打开它的控件。视觉回归基准组件之一。 + +## 最小示例 + +```go +open := true +name := "Ophelia" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Dialog(c, &open, mujica.DialogOptions{ + Title: "Rename the chamber", + Description: "The new name shows in every hall.", + Decorated: true, + Actions: func() { + if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + open = false + } + if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() { + open = false + } + }, + }, func() { + ui.TextInput(c, &name) + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element`,关闭时返回 nil。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 衬线标题,也是对话框的无障碍名称;为空时 panic。 | +| `Description` | `string` | `""` | 标题下的说明文字,同时作为无障碍描述。 | +| `Width` | `float32` | `440` | 面板宽度(DIP),始终不超过窗口宽度减 32。 | +| `Decorated` | `bool` | `false` | 开启双线边框、四角菱形角饰和标题下的装饰线。 | +| `Actions` | `func()` | `nil` | 页脚操作区,靠右排列,可换行。 | +| `KeepOnBackdrop` | `bool` | `false` | 点击遮罩不关闭。 | +| `KeepOnEscape` | `bool` | `false` | Escape 不关闭。 | +| `HideClose` | `bool` | `false` | 不显示右上角关闭按钮。 | + +`open` 为 false 时不构建任何元素。`content` 可为 nil;内容超出窗口高度时在内容区滚动。 + +## 状态 + +- 打开:遮罩渐入,面板 160 ms 淡入并上移 8 DIP;减少动画时直接显示。 +- 关闭按钮:悬停、按下、键盘焦点环(2 DIP,留 2 DIP 间隔)。 +- 装饰:只出现在边缘与标题区,不占焦点,不影响点击区域。 + +## 事件 + +- 关闭按钮、遮罩点击、Escape 把 `*open` 设为 false(各自可关闭)。 +- 业务事件由 `Actions` 和 `content` 中的控件自己报告。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Tab / Shift+Tab | 在对话框内循环,不会离开。 | +| Escape | 先交给输入法与正在编辑的控件,再关闭最上层浮层;嵌套 Popover 或菜单先关。 | +| Enter / Space | 触发聚焦的按钮。 | + +## 限制 + +- 关闭后焦点回到打开时的控件;若该控件已不存在,MyGo 不回退到父级(`ui/scope.go:137` restoreFocus 只恢复原控件),焦点为空,再按 Tab 从窗口开头开始。 +- 没有关闭过渡:MyGo 在 `*open` 变为 false 的下一帧即移除浮层元素,`Transition` 的 Exit 需要父元素保留,而浮层根只在有浮层时存在。 diff --git a/docs/components/082-alert-dialog.md b/docs/components/082-alert-dialog.md new file mode 100644 index 0000000..40c2a18 --- /dev/null +++ b/docs/components/082-alert-dialog.md @@ -0,0 +1,65 @@ +# 082 AlertDialog 警示对话框 + +## 用途 + +确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未确认目标前 Enter 不会执行。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + open = true + } + r := mujica.AlertDialog(c, &open, mujica.AlertDialogOptions{ + Title: "Delete the archive?", + Description: "Twelve letters will be destroyed.", + ConfirmText: "archive", + }) + if r.Confirmed { + fmt.Println("deleted") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult`。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 标题与无障碍名称;为空时 panic。 | +| `Description` | `string` | `""` | 后果说明,同时作为无障碍描述。 | +| `ConfirmLabel` | `string` | 内置“删除 / Delete” | 危险按钮文字。 | +| `CancelLabel` | `string` | 内置“取消 / Cancel” | 取消按钮文字。 | +| `ConfirmText` | `string` | `""` | 非空时显示输入框,输入内容与之完全相同后确认按钮才可用。 | + +`AlertDialogResult{Confirmed, Cancelled bool}` 只在发生选择的那一帧为 true,之后对话框关闭。 + +## 状态 + +- 确认按钮使用 Danger 变体;`ConfirmText` 未匹配时禁用(MyGo 统一变暗)。 +- 输入框有边框与焦点环;内容保留在对话框打开期间,关闭后清空。 + +## 事件 + +- `Confirmed`:点击确认,或在输入框内按 Enter 且目标已匹配。 +- `Cancelled`:点击取消、在取消上按 Enter / Space、或按 Escape。 +- 点击遮罩不产生事件。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| 打开时 | 焦点在“取消”。 | +| Enter | 触发聚焦的按钮;在输入框中只有目标匹配时确认。 | +| Escape | 取消并关闭。 | +| Tab | 在对话框内循环。 | + +## 限制 + +- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(MyGo 只在键盘移动焦点后设置可见焦点,`ui/input.go:868`);按 Tab 后显示。 +- 不替代系统原生警示框;需要系统样式时用 MyGo 的桌面对话框。 diff --git a/docs/components/083-drawer.md b/docs/components/083-drawer.md new file mode 100644 index 0000000..dce31ee --- /dev/null +++ b/docs/components/083-drawer.md @@ -0,0 +1,55 @@ +# 083 Drawer 抽屉 + +## 用途 + +从窗口四边之一滑出的面板,用于筛选、详情和设置。可选模态或非模态。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Filters", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Drawer(c, &open, mujica.DrawerOptions{Title: "Filters", Side: mujica.DrawerRight, Size: 320}, func() { + ui.Text(c, "Guests are placed by rank.") + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element`。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 标题与无障碍名称;为空时 panic。 | +| `Side` | `DrawerSide` | `DrawerRight` | `DrawerRight`、`DrawerLeft`、`DrawerTop`、`DrawerBottom`;越界时 panic。 | +| `Size` | `float32` | 左右 360,上下 280 | 宽度或高度;限制在 160 DIP 与窗口的 90% 之间;负数 panic。 | +| `NonModal` | `bool` | `false` | 非模态:不加遮罩、不限制焦点,窗口其余部分仍可操作。 | +| `Actions` | `func()` | `nil` | 页脚操作区。 | + +## 状态 + +- 打开时从所在边滑入并淡入 160 ms;减少动画时直接出现。 +- 面板贴边,只在朝内一侧画 1 DIP 分隔线。 +- 内容超出时在内容区滚动。 + +## 事件 + +关闭按钮与 Escape 把 `*open` 设为 false;模态抽屉点击遮罩同样关闭。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Tab / Shift+Tab | 模态:在抽屉内循环。非模态:按窗口焦点顺序,抽屉内容排在窗口内容之后。 | +| Escape | 关闭(先交给正在编辑的控件和更上层浮层)。 | + +## 限制 + +- 非模态抽屉不在打开时移动焦点,关闭时也不自动恢复焦点:MyGo 记录与恢复打开者的 `openOverlay` 不公开(`ui/scope.go:123`),也没有读取当前焦点的公开接口。 +- 非模态抽屉的 Escape 是窗口级快捷键,优先级最低;同时打开多个非模态浮层时由先构建的那个响应。 diff --git a/docs/components/084-popover.md b/docs/components/084-popover.md new file mode 100644 index 0000000..a1cf618 --- /dev/null +++ b/docs/components/084-popover.md @@ -0,0 +1,54 @@ +# 084 Popover 弹出框 + +## 用途 + +在锚点下方显示可交互的面板;空间不足时翻到上方,并保持在窗口内。外部点击与 Escape 关闭。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + open = !open + } + mujica.Popover(c, b, &open, mujica.PopoverOptions{Title: "Guest list"}, func() { + ui.Text(c, "Twelve guests confirmed.") + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element`。`anchor` 必须是本帧已构建的元素,nil 时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | `""` | 面板顶部的衬线标题,同时命名面板。 | +| `Width` | `float32` | 0 | 固定宽度;0 时随内容,至少与锚点同宽,最多 360。 | +| `Label` | `string` | `""` | 无标题时的无障碍名称。 | + +## 状态 + +- 6 DIP 圆角、Surface 底色、1 DIP 边框、160 ms 淡入。 +- 面板最高为锚点上下较大一侧的空间,超出时滚动。 +- 锚点首帧尚未布局时面板保持透明,避免闪到窗口左上角。 + +## 事件 + +外部点击与 Escape 把 `*open` 设为 false;内容内控件自行报告事件。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Tab | 从锚点进入弹层,弹层元素紧跟锚点排序。 | +| Escape | 关闭并把焦点还给锚点。 | + +## 限制 + +- 只支持下方与上方两个位置:位置由 MyGo `PopoverBase` 决定(`ui/widgets.go:689` keepInWindow)。 +- 打开时外部点击只用于关闭,不会传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 diff --git a/docs/components/085-tooltip.md b/docs/components/085-tooltip.md new file mode 100644 index 0000000..c3137b2 --- /dev/null +++ b/docs/components/085-tooltip.md @@ -0,0 +1,45 @@ +# 085 Tooltip 文字提示 + +## 用途 + +指针停留或键盘聚焦一段时间后,在元素旁显示简短说明。提示不含交互内容,不拦截指针,并作为元素的无障碍描述。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + mujica.Tooltip(c, b, "Seal the letter with wax", mujica.TooltipOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element`,返回 `anchor`。`anchor` 为 nil 时 panic;`text` 为空时什么也不做。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Delay` | `time.Duration` | 500 ms | 悬停或键盘聚焦持续多久后显示;负数 panic。 | + +## 状态 + +- 炭黑底(深色主题为象牙底)、2 DIP 圆角、12+1 DIP 文字,最宽 240 DIP 后换行。 +- 默认在元素下方居中,下方放不下时翻到上方,左右限制在窗口内。 + +## 事件 + +无业务事件。`text` 同时写入 `anchor` 的无障碍描述。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Tab 聚焦 | 键盘焦点停留 `Delay` 后显示。 | +| Escape | 隐藏,直到指针离开或焦点移走。 | + +## 限制 + +- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发,避免点击后弹出提示。 +- 不使用 MyGo 的 `Element.Tooltip`:它只跟随指针、不响应焦点(`ui/widgets.go:549`)。 diff --git a/docs/components/086-hover-card.md b/docs/components/086-hover-card.md new file mode 100644 index 0000000..18c1dc5 --- /dev/null +++ b/docs/components/086-hover-card.md @@ -0,0 +1,52 @@ +# 086 HoverCard 悬停卡片 + +## 用途 + +悬停或聚焦锚点时预览更多信息,例如人物资料。指针可以移入卡片并操作其中的控件,键盘可以 Tab 进入卡片,离开后延迟关闭。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost}) + mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() { + ui.Text(c, "Keyboardist of the court.") + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element`,返回卡片,未显示时为 nil。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OpenDelay` | `time.Duration` | 500 ms | 悬停或聚焦多久后打开;负数 panic。 | +| `CloseDelay` | `time.Duration` | 300 ms | 指针与焦点都离开后多久关闭;负数 panic。 | +| `Width` | `float32` | 280 | 卡片宽度。 | +| `Label` | `string` | `""` | 卡片的无障碍名称。 | + +## 状态 + +- 打开、关闭都按延迟进行;减少动画时取消淡入。 +- 指针在锚点或卡片上、或键盘焦点在锚点或卡片内时保持打开。 +- 下方空间不足时翻到上方,左右限制在窗口内。 + +## 事件 + +返回值由 nil 变为非 nil 即打开;卡片内控件自行报告事件。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Tab 聚焦锚点 | `OpenDelay` 后打开。 | +| Tab | 从锚点进入卡片内的控件,再继续到后面的元素。 | +| Escape | 立即关闭,焦点回到锚点,直到焦点离开前不再打开。 | + +## 限制 + +- 键盘打开时卡片由 `PopoverBase` 承载,以便焦点顺序紧跟锚点;此时卡片外的点击只用于关闭(`ui/base.go:373`)。指针打开时卡片不拦截窗口其余部分。 +- 指针打开时卡片元素在窗口焦点顺序的末尾。 diff --git a/docs/components/087-dropdown-menu.md b/docs/components/087-dropdown-menu.md new file mode 100644 index 0000000..605c7a7 --- /dev/null +++ b/docs/components/087-dropdown-menu.md @@ -0,0 +1,75 @@ +# 087 DropdownMenu 下拉菜单 + +## 用途 + +按钮触发的应用内菜单,由 MujicaUI 自绘(不是系统菜单)。支持分组、子菜单、勾选项与禁用项;选择后按规则关闭。 + +## 最小示例 + +```go +pinned := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{}, func(m *mujica.PopupMenu) { + m.Group("Letter", func() { + if m.Item("Rename", mujica.PopupMenuItemOptions{}) { + fmt.Println("rename") + } + m.Check("Pinned", &pinned, mujica.PopupMenuItemOptions{KeepOpen: true}) + }) + m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) { + m.Item("Archive", mujica.PopupMenuItemOptions{}) + }) + m.Separator() + m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: true}) + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element`,返回触发按钮。`build` 为 nil、或 `label` 与 `opts.Label` 都为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Icon` | `*ui.SVG` | `nil` | 标签前的图标。 | +| `Disabled` | `bool` | `false` | 禁用触发按钮并关闭已打开的菜单。 | +| `Label` | `string` | `""` | 仅图标按钮的无障碍名称。 | + +`PopupMenu` 方法: + +| 方法 | 说明 | +| --- | --- | +| `Item(label, opts) bool` | 普通项;本帧被选择时返回 true。 | +| `Check(label, *bool, opts) bool` | 勾选项,切换 `*bool`;本帧变化时返回 true。 | +| `Submenu(label, opts, build)` | 子菜单项;`build` 为 nil 时 panic。 | +| `Group(label, fn)` | 带说明文字的分组,前面有内容时自动加分隔线。 | +| `Separator()` | 分隔线。 | + +`PopupMenuItemOptions`:`Disabled bool`(不可选、不进入方向键序列),`KeepOpen bool`(选择后不关闭菜单)。 + +## 状态 + +- 触发按钮:次按钮外观加下拉箭头,打开时保持按下底色。 +- 菜单项:悬停或键盘焦点时显示 Selection 底色和左侧 2 DIP 强调条;勾选项显示勾号;禁用项变暗。 +- 菜单面板最高为可用空间,超出时滚动;子菜单在右侧打开,右侧放不下时翻到左侧,下方放不下时上移。 + +## 事件 + +`Item` / `Check` 的返回值;每次选择只报告一次。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Enter / Space / ↓ | 在触发按钮上打开菜单,焦点在第一项。 | +| ↑ / ↓ / Home / End | 在当前层移动,跳过禁用项,首尾循环。 | +| → / Enter | 在子菜单项上打开子菜单并进入。 | +| ← / Escape | 在子菜单中关闭子菜单,回到父项。 | +| Escape / Tab | 关闭菜单,焦点回到触发按钮。 | + +## 限制 + +- MyGo 没有 menu / menuitem 无障碍角色(`ui/access.go:22` Role 列表),菜单面板报告为 popup,普通项为 button,勾选项为 checkbox,触发按钮为 menu button。 +- 菜单打开期间外部点击只用于关闭(`ui/base.go:373`)。 diff --git a/docs/components/088-context-menu.md b/docs/components/088-context-menu.md new file mode 100644 index 0000000..b37874d --- /dev/null +++ b/docs/components/088-context-menu.md @@ -0,0 +1,55 @@ +# 088 ContextMenu 右键菜单 + +## 用途 + +为一块区域提供应用内右键菜单,由 MujicaUI 自绘。右键在指针处打开,键盘在焦点区域下方打开,并避开窗口边缘。菜单项与 DropdownMenu 相同(`PopupMenu`)。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + row := ui.Row(c).Padding(12).Children(func() { + ui.Text(c, "Letter to the Countess") + }) + mujica.ContextMenu(c, row, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Reply", mujica.PopupMenuItemOptions{}) { + fmt.Println("reply") + } + m.Item("Delete", mujica.PopupMenuItemOptions{}) + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element`,返回 `target`。`target` 或 `build` 为 nil 时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | `false` | 不响应右键与菜单键,并关闭已打开的菜单。 | + +`PopupMenu` 的方法与选项见 [087 DropdownMenu](087-dropdown-menu.md)。 + +## 状态 + +与 DropdownMenu 菜单面板相同。右下角附近打开时,菜单向左、向上翻转,保持在窗口内。 + +## 事件 + +`Item` / `Check` 的返回值;每次选择只报告一次。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Menu 键 / Shift+F10 | 焦点在 `target` 内时,在 `target` 左下方打开,焦点在第一项。 | +| 菜单内按键 | 同 DropdownMenu。 | +| Escape | 关闭,焦点回到打开前的控件。 | + +## 限制 + +- `target` 必须是容器:菜单会向它追加一个不可见的定位子元素,文字元素不能有非文字子元素(MyGo 会 panic)。 +- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(MyGo 每个元素只保存一个处理函数,`ui/handler.go:73`)。 +- 不使用 MyGo 的 `Element.ContextMenu`:那是系统菜单,PRD 不允许把它当作哥特样式菜单。 diff --git a/docs/components/089-popconfirm.md b/docs/components/089-popconfirm.md new file mode 100644 index 0000000..7998d35 --- /dev/null +++ b/docs/components/089-popconfirm.md @@ -0,0 +1,59 @@ +# 089 Popconfirm 气泡确认 + +## 用途 + +在触发控件旁做轻量确认(归档、移出列表等可撤销操作)。说明与面板关联,确认与取消都可聚焦。高风险操作请用 AlertDialog。 + +## 最小示例 + +```go +open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + open = true + } + if mujica.Popconfirm(c, b, &open, mujica.PopconfirmOptions{Title: "Archive this letter?"}).Confirmed { + fmt.Println("archived") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult`。`anchor` 为 nil 或 `Title` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 问题,也是面板的无障碍名称。 | +| `Description` | `string` | `""` | 补充说明,作为面板的无障碍描述。 | +| `ConfirmLabel` | `string` | 内置“确定 / Confirm” | 确认按钮文字。 | +| `CancelLabel` | `string` | 内置“取消 / Cancel” | 取消按钮文字。 | +| `Danger` | `bool` | `false` | 确认按钮使用危险变体,图标改为 Danger 色。 | + +`PopconfirmResult{Confirmed, Cancelled bool}` 只在发生选择的那一帧为 true。 + +## 状态 + +与 Popover 相同的面板;下方放不下时翻到上方。 + +## 事件 + +- `Confirmed`:点击确认或在其上按 Enter / Space。 +- `Cancelled`:点击取消、Escape、外部点击。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| 打开时 | 焦点在确认按钮。 | +| Enter | 触发聚焦的按钮。 | +| Tab | 在取消与确认间移动,焦点顺序紧跟锚点。 | +| Escape | 取消并把焦点还给锚点。 | + +## 限制 + +- 非模态:不屏蔽窗口,但外部点击只用于取消,不传给下方控件(`ui/base.go:373`)。 +- 位置只有下方与上方(`ui/widgets.go:689`)。 diff --git a/docs/components/090-overlay.md b/docs/components/090-overlay.md new file mode 100644 index 0000000..053a732 --- /dev/null +++ b/docs/components/090-overlay.md @@ -0,0 +1,51 @@ +# 090 Overlay 覆盖层 + +## 用途 + +在窗口内显示一层自定义内容:九个位置、可选遮罩、模态或非模态。模态时复用 MyGo `DialogBase` 的焦点限制与背景屏蔽,可与 MyGo 自带的 Select、Popover、菜单嵌套使用。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Overlay(c, &open, mujica.OverlayOptions{Place: mujica.OverlayBottomRight, Label: "Now playing"}, func() { + ui.Text(c, "Symbol I: △") + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element`。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Place` | `OverlayPlace` | `OverlayCenter` | `OverlayCenter`、`Top`、`Bottom`、`Left`、`Right`、`TopLeft`、`TopRight`、`BottomLeft`、`BottomRight`;越界 panic。 | +| `Inset` | `float32` | 16 | 面板与窗口边缘的距离。 | +| `Modal` | `bool` | `false` | 限制焦点、屏蔽背景,点击背景关闭。 | +| `Dim` | `bool` | `false` | 模态时加暗色遮罩;未开 `Modal` 时 panic。 | +| `Label` | `string` | `""` | 覆盖层的无障碍名称。 | + +## 状态 + +弹层外观(Surface 底、1 DIP 边框、6 DIP 圆角)与 160 ms 淡入;减少动画时直接显示。 + +## 事件 + +Escape 与(模态时)背景点击把 `*open` 设为 false。 + +## 键盘操作 + +| 按键 | 行为 | +| --- | --- | +| Escape | 关闭;先交给编辑中的控件和更上层浮层。 | +| Tab | 模态时在覆盖层内循环;非模态时覆盖层元素排在窗口焦点顺序末尾。 | + +## 限制 + +- 非模态覆盖层的 Escape 是窗口级快捷键(MyGo 浮层专用快捷键 `overlayShortcut` 不公开,`ui/scope.go:109`),同时打开多个非模态浮层时由先构建的那个响应。 +- 非模态覆盖层关闭时不自动恢复焦点(`ui/scope.go:123` openOverlay 不公开)。 +- 非模态面板首帧尚未测量尺寸,该帧透明,下一帧定位后淡入。 diff --git a/docs/components/091-alert.md b/docs/components/091-alert.md new file mode 100644 index 0000000..fef8f3d --- /dev/null +++ b/docs/components/091-alert.md @@ -0,0 +1,51 @@ +# 091 Alert 提示条 + +## 用途 + +在页面内显示一条信息、成功、警告或错误提示,可带标题、一个操作按钮和关闭按钮。严重程度同时由左侧色条、图标和无障碍名称(如“Error”)表达,不只靠颜色。 + +## 最小示例 + +```go +open := true +r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{ + Severity: mujica.SeverityError, + Title: "Import failed", + Action: "Retry", +}) +if r.Action { + // retry the import +} +``` + +签名:`func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | `nil` | 非 nil 时显示关闭按钮,关闭后置为 `false`;为 `false` 时不构建任何元素。`nil` 表示不可关闭。 | +| `message` | `string` | 必填 | 正文;为空时 panic。 | +| `Severity` | `Severity` | `SeverityInfo` | `SeverityInfo`、`SeveritySuccess`、`SeverityWarning`、`SeverityError`。 | +| `Title` | `string` | `""` | 正文上方的粗体标题。 | +| `Action` | `string` | `""` | 操作按钮文字;为空不显示。 | + +返回 `AlertResult{Element, Action, Closed}`;关闭状态下 `Element` 为 nil。 + +## 状态 + +四种严重程度;操作与关闭按钮有悬停、按下和 2 DIP 焦点环。警告与错误在出现或文字变化时通过 `Context.Announce` 播报一次。 + +## 事件 + +- `Action`:操作按钮被点击或经键盘触发。 +- `Closed`:用户关闭;同时 `*open` 变为 `false`。 + +## 键盘操作 + +Tab 依次聚焦操作按钮与关闭按钮;Enter 或 Space 触发。 + +## 限制 + +- 信息与成功提示不主动播报,避免页面加载时打扰读屏用户。 +- 关闭不带退出动画;需要动画时由调用方在外层加 `Transition`。 diff --git a/docs/components/092-toast.md b/docs/components/092-toast.md new file mode 100644 index 0000000..63a6f59 --- /dev/null +++ b/docs/components/092-toast.md @@ -0,0 +1,58 @@ +# 092 Toast 轻提示 + +## 用途 + +在窗口右下角短暂显示操作结果,可带一个操作(如撤销)与关闭按钮。每个窗口一条队列:按 ID 去重,同时最多显示 3 条,其余排队。 + +## 最小示例 + +```go +if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"}) +} +// Last in the view: +if r := mujica.Toaster(c); r.Action == "note-1" { + // restore the note +} +``` + +接口: + +- `func ShowToast(c *ui.Context, m ToastMessage)`:加入队列,须在界面线程调用(后台任务经 `Window.Update` 回到界面线程)。 +- `func DismissToast(c *ui.Context, id string)`:关闭显示中或排队中的消息。 +- `func Toaster(c *ui.Context) ToasterResult`:每帧在视图末尾调用一次,绘制消息并报告事件。 + +## 参数 + +`ToastMessage`: + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ID` | `string` | `Message` | 去重键。相同 ID 再次显示时替换内容并重新计时,不新增一条。 | +| `Severity` | `Severity` | `SeverityInfo` | 图标、色条与颜色。 | +| `Message` | `string` | 必填 | 文字;为空时 panic。 | +| `Action` | `string` | `""` | 操作按钮文字。 | +| `Duration` | `time.Duration` | `ToastDuration`(5 秒) | 显示时长;负数 panic。错误消息在为 0 时一直保留到用户关闭。 | + +常量:`ToastDuration = 5 * time.Second`,`ToastVisible = 3`。 + +## 状态 + +- 排队消息在显示时才开始计时。 +- 指针悬停或焦点位于消息内时暂停计时,离开后从剩余时间继续。 +- 出现时向读屏播报文字;进出场 160 ms 位移与淡入,减少动画时直接切换。 + +## 事件 + +`ToasterResult{Action, Closed}`:本帧被点击操作或被关闭的消息 ID;两者都会移除该消息。到时自动消失不报告。 + +## 键盘操作 + +消息从不抢夺焦点,正在输入的文本框保持焦点。Tab 在页面之后到达消息中的按钮;焦点在消息内时 Escape 关闭该消息。 + +## 复用与限制 + +MyGo 自带 `c.Toast` / `c.ToastAction`(docs/ui/toast.md),但它按文字去重、没有 ID、不限制条数、无关闭按钮、错误不能常驻、外观不可替换,计时只在悬停时暂停而不在聚焦时暂停。这些都是 PRD §5.5 的要求,因此 MujicaUI 自建队列,只复用 MyGo 的 `Overlay`、`Transition`、`Announce` 与 `After` 定时唤醒。 + +- 所有消息(不只是含操作的消息)都在悬停或聚焦时暂停,这是 PRD 要求的超集,便于阅读。 +- 位置固定在右下角;切换到未调用 `Toaster` 的视图时消息仍在队列中,回来后继续显示。 diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md new file mode 100644 index 0000000..8609972 --- /dev/null +++ b/docs/components/093-progress.md @@ -0,0 +1,64 @@ +# 093 Progress 进度 + +## 用途 + +显示任务进度(确定或不确定,线形或环形),以及计量值(如磁盘占用)及其阈值。归并 MyGo 的 Progress bar 与 Meter:`Progress` 为进度模式,`Meter` 为计量模式。两者分别基于 `ui.Progress` 与 `ui.Meter` 元素重绘,保留其无障碍角色与数值。 + +## 最小示例 + +```go +mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true}) +mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"}) +mujica.Meter(c, 93, mujica.MeterOptions{ + Max: 100, Label: "Disk", ShowValue: true, + Levels: &ui.MeterLevels{Warning: 70, Critical: 90}, +}) +``` + +签名: + +- `func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element` +- `func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element` + +## 参数 + +`ProgressOptions`: + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `float64` | 必填 | 0 到 1;超出或 NaN 时 panic(不确定模式忽略)。 | +| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`。 | +| `Indeterminate` | `bool` | `false` | 未知长度的进度。 | +| `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 | +| `ShowValue` | `bool` | `false` | 显示百分比。 | +| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数 panic。 | + +`MeterOptions`: + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `float64` | 必填 | 须在范围内,否则 panic。 | +| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;`Max <= Min` panic。 | +| `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 | +| `Label` | `string` | `""` | 名称。 | +| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 | +| `Width` | `float32` | 填满 | 宽度。 | + +## 状态 + +- 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。 +- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示静态填充和“In progress”文字。 +- 计量条按级别使用 Success / Warning / Danger 颜色,同时显示“Normal / High / Critical / Low”文字与阈值刻度,并把级别写入无障碍名称。 + +## 事件 + +无。 + +## 键盘操作 + +不可聚焦;读屏读取名称与数值。 + +## 限制 + +- 环形直径小于 44 DIP 时百分比显示在旁边,而不是环内。 +- MyGo 不提供自定义无障碍数值的公开接口,因此必须基于 `ui.Progress` / `ui.Meter` 元素绘制。 diff --git a/docs/components/094-spinner.md b/docs/components/094-spinner.md new file mode 100644 index 0000000..06d612f --- /dev/null +++ b/docs/components/094-spinner.md @@ -0,0 +1,39 @@ +# 094 Spinner 加载标记 + +## 用途 + +表示未知长度的加载:四个菱形绕中心旋转。可调尺寸,可显示说明文字。Button 的加载状态也使用它。标记基于 MyGo `ui.Spinner` 元素重绘,读屏视为不确定进度。 + +## 最小示例 + +```go +mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true}) +``` + +签名:`func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Size` | `float32` | 16 | 标记边长(DIP)。 | +| `Label` | `string` | 内置“Loading / 加载中” | 无障碍名称;`ShowLabel` 时也显示在旁边。 | +| `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 | +| `Color` | `ui.Color` | `AccentText` | 标记颜色。 | + +## 状态 + +- 只在被绘制时请求重绘:不再构建(隐藏或停止加载)或滚出视口后动画立即停止,不留定时器。 +- 减少动画模式下静止显示,保留名称与说明。 + +## 事件 + +无。 + +## 键盘操作 + +不可聚焦。 + +## 限制 + +- 每帧重绘,而非 MyGo 自带 spinner 的分步重绘;只在显示时产生开销。 diff --git a/docs/components/095-skeleton.md b/docs/components/095-skeleton.md new file mode 100644 index 0000000..4e7e7e1 --- /dev/null +++ b/docs/components/095-skeleton.md @@ -0,0 +1,45 @@ +# 095 Skeleton 骨架屏 + +## 用途 + +内容加载前占位,形状为文字行、圆形或矩形。尺寸固定,加载完成后替换为真实内容时不跳动。 + +## 最小示例 + +```go +ui.Row(c).Gap(12).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle}) + ui.Column(c).Grow(1).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2}) + }) +}) +``` + +签名:`func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Shape` | `SkeletonShape` | `SkeletonText` | `SkeletonText`、`SkeletonCircle`、`SkeletonRect`。 | +| `Width` | `float32` | 填满父级;圆形 40 | 宽度;圆形为直径。 | +| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 | +| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 | + +负值 panic。 + +## 状态 + +明暗缓慢脉动(1.6 秒周期),只重绘不重建视图;减少动画模式使用静态填充。 + +## 事件 + +无。 + +## 键盘操作 + +不可聚焦,不进入无障碍树。 + +## 限制 + +- 骨架本身不向读屏报告加载;请在容器上配合 Spinner 或 LoadingOverlay 的说明文字。 diff --git a/docs/components/096-empty-state.md b/docs/components/096-empty-state.md new file mode 100644 index 0000000..4c4d822 --- /dev/null +++ b/docs/components/096-empty-state.md @@ -0,0 +1,48 @@ +# 096 EmptyState 空状态 + +## 用途 + +说明视图为何为空并给出下一步。区分“无数据”(集合本身为空)与“无搜索结果”(筛选没有匹配),两者使用不同的默认图标与文案。 + +## 最小示例 + +```go +r := mujica.EmptyState(c, mujica.EmptyStateOptions{ + Kind: mujica.EmptyNoResults, + Query: query, + Action: "Clear search", +}) +if r.Action { + query = "" +} +``` + +签名:`func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Kind` | `EmptyKind` | `EmptyNoData` | `EmptyNoData` 或 `EmptyNoResults`。 | +| `Title` | `string` | 按类型的内置文案 | 衬线标题。 | +| `Description` | `string` | 按类型的内置文案 | 说明。 | +| `Query` | `string` | `""` | 仅 `EmptyNoResults`:默认说明写出该关键词。用于 `EmptyNoData` 时 panic。 | +| `Icon` | `*ui.SVG` | inbox / search | 替换图标。 | +| `NoIcon` | `bool` | `false` | 不显示图标;与 `Icon` 同时设置时 panic。 | +| `Action` | `string` | `""` | 次按钮文字。 | + +## 状态 + +静态展示;操作按钮有悬停、按下与焦点状态。 + +## 事件 + +`EmptyStateResult.Action`:操作按钮被触发。 + +## 键盘操作 + +Tab 聚焦操作按钮;Enter 或 Space 触发。 + +## 限制 + +- 只有一个操作;需要多个下一步时使用 Result。 diff --git a/docs/components/097-result.md b/docs/components/097-result.md new file mode 100644 index 0000000..30d0285 --- /dev/null +++ b/docs/components/097-result.md @@ -0,0 +1,46 @@ +# 097 Result 结果页 + +## 用途 + +任务完成后的整页结果:成功、失败或无权限,附说明与下一步操作。组件只报告选择了哪个操作,不执行跳转。标题与操作之间用旧金色细线与菱形分隔。 + +## 最小示例 + +```go +r := mujica.Result(c, mujica.ResultOptions{ + Kind: mujica.ResultForbidden, + Description: "You need the Editor role to change billing.", + Actions: []string{"Request access", "Go back"}, +}) +switch r.Action { +case 0: // send the request +case 1: // navigate back +} +``` + +签名:`func Result(c *ui.Context, opts ResultOptions) ResultResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Kind` | `ResultKind` | `ResultSuccess` | `ResultSuccess`、`ResultFailure`、`ResultForbidden`;未知值 panic。 | +| `Title` | `string` | 按类型的内置文案 | 衬线标题。 | +| `Description` | `string` | `""` | 说明。 | +| `Actions` | `[]string` | 无 | 下一步操作;第一个为主按钮,其余为次按钮。 | + +## 状态 + +图标(对勾、叉、盾牌)与标题文字共同表达结果,不只靠颜色。按钮有悬停、按下与焦点状态。 + +## 事件 + +`ResultResult.Action`:被触发的操作序号,未触发为 -1。 + +## 键盘操作 + +Tab 按顺序聚焦操作按钮;Enter 或 Space 触发。 + +## 限制 + +- 不含路由;导航由调用方完成。 diff --git a/docs/components/098-status-indicator.md b/docs/components/098-status-indicator.md new file mode 100644 index 0000000..63d659d --- /dev/null +++ b/docs/components/098-status-indicator.md @@ -0,0 +1,37 @@ +# 098 StatusIndicator 状态标记 + +## 用途 + +以图形加文字标出状态:成功、警告、错误、离线、处理中。每种状态有独立形状(对勾圆、三角、叉圆、断开的 Wi-Fi、旋转菱形),并总是显示文字,不只靠颜色。 + +## 最小示例 + +```go +mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"}) +mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{}) +``` + +签名:`func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `status` | `Status` | `StatusSuccess` | `StatusSuccess`、`StatusWarning`、`StatusError`、`StatusOffline`、`StatusBusy`;未知值 panic。 | +| `Label` | `string` | 内置词(Healthy、Degraded、Failing、Offline、Working) | 替换显示的文字。 | + +## 状态 + +处理中使用 Spinner,减少动画时静止。整体为 `RoleStatus`,名称为文字。 + +## 事件 + +无。 + +## 键盘操作 + +不可聚焦。 + +## 限制 + +- 无 Badge 式的纯圆点模式;PRD 要求不能只靠颜色。 diff --git a/docs/components/099-notification-center.md b/docs/components/099-notification-center.md new file mode 100644 index 0000000..95b16fa --- /dev/null +++ b/docs/components/099-notification-center.md @@ -0,0 +1,51 @@ +# 099 NotificationCenter 通知中心 + +## 用途 + +应用内消息列表:未读标记、已读/未读切换、单条清除、全部已读、全部清除和每条消息的操作。消息列表与持久化由调用方持有;组件就地修改调用方的切片,并报告发生了什么,供调用方保存。 + +## 最小示例 + +```go +r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{}) +if r.Action == "a" { + // open the build log +} +``` + +签名:`func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult` + +## 参数 + +`Notification`: + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ID` | `string` | 必填 | 列表内唯一;为空或重复时 panic。 | +| `Title` | `string` | `""` | 标题,未读时加粗。 | +| `Body` | `string` | `""` | 正文。 | +| `Time` | `string` | `""` | 按原样显示的时间,如“5 min ago”。 | +| `Read` | `bool` | `false` | 是否已读。 | +| `Action` | `string` | `""` | 操作按钮文字。 | + +`NotificationCenterOptions.Title`:面板标题,默认“Notifications / 通知”。`items` 为 nil 时 panic。 + +## 状态 + +- 未读:左侧酒红菱形、粗体标题、无障碍名称前缀“Unread”。 +- 行悬停加浅底;按钮有悬停、按下与焦点状态;无未读或无消息时头部按钮禁用。 +- 空列表显示 EmptyState。 +- 面板标题下为旧金细线。列表超出容器高度时滚动。 + +## 事件 + +`NotificationCenterResult`:`Action`、`Toggled`(已读状态切换)、`Opened`(点击正文后标为已读)、`Removed` 为对应 ID;`MarkedAllRead`、`ClearedAll` 为布尔值。 + +## 键盘操作 + +列表是一个 Tab 停靠点;方向键在各条消息及其按钮之间移动,Home/End 到首尾。焦点在消息正文上时 Enter 或 Space 标为已读;Delete 或 Backspace 清除当前消息。 + +## 限制 + +- 不分页,也不虚拟化;面向几十到几百条的应用内消息。 +- 时间文字由调用方格式化。 diff --git a/docs/components/100-loading-overlay.md b/docs/components/100-loading-overlay.md new file mode 100644 index 0000000..e94b819 --- /dev/null +++ b/docs/components/100-loading-overlay.md @@ -0,0 +1,48 @@ +# 100 LoadingOverlay 加载遮罩 + +## 用途 + +在一个容器范围内显示加载遮罩:被覆盖的内容不接收指针、按键和焦点,防止重复操作;窗口其余部分照常可用。显示说明文字与可选取消操作。 + +## 最小示例 + +```go +r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() { + ui.Text(c, "Quarterly report") + mujica.Button(c, "Export", mujica.ButtonOptions{}) +}) +if r.Canceled { + loading = false +} +``` + +签名:`func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `loading` | `bool` | 必填 | 是否覆盖。 | +| `content` | `func()` | 必填 | 构建被覆盖的内容;nil 时 panic。 | +| `Label` | `string` | 内置“Loading… / 正在加载…” | 说明文字。 | +| `Cancel` | `string` | `""` | 取消按钮文字;为空时不显示。 | + +返回 `LoadingOverlayResult{Element, Canceled}`。`Element` 即遮罩所覆盖的范围,可在其上设置宽高。 + +## 状态 + +- 加载时内容经 MyGo `Disabled` 阻止输入(MyGo 统一减淡),上方为半透明背景与居中面板。 +- 开始加载时播报说明;焦点原在内容内时移到取消按钮(无取消时移到可聚焦的说明面板),焦点不会离开该区域。 +- 遮罩淡入淡出,减少动画时直接切换。 + +## 事件 + +`Canceled`:点击取消,或焦点在面板内时按 Escape。 + +## 键盘操作 + +Tab 到达取消按钮;Enter、Space 或 Escape 取消。被覆盖的控件不进入 Tab 序列。 + +## 限制 + +- 只遮盖容器自身范围;整窗阻塞请使用模态对话框。 diff --git a/drawer.go b/drawer.go new file mode 100644 index 0000000..5897a86 --- /dev/null +++ b/drawer.go @@ -0,0 +1,151 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// DrawerSide is the window edge a Drawer comes from. The zero value is DrawerRight. +type DrawerSide int + +const ( + DrawerRight DrawerSide = iota + DrawerLeft + DrawerTop + DrawerBottom +) + +// DrawerOptions configure a Drawer. Title is required. +type DrawerOptions struct { + Title string + Side DrawerSide + // Size is the width (left, right) or height (top, bottom): 360 or 280 DIPs when zero. + // It is kept between 160 DIPs and 90% of the window. + Size float32 + // NonModal leaves the window usable and the focus free. + NonModal bool + // Actions builds the footer, aligned to the end. + Actions func() +} + +// Drawer shows a panel along a window edge while *open is true. A modal +// drawer traps the focus and restores it on close; Escape closes either kind. +func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element { + if opts.Title == "" { + panic("mujica: Drawer needs a Title") + } + if opts.Side < DrawerRight || opts.Side > DrawerBottom { + panic("mujica: Drawer Side out of range") + } + if opts.Size < 0 { + panic("mujica: Drawer Size is negative") + } + if !*open { + return nil + } + k := Tokens(c) + size := drawerSize(c, opts) + closed := false + body := func(panel *ui.Element, p float32) { + drawerPanel(c, panel, opts.Side, size, p) + panel.Label(opts.Title) + closed = dialogHeader(c, opts.Title, "", false, true, "drawer.close") + if content != nil { + ui.Scroll(c).Grow(1).Shrink(1).Children(content) + } + if opts.Actions != nil { + dialogActions(c, opts.Actions) + } + } + var panel *ui.Element + escaped := false + if opts.NonModal { + ui.Overlay(c, func() { + panel = ui.Column(c).Absolute().Role(ui.RoleDialog) + p := overlayEnter(c, panel) + drawerPlace(c, panel, opts.Side, size*p) + panel.Children(func() { body(panel, p) }) + }) + escaped = c.Shortcut(0, ui.KeyEscape) + } else { + shown := true + panel = ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + p := overlayEnter(c, panel) + back.Background(overlayScrim(k).Alpha(p)) + drawerBackdrop(back, opts.Side) + body(panel, p) + }) + escaped = !shown + } + if closed || escaped { + slog.Debug("mujica: drawer close", "escape", escaped) + *open = false + } + return panel +} + +func drawerSize(c *ui.Context, opts DrawerOptions) float32 { + w, h := c.Size() + extent, size := w, opts.Size + if opts.Side == DrawerTop || opts.Side == DrawerBottom { + extent = h + if size == 0 { + size = 280 + } + } else if size == 0 { + size = 360 + } + return max(min(size, extent*0.9), min(160, extent)) +} + +func drawerHorizontal(s DrawerSide) bool { return s == DrawerLeft || s == DrawerRight } + +// drawerBackdrop lines the panel up against its edge inside a modal backdrop. +func drawerBackdrop(back *ui.Element, s DrawerSide) { + if drawerHorizontal(s) { + back.Row() + } + back.AlignItems(ui.Stretch).Justify(ui.Start) + if s == DrawerRight || s == DrawerBottom { + back.Justify(ui.End) + } +} + +// drawerPlace puts a non-modal panel against its edge, shown by size. +func drawerPlace(c *ui.Context, panel *ui.Element, s DrawerSide, size float32) { + w, h := c.Size() + switch s { + case DrawerRight: + panel.Left(w - size).Top(0).Height(h) + case DrawerLeft: + panel.Left(0).Top(0).Height(h) + case DrawerTop: + panel.Left(0).Top(0).Width(w) + case DrawerBottom: + panel.Left(0).Top(h - size).Width(w) + } +} + +// drawerPanel styles the panel at entrance progress p, sliding in from its edge. +func drawerPanel(c *ui.Context, panel *ui.Element, s DrawerSide, size, p float32) { + k := Tokens(c) + u := Density(c).Unit() + off := -(1 - p) * size + panel.Background(k.Surface).TextColor(k.Text).Opacity(p).Padding(u*6).Gap(u*3). + Shadow(0, 0, 24, 0, ui.RGBA(0, 0, 0, 0.18*p)) + bw := theme.BorderWidth + switch s { + case DrawerRight: + panel.Width(size).BorderWidth(0, 0, 0, bw).Margin(0, off, 0, 0) + case DrawerLeft: + panel.Width(size).BorderWidth(0, bw, 0, 0).Margin(0, 0, 0, off) + case DrawerTop: + panel.Height(size).BorderWidth(0, 0, bw, 0).Margin(off, 0, 0, 0) + case DrawerBottom: + panel.Height(size).BorderWidth(bw, 0, 0, 0).Margin(0, 0, off, 0) + } + panel.BorderColor(k.Border) +} diff --git a/drawer_test.go b/drawer_test.go new file mode 100644 index 0000000..fdfa533 --- /dev/null +++ b/drawer_test.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type drawerApp struct { + open bool + opts DrawerOptions + clicked int + panel ui.Rect +} + +func (a *drawerApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Gap(8).Children(func() { + if Button(c, "Filters", ButtonOptions{}).Clicked() { + a.open = true + } + if Button(c, "Behind", ButtonOptions{}).Clicked() { + a.clicked++ + } + }) + if p := Drawer(c, &a.open, a.opts, func() { + Button(c, "Apply", ButtonOptions{}) + }); p != nil { + a.panel = p.Bounds() + } +} + +func newDrawerApp(opts DrawerOptions) (*drawerApp, *ui.Tester) { + opts.Title = "Filters panel" + a := &drawerApp{opts: opts} + tt := ui.NewTester(a.view, 800, 500) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + _ = tt.Click("Filters") + tt.Frame() + return a, tt +} + +func TestDrawerSides(t *testing.T) { + for _, tc := range []struct { + side DrawerSide + x, y, w, h float32 + }{ + {DrawerRight, 440, 0, 360, 500}, + {DrawerLeft, 0, 0, 360, 500}, + {DrawerTop, 0, 0, 800, 280}, + {DrawerBottom, 0, 220, 800, 280}, + } { + for _, nonModal := range []bool{false, true} { + a, _ := newDrawerApp(DrawerOptions{Side: tc.side, NonModal: nonModal}) + got := a.panel + if got.X != tc.x || got.Y != tc.y || got.W != tc.w || got.H != tc.h { + t.Errorf("side %d nonModal %v: panel %+v", tc.side, nonModal, got) + } + } + } +} + +func TestDrawerSizeLimits(t *testing.T) { + a, _ := newDrawerApp(DrawerOptions{Size: 5000}) + if a.panel.W != 720 { + t.Errorf("oversize width %v, want 720 (90%%)", a.panel.W) + } + a, _ = newDrawerApp(DrawerOptions{Size: 40}) + if a.panel.W != 160 { + t.Errorf("undersize width %v, want 160", a.panel.W) + } +} + +func TestDrawerModalTrapsAndRestores(t *testing.T) { + a, tt := newDrawerApp(DrawerOptions{}) + _ = tt.Click("Behind") + if a.clicked != 0 { + t.Fatal("modal drawer let a click through") + } + a.open = true + tt.Frame() + if !tt.Focused("Close") { + t.Fatal("focus not in the drawer") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Close") { + t.Fatal("tab left the modal drawer") + } + tt.Key(0, ui.KeyEscape) + if a.open || !tt.Focused("Filters") { + t.Fatalf("Escape: open %v, opener focused %v", a.open, tt.Focused("Filters")) + } +} + +func TestDrawerNonModalLeavesWindowUsable(t *testing.T) { + a, tt := newDrawerApp(DrawerOptions{NonModal: true}) + if err := tt.Click("Behind"); err != nil { + t.Fatal(err) + } + if a.clicked != 1 || !a.open { + t.Fatalf("clicked %d open %v", a.clicked, a.open) + } + if err := tt.Click("Close"); err != nil { + t.Fatal(err) + } + if a.open { + t.Fatal("close button did not close") + } + a.open = true + tt.Frame() + tt.Key(0, ui.KeyEscape) + if a.open { + t.Fatal("Escape did not close the non-modal drawer") + } +} + +func TestDrawerSnapshot(t *testing.T) { + snapshot(t, "drawer", 640, 360, func(c *ui.Context) { + open := true + ui.Column(c).Padding(24).Children(func() { ui.Text(c, "The gallery behind") }) + Drawer(c, &open, DrawerOptions{Title: "Seating chart", Size: 300, Actions: func() { + Button(c, "Reset", ButtonOptions{Variant: Secondary}) + Button(c, "Apply", ButtonOptions{}) + }}, func() { + ui.Text(c, "Guests are placed by rank, then by arrival.").TextColor(Tokens(c).TextMuted) + }) + }) +} diff --git a/dropdown_menu.go b/dropdown_menu.go new file mode 100644 index 0000000..aafd4cd --- /dev/null +++ b/dropdown_menu.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// DropdownMenuOptions configure a DropdownMenu. +type DropdownMenuOptions struct { + // Icon goes before the label. + Icon *ui.SVG + Disabled bool + // Label names an icon-only trigger for assistive technology. + Label string +} + +// DropdownMenu is a button opening an in-window menu that build fills. +// Click, Enter, Space or Down opens it with the focus on the first item; +// the arrows, Home and End move, Escape or Tab closes, and the focus +// returns to the button. It returns the button. +func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element { + if build == nil { + panic("mujica: DropdownMenu needs a build function") + } + if label == "" && opts.Label == "" { + panic("mujica: DropdownMenu needs a label or Label") + } + b := ui.ButtonBase(c).Role(ui.RoleMenuButton).Disabled(opts.Disabled) + if opts.Label != "" { + b.Label(opts.Label) + } + open := ui.Local(b, "mujica.dropdown", func() bool { return false }) + if opts.Disabled { + *open = false + } + if b.Clicked() || b.Shortcut(0, ui.KeyDown) { + *open = true + slog.Debug("mujica: dropdown_menu open") + } + popupMenuTrigger(c, b, label, opts.Icon, *open) + popupMenuOpen(c, b, open, build) + return b +} diff --git a/dropdown_menu_test.go b/dropdown_menu_test.go new file mode 100644 index 0000000..f6b46b6 --- /dev/null +++ b/dropdown_menu_test.go @@ -0,0 +1,158 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type menuApp struct { + chosen []string + pinned bool + locked bool +} + +func (a *menuApp) build(m *PopupMenu) { + m.Group("Letter", func() { + if m.Item("Rename", PopupMenuItemOptions{}) { + a.chosen = append(a.chosen, "Rename") + } + if m.Check("Pinned", &a.pinned, PopupMenuItemOptions{KeepOpen: true}) { + a.chosen = append(a.chosen, "Pinned") + } + }) + m.Group("Move", func() { + m.Submenu("Move to", PopupMenuItemOptions{}, func(m *PopupMenu) { + for _, f := range []string{"Archive", "Drafts"} { + if m.Item(f, PopupMenuItemOptions{}) { + a.chosen = append(a.chosen, f) + } + } + }) + }) + m.Separator() + if m.Item("Burn", PopupMenuItemOptions{Disabled: !a.locked}) { + a.chosen = append(a.chosen, "Burn") + } +} + +func (a *menuApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Gap(8).AlignItems(ui.Start).Children(func() { + DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build) + Button(c, "After", ButtonOptions{}) + }) +} + +func TestDropdownMenuChooseClosesAndRestores(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(a.view, 500, 400) + if err := tt.Click("Actions"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Letter") || !tt.Focused("Rename") { + t.Fatalf("menu not open with focus on the first item: %q", tt.Texts()) + } + _ = tt.Click("Rename") + if len(a.chosen) != 1 || a.chosen[0] != "Rename" || tt.HasText("Letter") { + t.Fatalf("chosen %q, still open %v", a.chosen, tt.HasText("Letter")) + } + if !tt.Focused("Actions") { + t.Fatal("focus not back on the trigger") + } +} + +func TestDropdownMenuCheckKeepsOpenAndDisabledBlocks(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(a.view, 500, 400) + _ = tt.Click("Actions") + _ = tt.Click("Pinned") + if !a.pinned || !tt.HasText("Letter") { + t.Fatalf("pinned %v, open %v", a.pinned, tt.HasText("Letter")) + } + _ = tt.Click("Burn") + if len(a.chosen) != 1 || !tt.HasText("Letter") { + t.Fatalf("disabled item acted: %q", a.chosen) + } +} + +func TestDropdownMenuKeyboard(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(a.view, 500, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if !tt.Focused("Rename") { + t.Fatal("Down did not open the menu on the first item") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + if !tt.Focused("Move to") { + t.Fatal("arrows did not reach Move to") + } + tt.Key(0, ui.KeyDown) // Burn is disabled: wraps to Rename + if !tt.Focused("Rename") { + t.Fatal("arrow did not skip the disabled item") + } + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Archive") { + t.Fatalf("Right did not enter the submenu: %q", tt.Texts()) + } + tt.Key(0, ui.KeyLeft) + if !tt.Focused("Move to") || tt.HasText("Archive") { + t.Fatal("Left did not close the submenu") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(a.chosen) != 1 || a.chosen[0] != "Drafts" || tt.HasText("Letter") { + t.Fatalf("submenu choice %q, open %v", a.chosen, tt.HasText("Letter")) + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEscape) + if tt.HasText("Letter") || !tt.Focused("Actions") { + t.Fatal("Escape did not close and restore") + } +} + +func TestDropdownMenuSubmenuFlips(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Fill().Justify(ui.End).Padding(20).Children(func() { + DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build) + }) + }, 420, 400) + _ = tt.Click("Actions") + r, _ := tt.Find("Move to") + tt.Move(r.X+5, r.Y+5) + tt.Frame() + s, ok := tt.Find("Archive") + if !ok || s.X+s.W > 420 || s.X > r.X { + t.Fatalf("submenu %+v not flipped left of %+v", s, r) + } +} + +func TestDropdownMenuDisabled(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DropdownMenu(c, "Actions", DropdownMenuOptions{Disabled: true}, a.build) + }, 300, 300) + _ = tt.Click("Actions") + if tt.HasText("Letter") { + t.Fatal("disabled trigger opened") + } +} + +func TestDropdownMenuSnapshot(t *testing.T) { + snapshot(t, "dropdown_menu", 460, 320, func(c *ui.Context) { + a := &menuApp{pinned: true} + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + b := ui.ButtonBase(c) + popupMenuTrigger(c, b, "Actions", nil, true) + open := true + popupMenuOpen(c, b, &open, a.build) + }) + }) +} diff --git a/empty_state.go b/empty_state.go new file mode 100644 index 0000000..67ca961 --- /dev/null +++ b/empty_state.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "cmp" + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// EmptyKind tells why there is nothing to show. The zero value is EmptyNoData. +type EmptyKind int + +const ( + // EmptyNoData: the collection itself is empty. + EmptyNoData EmptyKind = iota + // EmptyNoResults: a search or filter matched nothing. + EmptyNoResults +) + +// EmptyStateOptions configure an EmptyState. Empty texts use the kind's built-in copy. +type EmptyStateOptions struct { + Kind EmptyKind + Title string + Description string + // Query, for EmptyNoResults, is named in the default description. + Query string + // Icon replaces the kind's icon (inbox or search); NoIcon shows none. + Icon *ui.SVG + NoIcon bool + // Action labels a button, such as "Clear search"; empty shows none. + Action string +} + +// EmptyStateResult is what an EmptyState built and reported this frame. +type EmptyStateResult struct { + Element *ui.Element + Action bool +} + +// EmptyState explains an empty view and offers a next step. +func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult { + if opts.NoIcon && opts.Icon != nil { + panic("mujica: EmptyState with both Icon and NoIcon") + } + k := Tokens(c) + u := Density(c).Unit() + title, desc, icon := opts.Title, opts.Description, opts.Icon + switch opts.Kind { + case EmptyNoData: + if opts.Query != "" { + panic("mujica: EmptyState Query needs EmptyNoResults") + } + title = cmp.Or(title, msg(c, "empty_state.no_data.title")) + desc = cmp.Or(desc, msg(c, "empty_state.no_data.description")) + if icon == nil { + icon = icons.Must("inbox") + } + case EmptyNoResults: + title = cmp.Or(title, msg(c, "empty_state.no_results.title")) + if desc == "" && opts.Query != "" { + desc = fmt.Sprintf(msg(c, "empty_state.no_results.query"), opts.Query) + } + desc = cmp.Or(desc, msg(c, "empty_state.no_results.description")) + if icon == nil { + icon = icons.Must("search") + } + default: + panic(fmt.Sprintf("mujica: unknown EmptyKind %d", int(opts.Kind))) + } + var res EmptyStateResult + res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*2).Padding(u*8, u*4).Role(ui.RoleGroup).Label(title) + res.Element.Children(func() { + if !opts.NoIcon { + ui.Icon(c, icon).Size(32, 32).TextColor(k.TextMuted).Margin(0, 0, u*2, 0) + } + ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text).TextAlign(ui.Center) + ui.Text(c, desc).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(360).LineHeight(theme.LineHeight) + if opts.Action != "" { + ui.Box(c).Margin(u*2, 0, 0, 0).Children(func() { + res.Action = Button(c, opts.Action, ButtonOptions{Variant: Secondary}).Clicked() + }) + } + }) + return res +} diff --git a/empty_state_test.go b/empty_state_test.go new file mode 100644 index 0000000..246b611 --- /dev/null +++ b/empty_state_test.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestEmptyStateKinds(t *testing.T) { + opts := EmptyStateOptions{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + EmptyState(c, opts) + }, 500, 400) + if !tt.HasText("Nothing here yet") || !tt.HasText("Items you add will appear here.") { + t.Fatalf("no data: %q", tt.Texts()) + } + opts = EmptyStateOptions{Kind: EmptyNoResults, Query: "invoice"} + tt.Frame() + if !tt.HasText("No results") || !tt.HasText("Nothing matches “invoice”.") { + t.Fatalf("no results: %q", tt.Texts()) + } + opts = EmptyStateOptions{Kind: EmptyNoResults} + tt.Frame() + if !tt.HasText("Try other words or clear the filters.") { + t.Fatalf("no results without query: %q", tt.Texts()) + } +} + +func TestEmptyStateActionKeyboard(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + if EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: "发票", Action: "清除搜索"}).Action { + n++ + } + }, 500, 400) + if !tt.HasText("没有与“发票”匹配的内容。") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if err := tt.Click("清除搜索"); err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("actions %d", n) + } +} + +func TestEmptyStatePanics(t *testing.T) { + for name, o := range map[string]EmptyStateOptions{ + "icon and no icon": {Icon: icons.Must("inbox"), NoIcon: true}, + "query without kind": {Query: "x"}, + "unknown kind": {Kind: 9}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); EmptyState(c, o) }, 300, 200) + }() + } +} + +func TestEmptyStateSnapshot(t *testing.T) { + snapshot(t, "empty_state", 640, 280, func(c *ui.Context) { + ui.Row(c).Padding(12).Gap(12).Children(func() { + ui.Column(c).Grow(1).Basis(0).Children(func() { + EmptyState(c, EmptyStateOptions{Action: "New project"}) + }) + ui.Column(c).Grow(1).Basis(0).Children(func() { + EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: "quarterly", Action: "Clear search"}) + }) + }) + }) +} diff --git a/example_datetime_test.go b/example_datetime_test.go new file mode 100644 index 0000000..be9afcc --- /dev/null +++ b/example_datetime_test.go @@ -0,0 +1,110 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleCalendar() { + 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 ExampleDatePicker() { + 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 ExampleDateRangePicker() { + 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 ExampleTimePicker() { + 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 ExampleTimeRangePicker() { + 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 ExampleDateTimePicker() { + 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 ExampleMonthPicker() { + 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 ExampleYearPicker() { + 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) +} diff --git a/example_display_test.go b/example_display_test.go new file mode 100644 index 0000000..a3e05f4 --- /dev/null +++ b/example_display_test.go @@ -0,0 +1,134 @@ +package mujica_test + +import ( + "errors" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleText() { + 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 + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHeading() { + 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}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleIcon() { + 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"}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImage() { + 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 + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAvatar() { + 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{}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAvatarGroup() { + 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}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBadge() { + 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{}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTag() { + 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:]...) + } + } + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLink() { + 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 + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleKbd() { + 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") + }) + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_feedback_test.go b/example_feedback_test.go new file mode 100644 index 0000000..cd49f2e --- /dev/null +++ b/example_feedback_test.go @@ -0,0 +1,145 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleAlert() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{ + Severity: mujica.SeverityError, + Title: "Import failed", + Action: "Retry", + }) + if r.Action { + // retry the import + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToaster() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"}) + } + if r := mujica.Toaster(c); r.Action == "note-1" { + // restore the note + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleProgress() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(16).Gap(16).Children(func() { + mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true}) + mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"}) + mujica.Meter(c, 93, mujica.MeterOptions{ + Max: 100, Label: "Disk", ShowValue: true, + Levels: &ui.MeterLevels{Warning: 70, Critical: 90}, + }) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSpinner() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSkeleton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Row(c).Padding(16).Gap(12).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle}) + ui.Column(c).Grow(1).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2}) + }) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEmptyState() { + query := "quarterly" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EmptyState(c, mujica.EmptyStateOptions{ + Kind: mujica.EmptyNoResults, + Query: query, + Action: "Clear search", + }) + if r.Action { + query = "" + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleResult() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Result(c, mujica.ResultOptions{ + Kind: mujica.ResultForbidden, + Description: "You need the Editor role to change billing.", + Actions: []string{"Request access", "Go back"}, + }) + switch r.Action { + case 0: // send the request + case 1: // navigate back + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStatusIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Row(c).Gap(16).Children(func() { + mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"}) + mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleNotificationCenter() { + items := []mujica.Notification{ + {ID: "a", Title: "Build failed", Body: "3 tests failing", Time: "2 min ago", Action: "View log"}, + {ID: "b", Title: "Backup complete", Time: "Yesterday", Read: true}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{}) + if r.Action == "a" { + // open the build log + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLoadingOverlay() { + loading := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() { + ui.Text(c, "Quarterly report") + mujica.Button(c, "Export", mujica.ButtonOptions{}) + }) + if r.Canceled { + loading = false + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_form_test.go b/example_form_test.go new file mode 100644 index 0000000..0873135 --- /dev/null +++ b/example_form_test.go @@ -0,0 +1,124 @@ +package mujica_test + +import ( + "fmt" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleForm() { + 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) +} + +func ExampleFormField() { + 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) +} + +func ExampleInputGroup() { + 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) +} + +func ExamplePinInput() { + 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) +} + +func ExampleTagsInput() { + 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) +} + +func ExampleRating() { + 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) +} + +func ExampleColorPicker() { + 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) +} + +func ExampleFilePicker() { + 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) +} + +func ExampleFileDropZone() { + 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) +} + +func ExampleTransfer() { + 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) +} diff --git a/example_input_test.go b/example_input_test.go new file mode 100644 index 0000000..9828a07 --- /dev/null +++ b/example_input_test.go @@ -0,0 +1,130 @@ +package mujica_test + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +func ExampleButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() { + fmt.Println("saved") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleIconButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() { + fmt.Println("open settings") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleButtonGroup() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + 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) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTextInput() { + var name string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true}) + if r.Submitted() { + fmt.Println("hello", name) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTextArea() { + var notes string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200}) + if r.Submitted() { + fmt.Println("send", notes) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleNumberInput() { + copies := 1.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() { + fmt.Println("copies", copies) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePasswordInput() { + var password string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() { + fmt.Println("signing in") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSearchInput() { + var query string + current := 0 + text := "rose lys rose crown rose" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + matches := 0 + if query != "" { + matches = strings.Count(text, query) + } + r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t}) + if r.Moved() { + fmt.Println("show match", current) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCheckbox() { + var mail, calendar bool + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{ + {Label: "Mail", Value: &mail}, + {Label: "Calendar", Value: &calendar}, + }, mujica.CheckboxGroupOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRadioGroup() { + size := "m" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + 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) + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_layout_test.go b/example_layout_test.go new file mode 100644 index 0000000..2926e55 --- /dev/null +++ b/example_layout_test.go @@ -0,0 +1,129 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +func ExampleAppShell() { + var collapsed bool + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + 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") }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTitleBar() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{ + Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStatusBar() { + opened := 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + 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++ + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleContainer() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() { + ui.Text(c, "A centered column of prose.") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStack() { + showDraft := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + 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}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleGrid() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + 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") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScrollArea() { + var offset ui.ScrollState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() { + for range 50 { + ui.Text(c, "A line of the log") + } + }).Height(200) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSplitPane() { + split := mujica.SplitPaneState{Size: 160} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst}, + func() { ui.Text(c, "Files") }, + func() { ui.Text(c, "Editor") }, + ).Fill() + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAspectRatio() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(320, 240).Children(func() { + mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() { + ui.Text(c, "16 : 9") + }) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDivider() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(16).Gap(12).Children(func() { + mujica.Divider(c, mujica.DividerOptions{Ornament: true}) + mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"}) + }) + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_navigation_test.go b/example_navigation_test.go new file mode 100644 index 0000000..5b5a611 --- /dev/null +++ b/example_navigation_test.go @@ -0,0 +1,155 @@ +package mujica_test + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +func ExampleTabs() { + tab := 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Tabs(c, &tab, []mujica.Tab{ + {Label: "General", Panel: func() { ui.Text(c, "General settings") }}, + {Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }}, + {Label: "Archive", Disabled: true}, + }, mujica.TabsOptions{Label: "Settings"}).Changed() { + fmt.Println("tab", tab) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSegmentedControl() { + view := 0 + build := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SegmentedControl(c, &view, []mujica.Segment{ + {Label: "List", Icon: icons.Must("list"), HideLabel: true}, + {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true}, + }, mujica.SegmentedControlOptions{Label: "View"}).Changed() { + fmt.Println("view", view) + } + } + ui.Render(build, 400, 300, 1) +} + +func ExampleBreadcrumb() { + path := []mujica.BreadcrumbItem{{Label: "Home", Icon: icons.Must("house")}, {Label: "Library"}, {Label: "Poems"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok { + path = path[:i+1] + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePagination() { + page, size := 1, 20 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() { + fmt.Println("load page", page, "of size", size) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSteps() { + current := 1 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Steps(c, ¤t, []mujica.Step{ + {Title: "Account", Clickable: true}, + {Title: "Profile", Description: "Name and seal"}, + {Title: "Confirm"}, + }, mujica.StepsOptions{Label: "Sign-up"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSidebar() { + selected := "inbox" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sidebar(c, &selected, []mujica.SidebarSection{ + {Title: "Mail", Items: []mujica.SidebarItem{ + {ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3}, + {Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}}, + }}, + }, mujica.SidebarOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleNavigationMenu() { + router := ui.NewRouter("/") + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.NavigationMenu(c, router, []mujica.NavigationLink{ + {Label: "Home", Path: "/"}, + {Label: "Library", Items: []mujica.NavigationLink{ + {Label: "Poems", Path: "/library/poems", Description: "Verse of the court"}, + }}, + }, mujica.NavigationMenuOptions{History: true}) + router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMenubar() { + wrap := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + res := mujica.Menubar(c, []mujica.MenubarMenu{ + {Label: "File", Items: []mujica.MenubarItem{ + {ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}}, + {Label: "Export", Items: []mujica.MenubarItem{{ID: "pdf", Label: "PDF"}}}, + }}, + {Label: "View", Items: []mujica.MenubarItem{ + {ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: wrap}, + }}, + }, mujica.MenubarOptions{}) + if id, ok := res.Chosen(); ok && id == "wrap" { + wrap = !wrap + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToolbar() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() { + mujica.ToolbarGroup(c, func() { + mujica.ToolbarButton(c, "Bold", mujica.ToolbarButtonOptions{Icon: icons.Must("bold")}) + mujica.ToolbarButton(c, "Italic", mujica.ToolbarButtonOptions{Icon: icons.Must("italic")}) + }) + mujica.ToolbarSeparator(c) + if mujica.ToolbarButton(c, "Copy", mujica.ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() { + fmt.Println("copy") + } + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommandPalette() { + open := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + res := mujica.CommandPalette(c, &open, []mujica.Command{ + {ID: "new", Label: "New document", Group: "File"}, + {ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}}, + }, mujica.CommandPaletteOptions{}) + if id, ok := res.Chosen(); ok { + fmt.Println("run", id) + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_overlay_test.go b/example_overlay_test.go new file mode 100644 index 0000000..acbcf5b --- /dev/null +++ b/example_overlay_test.go @@ -0,0 +1,167 @@ +package mujica_test + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleDialog() { + open := true + name := "Ophelia" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Dialog(c, &open, mujica.DialogOptions{ + Title: "Rename the chamber", + Description: "The new name shows in every hall.", + Decorated: true, + Actions: func() { + if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + open = false + } + if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() { + open = false + } + }, + }, func() { + ui.TextInput(c, &name) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAlertDialog() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + open = true + } + r := mujica.AlertDialog(c, &open, mujica.AlertDialogOptions{ + Title: "Delete the archive?", + Description: "Twelve letters will be destroyed.", + ConfirmText: "archive", + }) + if r.Confirmed { + fmt.Println("deleted") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDrawer() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Filters", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Drawer(c, &open, mujica.DrawerOptions{Title: "Filters", Side: mujica.DrawerRight, Size: 320}, func() { + ui.Text(c, "Guests are placed by rank.") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePopover() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + open = !open + } + mujica.Popover(c, b, &open, mujica.PopoverOptions{Title: "Guest list"}, func() { + ui.Text(c, "Twelve guests confirmed.") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTooltip() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + mujica.Tooltip(c, b, "Seal the letter with wax", mujica.TooltipOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHoverCard() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost}) + mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() { + ui.Text(c, "Keyboardist of the court.") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDropdownMenu() { + pinned := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{}, func(m *mujica.PopupMenu) { + m.Group("Letter", func() { + if m.Item("Rename", mujica.PopupMenuItemOptions{}) { + fmt.Println("rename") + } + m.Check("Pinned", &pinned, mujica.PopupMenuItemOptions{KeepOpen: true}) + }) + m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) { + m.Item("Archive", mujica.PopupMenuItemOptions{}) + }) + m.Separator() + m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: true}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleContextMenu() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + row := ui.Row(c).Padding(12).Children(func() { + ui.Text(c, "Letter to the Countess") + }) + mujica.ContextMenu(c, row, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Reply", mujica.PopupMenuItemOptions{}) { + fmt.Println("reply") + } + m.Item("Delete", mujica.PopupMenuItemOptions{}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePopconfirm() { + open := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + open = true + } + if mujica.Popconfirm(c, b, &open, mujica.PopconfirmOptions{Title: "Archive this letter?"}).Confirmed { + fmt.Println("archived") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOverlay() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Overlay(c, &open, mujica.OverlayOptions{Place: mujica.OverlayBottomRight, Label: "Now playing"}, func() { + ui.Text(c, "Symbol I: △") + }) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_datetime.go b/examples/gallery/pages_datetime.go index ec8e090..07f6b8c 100644 --- a/examples/gallery/pages_datetime.go +++ b/examples/gallery/pages_datetime.go @@ -1,3 +1,399 @@ package main -var datetimePages = []page{} +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +const dtCategory = "Date & Time" + +var datetimePages = []page{ + {ID: "051", Name: "Calendar", ZhName: "日历", Category: dtCategory, Demo: dtCalendarDemo, + Code: dtCalendarCode, Keys: "Tab enters the days; arrows move by day and week; PageUp/PageDown by month, with Shift by year; Home/End go to the first and last day; Enter or Space chooses."}, + {ID: "052", Name: "DatePicker", ZhName: "日期选择器", Category: dtCategory, Demo: dtDatePickerDemo, + Code: dtDatePickerCode, Keys: "Type a date and press Enter, or leave the field, to commit; Down opens the calendar; in it arrows, PageUp/PageDown, Enter choose and Escape closes."}, + {ID: "053", Name: "DateRangePicker", ZhName: "日期范围选择器", Category: dtCategory, Demo: dtDateRangeDemo, + Code: dtDateRangeCode, Keys: "Enter, Space or Down opens; Enter picks the start, then the end; Tab reaches Clear, Cancel and Confirm; Escape discards the draft."}, + {ID: "054", Name: "TimePicker", ZhName: "时间选择器", Category: dtCategory, Demo: dtTimePickerDemo, + Code: dtTimePickerCode, Keys: "Tab enters the segments; Left/Right move between them; Up/Down step; digits type; A/P set the period; Alt+Down or the clock button opens the lists."}, + {ID: "055", Name: "TimeRangePicker", ZhName: "时间范围选择器", Category: dtCategory, Demo: dtTimeRangeDemo, + Code: dtTimeRangeCode, Keys: "Each end works as a TimePicker; Tab moves from the start to the end."}, + {ID: "056", Name: "DateTimePicker", ZhName: "日期时间选择器", Category: dtCategory, Demo: dtDateTimeDemo, + Code: dtDateTimeCode, Keys: "Enter, Space or Down opens; the calendar and time segments work as in DatePicker and TimePicker; Tab reaches Cancel and Confirm; Escape cancels."}, + {ID: "057", Name: "MonthPicker", ZhName: "月份选择器", Category: dtCategory, Demo: dtMonthDemo, + Code: dtMonthCode, Keys: "Enter, Space or Down opens; arrows move by month and row; PageUp/PageDown by year; Enter chooses; Escape closes."}, + {ID: "058", Name: "YearPicker", ZhName: "年份选择器", Category: dtCategory, Demo: dtYearDemo, + Code: dtYearCode, Keys: "Enter, Space or Down opens; arrows move by year and row; PageUp/PageDown by decade; Enter chooses; Escape closes."}, +} + +// dtSection lays out a captioned block of a demo. +func dtSection(c *ui.Context, title string, fn func()) { + k := mujica.Tokens(c) + ui.Column(c).Gap(8).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text) + fn() + }) +} + +// dtStates lays labeled variants side by side. +func dtStates(c *ui.Context, fn func(state func(name string, body func()))) { + k := mujica.Tokens(c) + ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() { + fn(func(name string, body func()) { + ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() { + ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted) + body() + }) + }) + }) +} + +func dtControls(c *ui.Context, fn func()) { + ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn) +} + +var dtWeekStarts = []string{"Locale", "Sunday", "Monday", "Saturday"} + +func dtWeekStart(s string) mujica.WeekStart { + for i, n := range dtWeekStarts { + if n == s { + return mujica.WeekStart(i) + } + } + panic("gallery: unknown week start " + s) +} + +func dtCalendarDemo(c *ui.Context, log func(string)) { + type state struct { + day mujica.Date + weekStart string + weekends, bound, frozen bool + } + s := ui.Local(c.Root(), "051", func() state { return state{day: mujica.DateOf(time.Now()), weekStart: "Locale"} }) + dtSection(c, "Calendar", func() { + dtControls(c, func() { + ui.Select(c, &s.weekStart, dtWeekStarts) + ui.Checkbox(c, &s.weekends, "Disable weekends") + ui.Checkbox(c, &s.bound, "Only this month") + ui.Checkbox(c, &s.frozen, "Disabled") + }) + opts := mujica.CalendarOptions{WeekStart: dtWeekStart(s.weekStart), Disabled: s.frozen, Label: "Calendar demo"} + if s.weekends { + opts.DisableDate = func(d mujica.Date) bool { return d.Weekday() == time.Saturday || d.Weekday() == time.Sunday } + } + if s.bound { + now := mujica.DateOf(time.Now()) + opts.Min = mujica.Date{Year: now.Year, Month: now.Month, Day: 1} + opts.Max = opts.Min.AddMonths(1).AddDays(-1) + } + if mujica.Calendar(c, &s.day, opts).Changed() { + log("Calendar changed: " + s.day.String()) + } + }) +} + +func dtDatePickerDemo(c *ui.Context, log func(string)) { + type state struct { + a, b, d mujica.Date + clearable, slash bool + } + s := ui.Local(c.Root(), "052", func() state { + t := mujica.DateOf(time.Now()) + return state{a: t, d: t, clearable: true} + }) + dtSection(c, "DatePicker", func() { + dtControls(c, func() { + ui.Checkbox(c, &s.clearable, "Clearable") + ui.Checkbox(c, &s.slash, "Layout 02/01/2006") + }) + layout := "" + if s.slash { + layout = "02/01/2006" + } + now := mujica.DateOf(time.Now()) + dtStates(c, func(state func(string, func())) { + state("Default", func() { + if mujica.DatePicker(c, &s.a, mujica.DatePickerOptions{Label: "Due date", Layout: layout, Clearable: s.clearable}).Changed() { + log("DatePicker changed: " + s.a.String()) + } + }) + state("Next 30 days only", func() { + opts := mujica.DatePickerOptions{Label: "Delivery", Layout: layout, Clearable: s.clearable, Min: now, Max: now.AddDays(30)} + if mujica.DatePicker(c, &s.b, opts).Changed() { + log("DatePicker (bounded) changed: " + s.b.String()) + } + }) + state("Disabled", func() { + mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Locked", Layout: layout, Disabled: true}) + }) + }) + }) +} + +func dtDateRangeDemo(c *ui.Context, log func(string)) { + type state struct { + a mujica.DateRange + noTuesdays bool + frozen bool + fromThisWeek bool + } + s := ui.Local(c.Root(), "053", func() state { return state{} }) + dtSection(c, "DateRangePicker", func() { + dtControls(c, func() { + ui.Checkbox(c, &s.noTuesdays, "Tuesdays unavailable") + ui.Checkbox(c, &s.fromThisWeek, "Start from today") + ui.Checkbox(c, &s.frozen, "Disabled") + }) + opts := mujica.DateRangePickerOptions{Label: "Stay", Disabled: s.frozen} + if s.noTuesdays { + opts.DisableDate = func(d mujica.Date) bool { return d.Weekday() == time.Tuesday } + } + if s.fromThisWeek { + opts.Min = mujica.DateOf(time.Now()) + } + r := mujica.DateRangePicker(c, &s.a, opts) + if r.Changed() { + log(fmt.Sprintf("DateRangePicker changed: %v – %v (%d days)", s.a.Start, s.a.End, s.a.Days())) + } + if r.Cancelled() { + log("DateRangePicker cancelled") + } + }) +} + +func dtTimePickerDemo(c *ui.Context, log func(string)) { + type state struct { + a, b mujica.TimeOfDay + seconds, hour12 bool + step string + } + s := ui.Local(c.Root(), "054", func() state { + return state{a: mujica.TimeOfDay{Hour: 9, Minute: 30}, b: mujica.TimeOfDay{Hour: 10}, step: "1"} + }) + dtSection(c, "TimePicker", func() { + dtControls(c, func() { + ui.Checkbox(c, &s.seconds, "Seconds") + ui.Checkbox(c, &s.hour12, "12-hour") + ui.Text(c, "Minute step") + ui.Select(c, &s.step, []string{"1", "5", "15", "30"}) + }) + step := 0 + if _, err := fmt.Sscan(s.step, &step); err != nil { + panic(err) + } + base := mujica.TimePickerOptions{Seconds: s.seconds, Hour12: s.hour12, MinuteStep: step} + dtStates(c, func(state func(string, func())) { + state("Any time", func() { + o := base + o.Label = "Alarm" + if mujica.TimePicker(c, &s.a, o).Changed() { + log("TimePicker changed: " + s.a.String()) + } + }) + state("Office hours 09:00–18:00", func() { + o := base + o.Label, o.Min, o.Max = "Meeting", mujica.TimeOfDay{Hour: 9}, mujica.TimeOfDay{Hour: 18} + if mujica.TimePicker(c, &s.b, o).Changed() { + log("TimePicker (bounded) changed: " + s.b.String()) + } + }) + state("Disabled", func() { + o := base + o.Label, o.Disabled = "Locked", true + mujica.TimePicker(c, &s.a, o) + }) + }) + }) +} + +func dtTimeRangeDemo(c *ui.Context, log func(string)) { + type state struct { + v mujica.TimeRange + overnight bool + } + s := ui.Local(c.Root(), "055", func() state { + return state{v: mujica.TimeRange{Start: mujica.TimeOfDay{Hour: 9}, End: mujica.TimeOfDay{Hour: 17}}} + }) + dtSection(c, "TimeRangePicker", func() { + dtControls(c, func() { + if ui.Checkbox(c, &s.overnight, "Allow overnight").Changed() && !s.overnight && s.v.Overnight() { + s.v = mujica.TimeRange{Start: s.v.End, End: s.v.Start} + log("TimeRangePicker swapped ends: overnight turned off") + } + }) + opts := mujica.TimeRangePickerOptions{Label: "Opening hours", AllowOvernight: s.overnight, MinuteStep: 15} + if mujica.TimeRangePicker(c, &s.v, opts).Changed() { + log(fmt.Sprintf("TimeRangePicker changed: %v – %v overnight=%v", s.v.Start, s.v.End, s.v.Overnight())) + } + }) +} + +var dtZones = []string{"Local", "UTC", "Asia/Shanghai", "America/New_York", "Europe/London"} + +func dtDateTimeDemo(c *ui.Context, log func(string)) { + type state struct { + v time.Time + zone string + hour12 bool + } + s := ui.Local(c.Root(), "056", func() state { return state{zone: "Local"} }) + dtSection(c, "DateTimePicker", func() { + dtControls(c, func() { + ui.Text(c, "Time zone") + ui.Select(c, &s.zone, dtZones) + ui.Checkbox(c, &s.hour12, "12-hour") + }) + loc, err := time.LoadLocation(s.zone) + if err != nil { + panic(err) + } + r := mujica.DateTimePicker(c, &s.v, mujica.DateTimePickerOptions{Label: "Meeting", Location: loc, Hour12: s.hour12}) + if r.Changed() { + log("DateTimePicker changed: " + s.v.Format(time.RFC3339)) + } + if r.Cancelled() { + log("DateTimePicker cancelled") + } + k := mujica.Tokens(c) + ui.Text(c, "Try 2026-03-08 02:30 in America/New_York: the picker reports the DST gap."). + FontSize(theme.CaptionSize).TextColor(k.TextMuted) + }) +} + +func dtMonthDemo(c *ui.Context, log func(string)) { + type state struct { + v mujica.YearMonth + clearable, past bool + } + s := ui.Local(c.Root(), "057", func() state { return state{clearable: true} }) + dtSection(c, "MonthPicker", func() { + dtControls(c, func() { + ui.Checkbox(c, &s.clearable, "Clearable") + ui.Checkbox(c, &s.past, "Past months only") + }) + opts := mujica.MonthPickerOptions{Label: "Billing month", Clearable: s.clearable, + DisableMonth: func(m mujica.YearMonth) bool { return m.Month == time.February }} + if s.past { + now := time.Now() + opts.Max = mujica.YearMonth{Year: now.Year(), Month: now.Month()} + } + dtStates(c, func(state func(string, func())) { + state("February unavailable", func() { + if mujica.MonthPicker(c, &s.v, opts).Changed() { + log("MonthPicker changed: " + s.v.String()) + } + }) + state("Disabled", func() { + mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Locked", Disabled: true}) + }) + }) + }) +} + +func dtYearDemo(c *ui.Context, log func(string)) { + type state struct { + v int + clearable bool + } + s := ui.Local(c.Root(), "058", func() state { return state{v: time.Now().Year(), clearable: true} }) + dtSection(c, "YearPicker", func() { + dtControls(c, func() { ui.Checkbox(c, &s.clearable, "Clearable") }) + opts := mujica.YearPickerOptions{Label: "Vintage", Min: 1990, Max: time.Now().Year(), Clearable: s.clearable, + DisableYear: func(y int) bool { return y == 2020 }} + dtStates(c, func(state func(string, func())) { + state("1990 to now, 2020 unavailable", func() { + if mujica.YearPicker(c, &s.v, opts).Changed() { + log(fmt.Sprintf("YearPicker changed: %d", s.v)) + } + }) + state("Disabled", func() { + mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Locked", Disabled: true}) + }) + }) + }) +} + +// Code snippets mirror example_datetime_test.go. +const ( + dtCalendarCode = `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)` + dtDatePickerCode = `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)` + dtDateRangeCode = `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)` + dtTimePickerCode = `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)` + dtTimeRangeCode = `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)` + dtDateTimeCode = `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)` + dtMonthCode = `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)` + dtYearCode = `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)` +) diff --git a/examples/gallery/pages_display.go b/examples/gallery/pages_display.go index dde3607..b151ddb 100644 --- a/examples/gallery/pages_display.go +++ b/examples/gallery/pages_display.go @@ -1,3 +1,495 @@ package main -var displayPages = []page{} +import ( + "errors" + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +var displayPages = []page{ + {ID: "011", Name: "Text", ZhName: "文本", Category: "Display", Demo: textDemo, Code: textCode, + Keys: "Selectable 文本:点击后 Cmd/Ctrl+A 全选、Cmd/Ctrl+C 复制。可编辑:Tab 聚焦,Enter 或双击开始编辑,Enter 确认,Escape 取消,移走焦点也确认。"}, + {ID: "012", Name: "Heading", ZhName: "标题", Category: "Display", Demo: headingDemo, Code: headingCode, + Keys: "纯展示,不获取焦点。"}, + {ID: "013", Name: "Icon", ZhName: "图标", Category: "Display", Demo: iconDemo, Code: iconCode, + Keys: "纯展示,不获取焦点。"}, + {ID: "014", Name: "Image", ZhName: "图片", Category: "Display", Demo: imageDemo, Code: imageCode, + Keys: "错误状态的「重试」按钮可 Tab 聚焦,Enter 或 Space 触发。"}, + {ID: "015", Name: "Avatar", ZhName: "头像", Category: "Display", Demo: avatarDemo, Code: avatarCode, + Keys: "纯展示,不获取焦点。"}, + {ID: "016", Name: "AvatarGroup", ZhName: "头像组", Category: "Display", Demo: avatarGroupDemo, Code: avatarGroupCode, + Keys: "Tab 聚焦「+N」按钮,Enter 或 Space 展开完整名单,Escape 或点击外部关闭。"}, + {ID: "017", Name: "Badge", ZhName: "徽标", Category: "Display", Demo: badgeDemo, Code: badgeCode, + Keys: "纯展示,不获取焦点;辅助技术朗读完整数值。"}, + {ID: "018", Name: "Tag", ZhName: "标签", Category: "Display", Demo: tagDemo, Code: tagCode, + Keys: "可点击标签:Tab 聚焦,Enter 或 Space 点击,Delete 或 Backspace 关闭。关闭按钮是独立的 Tab 停靠点。"}, + {ID: "019", Name: "Link", ZhName: "链接", Category: "Display", Demo: linkDemo, Code: linkCode, + Keys: "Tab 聚焦链接,Enter 激活。"}, + {ID: "020", Name: "Kbd", ZhName: "快捷键", Category: "Display", Demo: kbdDemo, Code: kbdCode, + Keys: "纯展示,不注册快捷键,不获取焦点。"}, +} + +type textState struct { + maxLines float64 + selectable bool + name string + saving bool + failNext bool + saveErr string +} + +func textDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "011", func() textState { return textState{maxLines: 0, selectable: true, name: "Coronation.md"} }) + mujica.Heading(c, "Text", mujica.HeadingOptions{Subtitle: "换行、省略、选择复制与可编辑模式", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.Text(c, fmt.Sprintf("MaxLines %d", int(s.maxLines))) + ui.StepSlider(c, &s.maxLines, 0, 4, 1).Width(160) + ui.Checkbox(c, &s.selectable, "Selectable") + ui.Checkbox(c, &s.saving, "Saving") + ui.Checkbox(c, &s.failNext, "Next save fails") + }) + }) + displaySection(c, "Display", func() { + ui.Box(c).Width(360).Children(func() { + mujica.Text(c, "Velvet curtains fall over the long gallery. Every candle leans toward the open door, and the quartet plays on while the guests take their places beneath the chandeliers.", + mujica.TextOptions{MaxLines: int(s.maxLines), Selectable: s.selectable}) + }) + }) + displaySection(c, "Editable", func() { + e := mujica.EditableText(c, &s.name, mujica.EditableTextOptions{Saving: s.saving, Error: s.saveErr, Label: "File name"}) + if e.Changed() { + if s.failNext { + s.saveErr = "Save failed: the name is already taken." + log("save failed: " + s.name) + } else { + s.saveErr = "" + log("saved: " + s.name) + } + } + }) + displayCode(c, textCode, log) +} + +const textCode = `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 +}` + +type headingState struct { + level float64 + subtitle, divide bool +} + +func headingDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "012", func() headingState { return headingState{level: 1, subtitle: true, divide: true} }) + mujica.Heading(c, "Heading", mujica.HeadingOptions{Subtitle: "三级衬线标题、副标题与装饰分隔线", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.Text(c, fmt.Sprintf("Level %d", int(s.level))) + if ui.StepSlider(c, &s.level, 1, 3, 1).Width(120).Changed() { + log(fmt.Sprintf("level %d", int(s.level))) + } + ui.Checkbox(c, &s.subtitle, "Subtitle") + ui.Checkbox(c, &s.divide, "Divider") + }) + }) + displaySection(c, "Preview", func() { + sub := "" + if s.subtitle { + sub = "Ceremonies of the winter season" + } + mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Level: int(s.level), Subtitle: sub, Divider: s.divide}) + }) + displaySection(c, "Levels", func() { + for l := 1; l <= 3; l++ { + mujica.Heading(c, fmt.Sprintf("Level %d · 宫廷", l), mujica.HeadingOptions{Level: l}) + } + }) + displayCode(c, headingCode, log) +} + +const headingCode = `mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true}) +mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})` + +type iconState struct { + name string + size string + tone string + label bool +} + +var iconTones = map[string]mujica.IconTone{"Inherit": mujica.IconInherit, "Muted": mujica.IconMuted, "Accent": mujica.IconAccent, + "Success": mujica.IconSuccess, "Warning": mujica.IconWarning, "Danger": mujica.IconDanger, "Info": mujica.IconInfo} + +func iconDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "013", func() iconState { return iconState{name: "star", size: "24", tone: "Accent"} }) + mujica.Heading(c, "Icon", mujica.HeadingOptions{Subtitle: "按名称取图标;尺寸与语义颜色;缺失时可见回退", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + if ui.Select(c, &s.name, []string{"star", "bell", "settings", "moon", "sun", "no-such-icon"}).Changed() { + log("name " + s.name) + } + if ui.Select(c, &s.size, []string{"16", "20", "24"}).Changed() { + log("size " + s.size) + } + if ui.Select(c, &s.tone, []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"}).Changed() { + log("tone " + s.tone) + } + ui.Checkbox(c, &s.label, "Label") + }) + }) + displaySection(c, "Preview", func() { + var size float32 + fmt.Sscan(s.size, &size) + label := "" + if s.label { + label = "Preview icon" + } + mujica.Icon(c, s.name, mujica.IconOptions{Size: size, Tone: iconTones[s.tone], Label: label}) + }) + displaySection(c, "Tones and fallback", func() { + ui.Row(c).Gap(14).Children(func() { + for _, t := range []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"} { + mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: iconTones[t]}).Key(t) + } + mujica.Icon(c, "missing-name", mujica.IconOptions{Size: 20}) + }) + }) + displayCode(c, iconCode, log) +} + +const iconCode = `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"}) +})` + +type imageState struct { + crop, loading, fail, empty bool + err error +} + +var displayPicture = func() *ui.SVG { + return ui.MustParseSVG([]byte(` + +`)) +}() + +func imageDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} }) + mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.Checkbox(c, &s.crop, "Crop") + ui.Checkbox(c, &s.empty, "No source") + ui.Checkbox(c, &s.loading, "Loading") + if ui.Checkbox(c, &s.fail, "Error").Changed() { + s.err = nil + if s.fail { + s.err = errors.New("connection reset") + } + } + }) + }) + displaySection(c, "Preview", func() { + var src ui.ImageSource = displayPicture + if s.empty { + src = nil + } + v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err}) + if v.Retry { + s.fail, s.err = false, nil + log("retry") + } + }) + displaySection(c, "States", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120}) + mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true}) + mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120}) + mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true}) + if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry { + log("retry (static example)") + } + }) + }) + displayCode(c, imageCode, log) +} + +const imageCode = `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 +}` + +type avatarState struct { + name string + size float64 + img bool +} + +var displayBitmap = func() *ui.Bitmap { + return ui.NewBitmap(ui.Render(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{Mode: mujica.Dark}) + ui.Box(c).Fill().Background(ui.Hex("#7D2034")) + }, 64, 64, 1)) +}() + +func avatarDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} }) + mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + if ui.TextInput(c, &s.name).Width(200).Changed() { + log("name " + s.name) + } + ui.Text(c, fmt.Sprintf("Size %d", int(s.size))) + ui.StepSlider(c, &s.size, 24, 80, 4).Width(160) + ui.Checkbox(c, &s.img, "Image") + }) + }) + displaySection(c, "Preview", func() { + var img *ui.Bitmap + if s.img { + img = displayBitmap + } + mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img}) + }) + displaySection(c, "Fallbacks", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40}) + mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40}) + mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40}) + mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40}) + }) + }) + displayCode(c, avatarCode, log) +} + +const avatarCode = `ui.Row(c).Gap(8).Children(func() { + mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40}) + mujica.Avatar(c, "", mujica.AvatarOptions{}) +})` + +var displayPeople = []mujica.AvatarItem{ + {Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"}, + {Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"}, +} + +type avatarGroupState struct{ max, count float64 } + +func avatarGroupDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "016", func() avatarGroupState { return avatarGroupState{max: 3, count: 6} }) + mujica.Heading(c, "AvatarGroup", mujica.HeadingOptions{Subtitle: "最大显示数量、剩余数量与完整名单", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.Text(c, fmt.Sprintf("Max %d", int(s.max))) + if ui.StepSlider(c, &s.max, 1, 8, 1).Width(160).Changed() { + log(fmt.Sprintf("max %d", int(s.max))) + } + ui.Text(c, fmt.Sprintf("People %d", int(s.count))) + if ui.StepSlider(c, &s.count, 1, 8, 1).Width(160).Changed() { + log(fmt.Sprintf("people %d", int(s.count))) + } + }) + }) + displaySection(c, "Preview", func() { + for i, g := range []mujica.AvatarGroupView{ + mujica.AvatarGroup(c, displayPeople[:int(s.count)], mujica.AvatarGroupOptions{Max: int(s.max)}), + mujica.AvatarGroup(c, displayPeople, mujica.AvatarGroupOptions{Max: 4, Size: 24}), + } { + if g.Toggled { + log(fmt.Sprintf("group %d open=%v", i+1, g.Open)) + } + } + }) + displayCode(c, avatarGroupCode, log) +} + +const avatarGroupCode = `mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})` + +type badgeState struct { + count, max float64 + text string + tone, pos string +} + +var badgeTones = map[string]mujica.BadgeTone{"Accent": mujica.BadgeAccent, "Neutral": mujica.BadgeNeutral, "Success": mujica.BadgeSuccess, + "Warning": mujica.BadgeWarning, "Danger": mujica.BadgeDanger, "Info": mujica.BadgeInfo} +var badgePositions = map[string]mujica.BadgePosition{"TopRight": mujica.BadgeTopRight, "TopLeft": mujica.BadgeTopLeft, + "BottomRight": mujica.BadgeBottomRight, "BottomLeft": mujica.BadgeBottomLeft} + +func badgeDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "017", func() badgeState { return badgeState{count: 120, max: 99, tone: "Accent", pos: "TopRight"} }) + mujica.Heading(c, "Badge", mujica.HeadingOptions{Subtitle: "文字或计数、上限、位置与完整朗读", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.Text(c, fmt.Sprintf("Count %d", int(s.count))) + ui.StepSlider(c, &s.count, 0, 200, 1).Width(160) + ui.Text(c, fmt.Sprintf("Max %d", int(s.max))) + ui.StepSlider(c, &s.max, 9, 999, 10).Width(120) + ui.TextInput(c, &s.text).Placeholder("Text overrides count").Width(180) + if ui.Select(c, &s.tone, []string{"Accent", "Neutral", "Success", "Warning", "Danger", "Info"}).Changed() { + log("tone " + s.tone) + } + if ui.Select(c, &s.pos, []string{"TopRight", "TopLeft", "BottomRight", "BottomLeft"}).Changed() { + log("position " + s.pos) + } + }) + }) + opts := mujica.BadgeOptions{Text: s.text, Count: int(s.count), Max: int(s.max), Tone: badgeTones[s.tone], Label: "Unread"} + displaySection(c, "Inline", func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Inbox") + mujica.Badge(c, opts, nil) + }) + }) + displaySection(c, "Corner", func() { + opts.Position = badgePositions[s.pos] + ui.Box(c).Padding(10).Children(func() { + mujica.Badge(c, opts, func() { mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40}) }) + }) + }) + displayCode(c, badgeCode, log) +} + +const badgeCode = `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{}) +})` + +type tagState struct { + tags []string + icon, clickable, closable, dis bool +} + +func tagDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "018", func() tagState { + return tagState{tags: []string{"Baroque", "Gothic", "Rococo", "Court"}, icon: true, clickable: true, closable: true} + }) + mujica.Heading(c, "Tag", mujica.HeadingOptions{Subtitle: "标签文字、图标;点击与关闭分离", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.Checkbox(c, &s.icon, "Icon") + ui.Checkbox(c, &s.clickable, "Clickable") + ui.Checkbox(c, &s.closable, "Closable") + ui.Checkbox(c, &s.dis, "Disabled") + if mujica.Button(c, "Reset tags", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + s.tags = []string{"Baroque", "Gothic", "Rococo", "Court"} + log("reset") + } + }) + }) + displaySection(c, "Preview", func() { + icon := "" + if s.icon { + icon = "star" + } + ui.Row(c).Gap(8).Wrap().Children(func() { + for i := 0; i < len(s.tags); i++ { + t := s.tags[i] + v := mujica.Tag(c, t, mujica.TagOptions{Icon: icon, Clickable: s.clickable, Closable: s.closable, Disabled: s.dis}) + v.Element.Key(t) + if v.Clicked { + log("clicked " + t) + } + if v.Closed { + log("closed " + t) + s.tags = append(s.tags[:i:i], s.tags[i+1:]...) + i-- + } + } + }) + }) + displayCode(c, tagCode, log) +} + +const tagCode = `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:]...) + } +}` + +type linkState struct { + url string + external string +} + +func linkDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "019", func() linkState { return linkState{url: "https://example.com/charter"} }) + mujica.Heading(c, "Link", mujica.HeadingOptions{Subtitle: "外部地址或应用内动作;键盘激活;打开失败反馈", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + ui.TextInput(c, &s.url).Placeholder("URL (empty = in-app action)").Width(320) + if mujica.Button(c, "Simulate open failure", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.external = "No application can open this link." + log("open failure set") + } + if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + s.external = "" + } + }) + }) + displaySection(c, "Preview", func() { + if mujica.Link(c, "Read the court charter", mujica.LinkOptions{URL: s.url, Error: s.external}).Clicked() { + log("activated " + s.url) + } + if mujica.Link(c, "Show the guest list", mujica.LinkOptions{}).Clicked() { + log("in-app action") + } + }) + displayCode(c, linkCode, log) +} + +const linkCode = `mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"}) +if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() { + details = !details +}` + +type kbdState struct{ combo string } + +func kbdDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "020", func() kbdState { return kbdState{combo: "Mod+Shift+P"} }) + mujica.Heading(c, "Kbd", mujica.HeadingOptions{Subtitle: "单键与组合键;按平台显示 Command 或 Ctrl", Divider: true}) + displaySection(c, "Parameters", func() { + displayControls(c, func() { + if ui.TextInput(c, &s.combo).Placeholder("Keys joined by +").Width(220).Changed() { + log("keys " + s.combo) + } + }) + }) + displaySection(c, "Preview", func() { + var keys []string + for _, k := range strings.Split(s.combo, "+") { + if k = strings.TrimSpace(k); k != "" { + keys = append(keys, k) + } + } + if len(keys) > 0 { + mujica.Kbd(c, keys...) + } + }) + displaySection(c, "Common", func() { + for _, row := range [][]string{{"Mod", "K"}, {"Mod", "Shift", "P"}, {"Alt", "Up"}, {"Esc"}, {"Ctrl", "Tab"}} { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + mujica.Kbd(c, row...) + ui.Text(c, strings.Join(row, " + ")).TextColor(mujica.Tokens(c).TextMuted) + }) + } + }) + displayCode(c, kbdCode, log) +} + +const kbdCode = `ui.Row(c).Gap(6).Children(func() { + mujica.Text(c, "Command palette", mujica.TextOptions{}) + mujica.Kbd(c, "Mod", "Shift", "P") +})` diff --git a/examples/gallery/pages_display_parts.go b/examples/gallery/pages_display_parts.go new file mode 100644 index 0000000..57d8d45 --- /dev/null +++ b/examples/gallery/pages_display_parts.go @@ -0,0 +1,32 @@ +package main + +import ( + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +// displaySection shows a titled block of a demo. +func displaySection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(10).Margin(0, 0, 20, 0).Children(func() { + mujica.Heading(c, title, mujica.HeadingOptions{Level: 3}) + fn() + }) +} + +// displayControls lays out a row of parameter controls. +func displayControls(c *ui.Context, fn func()) { + ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn) +} + +// displayCode shows a page's snippet with a copy button. +func displayCode(c *ui.Context, code string, log func(string)) { + displaySection(c, "Code", func() { + if mujica.Button(c, "Copy code", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() { + c.WriteClipboard(code) + log("code copied") + } + mujica.Text(c, code, mujica.TextOptions{Selectable: true}).Font(theme.MonoFont).FontSize(12) + }) +} diff --git a/examples/gallery/pages_feedback.go b/examples/gallery/pages_feedback.go index f83272f..ea89ade 100644 --- a/examples/gallery/pages_feedback.go +++ b/examples/gallery/pages_feedback.go @@ -1,3 +1,113 @@ package main -var feedbackPages = []page{} +var feedbackPages = []page{ + { + ID: "091", Name: "Alert", ZhName: "提示条 警告 通知横幅", Category: "Feedback", + Demo: alertDemo, + Code: `open := true +r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{ + Severity: mujica.SeverityError, + Title: "Import failed", + Action: "Retry", +}) +if r.Action { + // retry the import +}`, + Keys: "Tab moves to the action and the dismiss button; Enter or Space presses them.", + }, + { + ID: "092", Name: "Toast", ZhName: "轻提示 消息队列 吐司", Category: "Feedback", + Demo: toastDemo, + Code: `if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"}) +} +// Last in the view: +if r := mujica.Toaster(c); r.Action == "note-1" { + // restore the note +}`, + Keys: "Toasts never take the focus. Tab reaches their buttons after the page; Escape closes the focused toast. Hover or focus pauses its timer.", + }, + { + ID: "093", Name: "Progress", ZhName: "进度条 环形进度 计量条", Category: "Feedback", + Demo: progressDemo, + Code: `mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true}) +mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"}) +mujica.Meter(c, 93, mujica.MeterOptions{ + Max: 100, Label: "Disk", ShowValue: true, + Levels: &ui.MeterLevels{Warning: 70, Critical: 90}, +})`, + Keys: "Not focusable; assistive technology reads its name and value.", + }, + { + ID: "094", Name: "Spinner", ZhName: "加载标记 加载中 转圈", Category: "Feedback", + Demo: spinnerDemo, + Code: `mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})`, + Keys: "Not focusable; read as an indeterminate progress indicator named by Label.", + }, + { + ID: "095", Name: "Skeleton", ZhName: "骨架屏 占位", Category: "Feedback", + Demo: skeletonDemo, + Code: `ui.Row(c).Gap(12).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle}) + ui.Column(c).Grow(1).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2}) + }) +})`, + Keys: "Decorative; not focusable and hidden from assistive technology.", + }, + { + ID: "096", Name: "EmptyState", ZhName: "空状态 无数据 无搜索结果", Category: "Feedback", + Demo: emptyStateDemo, + Code: `r := mujica.EmptyState(c, mujica.EmptyStateOptions{ + Kind: mujica.EmptyNoResults, + Query: query, + Action: "Clear search", +}) +if r.Action { + query = "" +}`, + Keys: "Tab reaches the action button; Enter or Space presses it.", + }, + { + ID: "097", Name: "Result", ZhName: "结果页 成功 失败 无权限", Category: "Feedback", + Demo: resultDemo, + Code: `r := mujica.Result(c, mujica.ResultOptions{ + Kind: mujica.ResultForbidden, + Description: "You need the Editor role to change billing.", + Actions: []string{"Request access", "Go back"}, +}) +switch r.Action { +case 0: // send the request +case 1: // navigate back +}`, + Keys: "Tab moves through the actions, primary first; Enter or Space presses one.", + }, + { + ID: "098", Name: "StatusIndicator", ZhName: "状态标记 在线 离线", Category: "Feedback", + Demo: statusDemo, + Code: `mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"}) +mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})`, + Keys: "Not focusable; read as a status with its word.", + }, + { + ID: "099", Name: "NotificationCenter", ZhName: "通知中心 消息列表", Category: "Feedback", + Demo: notificationDemo, + Code: `r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{}) +if r.Action == "a" { + // open the build log +}`, + Keys: "The list is one Tab stop. Arrows move among notifications and their buttons; Enter marks the focused one read; Delete or Backspace removes it.", + }, + { + ID: "100", Name: "LoadingOverlay", ZhName: "加载遮罩 区域加载", Category: "Feedback", + Demo: loadingOverlayDemo, + Code: `r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() { + ui.Text(c, "Quarterly report") + mujica.Button(c, "Export", mujica.ButtonOptions{}) +}) +if r.Canceled { + loading = false +}`, + Keys: "While loading, the covered region takes no input; focus inside moves to Cancel, and Escape there cancels. The rest of the window stays usable.", + }, +} diff --git a/examples/gallery/pages_feedback_demos.go b/examples/gallery/pages_feedback_demos.go new file mode 100644 index 0000000..f7c6cbd --- /dev/null +++ b/examples/gallery/pages_feedback_demos.go @@ -0,0 +1,401 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +// feedbackSection titles a part of a demo. +func feedbackSection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(8).FillWidth().Children(func() { + ui.Text(c, title).FontSize(theme.CaptionSize).TextColor(mujica.Tokens(c).TextMuted) + fn() + }) +} + +var feedbackSeverities = []mujica.Severity{mujica.SeverityInfo, mujica.SeveritySuccess, mujica.SeverityWarning, mujica.SeverityError} + +func alertDemo(c *ui.Context, log func(string)) { + type state struct { + sev int + title, action bool + closable bool + open bool + } + s := ui.Local(c.Root(), "091", func() state { return state{sev: 3, title: true, action: true, closable: true, open: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error") + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.title, "Title") + ui.Checkbox(c, &s.action, "Action") + ui.Checkbox(c, &s.closable, "Closable") + }) + }) + feedbackSection(c, "Live", func() { + opts := mujica.AlertOptions{Severity: feedbackSeverities[s.sev]} + if s.title { + opts.Title = "Import failed" + } + if s.action { + opts.Action = "Retry" + } + var open *bool + if s.closable { + open = &s.open + } + r := mujica.Alert(c, open, "The archive could not be read.", opts) + if r.Action { + log("alert: action Retry") + } + if r.Closed { + log("alert: closed") + } + if s.closable && !s.open && mujica.Button(c, "Show again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.open = true + log("alert: reopened") + } + }) + feedbackSection(c, "Severities", func() { + for _, sev := range feedbackSeverities { + mujica.Alert(c, nil, "Severity "+sev.String()+" uses its own icon, bar and name.", mujica.AlertOptions{Severity: sev}).Element.Key(sev) + } + }) + }) +} + +func toastDemo(c *ui.Context, log func(string)) { + type state struct { + sev int + action bool + seconds float64 + n int + note string + } + s := ui.Local(c.Root(), "092", func() state { return state{seconds: 5} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error") + ui.Checkbox(c, &s.action, "Undo action") + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Duration %.0f s (errors stay until closed)", s.seconds)) + ui.Slider(c, &s.seconds, 1, 10).Width(200) + }) + }) + feedbackSection(c, "Actions", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + if mujica.Button(c, "Show toast", mujica.ButtonOptions{}).Clicked() { + s.n++ + m := mujica.ToastMessage{ID: fmt.Sprint(s.n), Severity: feedbackSeverities[s.sev], Message: fmt.Sprintf("Message %d", s.n)} + if s.action { + m.Action = "Undo" + } + if m.Severity != mujica.SeverityError { + m.Duration = time.Duration(s.seconds * float64(time.Second)) + } + mujica.ShowToast(c, m) + log("toast: show " + m.ID) + } + if mujica.Button(c, "Show five at once", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + for range 5 { + s.n++ + mujica.ShowToast(c, mujica.ToastMessage{ID: fmt.Sprint(s.n), Message: fmt.Sprintf("Queued %d", s.n)}) + } + log("toast: five queued, three visible") + } + if mujica.Button(c, "Repeat same ID", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.n++ + mujica.ShowToast(c, mujica.ToastMessage{ID: "sync", Message: fmt.Sprintf("Synced %d times", s.n)}) + log("toast: dedupe id sync") + } + }) + ui.Text(c, "Type here while toasts appear; the focus stays:") + ui.TextInput(c, &s.note).Width(320) + }) + }) + if r := mujica.Toaster(c); r.Action != "" { + log("toast: action " + r.Action) + } else if r.Closed != "" { + log("toast: closed " + r.Closed) + } +} + +func progressDemo(c *ui.Context, log func(string)) { + type state struct { + value, meter float64 + indet, showValue bool + ring int + } + s := ui.Local(c.Root(), "093", func() state { return state{value: 0.62, meter: 72, showValue: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.ring, "Linear", "Ring") + ui.Row(c).Gap(16).Children(func() { + if ui.Checkbox(c, &s.indet, "Indeterminate").Changed() { + log(fmt.Sprintf("progress: indeterminate %v", s.indet)) + } + ui.Checkbox(c, &s.showValue, "Show value") + }) + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Value") + if ui.Slider(c, &s.value, 0, 1).Width(240).Changed() { + log(fmt.Sprintf("progress: value %.2f", s.value)) + } + }) + }) + feedbackSection(c, "Progress", func() { + mujica.Progress(c, s.value, mujica.ProgressOptions{ + Shape: mujica.ProgressShape(s.ring), Indeterminate: s.indet, Label: "Uploading", ShowValue: s.showValue, + }) + }) + feedbackSection(c, "Meter with thresholds 70 and 90", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Level") + if ui.Slider(c, &s.meter, 0, 100).Width(240).Changed() { + log(fmt.Sprintf("meter: value %.0f", s.meter)) + } + }) + v := float64(int(s.meter)) + mujica.Meter(c, v, mujica.MeterOptions{Max: 100, Label: "Disk", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}}) + mujica.Meter(c, 100-v, mujica.MeterOptions{Max: 100, Label: "Battery (low is bad)", ShowValue: true, Levels: &ui.MeterLevels{Warning: 30, Critical: 10}}) + }) + }) +} + +func spinnerDemo(c *ui.Context, log func(string)) { + type state struct { + size float64 + showLabel, active bool + } + s := ui.Local(c.Root(), "094", func() state { return state{size: 20, showLabel: true, active: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Size %.0f", s.size)) + ui.Slider(c, &s.size, 12, 48).Width(200) + }) + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.showLabel, "Show label") + if ui.Checkbox(c, &s.active, "Loading (unchecked stops the animation)").Changed() { + log(fmt.Sprintf("spinner: loading %v", s.active)) + } + }) + }) + feedbackSection(c, "Live", func() { + if s.active { + mujica.Spinner(c, mujica.SpinnerOptions{Size: float32(s.size), Label: "Syncing", ShowLabel: s.showLabel}) + } else { + ui.Text(c, "Done") + } + }) + feedbackSection(c, "In a button", func() { + mujica.Button(c, "Saving", mujica.ButtonOptions{Loading: true}) + }) + }) +} + +func skeletonDemo(c *ui.Context, log func(string)) { + type state struct { + lines float64 + loading bool + } + s := ui.Local(c.Root(), "095", func() state { return state{lines: 3, loading: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Lines %.0f", s.lines)) + ui.Slider(c, &s.lines, 1, 6).Width(200) + }) + if ui.Checkbox(c, &s.loading, "Loading").Changed() { + log(fmt.Sprintf("skeleton: loading %v", s.loading)) + } + }) + feedbackSection(c, "Card (same size loading and loaded)", func() { + ui.Row(c).Gap(12).Padding(16).Width(420).Background(mujica.Tokens(c).Surface).Radius(theme.CardRadius).Children(func() { + if s.loading { + mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle}) + ui.Column(c).Grow(1).Children(func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Lines: int(s.lines)}) + }) + return + } + ui.Box(c).Size(40, 40).Radius(20).Background(mujica.Tokens(c).Selection) + ui.Column(c).Grow(1).Children(func() { + for i := range int(s.lines) { + ui.Text(c, fmt.Sprintf("Loaded line %d", i+1)).LineHeight(theme.LineHeight) + } + }) + }) + }) + feedbackSection(c, "Rect", func() { + mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonRect, Width: 420, Height: 96}) + }) + }) +} + +func emptyStateDemo(c *ui.Context, log func(string)) { + type state struct { + kind int + query string + icon, action bool + } + s := ui.Local(c.Root(), "096", func() state { return state{kind: 1, query: "quarterly", icon: true, action: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.kind, "No data", "No results") + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + ui.Checkbox(c, &s.icon, "Icon") + ui.Checkbox(c, &s.action, "Action") + if s.kind == 1 { + ui.TextInput(c, &s.query).Width(200) + } + }) + }) + feedbackSection(c, "Live", func() { + opts := mujica.EmptyStateOptions{Kind: mujica.EmptyKind(s.kind), NoIcon: !s.icon} + if s.kind == 1 { + opts.Query = s.query + } + if s.action { + opts.Action = []string{"New project", "Clear search"}[s.kind] + } + if mujica.EmptyState(c, opts).Action { + log("empty state: action " + opts.Action) + s.query = "" + } + }) + }) +} + +func resultDemo(c *ui.Context, log func(string)) { + type state struct{ kind int } + s := ui.Local(c.Root(), "097", func() state { return state{kind: 2} }) + descs := []string{"Your order has been placed.", "The payment was declined by the bank.", "You need the Editor role to change billing."} + actions := [][]string{{"View order", "Keep shopping"}, {"Try again", "Back to cart"}, {"Request access", "Go back"}} + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.kind, "Success", "Failure", "Forbidden") + }) + r := mujica.Result(c, mujica.ResultOptions{Kind: mujica.ResultKind(s.kind), Description: descs[s.kind], Actions: actions[s.kind]}) + if r.Action >= 0 { + log("result: action " + actions[s.kind][r.Action]) + } + }) +} + +func statusDemo(c *ui.Context, log func(string)) { + type state struct { + status int + custom bool + } + s := ui.Local(c.Root(), "098", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + if ui.Segmented(c, &s.status, "Success", "Warning", "Error", "Offline", "Busy").Changed() { + log("status: " + mujica.Status(s.status).String()) + } + ui.Checkbox(c, &s.custom, "Custom label") + }) + feedbackSection(c, "Live", func() { + opts := mujica.StatusIndicatorOptions{} + if s.custom { + opts.Label = "Database replica" + } + mujica.StatusIndicator(c, mujica.Status(s.status), opts) + }) + feedbackSection(c, "All states (shape and word, not color alone)", func() { + ui.Row(c).Gap(20).Wrap().Children(func() { + for st := mujica.StatusSuccess; st <= mujica.StatusBusy; st++ { + mujica.StatusIndicator(c, st, mujica.StatusIndicatorOptions{}).Key(st) + } + }) + }) + }) +} + +func notificationDemo(c *ui.Context, log func(string)) { + type state struct { + items []mujica.Notification + n int + } + s := ui.Local(c.Root(), "099", func() state { + return state{items: []mujica.Notification{ + {ID: "a", Title: "Build failed", Body: "main · 3 tests failing", Time: "2 min ago", Action: "View log"}, + {ID: "b", Title: "Invite accepted", Body: "Mira joined the workspace", Time: "1 h ago"}, + {ID: "c", Title: "Backup complete", Time: "Yesterday", Read: true}, + }} + }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Caller-owned list", func() { + if mujica.Button(c, "Add notification", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.n++ + id := fmt.Sprintf("new-%d", s.n) + s.items = append([]mujica.Notification{{ID: id, Title: fmt.Sprintf("Comment %d on the design doc", s.n), Time: "now"}}, s.items...) + log("notifications: added " + id) + } + }) + ui.Column(c).Width(520).Height(420).Children(func() { + r := mujica.NotificationCenter(c, &s.items, mujica.NotificationCenterOptions{}) + for _, ev := range [][2]string{{"action", r.Action}, {"toggled", r.Toggled}, {"opened", r.Opened}, {"removed", r.Removed}} { + if ev[1] != "" { + log("notifications: " + ev[0] + " " + ev[1]) + } + } + if r.MarkedAllRead { + log("notifications: marked all read") + } + if r.ClearedAll { + log("notifications: cleared all") + } + }) + }) +} + +func loadingOverlayDemo(c *ui.Context, log func(string)) { + type state struct { + loading, cancel bool + saves int + } + s := ui.Local(c.Root(), "100", func() state { return state{loading: true, cancel: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + if ui.Checkbox(c, &s.loading, "Loading").Changed() { + log(fmt.Sprintf("overlay: loading %v", s.loading)) + } + ui.Checkbox(c, &s.cancel, "Cancel action") + }) + }) + feedbackSection(c, "Covered region", func() { + opts := mujica.LoadingOverlayOptions{Label: "Generating report…"} + if s.cancel { + opts.Cancel = "Cancel" + } + r := mujica.LoadingOverlay(c, s.loading, opts, func() { + ui.Column(c).Gap(8).Padding(16).FillWidth().AlignItems(ui.Start).Background(mujica.Tokens(c).Surface).Radius(theme.CardRadius).Children(func() { + ui.Text(c, "Quarterly report").Font(theme.SerifFont).FontSize(theme.H3Size) + ui.Text(c, fmt.Sprintf("Exported %d times.", s.saves)) + if mujica.Button(c, "Export", mujica.ButtonOptions{}).Clicked() { + s.saves++ + log("overlay: export clicked") + } + }) + }) + r.Element.Width(420) + if r.Canceled { + s.loading = false + log("overlay: canceled") + } + }) + feedbackSection(c, "Outside the region (always usable)", func() { + if mujica.Button(c, "Outside button", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() { + log("overlay: outside clicked") + } + }) + }) +} diff --git a/examples/gallery/pages_form.go b/examples/gallery/pages_form.go index 4c1b18b..fbab963 100644 --- a/examples/gallery/pages_form.go +++ b/examples/gallery/pages_form.go @@ -1,3 +1,416 @@ package main -var formPages = []page{} +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +// formStates shows a component's states side by side. +func formStates(c *ui.Context, fn func()) { + ui.Column(c).Gap(8).Children(func() { + ui.Text(c, "States").FontWeight(600) + ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(fn) + }) +} + +// formToggles shows parameter check boxes in one row. +func formToggles(c *ui.Context, names []string, values ...*bool) { + ui.Row(c).Gap(16).Wrap().Children(func() { + for i, n := range names { + ui.Checkbox(c, values[i], n) + } + }) +} + +type formDemo struct { + st mujica.FormState + name, email string + tags []string + horizontal, slowServer bool +} + +type fieldDemo struct { + value string + required, horizontal bool + withError bool +} + +type groupDemo struct { + value string + disabled, readOnly, invalid bool +} + +type pinDemo struct { + code string + length4, disabled bool + invalid bool +} + +type tagsDemo struct { + tags []string + limit, disabled bool +} + +type ratingDemo struct { + value float64 + half, readOnly, disabled bool +} + +type colorDemo struct { + color ui.Color + disabled bool +} + +type pickerDemo struct { + paths []string + directory, multiple, filter bool + disabled bool +} + +type dropDemo struct { + files []mujica.DroppedFile + images bool + disabled bool +} + +type transferDemo struct { + chosen []string + disabled bool +} + +var transferDemoItems = []mujica.TransferItem{ + {ID: "sakiko", Label: "Sakiko Togawa"}, {ID: "uika", Label: "Uika Misumi"}, {ID: "mutsumi", Label: "Mutsumi Wakaba"}, + {ID: "umiri", Label: "Umiri Yahata"}, {ID: "nyamu", Label: "Nyamu Yutenji"}, {ID: "tomori", Label: "Tomori Takamatsu", Disabled: true}, + {ID: "anon", Label: "Anon Chihaya"}, {ID: "rana", Label: "Rana Kaname"}, {ID: "soyo", Label: "Soyo Nagasaki"}, +} + +type formAsync struct { + version uint64 + err string +} + +var formPages = []page{ + {ID: "041", Name: "Form", ZhName: "表单", Category: "Forms", Code: formCode["Form"], + Keys: "Tab moves between fields; leaving a field validates it. Enter in a field submits. A failed submit focuses the first error.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "041", func() formDemo { + return formDemo{name: "Sakiko", tags: []string{"keyboard"}} + }) + async := *ui.Local(c.Root(), "041.async", func() chan formAsync { return make(chan formAsync, 8) }) + saved := *ui.Local(c.Root(), "041.save", func() chan string { return make(chan string, 1) }) + for drained := false; !drained; { + select { + case r := <-async: + log(fmt.Sprintf("async name check v%d: %q, kept %v", r.version, r.err, d.st.Resolve("name", r.version, r.err))) + case e := <-saved: + d.st.EndSubmit(e) + log(fmt.Sprintf("submit ended, error %q", e)) + default: + drained = true + } + } + formToggles(c, []string{"Labels beside fields", "Server rejects the submit"}, &d.horizontal, &d.slowServer) + res := mujica.Form(c, &d.st, mujica.FormOptions{Horizontal: d.horizontal}, func(f *mujica.FormScope) { + mujica.FormItem(f, "name", &d.name, mujica.FormItemOptions[string]{Label: "Stage name", Required: true, Initial: "Sakiko", + Description: "Checked against the archive; \"Oblivionis\" is taken.", + Async: func(v string, version uint64) { + log(fmt.Sprintf("async check started v%d", version)) + go func() { + time.Sleep(700 * time.Millisecond) + msg := "" + if strings.EqualFold(v, "Oblivionis") { + msg = "That name is taken." + } + async <- formAsync{version, msg} + c.Invalidate() + }() + }}, func() *ui.Element { + return mujica.InputGroup(c, &d.name, mujica.InputGroupOptions{Invalid: d.st.Error("name") != ""}).Input + }) + mujica.FormItem(f, "email", &d.email, mujica.FormItemOptions[string]{Label: "Email", Required: true, + Validate: func(v string) string { + if !strings.Contains(v, "@") { + return "Enter an email address." + } + return "" + }}, func() *ui.Element { + g := mujica.InputGroup(c, &d.email, mujica.InputGroupOptions{Invalid: d.st.Error("email") != ""}) + if g.Submitted() { + d.st.Submit() + } + return g.Input + }) + mujica.FormItem(f, "tags", &d.tags, mujica.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element { + return mujica.TagsInput(c, &d.tags, mujica.TagsInputOptions{Max: 4, Invalid: d.st.Error("tags") != ""}).Input + }) + }) + if res.Submitted() { + log(fmt.Sprintf("submitted %q %q %q", d.name, d.email, d.tags)) + fail := "" + if d.slowServer { + fail = "The archive refused the entry. Your values are kept." + } + go func() { + time.Sleep(900 * time.Millisecond) + saved <- fail + c.Invalidate() + }() + } + if res.Reset() { + log("reset to initial values") + } + }}, + {ID: "042", Name: "FormField", ZhName: "表单字段", Category: "Forms", Code: formCode["FormField"], + Keys: "Clicking the label focuses the control; Tab reaches the control directly.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "042", func() fieldDemo { return fieldDemo{} }) + formToggles(c, []string{"Required", "Label beside", "Show error"}, &d.required, &d.horizontal, &d.withError) + e := "" + if d.withError { + e = "This name is already in the guest list." + } + mujica.FormField(c, "Guest name", mujica.FormFieldOptions{Required: d.required, Horizontal: d.horizontal, + Description: "As written on the invitation.", Error: e}, func() { + if mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError}).Changed() { + log(fmt.Sprintf("value %q", d.value)) + } + }) + }}, + {ID: "043", Name: "InputGroup", ZhName: "输入组合", Category: "Forms", Code: formCode["InputGroup"], + Keys: "Each button and the input are separate Tab stops. Enter in the input submits.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "043", func() groupDemo { return groupDemo{value: "mujica"} }) + formToggles(c, []string{"Disabled", "Read-only", "Invalid"}, &d.disabled, &d.readOnly, &d.invalid) + opts := mujica.InputGroupOptions{Prefix: "https://", Suffix: ".org", Label: "Site", Disabled: d.disabled, ReadOnly: d.readOnly, Invalid: d.invalid, + Buttons: []mujica.InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}, {Label: "Copy", Icon: icons.Must("copy")}}} + g := mujica.InputGroup(c, &d.value, opts) + switch g.Clicked { + case 0: + d.value = "" + log("clear") + case 1: + c.WriteClipboard("https://" + d.value + ".org") + log("copied") + } + if g.Changed() { + log(fmt.Sprintf("value %q", d.value)) + } + if g.Submitted() { + log("submitted") + } + formStates(c, func() { + for _, o := range []mujica.InputGroupOptions{{Label: "Disabled", Disabled: true}, {Label: "Read-only", ReadOnly: true}, {Label: "Invalid", Invalid: true}} { + v := o.Label + mujica.InputGroup(c, &v, o) + } + }) + }}, + {ID: "044", Name: "PinInput", ZhName: "验证码输入", Category: "Forms", Code: formCode["PinInput"], + Keys: "Type digits to fill cells, Backspace clears the last, Cmd/Ctrl+V pastes the whole code.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "044", func() pinDemo { return pinDemo{} }) + formToggles(c, []string{"4 digits", "Disabled", "Invalid"}, &d.length4, &d.disabled, &d.invalid) + n := 6 + if d.length4 { + n = 4 + } + if len(d.code) > n { + d.code = d.code[:n] + } + p := mujica.PinInput(c, &d.code, mujica.PinInputOptions{Length: n, Disabled: d.disabled, Invalid: d.invalid}) + if p.Changed() { + log(fmt.Sprintf("code %q", d.code)) + } + if p.Completed() { + log(fmt.Sprintf("completed %q", d.code)) + } + formStates(c, func() { + a, b := "12", "0420" + mujica.PinInput(c, &a, mujica.PinInputOptions{Length: 4, Disabled: true, Label: "Disabled code"}) + mujica.PinInput(c, &b, mujica.PinInputOptions{Length: 4, Invalid: true, Label: "Invalid code"}) + }) + }}, + {ID: "045", Name: "TagsInput", ZhName: "标签输入", Category: "Forms", Code: formCode["TagsInput"], + Keys: "Enter or a comma adds a tag; Backspace in the empty input removes the last; Tab reaches each remove button.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "045", func() tagsDemo { return tagsDemo{tags: []string{"rose", "thorn"}} }) + formToggles(c, []string{"At most 5", "Disabled"}, &d.limit, &d.disabled) + opts := mujica.TagsInputOptions{Placeholder: "Add a flower", Label: "Flowers", Disabled: d.disabled, + Validate: func(s string) string { + if len([]rune(s)) > 16 { + return "Keep tags under 16 characters." + } + return "" + }} + if d.limit { + opts.Max = 5 + } + if mujica.TagsInput(c, &d.tags, opts).Changed() { + log(fmt.Sprintf("tags %q", d.tags)) + } + }}, + {ID: "046", Name: "Rating", ZhName: "评分", Category: "Forms", Code: formCode["Rating"], + Keys: "Arrows change by one step, Home clears, End fills.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "046", func() ratingDemo { return ratingDemo{value: 3, half: true} }) + formToggles(c, []string{"Half stars", "Read-only", "Disabled"}, &d.half, &d.readOnly, &d.disabled) + step := 1.0 + if d.half { + step = 0.5 + } + if !d.half { + d.value = float64(int(d.value + 0.5)) + } + if mujica.Rating(c, &d.value, mujica.RatingOptions{Step: step, ReadOnly: d.readOnly, Disabled: d.disabled, ShowValue: true}).Changed() { + log(fmt.Sprintf("rating %v", d.value)) + } + formStates(c, func() { + a, b := 4.5, 2.0 + mujica.Rating(c, &a, mujica.RatingOptions{Step: 0.5, ReadOnly: true, Label: "Read-only rating"}) + mujica.Rating(c, &b, mujica.RatingOptions{Disabled: true, Label: "Disabled rating"}) + }) + }}, + {ID: "047", Name: "ColorPicker", ZhName: "颜色选择器", Category: "Forms", Code: formCode["ColorPicker"], + Keys: "Enter or Space opens; arrows move in the square and sliders; Escape closes and returns focus.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "047", func() colorDemo { return colorDemo{color: ui.Hex("#7D2034")} }) + formToggles(c, []string{"Disabled"}, &d.disabled) + if mujica.ColorPicker(c, &d.color, mujica.ColorPickerOptions{Label: "Banner color", Disabled: d.disabled}).Changed() { + log(fmt.Sprintf("color %v", d.color)) + } + }}, + {ID: "048", Name: "FilePicker", ZhName: "文件选择", Category: "Forms", Code: formCode["FilePicker"], + Keys: "Enter or Space opens the system dialog; the dialog's own keys apply.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "048", func() pickerDemo { return pickerDemo{multiple: true} }) + formToggles(c, []string{"Folders", "Multiple", "PDF and MIDI only", "Disabled"}, &d.directory, &d.multiple, &d.filter, &d.disabled) + opts := mujica.FilePickerOptions{Directory: d.directory, Multiple: d.multiple, Disabled: d.disabled, Title: "Choose scores"} + if d.filter { + opts.Filters = []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}} + } + p := mujica.FilePicker(c, &d.paths, opts) + if p.Changed() { + log(fmt.Sprintf("picked %d: %q", len(d.paths), d.paths)) + } + if p.Canceled() { + log("canceled (not an error)") + } + }}, + {ID: "049", Name: "FileDropZone", ZhName: "文件拖放区", Category: "Forms", Code: formCode["FileDropZone"], + Keys: "Tab reaches Browse… and each remove button; dropping needs a pointer, Browse is the keyboard path.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "049", func() dropDemo { return dropDemo{} }) + formToggles(c, []string{"Images only", "Disabled"}, &d.images, &d.disabled) + opts := mujica.FileDropZoneOptions{MaxFiles: 4, MaxSize: 10 << 20, Disabled: d.disabled} + if d.images { + opts.Extensions = []string{"png", "jpg", "jpeg", "gif"} + } + if mujica.FileDropZone(c, &d.files, opts).Changed() { + log(fmt.Sprintf("files %d", len(d.files))) + } + }}, + {ID: "050", Name: "Transfer", ZhName: "穿梭框", Category: "Forms", Code: formCode["Transfer"], + Keys: "Up and Down move within a list, Space checks; Tab reaches the move buttons.", + Demo: func(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "050", func() transferDemo { return transferDemo{chosen: []string{"sakiko"}} }) + formToggles(c, []string{"Disabled"}, &d.disabled) + if mujica.Transfer(c, &d.chosen, transferDemoItems, mujica.TransferOptions{SourceTitle: "Auditions", TargetTitle: "Band", Disabled: d.disabled}).Changed() { + log(fmt.Sprintf("band %q", d.chosen)) + } + }}, +} + +// formCode holds each page's copyable snippet, the same as its doc and Example. +var formCode = map[string]string{ + "Form": `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)`, + "FormField": `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)`, + "InputGroup": `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)`, + "PinInput": `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)`, + "TagsInput": `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)`, + "Rating": `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)`, + "ColorPicker": `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)`, + "FilePicker": `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)`, + "FileDropZone": `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)`, + "Transfer": `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)`, +} diff --git a/examples/gallery/pages_input.go b/examples/gallery/pages_input.go index 72bd7b6..f1db221 100644 --- a/examples/gallery/pages_input.go +++ b/examples/gallery/pages_input.go @@ -1,3 +1,358 @@ package main -var inputPages = []page{} +import ( + "fmt" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +const inputCategory = "Inputs" + +// inputVariants lists the button variants for variant pickers. +var inputVariants = []mujica.RadioItem[mujica.ButtonVariant]{ + {Value: mujica.Primary, Label: "Primary"}, {Value: mujica.Secondary, Label: "Secondary"}, + {Value: mujica.Outline, Label: "Outline"}, {Value: mujica.Ghost, Label: "Ghost"}, {Value: mujica.Danger, Label: "Danger"}, +} + +// inputSection titles one part of a demo. +func inputSection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(8).Children(func() { + ui.Text(c, title).FontWeight(600).TextColor(mujica.Tokens(c).TextMuted) + fn() + }) +} + +// inputFlags builds one checkbox per named flag, logging each toggle. +func inputFlags(c *ui.Context, log func(string), flags map[string]*bool, order ...string) { + ui.Row(c).Gap(16).Wrap().Children(func() { + for _, name := range order { + if mujica.Checkbox(c, flags[name], name, mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("option %s = %v", name, *flags[name])) + } + } + }) +} + +type inputButtonDemo struct { + variant mujica.ButtonVariant + icon, loading, disabled bool +} + +type inputTextDemo struct { + value, err string + clearable, readOnly, disabled, sl, fail bool +} + +type inputAreaDemo struct { + value string + max float64 + readOnly, disabled bool +} + +type inputNumberDemo struct { + value, step float64 + empty, stepper, disabled, fail bool +} + +type inputSearchDemo struct { + query, find string + current int + disabled bool +} + +type inputCheckDemo struct { + single, mixed, disabled bool + items [4]bool +} + +type inputGroupDemo struct { + vertical, disabled bool +} + +type inputRadioDemo struct { + value string + horizontal, disabledAll bool +} + +var inputPages = []page{ + {ID: "021", Name: "Button", ZhName: "按钮", Category: inputCategory, Keys: "Tab 聚焦;Enter 或 Space 触发。", + Code: `if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() { + fmt.Println("saved") +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "021", func() inputButtonDemo { return inputButtonDemo{} }) + inputSection(c, "Parameters", func() { + mujica.RadioGroup(c, &s.variant, inputVariants, mujica.RadioGroupOptions{Horizontal: true, Label: "Variant"}) + inputFlags(c, log, map[string]*bool{"Icon": &s.icon, "Loading": &s.loading, "Disabled": &s.disabled}, "Icon", "Loading", "Disabled") + }) + inputSection(c, "Live", func() { + opts := mujica.ButtonOptions{Variant: s.variant, Loading: s.loading, Disabled: s.disabled} + if s.icon { + opts.Icon = icons.Must("check") + } + ui.Row(c).Children(func() { + if mujica.Button(c, "Save changes", opts).Clicked() { + log("Button clicked") + } + }) + }) + inputSection(c, "Variants", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + for _, v := range inputVariants { + if mujica.Button(c, v.Label, mujica.ButtonOptions{Variant: v.Value}).Clicked() { + log(v.Label + " clicked") + } + } + mujica.Button(c, "Loading", mujica.ButtonOptions{Loading: true}) + mujica.Button(c, "Disabled", mujica.ButtonOptions{Disabled: true}) + }) + }) + }}, + {ID: "022", Name: "IconButton", ZhName: "图标按钮", Category: inputCategory, Keys: "Tab 聚焦;Enter 或 Space 触发;悬停显示提示。", + Code: `if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() { + fmt.Println("open settings") +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "022", func() inputButtonDemo { return inputButtonDemo{variant: mujica.Ghost} }) + inputSection(c, "Parameters", func() { + mujica.RadioGroup(c, &s.variant, inputVariants, mujica.RadioGroupOptions{Horizontal: true, Label: "Variant"}) + inputFlags(c, log, map[string]*bool{"Loading": &s.loading, "Disabled": &s.disabled}, "Loading", "Disabled") + }) + inputSection(c, "Live", func() { + ui.Row(c).Gap(8).Children(func() { + for _, n := range []string{"bold", "italic", "underline", "copy", "trash-2"} { + opts := mujica.IconButtonOptions{Variant: s.variant, Loading: s.loading, Disabled: s.disabled} + if mujica.IconButton(c, icons.Must(n), n, opts).Clicked() { + log("IconButton " + n + " clicked") + } + } + }) + }) + }}, + {ID: "023", Name: "ButtonGroup", ZhName: "按钮组", Category: inputCategory, Keys: "Tab 进入组;方向键在组内移动并跳过禁用项;Enter 或 Space 触发。", + Code: `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) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "023", func() inputGroupDemo { return inputGroupDemo{} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Vertical": &s.vertical, "Disable group": &s.disabled}, "Vertical", "Disable group") + }) + inputSection(c, "Live", func() { + items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week", Disabled: true}, {Label: "Month"}, {Icon: icons.Must("calendar"), Name: "Pick a date"}} + r := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Vertical: s.vertical, Disabled: s.disabled, Label: "Range"}) + if i := r.ClickedItem(); i >= 0 { + log(fmt.Sprintf("ButtonGroup item %d clicked", i)) + } + }) + }}, + {ID: "024", Name: "TextInput", ZhName: "文本输入框", Category: inputCategory, Keys: "Tab 聚焦;Enter 提交;Escape 清空(可清空时);Cmd/Ctrl+Z 撤销、+C/X/V 剪贴板;支持输入法组合。", + Code: `r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true}) +if r.Submitted() { + fmt.Println("hello", name) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "024", func() inputTextDemo { return inputTextDemo{clearable: true, sl: true} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Clearable": &s.clearable, "Slots": &s.sl, "ReadOnly": &s.readOnly, "Disabled": &s.disabled}, "Clearable", "Slots", "ReadOnly", "Disabled") + mujica.TextInput(c, &s.err, mujica.TextInputOptions{Label: "Error message", Placeholder: "Error message (empty for none)"}) + }) + inputSection(c, "Live", func() { + opts := mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: s.clearable, ReadOnly: s.readOnly, Disabled: s.disabled, Error: s.err} + if s.sl { + opts.Leading = func() { ui.Icon(c, icons.Must("user")).Size(16, 16).TextColor(mujica.Tokens(c).TextMuted) } + opts.Trailing = func() { ui.Text(c, fmt.Sprintf("%d", len([]rune(s.value)))).TextColor(mujica.Tokens(c).TextMuted) } + } + r := mujica.TextInput(c, &s.value, opts) + if r.Changed() { + log(fmt.Sprintf("TextInput changed: %q", s.value)) + } + if r.Submitted() { + log(fmt.Sprintf("TextInput submitted: %q", s.value)) + } + }) + inputSection(c, "States", func() { + ui.Row(c).Gap(12).Children(func() { + e, ro, off := "1789-13-01", "Read only", "Disabled" + mujica.TextInput(c, &e, mujica.TextInputOptions{Label: "Error state", Error: "No such date"}).Grow(1) + mujica.TextInput(c, &ro, mujica.TextInputOptions{Label: "Read-only state", ReadOnly: true}).Grow(1) + mujica.TextInput(c, &off, mujica.TextInputOptions{Label: "Disabled state", Disabled: true}).Grow(1) + }) + }) + }}, + {ID: "025", Name: "TextArea", ZhName: "多行文本框", Category: inputCategory, Keys: "Enter 换行;Cmd/Ctrl+Enter 提交(可配置);滚轮与方向键滚动;撤销、剪贴板与输入法同 TextInput。", + Code: `r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200}) +if r.Submitted() { + fmt.Println("send", notes) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "025", func() inputAreaDemo { return inputAreaDemo{max: 120} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"ReadOnly": &s.readOnly, "Disabled": &s.disabled}, "ReadOnly", "Disabled") + if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 0, Max: 1000, Step: 10, Label: "MaxLength"}).Changed() { + log(fmt.Sprintf("option MaxLength = %v", s.max)) + } + }) + inputSection(c, "Live", func() { + r := mujica.TextArea(c, &s.value, mujica.TextAreaOptions{Label: "Notes", Placeholder: "Write a dedication…", MinHeight: 80, MaxHeight: 160, + MaxLength: int(s.max), ReadOnly: s.readOnly, Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("TextArea changed: %d characters", len([]rune(s.value)))) + } + if r.Submitted() { + log("TextArea submitted") + } + }) + }}, + {ID: "026", Name: "NumberInput", ZhName: "数字输入框", Category: inputCategory, Keys: "Up/Down 按步长增减;步进模式下 Up/Down/Left/Right、Home、End。", + Code: `if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() { + fmt.Println("copies", copies) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "026", func() inputNumberDemo { return inputNumberDemo{value: 3, step: 1} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"AllowEmpty": &s.empty, "StepperOnly": &s.stepper, "Disabled": &s.disabled, "Error": &s.fail}, "AllowEmpty", "StepperOnly", "Disabled", "Error") + steps := []mujica.RadioItem[float64]{{Value: 1, Label: "Step 1"}, {Value: 0.5, Label: "Step 0.5"}, {Value: 0.01, Label: "Step 0.01"}} + if mujica.RadioGroup(c, &s.step, steps, mujica.RadioGroupOptions{Horizontal: true, Label: "Step"}).Changed() { + log(fmt.Sprintf("option Step = %v", s.step)) + } + }) + if s.stepper { + s.empty = false + } + if !s.empty && math.IsNaN(s.value) { + s.value = 0 + } + inputSection(c, "Live", func() { + opts := mujica.NumberInputOptions{Min: 0, Max: 10, Step: s.step, AllowEmpty: s.empty, StepperOnly: s.stepper, Disabled: s.disabled, Label: "Quantity", Placeholder: "Empty"} + if s.fail { + opts.Error = "Not in stock" + } + ui.Box(c).Width(240).Children(func() { + if mujica.NumberInput(c, &s.value, opts).Changed() { + log(fmt.Sprintf("NumberInput changed: %v", s.value)) + } + }) + }) + inputSection(c, "States", func() { + ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() { + a, b, e := 4.0, 2.0, 7.5 + mujica.NumberInput(c, &a, mujica.NumberInputOptions{Min: 1, Max: 8, StepperOnly: true, Label: "Stepper state"}) + mujica.NumberInput(c, &b, mujica.NumberInputOptions{Label: "Disabled state", Disabled: true}).Width(160) + mujica.NumberInput(c, &e, mujica.NumberInputOptions{Step: 0.5, Label: "Error state", Error: "Must be whole"}).Width(160) + }) + }) + }}, + {ID: "027", Name: "PasswordInput", ZhName: "密码输入框", Category: inputCategory, Keys: "Tab 依次聚焦输入框与显隐按钮;Space 或 Enter 切换显隐;Enter 在输入框中提交;粘贴可用,遮蔽时不可复制。", + Code: `if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() { + fmt.Println("signing in") +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "027", func() inputTextDemo { return inputTextDemo{} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled, "Error": &s.fail}, "Disabled", "Error") + }) + inputSection(c, "Live", func() { + opts := mujica.PasswordInputOptions{Label: "Password", Placeholder: "Password", Disabled: s.disabled} + if s.fail { + opts.Error = "At least 8 characters" + } + r := mujica.PasswordInput(c, &s.value, opts) + if r.Changed() { + // The contents never reach the log. + log(fmt.Sprintf("PasswordInput changed: %d characters", len([]rune(s.value)))) + } + if r.Submitted() { + log("PasswordInput submitted") + } + }) + }}, + {ID: "028", Name: "SearchInput", ZhName: "搜索框", Category: inputCategory, Keys: "Enter 提交(搜索模式)或下一项(查找栏);Shift+Enter 上一项;Cmd+G / F3 下一项,Shift 加上为上一项;Escape 清空。", + Code: `matches := 0 +if query != "" { + matches = strings.Count(text, query) +} +r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t}) +if r.Moved() { + fmt.Println("show match", current) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "028", func() inputSearchDemo { return inputSearchDemo{} }) + const text = "The rose of the court, the rose of the chapel, and the rose of the garden all bloom in May." + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled") + }) + inputSection(c, "Search mode", func() { + r := mujica.SearchInput(c, &s.query, mujica.SearchInputOptions{Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("SearchInput changed: %q", s.query)) + } + if r.Submitted() { + log(fmt.Sprintf("SearchInput submitted: %q", s.query)) + } + }) + inputSection(c, "Find bar mode", func() { + n := 0 + if s.find != "" { + n = strings.Count(text, s.find) + } + r := mujica.SearchInput(c, &s.find, mujica.SearchInputOptions{Find: true, Matches: n, Current: &s.current, Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("find query: %q, %d matches", s.find, n)) + } + if r.Moved() { + log(fmt.Sprintf("find moved to match %d", s.current+1)) + } + ui.Text(c, text).TextColor(mujica.Tokens(c).TextMuted) + }) + }}, + {ID: "029", Name: "Checkbox", ZhName: "复选框", Category: inputCategory, Keys: "Tab 聚焦;Space 切换;点击标签同样切换;全选框在组内勾选全部或清空。", + Code: `mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{ + {Label: "Mail", Value: &mail}, + {Label: "Calendar", Value: &calendar}, +}, mujica.CheckboxGroupOptions{})`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "029", func() inputCheckDemo { return inputCheckDemo{items: [4]bool{true, false, false, true}} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Indeterminate": &s.mixed, "Disabled": &s.disabled}, "Indeterminate", "Disabled") + }) + inputSection(c, "Live", func() { + if mujica.Checkbox(c, &s.single, "Accept the terms", mujica.CheckboxOptions{Indeterminate: s.mixed, Disabled: s.disabled}).Changed() { + s.mixed = false + log(fmt.Sprintf("Checkbox changed: %v", s.single)) + } + }) + inputSection(c, "Group with select all", func() { + items := []mujica.CheckboxItem{ + {Label: "Mail", Value: &s.items[0]}, {Label: "Calendar", Value: &s.items[1]}, + {Label: "Messages", Value: &s.items[2]}, {Label: "Security alerts (required)", Value: &s.items[3], Disabled: true}, + } + if mujica.CheckboxGroup(c, "Notifications", items, mujica.CheckboxGroupOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("CheckboxGroup changed: %v", s.items)) + } + }) + }}, + {ID: "030", Name: "RadioGroup", ZhName: "单选组", Category: inputCategory, Keys: "Tab 进入组并落在已选项;方向键选择并跳过禁用项;Home、End 选择首末项。", + Code: `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) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "030", func() inputRadioDemo { return inputRadioDemo{value: "m"} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Horizontal": &s.horizontal, "Disabled": &s.disabledAll}, "Horizontal", "Disabled") + }) + inputSection(c, "Live", func() { + items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}, {Value: "xl", Label: "Sold out", Disabled: true}} + if mujica.RadioGroup(c, &s.value, items, mujica.RadioGroupOptions{Horizontal: s.horizontal, Disabled: s.disabledAll, Label: "Size"}).Changed() { + log("RadioGroup chose " + s.value) + } + }) + }}, +} diff --git a/examples/gallery/pages_layout.go b/examples/gallery/pages_layout.go index 9d315c6..cb295e1 100644 --- a/examples/gallery/pages_layout.go +++ b/examples/gallery/pages_layout.go @@ -1,3 +1,499 @@ package main -var layoutPages = []page{} +import ( + "fmt" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +var layoutPages = []page{ + {ID: "001", Name: "AppShell", ZhName: "应用外壳", Category: "Layout", Demo: layoutAppShellDemo, Code: layoutAppShellCode, + Keys: "Cmd/Ctrl+B or the toggle button folds the sidebar. Tab moves between the toggle, the sidebar and the content; arrows, Page Up/Down, Home and End scroll the focused region."}, + {ID: "002", Name: "TitleBar", ZhName: "标题栏", Category: "Layout", Demo: layoutTitleBarDemo, Code: layoutTitleBarCode, + Keys: "Tools in the slots take Tab and Enter/Space as usual. Dragging the empty bar moves the window; double-click zooms it."}, + {ID: "003", Name: "StatusBar", ZhName: "状态栏", Category: "Layout", Demo: layoutStatusBarDemo, Code: layoutStatusBarCode, + Keys: "Tab reaches action items; Enter or Space activates them. Plain status text is not a tab stop."}, + {ID: "004", Name: "Container", ZhName: "容器", Category: "Layout", Demo: layoutContainerDemo, Code: layoutContainerCode, + Keys: "None: Container never takes the focus."}, + {ID: "005", Name: "Stack", ZhName: "堆叠", Category: "Layout", Demo: layoutStackDemo, Code: layoutStackCode, + Keys: "None: Stack never takes the focus; Tab follows the children's order."}, + {ID: "006", Name: "Grid", ZhName: "网格", Category: "Layout", Demo: layoutGridDemo, Code: layoutGridCode, + Keys: "None: Grid never takes the focus; Tab follows the build order."}, + {ID: "007", Name: "ScrollArea", ZhName: "滚动区域", Category: "Layout", Demo: layoutScrollAreaDemo, Code: layoutScrollAreaCode, + Keys: "When the content overflows, Tab focuses the area; arrows scroll by a line, Page Up/Down and Space by a page, Home and End to the ends."}, + {ID: "008", Name: "SplitPane", ZhName: "分栏", Category: "Layout", Demo: layoutSplitPaneDemo, Code: layoutSplitPaneCode, + Keys: "Tab focuses the divider. Left/Right (Up/Down when vertical) move it by 4 units, Home and End to the limits, Enter folds the collapsible pane; an arrow past its minimum folds it, the opposite arrow opens it."}, + {ID: "009", Name: "AspectRatio", ZhName: "宽高比", Category: "Layout", Demo: layoutAspectRatioDemo, Code: layoutAspectRatioCode, + Keys: "None: AspectRatio never takes the focus."}, + {ID: "010", Name: "Divider", ZhName: "分隔线", Category: "Layout", Demo: layoutDividerDemo, Code: layoutDividerCode, + Keys: "None: Divider and its ornament never take the focus."}, +} + +// layoutCheck is a check box that logs its changes. +func layoutCheck(c *ui.Context, v *bool, label string, log func(string)) { + was := *v + ui.Checkbox(c, v, label) + if *v != was { + log(fmt.Sprintf("%s: %v", label, *v)) + } +} + +// layoutChoice is a labeled drop-down that logs its changes. +func layoutChoice(c *ui.Context, v *string, label string, options []string, log func(string)) { + was := *v + ui.Row(c).Gap(8).Children(func() { + ui.Text(c, label) + ui.Select(c, v, options) + }) + if *v != was { + log(fmt.Sprintf("%s: %s", label, *v)) + } +} + +// layoutSlider is a labeled slider that logs whole-number changes. +func layoutSlider(c *ui.Context, v *float64, label string, lo, hi float64, log func(string)) { + was := math.Round(*v) + ui.Row(c).Gap(8).Children(func() { + ui.Text(c, fmt.Sprintf("%s %.0f", label, *v)) + ui.Slider(c, v, lo, hi).Width(140) + }) + if math.Round(*v) != was { + log(fmt.Sprintf("%s: %.0f", label, *v)) + } +} + +func layoutControls(c *ui.Context, fn func()) { ui.Row(c).Wrap().Gap(12).Children(fn) } + +func layoutFrame(c *ui.Context, h float32, fn func()) *ui.Element { + k := mujica.Tokens(c) + return ui.Column(c).Height(h).Border(1, k.Border).Radius(4).Clip().Children(fn) +} + +var layoutSpaces = []string{"Default", "None", "XS", "S", "M", "L", "XL", "XXL"} + +func layoutSpace(s string) mujica.Space { return mujica.Space(slices.Index(layoutSpaces, s)) } + +const layoutAppShellCode = `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 layoutAppShellDemo(c *ui.Context, log func(string)) { + type state struct { + collapsed, title, status bool + width float64 + scroll ui.ScrollState + } + s := ui.Local(c.Root(), "001", func() state { return state{title: true, status: true, width: 220} }) + layoutControls(c, func() { + layoutCheck(c, &s.title, "Title bar", log) + layoutCheck(c, &s.status, "Status bar", log) + layoutCheck(c, &s.collapsed, "Collapsed", log) + layoutSlider(c, &s.width, "Sidebar width", 160, 320, log) + }) + opts := mujica.AppShellOptions{ + SidebarWidth: float32(s.width), + ContentScroll: &s.scroll, + Sidebar: func() { + for _, n := range []string{"Chronicles", "Letters", "Decrees", "Portraits", "Ledgers"} { + ui.Text(c, n).Padding(6, 12) + } + }, + Content: func() { + mujica.Container(c, mujica.ContainerOptions{}, func() { + for i := range 30 { + ui.Text(c, fmt.Sprintf("Entry %d of the court record", i+1)).Padding(4, 0) + } + }) + }, + } + if s.title { + opts.TitleBar = func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) } + } + if s.status { + opts.StatusBar = func() { + mujica.StatusBar(c, mujica.StatusBarOptions{Left: []mujica.StatusItem{{ID: "pos", Text: fmt.Sprintf("Scrolled %.0f", s.scroll.Y)}}}) + } + } + ui.Column(c).Margin(16, 0, 0, 0).Children(func() { + layoutFrame(c, 360, func() { + if mujica.AppShell(c, &s.collapsed, opts).Toggled { + log(fmt.Sprintf("sidebar collapsed: %v", s.collapsed)) + } + }) + }) +} + +const layoutTitleBarCode = `mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{ + Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) }, +})` + +func layoutTitleBarDemo(c *ui.Context, log func(string)) { + type state struct { + leading, trailing bool + title string + } + s := ui.Local(c.Root(), "002", func() state { return state{trailing: true, title: "Court Records"} }) + layoutControls(c, func() { + layoutCheck(c, &s.leading, "Leading tool", log) + layoutCheck(c, &s.trailing, "Trailing tool", log) + layoutChoice(c, &s.title, "Title", []string{"Court Records", "宫廷档案", "A very long title of the royal chronicle of the court"}, log) + }) + var opts mujica.TitleBarOptions + if s.leading { + opts.Leading = func() { + if mujica.Button(c, "", mujica.ButtonOptions{Variant: mujica.Ghost, Icon: icons.Must("panel-left"), Label: "Sidebar"}).Clicked() { + log("leading tool clicked") + } + } + } + if s.trailing { + opts.Trailing = func() { + if mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + log("Share clicked") + } + } + } + ui.Column(c).Margin(16, 0, 0, 0).Width(520).Children(func() { + layoutFrame(c, 90, func() { mujica.TitleBar(c, s.title, opts) }) + }) + bar := c.TitleBar() + ui.Text(c, fmt.Sprintf("Window controls reserve: left %.0f, right %.0f, height %.0f DIP", bar.Left, bar.Right, bar.Height)). + TextColor(mujica.Tokens(c).TextMuted).Margin(8, 0, 0, 0) +} + +const layoutStatusBarCode = `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 layoutStatusBarDemo(c *ui.Context, log func(string)) { + type state struct { + width, progress float64 + showProgress bool + tone string + } + s := ui.Local(c.Root(), "003", func() state { return state{width: 640, progress: 40, showProgress: true, tone: "Warning"} }) + layoutControls(c, func() { + layoutSlider(c, &s.width, "Width", 240, 720, log) + layoutCheck(c, &s.showProgress, "Progress", log) + layoutSlider(c, &s.progress, "Percent", 0, 100, log) + layoutChoice(c, &s.tone, "Tone", []string{"Neutral", "Success", "Warning", "Danger", "Info"}, log) + }) + tones := map[string]mujica.StatusItemTone{"Neutral": mujica.StatusItemNeutral, "Success": mujica.StatusItemSuccess, + "Warning": mujica.StatusItemWarning, "Danger": mujica.StatusItemDanger, "Info": mujica.StatusItemInfo} + opts := mujica.StatusBarOptions{ + Left: []mujica.StatusItem{ + {ID: "branch", Text: "main", Icon: icons.Must("folder")}, + {ID: "sync", Text: "Syncing", ShowProgress: s.showProgress, Progress: float32(s.progress / 100)}, + {ID: "lint", Text: "2 warnings", Tone: tones[s.tone], Secondary: true, Action: true}, + }, + Right: []mujica.StatusItem{ + {ID: "enc", Text: "UTF-8", Secondary: true}, + {ID: "pos", Text: "Ln 12, Col 4", Secondary: true}, + {ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}, + }, + } + ui.Column(c).Margin(16, 0, 0, 0).Width(float32(s.width)).Children(func() { + if id := mujica.StatusBar(c, opts).Clicked; id != "" { + log("clicked " + id) + } + }) + ui.Text(c, "Narrow the width: secondary items fold into a count, last first."). + TextColor(mujica.Tokens(c).TextMuted).Margin(8, 0, 0, 0) +} + +const layoutContainerCode = `mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() { + ui.Text(c, "A centered column of prose.") +})` + +func layoutContainerDemo(c *ui.Context, log func(string)) { + type state struct { + maxWidth float64 + padding string + center, fill, clip, wide bool + } + s := ui.Local(c.Root(), "004", func() state { return state{maxWidth: 360, padding: "Default", center: true} }) + layoutControls(c, func() { + layoutSlider(c, &s.maxWidth, "Max width", 0, 600, log) + layoutChoice(c, &s.padding, "Padding", layoutSpaces, log) + layoutCheck(c, &s.center, "Center", log) + layoutCheck(c, &s.fill, "Fill", log) + layoutCheck(c, &s.clip, "Clip", log) + layoutCheck(c, &s.wide, "Overflowing child", log) + }) + k := mujica.Tokens(c) + ui.Column(c).Margin(16, 0, 0, 0).Children(func() { + layoutFrame(c, 200, func() { + mujica.Container(c, mujica.ContainerOptions{MaxWidth: float32(s.maxWidth), Padding: layoutSpace(s.padding), + Center: s.center, Fill: s.fill, Clip: s.clip}, func() { + ui.Text(c, "Of the Court").FontSize(18) + ui.Text(c, "Prose keeps a readable measure inside a container.").TextColor(k.TextMuted) + if s.wide { + ui.Box(c).Size(900, 24).Background(k.Selection).Shrink(0) + } + }).Background(k.Surface).Border(1, k.Border) + }) + }) +} + +const layoutStackCode = `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 layoutStackDemo(c *ui.Context, log func(string)) { + type state struct { + horizontal, wrap, hide bool + gap, align, justify string + } + s := ui.Local(c.Root(), "005", func() state { + return state{horizontal: true, gap: "Default", align: "Auto", justify: "Start"} + }) + aligns := []string{"Auto", "Start", "Center", "End", "Stretch"} + justifies := map[string]ui.Align{"Start": ui.Start, "Center": ui.Center, "End": ui.End, "SpaceBetween": ui.SpaceBetween} + layoutControls(c, func() { + layoutCheck(c, &s.horizontal, "Horizontal", log) + s.wrap = s.wrap && s.horizontal + layoutCheck(c, &s.wrap, "Wrap", log) + layoutCheck(c, &s.hide, "Hide Draft", log) + layoutChoice(c, &s.gap, "Gap", layoutSpaces, log) + layoutChoice(c, &s.align, "Align", aligns, log) + layoutChoice(c, &s.justify, "Justify", []string{"Start", "Center", "End", "SpaceBetween"}, log) + }) + align := mujica.StackAlign(slices.Index(aligns, s.align)) + ui.Column(c).Margin(16, 0, 0, 0).Width(420).Children(func() { + layoutFrame(c, 200, func() { + mujica.Stack(c, mujica.StackOptions{Horizontal: s.horizontal, Wrap: s.wrap, Gap: layoutSpace(s.gap), + Align: align, Justify: justifies[s.justify]}, func() { + for _, n := range []string{"Save", "Draft", "Publish", "Archive", "Cancel"} { + mujica.StackItem(s.hide && n == "Draft", func() { + if mujica.Button(c, n, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + log(n + " clicked") + } + }) + } + }).Grow(1).Padding(12) + }) + }) +} + +const layoutGridCode = `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 layoutGridDemo(c *ui.Context, log func(string)) { + type state struct { + first, gap, rowGap string + header, tall bool + } + s := ui.Local(c.Root(), "006", func() state { + return state{first: "Fixed 120", gap: "Default", rowGap: "Default", header: true, tall: true} + }) + tracks := map[string]ui.Track{"Fixed 120": ui.Fixed(120), "Fit content": ui.FitContent(), "Fr 1": ui.Fr(1), "Fr 2": ui.Fr(2)} + layoutControls(c, func() { + layoutChoice(c, &s.first, "First column", []string{"Fixed 120", "Fit content", "Fr 1", "Fr 2"}, log) + layoutChoice(c, &s.gap, "Gap", layoutSpaces, log) + layoutChoice(c, &s.rowGap, "Row gap", layoutSpaces, log) + layoutCheck(c, &s.header, "Header spans all", log) + layoutCheck(c, &s.tall, "First cell spans 2 rows", log) + }) + k := mujica.Tokens(c) + cell := func(text string) { + ui.Box(c).Center().Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4). + Children(func() { ui.Text(c, text).TextColor(k.TextMuted) }) + } + ui.Column(c).Margin(16, 0, 0, 0).Width(520).Children(func() { + mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{tracks[s.first], ui.Fr(1), ui.Fr(1)}, + Gap: layoutSpace(s.gap), RowGap: layoutSpace(s.rowGap)}, func() { + if s.header { + mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { cell("Header, ColumnSpan -1") }) + } + span := 1 + if s.tall { + span = 2 + } + mujica.GridCell(c, mujica.GridCellOptions{RowSpan: span}, func() { cell(s.first) }) + for i := range 4 { + cell(fmt.Sprintf("Cell %d", i+1)) + } + }) + }) +} + +const layoutScrollAreaCode = `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 layoutScrollAreaDemo(c *ui.Context, log func(string)) { + type state struct { + direction string + offset ui.ScrollState + lastX, lastY float32 + } + s := ui.Local(c.Root(), "007", func() state { return state{direction: "Vertical"} }) + dirs := map[string]mujica.ScrollDirection{"Vertical": mujica.ScrollVertical, "Horizontal": mujica.ScrollHorizontal, "Both": mujica.ScrollBoth} + layoutControls(c, func() { + layoutChoice(c, &s.direction, "Direction", []string{"Vertical", "Horizontal", "Both"}, log) + if mujica.Button(c, "Top", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.offset.X, s.offset.Y = 0, 0 + log("scroll to start") + } + if mujica.Button(c, "Middle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.offset.X, s.offset.Y = s.offset.MaxX/2, s.offset.MaxY/2 + log("scroll to middle") + } + if mujica.Button(c, "End", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.offset.X, s.offset.Y = math.MaxFloat32, math.MaxFloat32 + log("scroll to end") + } + }) + k := mujica.Tokens(c) + ui.Column(c).Margin(16, 0, 0, 0).Width(420).Children(func() { + mujica.ScrollArea(c, &s.offset, mujica.ScrollAreaOptions{Direction: dirs[s.direction], Label: "Ledger"}, func() { + for i := range 40 { + ui.Text(c, fmt.Sprintf("%02d Ledger line of the treasury, folio %d, carried over from the last quarter", i+1, i+7)). + SingleLine().Padding(4, 12) + } + }).Height(220).Background(k.Surface).Border(1, k.Border).Radius(4) + }) + if s.offset.Y != s.lastY || s.offset.X != s.lastX { + s.lastX, s.lastY = s.offset.X, s.offset.Y + log(fmt.Sprintf("offset %.0f, %.0f", s.offset.X, s.offset.Y)) + } + ui.Text(c, fmt.Sprintf("Offset %.0f, %.0f of %.0f, %.0f", s.offset.X, s.offset.Y, s.offset.MaxX, s.offset.MaxY)). + TextColor(k.TextMuted).Margin(8, 0, 0, 0) +} + +const layoutSplitPaneCode = `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 layoutSplitPaneDemo(c *ui.Context, log func(string)) { + type state struct { + split mujica.SplitPaneState + vertical bool + collapsible string + minFirst float64 + } + s := ui.Local(c.Root(), "008", func() state { + return state{split: mujica.SplitPaneState{Size: 180}, collapsible: "First", minFirst: 120} + }) + sides := map[string]mujica.SplitPaneSide{"Neither": mujica.SplitPaneNeither, "First": mujica.SplitPaneFirst, "Second": mujica.SplitPaneSecond} + layoutControls(c, func() { + layoutCheck(c, &s.vertical, "Vertical", log) + layoutChoice(c, &s.collapsible, "Collapsible", []string{"Neither", "First", "Second"}, log) + layoutSlider(c, &s.minFirst, "Min first", 40, 200, log) + }) + s.split.Collapsed = s.split.Collapsed && s.collapsible != "Neither" + k := mujica.Tokens(c) + pane := func(title, body string, bg ui.Color) func() { + return func() { + ui.Column(c).Fill().Padding(12).Gap(6).Background(bg).Children(func() { + ui.Text(c, title).FontSize(16) + ui.Text(c, body).TextColor(k.TextMuted) + }) + } + } + ui.Column(c).Margin(16, 0, 0, 0).Width(560).Children(func() { + layoutFrame(c, 280, func() { + r := mujica.SplitPane(c, &s.split, mujica.SplitPaneOptions{Vertical: s.vertical, MinFirst: float32(s.minFirst), + Collapsible: sides[s.collapsible]}, + pane("Files", "Drag the divider, or focus it and use the arrows.", k.Surface), + pane("Editor", "Enter on the divider folds the collapsible pane.", k.Background)) + r.Fill() + if r.Resized { + log(fmt.Sprintf("resized to %.0f", s.split.Size)) + } + if r.Toggled { + log(fmt.Sprintf("collapsed: %v", s.split.Collapsed)) + } + }) + }) +} + +const layoutAspectRatioCode = `ui.Box(c).Size(320, 240).Children(func() { + mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() { + ui.Text(c, "16 : 9") + }) +})` + +func layoutAspectRatioDemo(c *ui.Context, log func(string)) { + type state struct { + ratio string + width, height float64 + } + s := ui.Local(c.Root(), "009", func() state { return state{ratio: "16 : 9", width: 360, height: 220} }) + ratios := map[string]float32{"1 : 1": 1, "4 : 3": 4.0 / 3, "16 : 9": 16.0 / 9, "3 : 4": 3.0 / 4, "21 : 9": 21.0 / 9} + layoutControls(c, func() { + layoutChoice(c, &s.ratio, "Ratio", []string{"1 : 1", "4 : 3", "16 : 9", "3 : 4", "21 : 9"}, log) + layoutSlider(c, &s.width, "Parent width", 120, 560, log) + layoutSlider(c, &s.height, "Parent height", 80, 320, log) + }) + k := mujica.Tokens(c) + ui.Box(c).Margin(16, 0, 0, 0).Size(float32(s.width), float32(s.height)).Border(1, k.Border).Children(func() { + mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: ratios[s.ratio]}, func() { + ui.Box(c).Fill().Center().Background(k.Surface).Border(1, k.Ornament).Children(func() { + ui.Text(c, s.ratio).TextColor(k.TextMuted) + }) + }) + }) +} + +const layoutDividerCode = `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 layoutDividerDemo(c *ui.Context, log func(string)) { + type state struct { + vertical, ornament bool + label string + } + s := ui.Local(c.Root(), "010", func() state { return state{ornament: true, label: "None"} }) + layoutControls(c, func() { + layoutCheck(c, &s.vertical, "Vertical", log) + layoutCheck(c, &s.ornament, "Ornament", log) + if !s.vertical { + layoutChoice(c, &s.label, "Label", []string{"None", "Chapter II", "第二章"}, log) + } + }) + label := s.label + if label == "None" || s.vertical { + label = "" + } + opts := mujica.DividerOptions{Vertical: s.vertical, Ornament: s.ornament, Label: label} + ui.Column(c).Margin(16, 0, 0, 0).Width(480).Children(func() { + if s.vertical { + ui.Row(c).Height(80).Gap(16).Children(func() { + ui.Text(c, "Overture") + mujica.Divider(c, opts) + ui.Text(c, "Aria") + }) + return + } + ui.Column(c).Gap(16).Children(func() { + ui.Text(c, "Overture") + mujica.Divider(c, opts) + ui.Text(c, "Aria") + }) + }) +} diff --git a/examples/gallery/pages_navigation.go b/examples/gallery/pages_navigation.go index 1f53a12..3809106 100644 --- a/examples/gallery/pages_navigation.go +++ b/examples/gallery/pages_navigation.go @@ -1,3 +1,462 @@ package main -var navigationPages = []page{} +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +var navigationPages = []page{ + {ID: "059", Name: "Tabs", ZhName: "标签页", Category: "Navigation", Demo: navigationTabsDemo, Code: navigationTabsCode, + Keys: "Tab enters the tab list at the chosen tab. Left/Right (Up/Down when vertical) choose the next enabled tab, skipping disabled ones and wrapping; Home and End choose the first and last."}, + {ID: "060", Name: "SegmentedControl", ZhName: "分段控件", Category: "Navigation", Demo: navigationSegmentedDemo, Code: navigationSegmentedCode, + Keys: "One Tab stop. Arrow keys choose the previous or next segment and wrap; Home and End choose the ends."}, + {ID: "061", Name: "Breadcrumb", ZhName: "面包屑", Category: "Navigation", Demo: navigationBreadcrumbDemo, Code: navigationBreadcrumbCode, + Keys: "Tab moves through the links and the fold button; Enter or Space activates. In the fold menu, Tab moves between items and Escape closes it, returning focus to the button."}, + {ID: "062", Name: "Pagination", ZhName: "分页", Category: "Navigation", Demo: navigationPaginationDemo, Code: navigationPaginationCode, + Keys: "The page buttons are one Tab stop: Left/Right move, Home/End jump to the ends, Enter or Space goes to the page. The size select opens with Up/Down; the jump field goes on Enter."}, + {ID: "063", Name: "Steps", ZhName: "步骤条", Category: "Navigation", Demo: navigationStepsDemo, Code: navigationStepsCode, + Keys: "Clickable steps are one Tab stop; arrow keys move between them and Enter or Space goes to the step."}, + {ID: "064", Name: "Sidebar", ZhName: "侧边栏", Category: "Navigation", Demo: navigationSidebarDemo, Code: navigationSidebarCode, + Keys: "One Tab stop. Up/Down choose the previous or next item, Home/End the ends, typing the first letters of a label jumps to it. Section and group titles open and close with a click."}, + {ID: "065", Name: "NavigationMenu", ZhName: "导航菜单", Category: "Navigation", Demo: navigationMenuDemo, Code: navigationMenuCode, + Keys: "Left/Right move between top links; Down or Enter opens a group with focus on its first link, Up/Down move inside, Escape closes. Back/Forward also answer Cmd+[ / Cmd+] (Alt+Left/Right off macOS)."}, + {ID: "066", Name: "Menubar", ZhName: "菜单栏", Category: "Navigation", Demo: navigationMenubarDemo, Code: navigationMenubarCode, + Keys: "Tab reaches the bar; Left/Right move between menus, Down opens one. Up/Down/Home/End move through items, Right opens a submenu (or the next menu), Left closes it, Enter/Space chooses, Escape closes one level."}, + {ID: "067", Name: "Toolbar", ZhName: "工具栏", Category: "Navigation", Demo: navigationToolbarDemo, Code: navigationToolbarCode, + Keys: "One Tab stop. Left/Right move between actions, Home/End to the ends, Enter or Space runs one. Actions that do not fit move to the » menu at the end."}, + {ID: "068", Name: "CommandPalette", ZhName: "命令面板", Category: "Navigation", Demo: navigationPaletteDemo, Code: navigationPaletteCode, + Keys: "Cmd+K (Ctrl+K off macOS) opens it with focus in the search. Type to filter, Up/Down choose (wrapping), Enter runs and closes, Escape closes without running."}, +} + +// navigationCheck is a check box that logs its changes. +func navigationCheck(c *ui.Context, v *bool, label string, log func(string)) { + was := *v + ui.Checkbox(c, v, label) + if *v != was { + log(fmt.Sprintf("%s: %v", label, *v)) + } +} + +// navigationSlider is a labeled whole-number slider that logs its changes. +func navigationSlider(c *ui.Context, v *float64, label string, lo, hi float64, log func(string)) { + was := math.Round(*v) + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("%s %.0f", label, math.Round(*v))) + ui.StepSlider(c, v, lo, hi, 1).Width(160) + }) + if math.Round(*v) != was { + log(fmt.Sprintf("%s: %.0f", label, math.Round(*v))) + } +} + +func navigationControls(c *ui.Context, fn func()) { ui.Row(c).Wrap().Gap(16).Children(fn) } + +func navigationDemo(c *ui.Context, controls, body func()) { + ui.Column(c).Gap(20).AlignItems(ui.Stretch).Children(func() { + navigationControls(c, controls) + body() + }) +} + +const navigationTabsCode = `mujica.Tabs(c, &tab, []mujica.Tab{ + {Label: "General", Panel: func() { ui.Text(c, "General settings") }}, + {Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }}, + {Label: "Archive", Disabled: true}, +}, mujica.TabsOptions{Label: "Settings"})` + +func navigationTabsDemo(c *ui.Context, log func(string)) { + type state struct { + tab int + vertical, archive, many bool + } + s := ui.Local(c.Root(), "059", func() state { return state{archive: true} }) + navigationDemo(c, func() { + navigationCheck(c, &s.vertical, "Vertical", log) + navigationCheck(c, &s.archive, "Disable Archive", log) + navigationCheck(c, &s.many, "Many tabs (overflow)", log) + }, func() { + names := []string{"General", "Fonts", "Archive", "Advanced"} + if s.many { + names = append(names, "Seals", "Heraldry", "Ledgers", "Envoys", "Banquets", "Chapel") + } + var tabs []mujica.Tab + for i, n := range names { + tabs = append(tabs, mujica.Tab{Label: n, Disabled: n == "Archive" && s.archive, Panel: func() { + ui.Text(c, fmt.Sprintf("The %s panel (tab %d).", n, i+1)) + }}) + } + tabs[1].Icon = icons.Must("italic") + s.tab = min(s.tab, len(tabs)-1) + if tabs[s.tab].Disabled { + s.tab = 0 + } + ui.Column(c).Width(420).Children(func() { + if mujica.Tabs(c, &s.tab, tabs, mujica.TabsOptions{Vertical: s.vertical, Label: "Settings"}).Changed() { + log("Changed: tab " + names[s.tab]) + } + }) + }) +} + +const navigationSegmentedCode = `mujica.SegmentedControl(c, &view, []mujica.Segment{ + {Label: "List", Icon: icons.Must("list"), HideLabel: true}, + {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true}, +}, mujica.SegmentedControlOptions{Label: "View"})` + +func navigationSegmentedDemo(c *ui.Context, log func(string)) { + type state struct { + text, icon int + equal, iconOnly, disab bool + } + s := ui.Local(c.Root(), "060", func() state { return state{} }) + navigationDemo(c, func() { + navigationCheck(c, &s.equal, "Equal widths", log) + navigationCheck(c, &s.iconOnly, "Icons only", log) + navigationCheck(c, &s.disab, "Disabled", log) + }, func() { + opts := mujica.SegmentedControlOptions{Equal: s.equal, Disabled: s.disab, Label: "Range"} + ui.Row(c).Gap(24).AlignItems(ui.Center).Children(func() { + if mujica.SegmentedControl(c, &s.text, []mujica.Segment{{Label: "Day"}, {Label: "Week"}, {Label: "Fortnight"}}, opts).Changed() { + log(fmt.Sprintf("Changed: range %d", s.text)) + } + opts.Label = "View" + if mujica.SegmentedControl(c, &s.icon, []mujica.Segment{ + {Label: "List", Icon: icons.Must("list"), HideLabel: s.iconOnly}, + {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: s.iconOnly}, + }, opts).Changed() { + log(fmt.Sprintf("Changed: view %d", s.icon)) + } + }) + }) +} + +const navigationBreadcrumbCode = `if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok { + path = path[:i+1] +}` + +var navigationBreadcrumbNames = []string{"Home", "Library", "Manuscripts", "Seventeenth century", "Correspondence of the court", "Letters to the Duchess", "Draft of the winter decree"} + +func navigationBreadcrumbDemo(c *ui.Context, log func(string)) { + type state struct{ depth, max float64 } + s := ui.Local(c.Root(), "061", func() state { return state{depth: 6, max: 4} }) + navigationDemo(c, func() { + navigationSlider(c, &s.depth, "Depth", 1, 7, log) + navigationSlider(c, &s.max, "MaxItems", 3, 7, log) + }, func() { + var path []mujica.BreadcrumbItem + for i, n := range navigationBreadcrumbNames[:int(s.depth)] { + it := mujica.BreadcrumbItem{Label: n} + if i == 0 { + it.Icon = icons.Must("house") + } + path = append(path, it) + } + if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{MaxItems: int(s.max)}).Chosen(); ok { + log("Chosen: " + path[i].Label) + s.depth = float64(i + 1) + } + }) +} + +const navigationPaginationCode = `if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() { + load(page, size) +}` + +func navigationPaginationDemo(c *ui.Context, log func(string)) { + type state struct { + page, size int + total float64 + sizes, jump, disab bool + } + s := ui.Local(c.Root(), "062", func() state { return state{page: 1, size: 20, total: 420, sizes: true, jump: true} }) + navigationDemo(c, func() { + navigationSlider(c, &s.total, "Total", 0, 1000, log) + navigationCheck(c, &s.sizes, "Page sizes", log) + navigationCheck(c, &s.jump, "Jump", log) + navigationCheck(c, &s.disab, "Disabled", log) + }, func() { + opts := mujica.PaginationOptions{Total: int(s.total), Jump: s.jump, Disabled: s.disab} + if s.sizes { + opts.PageSizes = []int{10, 20, 50} + } else { + s.size = 20 + } + if mujica.Pagination(c, &s.page, &s.size, opts).Changed() { + log(fmt.Sprintf("Changed: page %d, size %d", s.page, s.size)) + } + }) +} + +const navigationStepsCode = `mujica.Steps(c, ¤t, []mujica.Step{ + {Title: "Account", Clickable: true}, + {Title: "Profile", Description: "Name and seal"}, + {Title: "Confirm"}, +}, mujica.StepsOptions{Label: "Sign-up"})` + +func navigationStepsDemo(c *ui.Context, log func(string)) { + type state struct { + current int + vertical, failed, clickOK bool + } + s := ui.Local(c.Root(), "063", func() state { return state{current: 1, clickOK: true} }) + steps := []mujica.Step{ + {Title: "Account", Description: "Name and seal"}, + {Title: "Profile", Description: "Titles and house"}, + {Title: "Payment", Description: "Treasury"}, + {Title: "Confirm"}, + } + navigationDemo(c, func() { + navigationCheck(c, &s.vertical, "Vertical", log) + navigationCheck(c, &s.failed, "Payment failed", log) + navigationCheck(c, &s.clickOK, "Done steps clickable", log) + if mujica.Button(c, "Back", mujica.ButtonOptions{Variant: mujica.Secondary, Disabled: s.current == 0}).Clicked() { + s.current-- + log(fmt.Sprintf("Back: step %d", s.current+1)) + } + if mujica.Button(c, "Next", mujica.ButtonOptions{Disabled: s.current == len(steps)}).Clicked() { + s.current++ + log(fmt.Sprintf("Next: step %d", s.current+1)) + } + }, func() { + for i := range steps { + steps[i].Clickable = s.clickOK && i < s.current + } + steps[2].Error = s.failed + if mujica.Steps(c, &s.current, steps, mujica.StepsOptions{Vertical: s.vertical, Label: "Sign-up"}).Changed() { + log("Changed: step " + steps[s.current].Title) + } + }) +} + +const navigationSidebarCode = `mujica.Sidebar(c, &selected, []mujica.SidebarSection{ + {Title: "Mail", Items: []mujica.SidebarItem{ + {ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3}, + {Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}}, + }}, +}, mujica.SidebarOptions{})` + +func navigationSidebarDemo(c *ui.Context, log func(string)) { + type state struct { + selected string + compact bool + unread float64 + } + s := ui.Local(c.Root(), "064", func() state { return state{selected: "inbox", unread: 12} }) + navigationDemo(c, func() { + navigationCheck(c, &s.compact, "Compact", log) + navigationSlider(c, &s.unread, "Unread", 0, 1200, log) + }, func() { + sections := []mujica.SidebarSection{ + {Title: "Library", Items: []mujica.SidebarItem{ + {ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: int(s.unread)}, + {ID: "starred", Label: "Starred", Icon: icons.Must("star")}, + {Label: "Projects", Items: []mujica.SidebarItem{ + {ID: "opera", Label: "Opera", Icon: icons.Must("folder")}, + {ID: "ballet", Label: "Ballet", Icon: icons.Must("folder")}, + }}, + }}, + {Title: "System", Items: []mujica.SidebarItem{ + {ID: "settings", Label: "Settings", Icon: icons.Must("settings")}, + {ID: "trash", Label: "Trash", Icon: icons.Must("trash-2")}, + }}, + } + ui.Row(c).Height(360).Border(1, mujica.Tokens(c).Border).Radius(4).Clip().Children(func() { + if mujica.Sidebar(c, &s.selected, sections, mujica.SidebarOptions{Compact: s.compact}).Changed() { + log("Changed: " + s.selected) + } + ui.Text(c, "Showing "+s.selected).Padding(24) + }) + }) +} + +const navigationMenuCode = `mujica.NavigationMenu(c, router, []mujica.NavigationLink{ + {Label: "Home", Path: "/"}, + {Label: "Library", Items: []mujica.NavigationLink{ + {Label: "Poems", Path: "/library/poems", Description: "Verse of the court"}, + }}, +}, mujica.NavigationMenuOptions{History: true}) +router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) })` + +func navigationMenuDemo(c *ui.Context, log func(string)) { + type state struct { + router *ui.Router + history bool + last string + } + s := ui.Local(c.Root(), "065", func() state { return state{router: ui.NewRouter("/"), history: true, last: "/"} }) + navigationDemo(c, func() { + navigationCheck(c, &s.history, "Back and forward", log) + }, func() { + mujica.NavigationMenu(c, s.router, []mujica.NavigationLink{ + {Label: "Home", Path: "/", Icon: icons.Must("house")}, + {Label: "Library", Items: []mujica.NavigationLink{ + {Label: "Poems", Path: "/library/poems", Description: "Verse of the court"}, + {Label: "Letters", Path: "/library/letters", Description: "Correspondence"}, + {Label: "Archive", Items: []mujica.NavigationLink{ + {Label: "Older letters", Path: "/archive/letters"}, + }}, + }}, + {Label: "About", Path: "/about"}, + }, mujica.NavigationMenuOptions{History: s.history}) + if p := s.router.Path(); p != s.last { + log(fmt.Sprintf("Path: %s → %s", s.last, p)) + s.last = p + } + ui.Column(c).Height(120).Children(func() { + s.router.View(c, func(r *ui.Route) { + r.Title(r.Path()) + ui.Text(c, "Page "+r.Path()).Padding(16) + }) + }) + }) +} + +const navigationMenubarCode = `res := mujica.Menubar(c, []mujica.MenubarMenu{ + {Label: "File", Items: []mujica.MenubarItem{ + {ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}}, + {Label: "Export", Items: []mujica.MenubarItem{{ID: "pdf", Label: "PDF"}}}, + }}, + {Label: "View", Items: []mujica.MenubarItem{ + {ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: wrap}, + }}, +}, mujica.MenubarOptions{}) +if id, ok := res.Chosen(); ok && id == "wrap" { + wrap = !wrap +}` + +func navigationMenubarDemo(c *ui.Context, log func(string)) { + type state struct { + wrap, ruler, locked bool + zoom string + } + s := ui.Local(c.Root(), "066", func() state { return state{wrap: true, zoom: "fit"} }) + navigationDemo(c, func() { + navigationCheck(c, &s.locked, "Lock editing (disables Edit items)", log) + }, func() { + menus := []mujica.MenubarMenu{ + {Label: "File", Items: []mujica.MenubarItem{ + {ID: "new", Label: "New", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}}, + {ID: "open", Label: "Open…", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyO}}, + {Label: "Export", Items: []mujica.MenubarItem{ + {ID: "pdf", Label: "PDF"}, + {ID: "png", Label: "Image"}, + {Label: "Archive", Items: []mujica.MenubarItem{{ID: "zip", Label: "Zip"}, {ID: "tar", Label: "Tarball"}}}, + }}, + {Kind: mujica.MenubarSeparator}, + {ID: "quit", Label: "Quit", Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyQ}}, + }}, + {Label: "Edit", Items: []mujica.MenubarItem{ + {ID: "undo", Label: "Undo", Disabled: s.locked, Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyZ}}, + {ID: "redo", Label: "Redo", Disabled: s.locked, Shortcut: mujica.MenubarShortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyZ}}, + }}, + {Label: "View", Items: []mujica.MenubarItem{ + {ID: "wrap", Label: "Word wrap", Kind: mujica.MenubarCheck, Checked: s.wrap}, + {ID: "ruler", Label: "Ruler", Kind: mujica.MenubarCheck, Checked: s.ruler}, + {Kind: mujica.MenubarSeparator}, + {ID: "fit", Label: "Fit to window", Kind: mujica.MenubarRadio, Checked: s.zoom == "fit"}, + {ID: "actual", Label: "Actual size", Kind: mujica.MenubarRadio, Checked: s.zoom == "actual"}, + }}, + } + ui.Column(c).Height(260).Border(1, mujica.Tokens(c).Border).Radius(4).Clip().Children(func() { + if id, ok := mujica.Menubar(c, menus, mujica.MenubarOptions{}).Chosen(); ok { + switch id { + case "wrap": + s.wrap = !s.wrap + case "ruler": + s.ruler = !s.ruler + case "fit", "actual": + s.zoom = id + } + log("Chosen: " + id) + } + ui.Text(c, fmt.Sprintf("Word wrap %v · Ruler %v · Zoom %s", s.wrap, s.ruler, s.zoom)).Padding(16) + }) + }) +} + +const navigationToolbarCode = `mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() { + mujica.ToolbarGroup(c, func() { + mujica.ToolbarButton(c, "Bold", mujica.ToolbarButtonOptions{Icon: icons.Must("bold")}) + mujica.ToolbarButton(c, "Italic", mujica.ToolbarButtonOptions{Icon: icons.Must("italic")}) + }) + mujica.ToolbarSeparator(c) + if mujica.ToolbarButton(c, "Copy", mujica.ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() { + copySelection() + } +})` + +func navigationToolbarDemo(c *ui.Context, log func(string)) { + type state struct { + width float64 + labels bool + noDeletes bool + } + s := ui.Local(c.Root(), "067", func() state { return state{width: 520, noDeletes: true} }) + navigationDemo(c, func() { + navigationSlider(c, &s.width, "Width", 120, 640, log) + navigationCheck(c, &s.labels, "Show labels", log) + navigationCheck(c, &s.noDeletes, "Disable Delete", log) + }, func() { + ui.Column(c).Width(float32(s.width)).Children(func() { + mujica.Toolbar(c, mujica.ToolbarOptions{Label: "Format"}, func() { + button := func(label, icon string, disabled bool) { + if mujica.ToolbarButton(c, label, mujica.ToolbarButtonOptions{Icon: icons.Must(icon), ShowLabel: s.labels, Disabled: disabled}).Clicked() { + log("Clicked: " + label) + } + } + mujica.ToolbarGroup(c, func() { + button("Bold", "bold", false) + button("Italic", "italic", false) + button("Underline", "underline", false) + }) + mujica.ToolbarSeparator(c) + button("Copy", "copy", false) + button("Image", "image", false) + button("Delete", "trash-2", s.noDeletes) + mujica.ToolbarSeparator(c) + button("Settings", "settings", false) + }) + }) + }) +} + +const navigationPaletteCode = `res := mujica.CommandPalette(c, &open, []mujica.Command{ + {ID: "new", Label: "New document", Group: "File"}, + {ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"night"}}, +}, mujica.CommandPaletteOptions{}) +if id, ok := res.Chosen(); ok { + run(id) +}` + +func navigationPaletteDemo(c *ui.Context, log func(string)) { + type state struct{ open, wasOpen bool } + s := ui.Local(c.Root(), "068", func() state { return state{} }) + navigationDemo(c, func() { + if mujica.Button(c, "Open command palette", mujica.ButtonOptions{Icon: icons.Must("command")}).Clicked() { + s.open = true + } + ui.Text(c, "or press Cmd+K / Ctrl+K").TextColor(mujica.Tokens(c).TextMuted) + }, func() { + res := mujica.CommandPalette(c, &s.open, []mujica.Command{ + {ID: "new", Label: "New document", Group: "File", Icon: icons.Must("file")}, + {ID: "open", Label: "Open recent", Group: "File", Icon: icons.Must("folder")}, + {ID: "save", Label: "Save all", Group: "File", Icon: icons.Must("file-text")}, + {ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"theme", "night"}, Icon: icons.Must("moon")}, + {ID: "zoom-in", Label: "Zoom in", Group: "View", Icon: icons.Must("zoom-in")}, + {ID: "zoom-out", Label: "Zoom out", Group: "View", Icon: icons.Must("zoom-out")}, + {ID: "settings", Label: "Open settings", Group: "Application", Keywords: []string{"preferences"}, Icon: icons.Must("settings")}, + }, mujica.CommandPaletteOptions{}) + if s.open != s.wasOpen { + log(fmt.Sprintf("Open: %v", s.open)) + s.wasOpen = s.open + } + if id, ok := res.Chosen(); ok { + log("Run: " + id) + } + }) +} diff --git a/examples/gallery/pages_overlay.go b/examples/gallery/pages_overlay.go index c56982f..2458fd9 100644 --- a/examples/gallery/pages_overlay.go +++ b/examples/gallery/pages_overlay.go @@ -1,3 +1,465 @@ package main -var overlayPages = []page{} +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const overlayCategory = "Overlays" + +var overlayPages = []page{ + {ID: "081", Name: "Dialog", ZhName: "对话框", Category: overlayCategory, Demo: dialogDemo, Code: dialogCode, + Keys: "Tab / Shift+Tab 在对话框内循环;Escape 关闭(可关闭此规则);关闭后焦点回到触发按钮。"}, + {ID: "082", Name: "AlertDialog", ZhName: "警示对话框", Category: overlayCategory, Demo: alertDialogDemo, Code: alertDialogCode, + Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮;Escape 等同取消;点击遮罩无效。"}, + {ID: "083", Name: "Drawer", ZhName: "抽屉", Category: overlayCategory, Demo: drawerDemo, Code: drawerCode, + Keys: "模态:焦点限制在抽屉内并在关闭后恢复;Escape 关闭。非模态:窗口仍可操作,Escape 关闭。"}, + {ID: "084", Name: "Popover", ZhName: "弹出框", Category: overlayCategory, Demo: popoverDemo, Code: popoverCode, + Keys: "Tab 从锚点进入弹层;Escape 或外部点击关闭并把焦点还给锚点。"}, + {ID: "085", Name: "Tooltip", ZhName: "文字提示", Category: overlayCategory, Demo: tooltipDemo, Code: tooltipCode, + Keys: "键盘聚焦或悬停超过延迟后显示;Escape 隐藏;移开或失焦隐藏。"}, + {ID: "086", Name: "HoverCard", ZhName: "悬停卡片", Category: overlayCategory, Demo: hoverCardDemo, Code: hoverCardCode, + Keys: "键盘聚焦锚点后显示,Tab 进入卡片;Escape 关闭。"}, + {ID: "087", Name: "DropdownMenu", ZhName: "下拉菜单", Category: overlayCategory, Demo: dropdownMenuDemo, Code: dropdownMenuCode, + Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 或 Tab 关闭。"}, + {ID: "088", Name: "ContextMenu", ZhName: "右键菜单", Category: overlayCategory, Demo: contextMenuDemo, Code: contextMenuCode, + Keys: "右键在指针处打开;焦点在区域内时按 Menu 键或 Shift+F10 在区域下方打开;其余按键同下拉菜单。"}, + {ID: "089", Name: "Popconfirm", ZhName: "气泡确认", Category: overlayCategory, Demo: popconfirmDemo, Code: popconfirmCode, + Keys: "打开后焦点在确认;Enter 确认;Escape 或外部点击取消。"}, + {ID: "090", Name: "Overlay", ZhName: "覆盖层", Category: overlayCategory, Demo: overlayDemo, Code: overlayCode, + Keys: "Escape 关闭;模态时焦点限制在覆盖层内,点击遮罩关闭。"}, +} + +// overlayControls lays out a demo's parameter row. +func overlayControls(c *ui.Context, fn func()) { + ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Padding(0, 0, 12, 0).Children(fn) +} + +func overlayCheck(c *ui.Context, log func(string), v *bool, label string) { + if ui.Checkbox(c, v, label).Changed() { + log(fmt.Sprintf("%s = %v", label, *v)) + } +} + +const dialogCode = `open := true +name := "Ophelia" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Dialog(c, &open, mujica.DialogOptions{ + Title: "Rename the chamber", + Description: "The new name shows in every hall.", + Decorated: true, + Actions: func() { + if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + open = false + } + if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() { + open = false + } + }, + }, func() { + ui.TextInput(c, &name) + }) +} +ui.Render(view, 400, 300, 1)` + +type dialogDemoState struct { + open, decorated, keepBackdrop, keepEscape, hideClose bool + name string +} + +func dialogDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "081", func() dialogDemoState { return dialogDemoState{decorated: true, name: "Ophelia"} }) + overlayControls(c, func() { + overlayCheck(c, log, &s.decorated, "Decorated") + overlayCheck(c, log, &s.keepBackdrop, "KeepOnBackdrop") + overlayCheck(c, log, &s.keepEscape, "KeepOnEscape") + overlayCheck(c, log, &s.hideClose, "HideClose") + }) + if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() { + s.open = true + log("open") + } + was := s.open + mujica.Dialog(c, &s.open, mujica.DialogOptions{ + Title: "Rename the chamber", Description: "The new name shows in every hall.", Decorated: s.decorated, + KeepOnBackdrop: s.keepBackdrop, KeepOnEscape: s.keepEscape, HideClose: s.hideClose, + Actions: func() { + if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.open = false + } + if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() { + log("renamed to " + s.name) + s.open = false + } + }, + }, func() { ui.TextInput(c, &s.name) }) + if was && !s.open { + log("closed") + } +} + +const alertDialogCode = `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + open = true + } + r := mujica.AlertDialog(c, &open, mujica.AlertDialogOptions{ + Title: "Delete the archive?", + Description: "Twelve letters will be destroyed.", + ConfirmText: "archive", + }) + if r.Confirmed { + fmt.Println("deleted") + } +} +ui.Render(view, 400, 300, 1)` + +type alertDialogDemoState struct{ open, typed bool } + +func alertDialogDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "082", func() alertDialogDemoState { return alertDialogDemoState{} }) + overlayControls(c, func() { overlayCheck(c, log, &s.typed, "ConfirmText") }) + if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + s.open = true + log("open") + } + opts := mujica.AlertDialogOptions{Title: "Delete the archive?", Description: "Twelve letters will be destroyed. This cannot be undone."} + if s.typed { + opts.ConfirmText = "archive" + } + r := mujica.AlertDialog(c, &s.open, opts) + if r.Confirmed { + log("confirmed") + } + if r.Cancelled { + log("cancelled") + } +} + +const drawerCode = `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Filters", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Drawer(c, &open, mujica.DrawerOptions{Title: "Filters", Side: mujica.DrawerRight, Size: 320}, func() { + ui.Text(c, "Guests are placed by rank.") + }) +} +ui.Render(view, 400, 300, 1)` + +type drawerDemoState struct { + open, nonModal bool + side string + size float64 +} + +func drawerDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "083", func() drawerDemoState { return drawerDemoState{side: "Right", size: 320} }) + sides := map[string]mujica.DrawerSide{"Right": mujica.DrawerRight, "Left": mujica.DrawerLeft, "Top": mujica.DrawerTop, "Bottom": mujica.DrawerBottom} + overlayControls(c, func() { + if ui.Select(c, &s.side, []string{"Right", "Left", "Top", "Bottom"}).Changed() { + log("side = " + s.side) + } + overlayCheck(c, log, &s.nonModal, "NonModal") + ui.Text(c, fmt.Sprintf("Size %.0f", s.size)) + if ui.StepSlider(c, &s.size, 120, 900, 20).Width(160).Changed() { + log(fmt.Sprintf("size = %.0f", s.size)) + } + }) + if mujica.Button(c, "Open drawer", mujica.ButtonOptions{}).Clicked() { + s.open = true + log("open") + } + was := s.open + mujica.Drawer(c, &s.open, mujica.DrawerOptions{Title: "Seating chart", Side: sides[s.side], Size: float32(s.size), NonModal: s.nonModal, + Actions: func() { + if mujica.Button(c, "Apply", mujica.ButtonOptions{}).Clicked() { + log("applied") + s.open = false + } + }}, func() { + ui.Text(c, "Guests are placed by rank, then by arrival.").TextColor(mujica.Tokens(c).TextMuted) + }) + if was && !s.open { + log("closed") + } +} + +const popoverCode = `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + open = !open + } + mujica.Popover(c, b, &open, mujica.PopoverOptions{Title: "Guest list"}, func() { + ui.Text(c, "Twelve guests confirmed.") + }) +} +ui.Render(view, 400, 300, 1)` + +type popoverDemoState struct { + open, titled bool + rsvp bool +} + +func popoverDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "084", func() popoverDemoState { return popoverDemoState{titled: true} }) + overlayControls(c, func() { overlayCheck(c, log, &s.titled, "Title") }) + b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + s.open = !s.open + log(fmt.Sprintf("open = %v", s.open)) + } + opts := mujica.PopoverOptions{Label: "Guest list"} + if s.titled { + opts.Title = "Guest list" + } + was := s.open + mujica.Popover(c, b, &s.open, opts, func() { + ui.Text(c, "Twelve guests confirmed; three await a reply.") + if ui.Checkbox(c, &s.rsvp, "Remind the others").Changed() { + log(fmt.Sprintf("remind = %v", s.rsvp)) + } + }) + if was && !s.open { + log("closed") + } +} + +const tooltipCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + mujica.Tooltip(c, b, "Seal the letter with wax", mujica.TooltipOptions{}) +} +ui.Render(view, 400, 300, 1)` + +func tooltipDemo(c *ui.Context, log func(string)) { + delay := ui.Local(c.Root(), "085", func() float64 { return 500 }) + overlayControls(c, func() { + ui.Text(c, fmt.Sprintf("Delay %.0f ms", *delay)) + if ui.StepSlider(c, delay, 100, 1500, 100).Width(160).Changed() { + log(fmt.Sprintf("delay = %.0f ms", *delay)) + } + }) + opts := mujica.TooltipOptions{Delay: time.Duration(*delay) * time.Millisecond} + ui.Row(c).Gap(12).Children(func() { + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + if b.Clicked() { + log("seal clicked") + } + mujica.Tooltip(c, b, "Seal the letter with wax", opts) + d := mujica.Button(c, "Burn", mujica.ButtonOptions{Variant: mujica.Danger}) + if d.Clicked() { + log("burn clicked") + } + mujica.Tooltip(c, d, "Burn every copy of the letter; nothing is kept", opts) + }) +} + +const hoverCardCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost}) + mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() { + ui.Text(c, "Keyboardist of the court.") + }) +} +ui.Render(view, 400, 300, 1)` + +func hoverCardDemo(c *ui.Context, log func(string)) { + shown := ui.Local(c.Root(), "086", func() bool { return false }) + b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost}) + card := mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() { + ui.Text(c, "Lady Sakiko").FontWeight(600) + ui.Text(c, "Keyboardist of the court; writes the masquerade's score.").TextColor(mujica.Tokens(c).TextMuted) + if mujica.Button(c, "Follow", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() { + log("follow") + } + }) + if (card != nil) != *shown { + *shown = card != nil + log(fmt.Sprintf("card shown = %v", *shown)) + } +} + +const dropdownMenuCode = `pinned := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{}, func(m *mujica.PopupMenu) { + m.Group("Letter", func() { + if m.Item("Rename", mujica.PopupMenuItemOptions{}) { + fmt.Println("rename") + } + m.Check("Pinned", &pinned, mujica.PopupMenuItemOptions{KeepOpen: true}) + }) + m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) { + m.Item("Archive", mujica.PopupMenuItemOptions{}) + }) + m.Separator() + m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: true}) + }) +} +ui.Render(view, 400, 300, 1)` + +type menuDemoState struct{ pinned, sealed, locked, disabled bool } + +// overlayMenu fills the shared demo menu, logging every choice. +func overlayMenu(s *menuDemoState, log func(string)) func(m *mujica.PopupMenu) { + return func(m *mujica.PopupMenu) { + m.Group("Letter", func() { + if m.Item("Rename", mujica.PopupMenuItemOptions{}) { + log("rename") + } + if m.Check("Pinned", &s.pinned, mujica.PopupMenuItemOptions{KeepOpen: true}) { + log(fmt.Sprintf("pinned = %v", s.pinned)) + } + if m.Check("Sealed", &s.sealed, mujica.PopupMenuItemOptions{}) { + log(fmt.Sprintf("sealed = %v", s.sealed)) + } + }) + m.Group("Move", func() { + m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) { + for _, f := range []string{"Archive", "Drafts", "Vault"} { + if m.Item(f, mujica.PopupMenuItemOptions{}) { + log("move to " + f) + } + } + }) + }) + m.Separator() + if m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: s.locked}) { + log("burn") + } + } +} + +func dropdownMenuDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "087", func() menuDemoState { return menuDemoState{locked: true} }) + overlayControls(c, func() { + overlayCheck(c, log, &s.locked, "Burn disabled") + overlayCheck(c, log, &s.disabled, "Disabled") + }) + mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{Disabled: s.disabled}, overlayMenu(s, log)) +} + +const contextMenuCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + row := ui.Row(c).Padding(12).Children(func() { + ui.Text(c, "Letter to the Countess") + }) + mujica.ContextMenu(c, row, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Reply", mujica.PopupMenuItemOptions{}) { + fmt.Println("reply") + } + m.Item("Delete", mujica.PopupMenuItemOptions{}) + }) +} +ui.Render(view, 400, 300, 1)` + +func contextMenuDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "088", func() menuDemoState { return menuDemoState{} }) + overlayControls(c, func() { + overlayCheck(c, log, &s.locked, "Burn disabled") + overlayCheck(c, log, &s.disabled, "Disabled") + }) + k := mujica.Tokens(c) + row := ui.Column(c).Height(160).FillWidth().Padding(16).Gap(8).Background(k.Surface).Border(1, k.Border).Radius(4) + row.Children(func() { + ui.Text(c, "Right-click anywhere here, or focus the button and press Shift+F10.").TextColor(k.TextMuted) + if mujica.Button(c, "Letter to the Countess", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + log("letter clicked") + } + }) + mujica.ContextMenu(c, row, mujica.ContextMenuOptions{Disabled: s.disabled}, overlayMenu(s, log)) +} + +const popconfirmCode = `open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + open = true + } + if mujica.Popconfirm(c, b, &open, mujica.PopconfirmOptions{Title: "Archive this letter?"}).Confirmed { + fmt.Println("archived") + } +} +ui.Render(view, 400, 300, 1)` + +type popconfirmDemoState struct{ open, danger bool } + +func popconfirmDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "089", func() popconfirmDemoState { return popconfirmDemoState{} }) + overlayControls(c, func() { overlayCheck(c, log, &s.danger, "Danger") }) + b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + if b.Clicked() { + s.open = true + log("open") + } + r := mujica.Popconfirm(c, b, &s.open, mujica.PopconfirmOptions{Title: "Archive this letter?", Description: "It leaves the inbox.", Danger: s.danger}) + if r.Confirmed { + log("confirmed") + } + if r.Cancelled { + log("cancelled") + } +} + +const overlayCode = `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Overlay(c, &open, mujica.OverlayOptions{Place: mujica.OverlayBottomRight, Label: "Now playing"}, func() { + ui.Text(c, "Symbol I: △") + }) +} +ui.Render(view, 400, 300, 1)` + +type overlayDemoState struct { + open, modal, dim bool + place string +} + +func overlayDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "090", func() overlayDemoState { return overlayDemoState{place: "BottomRight"} }) + names := []string{"Center", "Top", "Bottom", "Left", "Right", "TopLeft", "TopRight", "BottomLeft", "BottomRight"} + place := mujica.OverlayCenter + for i, n := range names { + if n == s.place { + place = mujica.OverlayPlace(i) + } + } + overlayControls(c, func() { + if ui.Select(c, &s.place, names).Changed() { + log("place = " + s.place) + } + overlayCheck(c, log, &s.modal, "Modal") + if s.modal { + overlayCheck(c, log, &s.dim, "Dim") + } + }) + if mujica.Button(c, "Show overlay", mujica.ButtonOptions{}).Clicked() { + s.open = true + log("open") + } + was := s.open + mujica.Overlay(c, &s.open, mujica.OverlayOptions{Place: place, Modal: s.modal, Dim: s.modal && s.dim, Label: "Now playing"}, func() { + ui.Text(c, "Now playing").FontWeight(600) + ui.Text(c, "Symbol I: △").TextColor(mujica.Tokens(c).TextMuted) + if mujica.Button(c, "Close", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.open = false + } + }) + if was && !s.open { + log("closed") + } +} diff --git a/file_drop_zone.go b/file_drop_zone.go new file mode 100644 index 0000000..8b1a588 --- /dev/null +++ b/file_drop_zone.go @@ -0,0 +1,292 @@ +package mujica + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// DroppedFile is a file a FileDropZone accepted. +type DroppedFile struct { + Path string + Size int64 +} + +// FileDropZoneOptions configure a FileDropZone. Zero limits accept anything. +type FileDropZoneOptions struct { + // Extensions lists the accepted extensions without the dot, matched case-insensitively. + Extensions []string + // MaxFiles caps how many files the list holds. + MaxFiles int + // MaxSize caps each file's size in bytes. + MaxSize int64 + // Hint replaces the built-in "Drop files here". + Hint string + Disabled bool +} + +// FileDropZoneParts are the parts of a FileDropZone. +type FileDropZoneParts struct { + Element *ui.Element + changed bool +} + +// Changed reports whether files were added or removed this frame. +func (p FileDropZoneParts) Changed() bool { return p.changed } + +// fileDropZoneReject is a file that failed a check, shown until dismissed. +type fileDropZoneReject struct { + path, reason string +} + +type fileDropZoneStat struct { + path string + size int64 + dir bool + err error +} + +type fileDropZoneState struct { + pending []string + stats chan []fileDropZoneStat + browse chan filePickerResult + rejected []fileDropZoneReject +} + +// FileDropZone takes files dropped from the system or chosen with its Browse button, +// checks type, count and size, and lists them in *files. It never reads or uploads them. +func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts { + if opts.MaxFiles < 0 || opts.MaxSize < 0 { + panic("mujica: FileDropZone limits must not be negative") + } + for _, ext := range opts.Extensions { + if ext == "" || strings.HasPrefix(ext, ".") { + panic(fmt.Sprintf("mujica: FileDropZone extension %q: give it without the dot", ext)) + } + } + k := Tokens(c) + d := Density(c) + parts := FileDropZoneParts{} + col := ui.Column(c).Gap(d.Unit() * 2) + parts.Element = col + st := ui.Local(col, "zone", func() fileDropZoneState { return fileDropZoneState{} }) + reject := func(path, key string, args ...any) { + st.rejected = append(st.rejected, fileDropZoneReject{path, fmt.Sprintf(msg(c, key), args...)}) + slog.Debug("mujica: file drop zone rejected", "reason", key) + } + // take checks types now and sizes off the UI thread. + take := func(paths []string) { + var ok []string + for _, p := range paths { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".") + switch { + case slices.ContainsFunc(*files, func(f DroppedFile) bool { return f.Path == p }), slices.Contains(st.pending, p), slices.Contains(ok, p): + case len(opts.Extensions) > 0 && !slices.ContainsFunc(opts.Extensions, func(e string) bool { return strings.EqualFold(e, ext) }): + reject(p, "file_drop_zone.type", strings.Join(opts.Extensions, ", ")) + default: + ok = append(ok, p) + } + } + if len(ok) == 0 { + return + } + st.pending = append(st.pending, ok...) + if st.stats != nil { + return // the running check picks them up next + } + fileDropZoneCheck(c, st) + } + if st.stats != nil { + select { + case res := <-st.stats: + st.stats = nil + st.pending = st.pending[len(res):] + for _, s := range res { + switch { + case s.err != nil: + err := s.err + if pe, ok := errors.AsType[*fs.PathError](err); ok { + err = pe.Err + } + reject(s.path, "file_drop_zone.unreadable", err) + case s.dir: + reject(s.path, "file_drop_zone.folder") + case opts.MaxSize > 0 && s.size > opts.MaxSize: + reject(s.path, "file_drop_zone.size", fileDropZoneSize(opts.MaxSize)) + case opts.MaxFiles > 0 && len(*files) >= opts.MaxFiles: + reject(s.path, "file_drop_zone.count", opts.MaxFiles) + default: + *files = append(slices.Clone(*files), DroppedFile{s.path, s.size}) + parts.changed = true + } + } + if len(st.pending) > 0 { + fileDropZoneCheck(c, st) + } + default: + } + } + if st.browse != nil { + select { + case r := <-st.browse: + st.browse = nil + if r.err != nil { + reject("", "file_picker.failed", r.err) + } else { + take(r.paths) + } + default: + } + } + col.Children(func() { + zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5). + Radius(theme.CardRadius).Disabled(opts.Disabled) + if dropped := zone.DroppedFiles(); dropped != nil && !opts.Disabled { + slog.Debug("mujica: file drop zone dropped", "count", len(dropped)) + take(dropped) + } + over := zone.FileDragOver() && !opts.Disabled + bg, border := k.Surface, k.ControlBorder + if over { + bg, border = k.Selection, k.AccentText + } + zone.Background(bg).Draw(func(p *ui.Painter, r ui.Rect) { + p.StrokeDashed(r, border, theme.CardRadius, theme.BorderWidth*1.5) + }) + hint := opts.Hint + if hint == "" { + hint = msg(c, "file_drop_zone.hint") + } + zone.Children(func() { + ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText) + ui.Text(c, hint).TextAlign(ui.Center) + ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).TextAlign(ui.Center) + b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled}) + if b.Clicked() { + ch := make(chan filePickerResult, 1) + st.browse = ch + dlg := mygo.OpenDialogOptions{Multiple: opts.MaxFiles != 1} + if len(opts.Extensions) > 0 { + dlg.Filters = []mygo.FileFilter{{Name: hint, Extensions: opts.Extensions}} + } + go func() { + p, err := filePickerOpen(dlg) + ch <- filePickerResult{p, err} + c.Invalidate() + }() + } + }) + fileDropZoneList(c, st, files, &parts, opts.Disabled) + }) + return parts +} + +// fileDropZoneCheck stats the pending files on a goroutine. +func fileDropZoneCheck(c *ui.Context, st *fileDropZoneState) { + ch := make(chan []fileDropZoneStat, 1) + st.stats = ch + paths := slices.Clone(st.pending) + go func() { + res := make([]fileDropZoneStat, len(paths)) + for i, p := range paths { + info, err := os.Stat(p) + res[i] = fileDropZoneStat{path: p, err: err} + if err == nil { + res[i].size, res[i].dir = info.Size(), info.IsDir() + } + } + ch <- res + c.Invalidate() + }() +} + +// fileDropZoneList shows accepted, checking and rejected files. +func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile, parts *FileDropZoneParts, disabled bool) { + k := Tokens(c) + d := Density(c) + row := func(key string, icon string, color ui.Color, name, detail string, remove func()) { + ui.Row(c).Key(key).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). + BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color) + ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1) + ui.Text(c, detail).TextColor(color).FontSize(theme.CaptionSize).SingleLine().Grow(1) + if remove != nil { + x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted). + Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled) + internal.Ring(x, k.Focus, theme.ControlRadius) + if x.Hovered() { + x.Background(k.SurfaceHover).TextColor(k.Text) + } + x.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14) }) + if x.Clicked() { + remove() + } + } + }) + } + ui.Column(c).Children(func() { + for i, f := range *files { + row("ok"+f.Path, "file", k.TextMuted, filepath.Base(f.Path), fileDropZoneSize(f.Size), func() { + *files = slices.Delete(slices.Clone(*files), i, i+1) + parts.changed = true + slog.Debug("mujica: file drop zone removed", "count", len(*files)) + }) + } + for _, p := range st.pending { + row("pending"+p, "loader-circle", k.TextMuted, filepath.Base(p), msg(c, "file_drop_zone.checking"), nil) + } + for i, r := range st.rejected { + name := filepath.Base(r.path) + if r.path == "" { + name = msg(c, "file_drop_zone.dialog") + } + row(fmt.Sprint("bad", i, r.path), "circle-alert", k.Danger, name, r.reason, func() { + st.rejected = slices.Delete(slices.Clone(st.rejected), i, i+1) + }) + } + }) +} + +// fileDropZoneRules describes the limits, such as "PDF, PNG · up to 3 files · 2 MB each". +func fileDropZoneRules(c *ui.Context, opts FileDropZoneOptions) string { + var rules []string + if len(opts.Extensions) > 0 { + rules = append(rules, strings.ToUpper(strings.Join(opts.Extensions, ", "))) + } + if opts.MaxFiles > 0 { + rules = append(rules, fmt.Sprintf(msg(c, "file_drop_zone.max_files"), opts.MaxFiles)) + } + if opts.MaxSize > 0 { + rules = append(rules, fmt.Sprintf(msg(c, "file_drop_zone.max_size"), fileDropZoneSize(opts.MaxSize))) + } + if len(rules) == 0 { + return msg(c, "file_drop_zone.any") + } + return strings.Join(rules, " · ") +} + +// fileDropZoneSize formats n bytes in binary units. +func fileDropZoneSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + v, i := float64(n)/unit, 0 + for v >= unit && i < 3 { + v /= unit + i++ + } + return fmt.Sprintf("%.1f %s", v, []string{"KB", "MB", "GB", "TB"}[i]) +} diff --git a/file_drop_zone_test.go b/file_drop_zone_test.go new file mode 100644 index 0000000..ccd4527 --- /dev/null +++ b/file_drop_zone_test.go @@ -0,0 +1,120 @@ +package mujica + +import ( + "os" + "path/filepath" + "testing" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +// fileDropZoneFiles writes files of the given sizes into a temp dir. +func fileDropZoneFiles(t *testing.T, sizes map[string]int) string { + t.Helper() + dir := t.TempDir() + for name, n := range sizes { + if err := os.WriteFile(filepath.Join(dir, name), make([]byte, n), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func fileDropZoneTester(files *[]DroppedFile, opts FileDropZoneOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if FileDropZone(c, files, opts).Changed() { + *changes++ + } + }, 560, 600) +} + +// fileDropZoneBrowse answers the Browse dialog with paths. +func fileDropZoneBrowse(t *testing.T, paths ...string) *mygo.OpenDialogOptions { + saved := filePickerOpen + t.Cleanup(func() { filePickerOpen = saved }) + got := &mygo.OpenDialogOptions{} + filePickerOpen = func(o mygo.OpenDialogOptions) ([]string, error) { + *got = o + return paths, nil + } + return got +} + +func TestFileDropZoneChecksTypeSizeAndCount(t *testing.T) { + dir := fileDropZoneFiles(t, map[string]int{"a.pdf": 10, "b.PDF": 3000, "c.pdf": 20, "d.txt": 5, "e.pdf": 30}) + p := func(n string) string { return filepath.Join(dir, n) } + got := fileDropZoneBrowse(t, p("a.pdf"), p("b.PDF"), p("c.pdf"), p("d.txt"), p("e.pdf"), p("missing.pdf"), dir+"/sub.pdf") + if err := os.Mkdir(p("sub.pdf"), 0o755); err != nil { + t.Fatal(err) + } + var files []DroppedFile + n := 0 + tt := fileDropZoneTester(&files, FileDropZoneOptions{Extensions: []string{"pdf"}, MaxFiles: 2, MaxSize: 1024}, &n) + if err := tt.Click("Browse…"); err != nil { + t.Fatal(err) + } + filePickerWait(t, tt, func() bool { return len(files) == 2 && tt.HasText("More than 2 files") }) + if files[0] != (DroppedFile{p("a.pdf"), 10}) || files[1] != (DroppedFile{p("c.pdf"), 20}) || n != 1 { + t.Fatalf("files %+v changes %d", files, n) + } + for _, want := range []string{"Only pdf files", "Larger than 1.0 KB", "Folders are not accepted", "Cannot read: no such file or directory"} { + if !tt.HasText(want) { + t.Errorf("missing %q in %q", want, tt.Texts()) + } + } + if !got.Multiple || got.Filters[0].Extensions[0] != "pdf" { + t.Fatalf("dialog options %+v", got) + } +} + +func TestFileDropZoneRemoveAndDedupe(t *testing.T) { + dir := fileDropZoneFiles(t, map[string]int{"score.mid": 2048}) + path := filepath.Join(dir, "score.mid") + fileDropZoneBrowse(t, path) + files := []DroppedFile{{path, 2048}} + n := 0 + tt := fileDropZoneTester(&files, FileDropZoneOptions{}, &n) + _ = tt.Click("Browse…") + for range 20 { + tt.Frame() + } + if len(files) != 1 || n != 0 || !tt.HasText("2.0 KB") { + t.Fatalf("duplicate added: %+v", files) + } + if err := tt.Click("Remove score.mid"); err != nil { + t.Fatal(err) + } + if len(files) != 0 || n != 1 { + t.Fatalf("remove: %+v changes %d", files, n) + } +} + +func TestFileDropZoneDisabled(t *testing.T) { + called := false + saved := filePickerOpen + t.Cleanup(func() { filePickerOpen = saved }) + filePickerOpen = func(mygo.OpenDialogOptions) ([]string, error) { called = true; return nil, nil } + files := []DroppedFile{{"/x/kept.pdf", 1}} + n := 0 + tt := fileDropZoneTester(&files, FileDropZoneOptions{Disabled: true}, &n) + _ = tt.Click("Browse…") + _ = tt.Click("Remove kept.pdf") + tt.Frame() + if called || len(files) != 1 { + t.Fatal("disabled zone acted") + } + if fileDropZoneSize(1536) != "1.5 KB" || fileDropZoneSize(3<<20) != "3.0 MB" || fileDropZoneSize(12) != "12 B" { + t.Fatal("size format") + } +} + +func TestFileDropZoneSnapshot(t *testing.T) { + files := []DroppedFile{{"/scores/Imprisoned XII.pdf", 482133}, {"/scores/cover.png", 1 << 20}} + snapshot(t, "file_drop_zone", 520, 330, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + FileDropZone(c, &files, FileDropZoneOptions{Extensions: []string{"pdf", "png"}, MaxFiles: 3, MaxSize: 2 << 20}) + }) + }) +} diff --git a/file_picker.go b/file_picker.go new file mode 100644 index 0000000..44942e8 --- /dev/null +++ b/file_picker.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "fmt" + "log/slog" + "path/filepath" + "strings" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FilePickerOptions configure a FilePicker. +type FilePickerOptions struct { + // Directory picks folders instead of files. + Directory bool + Multiple bool + // Filters limit the files offered by extension, without the dot. + Filters []mygo.FileFilter + // Title is the system dialog's title. + Title string + // Label is the button text; empty uses the built-in "Choose file…" or "Choose folder…". + Label string + // Parent attaches the dialog to a window, as a sheet on macOS. + Parent *mygo.Window + Disabled bool +} + +// FilePickerParts are the parts of a FilePicker. +type FilePickerParts struct { + Element *ui.Element + changed bool + canceled bool +} + +// Changed reports whether the user picked new paths this frame. +func (p FilePickerParts) Changed() bool { return p.changed } + +// Canceled reports whether the user closed the dialog without picking, which is not an error. +func (p FilePickerParts) Canceled() bool { return p.canceled } + +// filePickerOpen shows the system dialog; tests replace it. +var filePickerOpen = mygo.Dialog.Open + +type filePickerResult struct { + paths []string + err error +} + +type filePickerState struct { + result chan filePickerResult + err string +} + +// FilePicker opens the system file or folder dialog and stores the chosen paths in *paths. +// The dialog runs off the UI thread; the button shows loading until it closes. +func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts { + for _, f := range opts.Filters { + for _, ext := range f.Extensions { + if ext == "" || strings.HasPrefix(ext, ".") { + panic(fmt.Sprintf("mujica: FilePicker extension %q: give it without the dot", ext)) + } + } + } + k := Tokens(c) + d := Density(c) + parts := FilePickerParts{} + col := ui.Column(c).Gap(d.Unit() * 2).AlignItems(ui.Start) + parts.Element = col + st := ui.Local(col, "picker", func() filePickerState { return filePickerState{} }) + if st.result != nil { + select { + case r := <-st.result: + st.result = nil + switch { + case r.err != nil: + st.err = fmt.Sprintf(msg(c, "file_picker.failed"), r.err) + slog.Debug("mujica: file picker failed", "err", r.err) + case r.paths == nil: + parts.canceled = true + slog.Debug("mujica: file picker canceled") + default: + *paths = r.paths + parts.changed = true + slog.Debug("mujica: file picker picked", "count", len(r.paths)) + } + default: + } + } + label := opts.Label + if label == "" { + label = msg(c, "file_picker.choose_file") + if opts.Directory { + label = msg(c, "file_picker.choose_folder") + } + } + icon := icons.Must("file") + if opts.Directory { + icon = icons.Must("folder") + } + col.Children(func() { + b := Button(c, label, ButtonOptions{Variant: Secondary, Icon: icon, Loading: st.result != nil, Disabled: opts.Disabled}) + if b.Clicked() { + st.err = "" + ch := make(chan filePickerResult, 1) + st.result = ch + dlg := mygo.OpenDialogOptions{Parent: opts.Parent, Title: opts.Title, Filters: opts.Filters, + Directory: opts.Directory, Multiple: opts.Multiple} + slog.Debug("mujica: file picker opened", "directory", opts.Directory, "multiple", opts.Multiple) + go func() { + p, err := filePickerOpen(dlg) + ch <- filePickerResult{p, err} + c.Invalidate() + }() + } + switch { + case st.err != "": + ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus) + case len(*paths) == 0: + ui.Text(c, msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(theme.CaptionSize) + default: + ui.Column(c).Gap(d.Unit()).Children(func() { + for _, p := range *paths { + ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted) + ui.Text(c, filepath.Base(p)).SingleLine() + ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1) + }) + } + }) + } + }) + return parts +} diff --git a/file_picker_test.go b/file_picker_test.go new file mode 100644 index 0000000..459c847 --- /dev/null +++ b/file_picker_test.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "errors" + "slices" + "testing" + "time" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +type filePickerProbe struct { + paths []string + changes, cancels int + got mygo.OpenDialogOptions + reply []string + err error + release chan struct{} +} + +func (p *filePickerProbe) stub(t *testing.T) { + saved := filePickerOpen + t.Cleanup(func() { filePickerOpen = saved }) + filePickerOpen = func(o mygo.OpenDialogOptions) ([]string, error) { + p.got = o + if p.release != nil { + <-p.release + } + return p.reply, p.err + } +} + +func (p *filePickerProbe) tester(opts FilePickerOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := FilePicker(c, &p.paths, opts) + if r.Changed() { + p.changes++ + } + if r.Canceled() { + p.cancels++ + } + }, 500, 200) +} + +// filePickerWait draws frames until done holds. +func filePickerWait(t *testing.T, tt *ui.Tester, done func() bool) { + t.Helper() + for range 200 { + tt.Frame() + if done() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("dialog result never arrived") +} + +func TestFilePickerPicksFiles(t *testing.T) { + p := &filePickerProbe{reply: []string{"/court/score.pdf", "/court/cast.png"}, release: make(chan struct{})} + p.stub(t) + filters := []mygo.FileFilter{{Name: "Docs", Extensions: []string{"pdf", "png"}}} + tt := p.tester(FilePickerOptions{Multiple: true, Filters: filters, Title: "Score"}) + if err := tt.Click("Choose file…"); err != nil { + t.Fatal(err) + } + _ = tt.Click("Choose file…") + close(p.release) + filePickerWait(t, tt, func() bool { return p.changes > 0 }) + if !slices.Equal(p.paths, p.reply) || p.changes != 1 || !tt.HasText("score.pdf") { + t.Fatalf("paths %q changes %d", p.paths, p.changes) + } + if !p.got.Multiple || p.got.Directory || p.got.Title != "Score" || p.got.Filters[0].Extensions[1] != "png" { + t.Fatalf("dialog options %+v", p.got) + } +} + +func TestFilePickerCancelIsNotAnError(t *testing.T) { + p := &filePickerProbe{paths: []string{"/kept"}} + p.stub(t) + tt := p.tester(FilePickerOptions{Directory: true}) + if err := tt.Click("Choose folder…"); err != nil { + t.Fatal(err) + } + filePickerWait(t, tt, func() bool { return p.cancels > 0 }) + if p.changes != 0 || p.paths[0] != "/kept" || !p.got.Directory { + t.Fatalf("cancel changed paths: %q", p.paths) + } +} + +func TestFilePickerReportsDialogError(t *testing.T) { + p := &filePickerProbe{err: errors.New("no display")} + p.stub(t) + tt := p.tester(FilePickerOptions{}) + _ = tt.Click("Choose file…") + want := "Could not open the file dialog: no display" + filePickerWait(t, tt, func() bool { return tt.HasText(want) }) + if p.changes != 0 || p.cancels != 0 { + t.Fatalf("error counted as result") + } +} + +func TestFilePickerDisabledAndBadFilter(t *testing.T) { + p := &filePickerProbe{} + p.stub(t) + called := false + filePickerOpen = func(mygo.OpenDialogOptions) ([]string, error) { called = true; return nil, nil } + tt := p.tester(FilePickerOptions{Disabled: true}) + _ = tt.Click("Choose file…") + tt.Frame() + if called { + t.Fatal("disabled picker opened the dialog") + } + defer func() { + if recover() == nil { + t.Fatal("dotted extension accepted") + } + }() + p.tester(FilePickerOptions{Filters: []mygo.FileFilter{{Name: "PDF", Extensions: []string{".pdf"}}}}) +} + +func TestFilePickerSnapshot(t *testing.T) { + a, b := []string{"/Users/sakiko/scores/Imprisoned XII.pdf", "/Users/sakiko/scores/Ave Mujica.mid"}, []string{} + snapshot(t, "file_picker", 460, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(20).Children(func() { + FilePicker(c, &a, FilePickerOptions{Multiple: true}) + FilePicker(c, &b, FilePickerOptions{Directory: true, Disabled: true}) + }) + }) +} diff --git a/form.go b/form.go new file mode 100644 index 0000000..278e916 --- /dev/null +++ b/form.go @@ -0,0 +1,309 @@ +package mujica + +import ( + "fmt" + "log/slog" + "reflect" + "slices" + + "github.com/egoist/mygo/ui" +) + +// FormState is a form's validation and submit state; keep it beside the form's values. +// Its methods run on the UI thread: deliver async results through Window.Update. +type FormState struct { + fields map[string]*formFieldState + order []string + resets []func() + seq uint64 + tried bool + submitting bool + awaiting bool + requested bool + focus string + formError string +} + +type formFieldState struct { + snapshot string + version uint64 + touched bool + focused bool + err string + asyncFor uint64 + asyncErr string + pending bool + check func() string + startAsync func() +} + +// Version returns the version of name's value, which changes whenever the value does. +func (s *FormState) Version(name string) uint64 { + if f := s.fields[name]; f != nil { + return f.version + } + return 0 +} + +// Resolve delivers the async check of name's value at version; "" means valid. +// It reports false and drops the result when the value has changed since. +func (s *FormState) Resolve(name string, version uint64, err string) bool { + f := s.fields[name] + if f == nil || f.version != version { + slog.Debug("mujica: form async result dropped", "field", name, "version", version) + return false + } + f.pending, f.asyncFor, f.asyncErr = false, version, err + slog.Debug("mujica: form async result", "field", name, "valid", err == "") + return true +} + +// Submit asks the form to submit in its next frame, as the submit button does. +func (s *FormState) Submit() { s.requested = true } + +// Submitting reports whether a submit is in progress. +func (s *FormState) Submitting() bool { return s.submitting } + +// EndSubmit finishes a submit; formError, if any, shows above the buttons and keeps all values. +func (s *FormState) EndSubmit(formError string) { + if !s.submitting { + panic("mujica: FormState.EndSubmit without a submit in progress") + } + s.submitting, s.formError = false, formError + slog.Debug("mujica: form submit ended", "failed", formError != "") +} + +// Error returns the error name shows now, "" when valid, untouched or pending. +func (s *FormState) Error(name string) string { + f := s.fields[name] + if f == nil || !(f.touched || s.tried) { + return "" + } + if f.err != "" { + return f.err + } + if f.asyncFor == f.version { + return f.asyncErr + } + return "" +} + +// FormOptions configure a Form. +type FormOptions struct { + // Horizontal puts labels in a column beside the controls. + Horizontal bool + // SubmitLabel and ResetLabel replace the built-in "Submit" and "Reset". + SubmitLabel, ResetLabel string +} + +// FormScope registers a Form's fields with FormItem. +type FormScope struct { + c *ui.Context + st *FormState +} + +// FormParts are the parts of a Form. +type FormParts struct { + Element *ui.Element + submitted, reset bool +} + +// Submitted reports a submit that passed every check; call EndSubmit when it finishes. +func (p FormParts) Submitted() bool { return p.submitted } + +// Reset reports that the form restored its initial values this frame. +func (p FormParts) Reset() bool { return p.reset } + +// FormItemOptions configure one field of a Form. +type FormItemOptions[T any] struct { + Label string + Required bool + Description string + // Initial is the value Reset restores. + Initial T + // Validate returns an error message for v, or ""; cross-field rules read other values here. + Validate func(v T) string + // Async starts a check of v off the UI thread, finished with FormState.Resolve(name, version, msg). + Async func(v T, version uint64) +} + +// Form lays out the fields that fields registers, validates them on blur and submit, +// and shows submit and reset buttons. It never stores or sends the values itself. +func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts { + if st.fields == nil { + st.fields = map[string]*formFieldState{} + } + st.order, st.resets = st.order[:0], nil + d := Density(c) + k := Tokens(c) + parts := FormParts{} + col := ui.Column(c).Gap(d.Unit() * 5) + parts.Element = col + scope := &FormScope{c: c, st: st} + col.Children(func() { + if opts.Horizontal { + ui.Form(c, func() { fields(scope) }) + } else { + ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) }) + } + if st.formError != "" { + ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus) + } + ui.Row(c).Gap(d.Unit() * 3).Children(func() { + submit, reset := opts.SubmitLabel, opts.ResetLabel + if submit == "" { + submit = msg(c, "form.submit") + } + if reset == "" { + reset = msg(c, "form.reset") + } + if Button(c, submit, ButtonOptions{Loading: st.submitting || st.awaiting}).Clicked() { + st.requested = true + } + if Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting}).Clicked() { + formReset(st) + parts.reset = true + } + }) + }) + if st.requested { + st.requested = false + if !st.submitting && !st.awaiting { + formStartSubmit(st) + } + } + if st.awaiting { + parts.submitted = formSettle(c, st) + } + return parts +} + +// formStartSubmit validates every field and starts the async checks still owed. +func formStartSubmit(st *FormState) { + st.tried, st.awaiting, st.formError = true, true, "" + for _, name := range st.order { + f := st.fields[name] + f.err = f.check() + if f.err == "" { + f.startAsync() + } + } + slog.Debug("mujica: form submit requested", "fields", len(st.order)) +} + +// formSettle finishes a waiting submit once no check is pending; it reports success. +func formSettle(c *ui.Context, st *FormState) bool { + first, pending := "", false + for _, name := range st.order { + if st.Error(name) != "" && first == "" { + first = name + } + pending = pending || st.fields[name].pending + } + switch { + case first != "": + st.awaiting, st.focus = false, first + c.Announce(msg(c, "form.failed")) + c.Invalidate() + slog.Debug("mujica: form submit failed", "first", first) + case pending: + default: + st.awaiting, st.submitting = false, true + slog.Debug("mujica: form submitted") + return true + } + return false +} + +// formReset restores the initial values and forgets every check. +func formReset(st *FormState) { + for _, r := range st.resets { + r() + } + st.fields = map[string]*formFieldState{} + st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", "" + slog.Debug("mujica: form reset") +} + +// FormItem registers field name, labels the control that control returns, and shows its error. +// control must return the element to focus when the field has the first error. +func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element { + c, st := f.c, f.st + if name == "" || opts.Label == "" { + panic("mujica: FormItem needs a name and a Label") + } + if slices.Contains(st.order, name) { + panic("mujica: FormItem " + name + " registered twice") + } + st.order = append(st.order, name) + snap := fmt.Sprintf("%#v", *value) + fs := st.fields[name] + if fs == nil { + st.seq++ + fs = &formFieldState{snapshot: snap, version: st.seq} + st.fields[name] = fs + } + fs.check = func() string { + if opts.Required && formEmpty(*value) { + return msg(c, "form.required") + } + if opts.Validate != nil { + return opts.Validate(*value) + } + return "" + } + fs.startAsync = func() { + if opts.Async != nil && fs.asyncFor != fs.version && !fs.pending { + fs.pending = true + slog.Debug("mujica: form async check started", "field", name, "version", fs.version) + opts.Async(*value, fs.version) + } + } + if snap != fs.snapshot { + st.seq++ + fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, "" + if fs.touched || st.tried { + fs.err = fs.check() + } + } + initial := opts.Initial + st.resets = append(st.resets, func() { *value = initial }) + desc := opts.Description + if fs.pending { + desc = msg(c, "form.checking") + } + var ctrl *ui.Element + field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, func() { + ctrl = control() + }) + if ctrl == nil { + panic("mujica: FormItem " + name + ": control returned nil") + } + focused := ctrl.FocusWithin() + if fs.focused && !focused { + fs.touched = true + fs.err = fs.check() + if fs.err == "" { + fs.startAsync() + } + slog.Debug("mujica: form field blurred", "field", name, "valid", fs.err == "") + } + fs.focused = focused + if st.focus == name { + st.focus = "" + ctrl.Focus().ScrollIntoView() + } + return field +} + +// formEmpty reports whether a required value is missing: zero, or an empty string, slice or map. +func formEmpty(v any) bool { + r := reflect.ValueOf(v) + switch r.Kind() { + case reflect.Invalid: + return true + case reflect.String, reflect.Slice, reflect.Map, reflect.Array: + return r.Len() == 0 + } + return r.IsZero() +} diff --git a/form_field.go b/form_field.go new file mode 100644 index 0000000..517840f --- /dev/null +++ b/form_field.go @@ -0,0 +1,38 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" +) + +// FormFieldOptions configure a FormField. +type FormFieldOptions struct { + // Required marks the label; the check itself belongs to the caller or Form. + Required bool + Description string + // Error shows below the control and marks it invalid for assistive technology. + Error string + // Horizontal puts the label beside the control instead of above it. + Horizontal bool +} + +// FormField labels the control that control builds and adds help and error text. +// The label names the control and a click on it focuses the control. +func FormField(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element { + if label == "" { + panic("mujica: FormField needs a label") + } + if opts.Horizontal { + var f *ui.Element + ui.Form(c, func() { f = formFieldBuild(c, label, opts, control) }) + return f + } + return formFieldBuild(c, label, opts, control) +} + +// formFieldBuild builds the field in the layout the caller set up. +func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element { + if opts.Required { + label += " *" + } + return ui.Field(c, label, control).Description(opts.Description).Error(opts.Error) +} diff --git a/form_field_test.go b/form_field_test.go new file mode 100644 index 0000000..c6ca7f6 --- /dev/null +++ b/form_field_test.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFormFieldLabelFocusesControl(t *testing.T) { + name := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() { + InputGroup(c, &name, InputGroupOptions{}) + }) + }, 400, 200) + if err := tt.Click("Name *"); err != nil { + t.Fatal(err) + } + tt.Type("Sakiko") + if name != "Sakiko" { + t.Fatalf("label click did not focus the control: %q", name) + } + if !tt.Focused("Name *") || !tt.HasText("As on your seal.") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestFormFieldErrorAndLayout(t *testing.T) { + v := "" + horizontal := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() { + InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}) + }) + }, 500, 200) + if !tt.HasText("Enter an email address.") { + t.Fatal("error not shown") + } + label, _ := tt.Find("Email") + input, _ := tt.Find("mailto:") + if !(input.Y > label.Y+label.H/2) { + t.Fatalf("vertical: label %v input %v", label, input) + } + horizontal = true + tt.Frame() + label, _ = tt.Find("Email") + input, _ = tt.Find("mailto:") + if !(input.X > label.X+label.W) || input.Y > label.Y+label.H { + t.Fatalf("horizontal: label %v input %v", label, input) + } +} + +func TestFormFieldNeedsLabel(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an empty label") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + FormField(c, "", FormFieldOptions{}, func() {}) + }, 100, 100, 1) +} + +func TestFormFieldSnapshot(t *testing.T) { + a, b := "Sakiko Togawa", "uika@" + snapshot(t, "form_field", 520, 260, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).Children(func() { + FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() { + InputGroup(c, &a, InputGroupOptions{}) + }) + FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() { + InputGroup(c, &b, InputGroupOptions{Invalid: true}) + }) + }) + }) +} diff --git a/form_test.go b/form_test.go new file mode 100644 index 0000000..2d00634 --- /dev/null +++ b/form_test.go @@ -0,0 +1,202 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +type formProbe struct { + st FormState + name, email string + tags []string + asyncCalls []uint64 + asyncValues []string + submits, resets int + horizontal bool + useAsync bool +} + +func (p *formProbe) run(c *ui.Context) { + Use(c, Settings{}) + p.view(c) +} + +func (p *formProbe) view(c *ui.Context) { + res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) { + FormItem(f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element { + return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input + }) + opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string { + if v != "" && !strings.Contains(v, "@") { + return "Enter an email address." + } + return "" + }} + if p.useAsync { + opts.Async = func(v string, version uint64) { + p.asyncCalls = append(p.asyncCalls, version) + p.asyncValues = append(p.asyncValues, v) + } + } + FormItem(f, "email", &p.email, opts, func() *ui.Element { + return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input + }) + FormItem(f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element { + return TagsInput(c, &p.tags, TagsInputOptions{}).Input + }) + }) + if res.Submitted() { + p.submits++ + } + if res.Reset() { + p.resets++ + } +} + +func TestFormBlurValidationAndUntouchedFields(t *testing.T) { + p := &formProbe{name: "Sakiko", tags: []string{"rose"}} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Email") + tt.Type("mutsumi") + if tt.HasText("Enter an email address.") { + t.Fatal("error before blur") + } + tt.Key(0, ui.KeyTab) + if !tt.HasText("Enter an email address.") { + t.Fatal("no error after blur") + } + if tt.HasText("Required") { + t.Fatal("untouched field shows an error") + } + _ = tt.Click("Email") + tt.Type("@court") + if tt.HasText("Enter an email address.") || p.email != "mutsumi@court" { + t.Fatalf("touched field did not revalidate: %q", p.email) + } +} + +func TestFormSubmitFailureFocusesFirstErrorAndKeepsValues(t *testing.T) { + p := &formProbe{email: "bad"} + tt := ui.NewTester(p.run, 600, 500) + if err := tt.Click("Submit"); err != nil { + t.Fatal(err) + } + if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") { + t.Fatalf("submits %d texts %q", p.submits, tt.Texts()) + } + if !tt.Focused("Name *") || p.email != "bad" { + t.Fatal("first error not focused or value lost") + } + if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Some fields need attention" { + t.Fatalf("announcements %q", got) + } +} + +func TestFormSubmitOnceAndEndSubmit(t *testing.T) { + p := &formProbe{name: "Uika", tags: []string{"x"}} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Submit") + if p.submits != 1 || !p.st.Submitting() { + t.Fatalf("submits %d", p.submits) + } + _ = tt.Click("Submit") + p.st.Submit() + tt.Frame() + if p.submits != 1 { + t.Fatalf("re-entered: %d", p.submits) + } + p.st.EndSubmit("The server is away.") + tt.Frame() + if !tt.HasText("The server is away.") || p.name != "Uika" { + t.Fatal("form error not shown") + } + _ = tt.Click("Submit") + if p.submits != 2 || tt.HasText("The server is away.") { + t.Fatalf("second submit: %d", p.submits) + } +} + +func TestFormAsyncStaleResultsAndPendingSubmit(t *testing.T) { + p := &formProbe{name: "Uika", tags: []string{"x"}, useAsync: true} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Email") + tt.Type("a@b") + tt.Key(0, ui.KeyTab) + if len(p.asyncCalls) != 1 || p.asyncValues[0] != "a@b" || !tt.HasText("Checking…") { + t.Fatalf("async calls %v", p.asyncCalls) + } + old := p.asyncCalls[0] + _ = tt.Click("Email") + tt.Type("c") + if p.st.Resolve("email", old, "Taken") { + t.Fatal("stale result accepted") + } + tt.Frame() + if tt.HasText("Taken") { + t.Fatal("stale result shown") + } + _ = tt.Click("Submit") + if len(p.asyncCalls) != 2 || p.asyncValues[1] != "a@bc" || p.submits != 0 { + t.Fatalf("submit did not wait for the new check: %v %d", p.asyncValues, p.submits) + } + if !p.st.Resolve("email", p.st.Version("email"), "") { + t.Fatal("current result refused") + } + tt.Frame() + if p.submits != 1 { + t.Fatalf("submit did not finish after the check: %d", p.submits) + } +} + +func TestFormAsyncErrorBlocksSubmit(t *testing.T) { + p := &formProbe{name: "Uika", tags: []string{"x"}, email: "a@b", useAsync: true} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Submit") + p.st.Resolve("email", p.st.Version("email"), "Already registered") + tt.Frame() + tt.Frame() + if p.submits != 0 || !tt.HasText("Already registered") || !tt.Focused("Email") { + t.Fatalf("submits %d texts %q", p.submits, tt.Texts()) + } +} + +func TestFormResetRestoresInitialValues(t *testing.T) { + p := &formProbe{name: "", email: "bad", tags: nil} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Submit") + if err := tt.Click("Reset"); err != nil { + t.Fatal(err) + } + tt.Frame() + if p.name != "Sakiko" || p.email != "" || len(p.tags) != 1 || p.tags[0] != "rose" || p.resets != 1 { + t.Fatalf("reset values %q %q %q", p.name, p.email, p.tags) + } + if tt.HasText("Required") || p.st.Error("email") != "" { + t.Fatal("errors survive reset") + } +} + +func TestFormHorizontalLayout(t *testing.T) { + p := &formProbe{horizontal: true} + tt := ui.NewTester(p.run, 600, 500) + name, _ := tt.Find("Name *") + email, _ := tt.Find("Email") + if name.X+name.W != email.X+email.W { + t.Fatalf("labels not right-aligned: %v %v", name, email) + } +} + +func TestFormSnapshot(t *testing.T) { + p := &formProbe{name: "Sakiko Togawa", email: "oblivionis", tags: []string{"Ave Mujica"}} + snapshot(t, "form", 560, 420, func(c *ui.Context) { + ui.Column(c).Padding(24).Children(func() { + p.view(c) + if !p.st.tried { + p.st.Submit() + c.Invalidate() + } + }) + }) +} diff --git a/grid.go b/grid.go new file mode 100644 index 0000000..9de4fe2 --- /dev/null +++ b/grid.go @@ -0,0 +1,72 @@ +package mujica + +import "github.com/egoist/mygo/ui" + +// GridOptions configure a Grid. +type GridOptions struct { + // Columns size each column with ui.Fixed, ui.Fr or ui.FitContent; nil is one Fr(1) column. + Columns []ui.Track + // Rows size the first rows; rows added after fit their content. + Rows []ui.Track + // Gap defaults to SpaceM. + Gap Space + // RowGap defaults to Gap. + RowGap Space +} + +// Grid lays children out in columns and rows; GridCell places and spans them. +func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element { + if children == nil { + panic("mujica: Grid needs children") + } + d := Density(c) + gap := containerSpace(d, opts.Gap, SpaceM) + rowGap := gap + if opts.RowGap != SpaceDefault { + rowGap = containerSpace(d, opts.RowGap, SpaceM) + } + cols := opts.Columns + if len(cols) == 0 { + cols = []ui.Track{ui.Fr(1)} + } + e := ui.Grid(c).ColumnTracks(cols...).GapX(gap).GapY(rowGap) + if len(opts.Rows) > 0 { + e.RowTracks(opts.Rows...) + } + return e.Children(children) +} + +// GridCellOptions place one cell. Lines count from 1; zero places the cell automatically. +type GridCellOptions struct { + Column, Row int + // ColumnSpan and RowSpan default to 1; -1 spans to the last track. + ColumnSpan, RowSpan int +} + +// GridCell is one child of a Grid, placed and spanned by opts; build makes one element, stretched over the cell. +func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element { + if build == nil { + panic("mujica: GridCell needs build") + } + if opts.Column < 0 || opts.Row < 0 { + panic("mujica: GridCell Column and Row count from 1") + } + if opts.ColumnSpan < -1 || opts.RowSpan < -1 { + panic("mujica: GridCell spans must be positive, or -1 for the rest") + } + // A one-track grid stretches its child over the whole cell, as the grid does its own children. + e := ui.Grid(c).Columns(1).Rows(1) + if opts.Column > 0 { + e.ColumnStart(opts.Column) + } + if opts.Row > 0 { + e.RowStart(opts.Row) + } + if opts.ColumnSpan != 0 { + e.ColumnSpan(opts.ColumnSpan) + } + if opts.RowSpan != 0 { + e.RowSpan(opts.RowSpan) + } + return e.Children(build) +} diff --git a/grid_test.go b/grid_test.go new file mode 100644 index 0000000..a04dce3 --- /dev/null +++ b/grid_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestGridFixedAndFrTracks(t *testing.T) { + var a, b ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Grid(c, GridOptions{Columns: []ui.Track{ui.Fixed(100), ui.Fr(1)}}, func() { + a = ui.Box(c).Height(20).Bounds() + b = ui.Box(c).Height(20).Bounds() + }).Width(400) + }, 400, 300).Frame() + if a.W != 100 || b.X != 112 || b.W != 288 { + t.Fatalf("tracks %v and %v at %v, want 100, 288 at 112", a.W, b.W, b.X) + } +} + +func TestGridSpans(t *testing.T) { + var head, tall, side ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Grid(c, GridOptions{Columns: []ui.Track{ui.Fr(1), ui.Fr(1), ui.Fr(1)}, Gap: SpaceS, RowGap: SpaceL}, func() { + head = GridCell(c, GridCellOptions{ColumnSpan: -1}, func() { ui.Box(c).Height(20) }).Bounds() + tall = GridCell(c, GridCellOptions{RowSpan: 2}, func() { ui.Box(c).Height(10) }).Bounds() + side = GridCell(c, GridCellOptions{Column: 3, Row: 3}, func() { ui.Box(c).Height(30) }).Bounds() + GridCell(c, GridCellOptions{Column: 2, Row: 2}, func() { ui.Box(c).Height(30) }) + }).Width(316) + }, 400, 300).Frame() + if head.W != 316 { + t.Fatalf("header width %v, want 316", head.W) + } + // Rows of 30 with a 16 gap: the tall cell spans 30+16+30. + if tall.H != 76 { + t.Fatalf("row-spanning cell height %v, want 76", tall.H) + } + if side.X != 216 || side.Y != tall.Y+46 { + t.Fatalf("placed cell at %v,%v, want 216,%v", side.X, side.Y, tall.Y+46) + } +} + +func TestGridRowTracksShareHeight(t *testing.T) { + var a, b ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Grid(c, GridOptions{Rows: []ui.Track{ui.Fixed(40), ui.Fr(1)}, Gap: SpaceNone}, func() { + a = ui.Box(c).Bounds() + b = ui.Box(c).Bounds() + }).Height(200) + }, 300, 300).Frame() + if a.H != 40 || b.H != 160 { + t.Fatalf("rows %v and %v, want 40 and 160", a.H, b.H) + } +} + +func TestGridCellRejectsBadPlacement(t *testing.T) { + for _, o := range []GridCellOptions{{Column: -1}, {RowSpan: -2}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", o) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Grid(c, GridOptions{}, func() { GridCell(c, o, func() {}) }) + }, 100, 100) + }() + } +} + +func TestGridSnapshot(t *testing.T) { + snapshot(t, "grid", 420, 200, func(c *ui.Context) { + k := Tokens(c) + cell := func(s string) { + ui.Box(c).Center().Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4). + Children(func() { ui.Text(c, s).TextColor(k.TextMuted) }) + } + ui.Column(c).Padding(16).Children(func() { + Grid(c, GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1), ui.Fr(1)}}, func() { + GridCell(c, GridCellOptions{RowSpan: 2}, func() { cell("Fixed 96") }) + GridCell(c, GridCellOptions{ColumnSpan: 2}, func() { cell("Span 2") }) + cell("Fr 1") + cell("Fr 1") + GridCell(c, GridCellOptions{ColumnSpan: -1}, func() { cell("Across") }) + }) + }) + }) +} diff --git a/heading.go b/heading.go new file mode 100644 index 0000000..7734afb --- /dev/null +++ b/heading.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// HeadingOptions configure a Heading. The zero value is a level 1 heading. +type HeadingOptions struct { + // Level is 1, 2 or 3; zero means 1. + Level int + // Subtitle shows below the title in muted text. + Subtitle string + // Divider draws an ornament rule with a center diamond below. + Divider bool +} + +// Heading shows a serif title that follows the desktop's text scale. +func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element { + k := Tokens(c) + size := headingSize(opts.Level) + scale := c.Preferences().TextScale + if scale <= 0 { + scale = 1 + } + u := Density(c).Unit() + col := ui.Column(c).Gap(u).Shrink(0) + col.Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(size * scale).FontWeight(600). + LineHeight(1.25).TextColor(k.Text) + if opts.Subtitle != "" { + ui.Text(c, opts.Subtitle).FontSize(theme.BodySize * scale).LineHeight(theme.LineHeight).TextColor(k.TextMuted) + } + if opts.Divider { + ui.Box(c).FillWidth().Height(9).Margin(u, 0, 0, 0).Draw(func(p *ui.Painter, r ui.Rect) { + headingOrnament(p, r, k.Ornament) + }) + } + }) + return col +} + +func headingSize(level int) float32 { + switch level { + case 0, 1: + return theme.H1Size + case 2: + return theme.H2Size + case 3: + return theme.H3Size + } + panic("mujica: Heading Level must be 1, 2 or 3") +} + +// headingOrnament draws symmetric rules around a center diamond. +func headingOrnament(p *ui.Painter, r ui.Rect, c ui.Color) { + cx, cy := r.X+r.W/2, r.Y+r.H/2 + const d, gap = 7, 6 + internal.Rule(p, r.X, cx-gap, cy, c) + internal.Rule(p, cx+gap, r.X+r.W, cy, c) + p.FillPath(internal.Diamond(cx, cy, d), c) +} diff --git a/heading_test.go b/heading_test.go new file mode 100644 index 0000000..0121972 --- /dev/null +++ b/heading_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func headingHeights(t *testing.T, p ui.Preferences) (h1, h2, h3 float32) { + t.Helper() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + h1 = Heading(c, "Court", HeadingOptions{}).Bounds().H + h2 = Heading(c, "Court", HeadingOptions{Level: 2}).Bounds().H + h3 = Heading(c, "Court", HeadingOptions{Level: 3}).Bounds().H + }) + }, 400, 400) + tt.SetPreferences(p) + tt.Frame() + return +} + +func TestHeadingLevels(t *testing.T) { + h1, h2, h3 := headingHeights(t, ui.Preferences{TextScale: 1}) + if !(h1 > h2 && h2 > h3 && h3 > 0) { + t.Fatalf("heights %v %v %v not decreasing", h1, h2, h3) + } +} + +func TestHeadingTextScale(t *testing.T) { + h1, _, _ := headingHeights(t, ui.Preferences{TextScale: 1}) + big, _, _ := headingHeights(t, ui.Preferences{TextScale: 2}) + if big < h1*1.8 { + t.Fatalf("scaled height %v, base %v", big, h1) + } +} + +func TestHeadingSubtitleAndDivider(t *testing.T) { + var plain, full float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + plain = Heading(c, "Archive", HeadingOptions{Level: 2}).Bounds().H + full = Heading(c, "Ledger", HeadingOptions{Level: 2, Subtitle: "Entries since 1802", Divider: true}).Bounds().H + }) + }, 400, 300) + tt.Frame() + if !tt.HasText("Entries since 1802") { + t.Fatal("subtitle missing") + } + if full < plain+20 { + t.Fatalf("subtitle and divider add %v DIPs", full-plain) + } +} + +func TestHeadingBadLevelPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Heading(c, "x", HeadingOptions{Level: 4}) + }, 100, 100) + tt.Frame() +} + +func TestHeadingSnapshot(t *testing.T) { + snapshot(t, "heading", 420, 260, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).Width(380).Children(func() { + Heading(c, "The Grand Hall", HeadingOptions{Subtitle: "Ceremonies of the winter season", Divider: true}) + Heading(c, "Guest Ledger", HeadingOptions{Level: 2}) + Heading(c, "宫廷礼仪", HeadingOptions{Level: 3, Subtitle: "第三章"}) + }) + }) +} diff --git a/hover_card.go b/hover_card.go new file mode 100644 index 0000000..6c9d658 --- /dev/null +++ b/hover_card.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" +) + +// HoverCardOptions configure a HoverCard. +type HoverCardOptions struct { + // OpenDelay is 500 ms and CloseDelay 300 ms when zero. + OpenDelay time.Duration + CloseDelay time.Duration + // Width is 280 DIPs when zero. + Width float32 + // Label names the card for assistive technology. + Label string +} + +// hoverCardState is what a card keeps between frames while its anchor lives. +type hoverCardState struct { + open, dismissed bool + // keyMode keeps the card in the focus order while the keyboard is on it. + keyMode bool + enterAt, leaveAt time.Time + panel ui.Rect +} + +// HoverCard previews content beside anchor while the pointer rests on it or +// it has the keyboard focus. The pointer may move into the card; with the +// keyboard, Tab from the anchor enters it. It closes after CloseDelay once +// neither holds it, or at once on Escape. It returns the card, or nil. +func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element { + if anchor == nil { + panic("mujica: HoverCard needs an anchor") + } + if opts.OpenDelay < 0 || opts.CloseDelay < 0 { + panic("mujica: HoverCard delays must not be negative") + } + openDelay, closeDelay := opts.OpenDelay, opts.CloseDelay + if openDelay == 0 { + openDelay = 500 * time.Millisecond + } + if closeDelay == 0 { + closeDelay = 300 * time.Millisecond + } + st := ui.Local(anchor, "mujica.hovercard", func() hoverCardState { return hoverCardState{} }) + b := anchor.Bounds() + keyboard := anchor.FocusVisible() + pointer := anchor.Hovered() + if st.open { + // The card's layer covers the anchor, so test the pointer by geometry. + x, y, in := c.Root().PointerPosition() + pointer = pointer || in && (b.Contains(x, y) || st.panel.Contains(x, y)) + } + hot := pointer || keyboard || st.keyMode + if !hot { + st.dismissed = false + } + if st.dismissed { + return nil + } + now := c.Now() + if hot { + st.leaveAt = time.Time{} + if !st.open { + if st.enterAt.IsZero() { + st.enterAt = now + } + if wait := openDelay - now.Sub(st.enterAt); wait > 0 { + c.After(wait) + return nil + } + slog.Debug("mujica: hover_card open", "keyboard", keyboard) + st.open = true + } + } else { + st.enterAt = time.Time{} + if !st.open { + return nil + } + if st.leaveAt.IsZero() { + st.leaveAt = now + } + if wait := closeDelay - now.Sub(st.leaveAt); wait > 0 { + c.After(wait) + } else { + slog.Debug("mujica: hover_card close") + st.open, st.keyMode, st.leaveAt = false, false, time.Time{} + return nil + } + } + width := opts.Width + if width == 0 { + width = 280 + } + style := func(panel *ui.Element, p float32) { + overlayStyle(c, panel, p) + panel.Width(width).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2).Label(opts.Label). + MaxHeight(popoverRoom(c, b)) + ui.Scroll(c).Shrink(1).Children(content) + } + var panel *ui.Element + dismissed, keyMode := false, keyboard || st.keyMode + if keyMode { + shown := true + panel = ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { + style(panel.Margin(6, 0, 0, 0), overlayAnchored(c, panel, b)) + }) + dismissed = !shown + } else { + ui.Overlay(c, func() { + panel = ui.Column(c).Absolute().Role(ui.RolePopup) + prev := panel.Bounds() + x, y := overlayPlace(c, b, width, prev.H, 6) + panel.Left(x).Top(y) + p := overlayEnter(c, panel) + if prev.H == 0 { + p = 0 // not measured yet + c.AnimationFrame() + } + panel.Children(func() { style(panel, p) }) + }) + dismissed = c.Shortcut(0, ui.KeyEscape) + } + if dismissed { + slog.Debug("mujica: hover_card dismiss") + st.open, st.dismissed, st.keyMode = false, true, false + if keyMode { + anchor.Focus() // at once, so the anchor stays hot and the card stays dismissed + } + return panel + } + st.panel, st.keyMode = panel.Bounds(), keyboard || keyMode && panel.FocusWithin() + return panel +} diff --git a/hover_card_test.go b/hover_card_test.go new file mode 100644 index 0000000..815ff06 --- /dev/null +++ b/hover_card_test.go @@ -0,0 +1,117 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +const hoverCardTestDelay = 20 * time.Millisecond + +type hoverCardApp struct { + follows, next int +} + +func (a *hoverCardApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() { + b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost}) + HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() { + ui.Text(c, "Keyboardist of the court") + if Button(c, "Follow", ButtonOptions{}).Clicked() { + a.follows++ + } + }) + if Button(c, "Next", ButtonOptions{}).Clicked() { + a.next++ + } + }) +} + +func hoverCardWait(tt *ui.Tester) { + time.Sleep(hoverCardTestDelay + 15*time.Millisecond) + tt.Frame() +} + +func TestHoverCardPointerCanEnterCard(t *testing.T) { + a := &hoverCardApp{} + tt := ui.NewTester(a.view, 500, 400) + r, _ := tt.Find("Lady Sakiko") + tt.Move(r.X+5, r.Y+5) + if tt.HasText("Keyboardist of the court") { + t.Fatal("opened before the delay") + } + hoverCardWait(tt) + tt.Frame() + f, ok := tt.Find("Follow") + if !ok { + t.Fatalf("card did not open: %q", tt.Texts()) + } + // Moving into the card keeps it; its button works. + tt.Move(f.X+5, f.Y+5) + hoverCardWait(tt) + if err := tt.Click("Follow"); err != nil { + t.Fatal(err) + } + if a.follows != 1 { + t.Fatalf("follows %d", a.follows) + } + // Leaving both closes it after the delay, and the window stays usable. + tt.Move(490, 390) + tt.Frame() + if !tt.HasText("Follow") { + t.Fatal("closed without the close delay") + } + hoverCardWait(tt) + if tt.HasText("Follow") { + t.Fatal("card stayed open") + } +} + +func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { + a := &hoverCardApp{} + tt := ui.NewTester(a.view, 500, 400) + tt.Key(0, ui.KeyTab) + hoverCardWait(tt) + if !tt.HasText("Keyboardist of the court") { + t.Fatal("no card on keyboard focus") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Follow") { + t.Fatal("Tab did not enter the card") + } + tt.Key(0, ui.KeyEnter) + if a.follows != 1 { + t.Fatalf("follows %d", a.follows) + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Follow") || !tt.Focused("Lady Sakiko") { + t.Fatalf("Escape: card shown %v, anchor focused %v", tt.HasText("Follow"), tt.Focused("Lady Sakiko")) + } + hoverCardWait(tt) + if tt.HasText("Follow") { + t.Fatal("dismissed card came back while focused") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Next") || a.next != 0 { + t.Fatal("Tab after the card did not reach Next") + } +} + +func TestHoverCardSnapshot(t *testing.T) { + snapshot(t, "hover_card", 420, 240, func(c *ui.Context) { + ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { + Button(c, "Lady Uika", ButtonOptions{Variant: Ghost}) + }) + ui.Overlay(c, func() { + p := ui.Column(c).Absolute().Left(24).Top(66) + overlayStyle(c, p, 1) + p.Width(280).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() { + ui.Text(c, "Lady Uika").FontWeight(600) + ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted) + Button(c, "Follow", ButtonOptions{Variant: Outline}) + }) + }) + }) +} diff --git a/icon.go b/icon.go new file mode 100644 index 0000000..ec0cc19 --- /dev/null +++ b/icon.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// IconTone is an icon's semantic color. The zero value takes the text color. +type IconTone int + +const ( + IconInherit IconTone = iota + IconMuted + IconAccent + IconSuccess + IconWarning + IconDanger + IconInfo +) + +// IconOptions configure an Icon. +type IconOptions struct { + // Size is the icon's width and height in DIPs; zero means 16. + Size float32 + Tone IconTone + // Label names a meaningful icon for assistive technology; empty hides it. + Label string +} + +type iconMissingKey struct{} + +// Icon shows the named icon of the icons package. A missing name shows a +// dashed fallback box in the Danger color and logs a warning. +func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element { + if name == "" { + panic("mujica: Icon needs a name") + } + size := opts.Size + if size < 0 { + panic("mujica: Icon Size must not be negative") + } + if size == 0 { + size = 16 + } + k := Tokens(c) + svg, ok := icons.Get(name) + if !ok { + return iconMissing(c, name, size, k) + } + e := ui.Icon(c, svg).Size(size, size).Shrink(0) + if col, set := iconColor(k, opts.Tone); set { + e.TextColor(col) + } + if opts.Label != "" { + e.Label(opts.Label) + } + return e +} + +func iconMissing(c *ui.Context, name string, size float32, k theme.Tokens) *ui.Element { + text := fmt.Sprintf(msg(c, "icon.missing"), name) + e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(text).Tooltip(text) + logged := ui.Local(e, iconMissingKey{}, func() string { return "" }) + if *logged != name { + *logged = name + slog.Warn("mujica: icon missing", "name", name) + } + return e.Draw(func(p *ui.Painter, r ui.Rect) { + in := ui.Rect{X: r.X + 1, Y: r.Y + 1, W: r.W - 2, H: r.H - 2} + p.StrokeDashed(in, k.Danger, theme.ControlRadius, 1) + p.Line(in.X+3, in.Y+3, in.X+in.W-3, in.Y+in.H-3, 1, k.Danger) + }) +} + +func iconColor(k theme.Tokens, t IconTone) (ui.Color, bool) { + switch t { + case IconInherit: + return ui.Color{}, false + case IconMuted: + return k.TextMuted, true + case IconAccent: + return k.AccentText, true + case IconSuccess: + return k.Success, true + case IconWarning: + return k.Warning, true + case IconDanger: + return k.Danger, true + case IconInfo: + return k.Info, true + } + panic(fmt.Sprintf("mujica: unknown IconTone %d", t)) +} diff --git a/icon_button.go b/icon_button.go new file mode 100644 index 0000000..5de2c78 --- /dev/null +++ b/icon_button.go @@ -0,0 +1,33 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" +) + +// IconButtonOptions configure an IconButton. +type IconButtonOptions struct { + // Variant is the emphasis; the zero value is Primary, as for Button. + Variant ButtonVariant + // Tooltip shows on hover; empty uses the button's name. + Tooltip string + Loading bool + Disabled bool +} + +// IconButton is a square button showing only icon; label names its action. +func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element { + if icon == nil { + panic("mujica: IconButton needs an icon") + } + if label == "" { + panic("mujica: IconButton needs an action name") + } + h := Density(c).ControlHeight() + b := Button(c, "", ButtonOptions{Variant: opts.Variant, Icon: icon, Loading: opts.Loading, Disabled: opts.Disabled, Label: label}) + b.Padding(0).Size(h, h) + tip := opts.Tooltip + if tip == "" { + tip = label + } + return b.Tooltip(tip) +} diff --git a/icon_button_test.go b/icon_button_test.go new file mode 100644 index 0000000..9b0794a --- /dev/null +++ b/icon_button_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestIconButtonClickByNameAndKeyboard(t *testing.T) { + n := 0 + var w, h float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + b := IconButton(c, icons.Must("settings"), "Settings", IconButtonOptions{Variant: Ghost}) + w, h = b.Bounds().W, b.Bounds().H + if b.Clicked() { + n++ + } + }) + }, 300, 200) + if err := tt.Click("Settings"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyEnter) + if n != 2 { + t.Fatalf("clicks %d", n) + } + if w != 36 || h != 36 { + t.Fatalf("size %vx%v, want 36 square", w, h) + } +} + +func TestIconButtonBlockedStates(t *testing.T) { + for _, opts := range []IconButtonOptions{{Disabled: true}, {Loading: true}} { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if IconButton(c, icons.Must("trash-2"), "Delete", opts).Clicked() { + n++ + } + }, 300, 200) + _ = tt.Click("Delete") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if n != 0 { + t.Fatalf("%+v: clicked %d", opts, n) + } + } +} + +func TestIconButtonNeedsName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without an action name") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IconButton(c, icons.Must("x"), "", IconButtonOptions{}) + }, 100, 100) +} + +func TestIconButtonSnapshot(t *testing.T) { + snapshot(t, "icon_button", 360, 90, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(12).Children(func() { + IconButton(c, icons.Must("plus"), "Add", IconButtonOptions{}) + IconButton(c, icons.Must("settings"), "Settings", IconButtonOptions{Variant: Secondary}) + IconButton(c, icons.Must("star"), "Favorite", IconButtonOptions{Variant: Outline}) + IconButton(c, icons.Must("copy"), "Copy", IconButtonOptions{Variant: Ghost}) + IconButton(c, icons.Must("trash-2"), "Delete", IconButtonOptions{Variant: Danger}) + IconButton(c, icons.Must("refresh-cw"), "Refresh", IconButtonOptions{Loading: true}) + IconButton(c, icons.Must("lock"), "Lock", IconButtonOptions{Disabled: true}) + }) + }) +} diff --git a/icon_test.go b/icon_test.go new file mode 100644 index 0000000..32ada91 --- /dev/null +++ b/icon_test.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestIconSizeAndLabel(t *testing.T) { + var def, big ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + def = Icon(c, "star", IconOptions{}).Bounds() + big = Icon(c, "bell", IconOptions{Size: 24, Tone: IconAccent, Label: "Alerts"}).Bounds() + }) + }, 200, 100) + tt.Frame() + if def.W != 16 || def.H != 16 || big.W != 24 || big.H != 24 { + t.Fatalf("sizes %v %v", def, big) + } + if _, ok := tt.Find("Alerts"); !ok { + t.Fatal("label not exposed") + } +} + +func TestIconMissingFallbackIsVisibleAndLogged(t *testing.T) { + var buf bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + defer slog.SetDefault(old) + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + r = Icon(c, "no-such-icon", IconOptions{Size: 20}).Bounds() + }) + }, 200, 100) + tt.Frame() + tt.Frame() + if r.W != 20 || r.H != 20 { + t.Fatalf("fallback size %v", r) + } + if _, ok := tt.Find("Missing icon: no-such-icon"); !ok { + t.Fatal("fallback not labeled") + } + if n := strings.Count(buf.String(), "mujica: icon missing"); n != 1 { + t.Fatalf("logged %d times: %s", n, buf.String()) + } + if !strings.Contains(buf.String(), "name=no-such-icon") { + t.Fatalf("log lacks name: %s", buf.String()) + } +} + +func TestIconBadOptionsPanic(t *testing.T) { + for name, opts := range map[string]IconOptions{"": {}, "star": {Size: -1}, "x": {Tone: IconTone(99)}} { + func() { + defer func() { + if recover() == nil { + t.Fatalf("%q %+v: no panic", name, opts) + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Icon(c, name, opts) + }, 100, 100) + tt.Frame() + }() + } +} + +func TestIconSnapshot(t *testing.T) { + snapshot(t, "icon", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(14).AlignItems(ui.Center).Children(func() { + Icon(c, "star", IconOptions{}) + Icon(c, "star", IconOptions{Size: 20, Tone: IconMuted}) + Icon(c, "star", IconOptions{Size: 24, Tone: IconAccent}) + Icon(c, "circle-check", IconOptions{Size: 20, Tone: IconSuccess}) + Icon(c, "triangle-alert", IconOptions{Size: 20, Tone: IconWarning}) + Icon(c, "circle-x", IconOptions{Size: 20, Tone: IconDanger}) + Icon(c, "info", IconOptions{Size: 20, Tone: IconInfo}) + Icon(c, "no-such-icon", IconOptions{Size: 20}) + }) + }) +} diff --git a/image.go b/image.go new file mode 100644 index 0000000..52edd9f --- /dev/null +++ b/image.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ImageOptions configure an Image. +type ImageOptions struct { + // Width and Height size the box in DIPs; zero keeps the picture's size, + // or 160×120 for the placeholder, loading and error states. + Width, Height float32 + // Crop covers the box and crops the rest; false fits the picture inside. + Crop bool + // Alt describes the picture for assistive technology. + Alt string + // Loading shows the caller's load in progress. + Loading bool + // Err shows the caller's load failure with a retry button. + Err error +} + +// ImageView is a shown Image and what the user did to it this frame. +type ImageView struct { + Element *ui.Element + // Retry reports a click on the retry button of the error state. + Retry bool +} + +// Image shows a *ui.Bitmap or *ui.SVG. A nil src shows a placeholder. +func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { + if opts.Width < 0 || opts.Height < 0 { + panic("mujica: Image Width and Height must not be negative") + } + if opts.Err == nil && !opts.Loading && src != nil { + e := ui.Image(c, src).Radius(theme.ControlRadius) + if opts.Crop { + e.Fit(ui.Cover) + } + if opts.Width > 0 { + e.Width(opts.Width) + } + if opts.Height > 0 { + e.Height(opts.Height) + } + if opts.Alt != "" { + e.Label(opts.Alt) + } + return ImageView{Element: e} + } + k := Tokens(c) + u := Density(c).Unit() + w, h := opts.Width, opts.Height + if w == 0 { + w = 160 + } + if h == 0 { + h = 120 + } + var v ImageView + frame := ui.Column(c).Size(w, h).Shrink(0).Center().Gap(u*2).Padding(u*2).Clip(). + Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border) + v.Element = frame + frame.Children(func() { + switch { + case opts.Err != nil: + frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error()) + Icon(c, "circle-alert", IconOptions{Size: 20, Tone: IconDanger}) + ui.Text(c, msg(c, "image.error")).FontSize(theme.CaptionSize).TextColor(k.Danger).TextAlign(ui.Center) + if Button(c, msg(c, "image.retry"), ButtonOptions{Variant: Secondary}).Clicked() { + v.Retry = true + slog.Debug("mujica: image retry", "alt", opts.Alt) + } + case opts.Loading: + frame.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt) + Spinner(c, SpinnerOptions{Size: 20, Label: msg(c, "image.loading")}) + default: + frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt) + Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted}) + } + }) + return v +} diff --git a/image_test.go b/image_test.go new file mode 100644 index 0000000..395fa69 --- /dev/null +++ b/image_test.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "errors" + "image" + "image/color" + "testing" + + "github.com/egoist/mygo/ui" +) + +func imageTestBitmap() *ui.Bitmap { + img := image.NewRGBA(image.Rect(0, 0, 80, 40)) + for y := range 40 { + for x := range 80 { + img.Set(x, y, color.RGBA{uint8(100 + x), 32, 52, 255}) + } + } + return ui.NewBitmap(img) +} + +func TestImageLoadedSizes(t *testing.T) { + bmp := imageTestBitmap() + var natural, sized ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + natural = Image(c, bmp, ImageOptions{Alt: "Gallery"}).Element.Bounds() + sized = Image(c, bmp, ImageOptions{Width: 50, Height: 50, Crop: true}).Element.Bounds() + }) + }, 300, 300) + tt.Frame() + if natural.W != 80 || natural.H != 40 { + t.Fatalf("natural %v", natural) + } + if sized.W != 50 || sized.H != 50 { + t.Fatalf("sized %v", sized) + } + if _, ok := tt.Find("Gallery"); !ok { + t.Fatal("alt text not exposed") + } +} + +func TestImageStates(t *testing.T) { + for _, tc := range []struct { + name string + src ui.ImageSource + opts ImageOptions + label string + }{ + {"placeholder", nil, ImageOptions{}, "No image"}, + {"loading", imageTestBitmap(), ImageOptions{Loading: true}, "Loading image"}, + {"error", imageTestBitmap(), ImageOptions{Loading: true, Err: errors.New("404")}, "Image failed to load"}, + } { + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + r = Image(c, tc.src, tc.opts).Element.Bounds() + }) + }, 300, 300) + tt.Frame() + if r.W != 160 || r.H != 120 { + t.Errorf("%s: size %v", tc.name, r) + } + if _, ok := tt.Find(tc.label); !ok { + t.Errorf("%s: no %q", tc.name, tc.label) + } + } +} + +func TestImageRetry(t *testing.T) { + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Image(c, nil, ImageOptions{Width: 200, Height: 140, Err: errors.New("timeout")}).Retry { + retries++ + } + }, 300, 300) + if err := tt.Click("Retry"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if retries != 2 { + t.Fatalf("retries %d, want 2", retries) + } +} + +func TestImageNegativeSizePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Image(c, nil, ImageOptions{Width: -1}) + }, 100, 100) + tt.Frame() +} + +func TestImageSnapshot(t *testing.T) { + bmp := imageTestBitmap() + snapshot(t, "image", 600, 180, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + Image(c, bmp, ImageOptions{Width: 120, Height: 120, Crop: true, Alt: "Cropped"}) + Image(c, nil, ImageOptions{Width: 120, Height: 120}) + Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true}) + Image(c, nil, ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}) + }) + }) +} diff --git a/input_group.go b/input_group.go new file mode 100644 index 0000000..2db0177 --- /dev/null +++ b/input_group.go @@ -0,0 +1,172 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// InputGroupButton is a button joined to an InputGroup's border. +type InputGroupButton struct { + // Label is shown, or names an icon-only button for assistive technology. + Label string + Icon *ui.SVG + // IconOnly hides Label on screen; Icon is then required. + IconOnly bool + // Leading puts the button before the input. + Leading bool + Disabled bool +} + +// InputGroupOptions configure an InputGroup. +type InputGroupOptions struct { + // Prefix and Suffix are fixed text beside the input, such as "https://". + Prefix, Suffix string + Buttons []InputGroupButton + Placeholder string + // Label names the input when it is not inside a FormField. + Label string + Disabled bool + // ReadOnly shows the value as selectable text that cannot change. + ReadOnly bool + // Invalid draws the border in the danger color. + Invalid bool +} + +// InputGroupParts are the parts of an InputGroup. +type InputGroupParts struct { + Group *ui.Element + // Input takes the focus; it is selectable text when ReadOnly. + Input *ui.Element + // Clicked is the index in Buttons of the button clicked, or -1. + Clicked int +} + +// Changed reports whether the user edited the value this frame. +func (p InputGroupParts) Changed() bool { return p.Input.Changed() } + +// Submitted reports whether Enter was pressed in the input. +func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() } + +// InputGroup joins a single-line input with text and buttons inside one border. +// Every button keeps its own place in the Tab order. +func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts { + for _, b := range opts.Buttons { + if b.IconOnly && b.Icon == nil { + panic("mujica: InputGroupButton IconOnly needs an Icon") + } + if b.Label == "" { + panic("mujica: InputGroupButton needs a Label") + } + } + k := Tokens(c) + d := Density(c) + parts := InputGroupParts{Clicked: -1} + g := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).Radius(theme.ControlRadius). + Padding(theme.BorderWidth).Background(k.Surface).Disabled(opts.Disabled) + parts.Group = g + r := theme.ControlRadius - theme.BorderWidth + // Index of each segment, for its corners and separators. + n := len(opts.Buttons) + 1 + if opts.Prefix != "" { + n++ + } + if opts.Suffix != "" { + n++ + } + seg := 0 + segment := func(e *ui.Element) { + var left, right float32 + if seg == 0 { + left = r + } + if seg == n-1 { + right = r + } + e.Radius(left, right, right, left) + if seg > 0 { + e.BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.ControlBorder) + } + seg++ + } + button := func(i int) { + b := opts.Buttons[i] + bb := ui.ButtonBase(c).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()). + Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label) + segment(bb) + internal.Ring(bb, k.Focus, theme.ControlRadius) + if bb.Pressed() { + bb.Background(k.Selection) + } else if bb.Hovered() { + bb.Background(k.SurfaceHover) + } + bb.Children(func() { + if b.Icon != nil { + ui.Icon(c, b.Icon).Size(16, 16) + } + if !b.IconOnly { + ui.Text(c, b.Label).SingleLine() + } + }) + if bb.Clicked() { + parts.Clicked = i + } + } + addon := func(s string) { + a := ui.Row(c).Padding(0, d.Unit()*3).Background(k.Background).Shrink(0) + segment(a) + a.Children(func() { ui.Text(c, s).TextColor(k.TextMuted).SingleLine() }) + } + g.Children(func() { + for i, b := range opts.Buttons { + if b.Leading { + button(i) + } + } + if opts.Prefix != "" { + addon(opts.Prefix) + } + box := ui.Row(c).Grow(1).MinWidth(d.Unit()*12).Padding(0, d.Unit()*3) + segment(box) + box.Children(func() { + if opts.ReadOnly { + // As wide as MyGo makes a text input. + parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0) + } else { + parts.Input = ui.TextInputBase(c, value).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder) + } + if opts.Label != "" { + parts.Input.Label(opts.Label) + } + }) + if box.Clicked() { + parts.Input.Focus() + } + if opts.Suffix != "" { + addon(opts.Suffix) + } + for i, b := range opts.Buttons { + if !b.Leading { + button(i) + } + } + }) + in := parts.Input + border := k.ControlBorder + switch { + case opts.Invalid: + border = k.Danger + case in.Focused(): + border = k.Focus + case g.Hovered() && !opts.Disabled: + border = k.ControlBorder.Mix(k.Text, 0.25) + } + g.Border(theme.BorderWidth, border) + g.DrawOver(func(p *ui.Painter, r ui.Rect) { + if in.Focused() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + return parts +} diff --git a/input_group_test.go b/input_group_test.go new file mode 100644 index 0000000..bcdd4e1 --- /dev/null +++ b/input_group_test.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestInputGroupTypingAndButtons(t *testing.T) { + value, clicked, submitted := "", -1, 0 + opts := InputGroupOptions{ + Prefix: "https://", Suffix: ".com", Label: "Site", + Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}, {Label: "Go"}}, + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + p := InputGroup(c, &value, opts) + if p.Clicked >= 0 { + clicked = p.Clicked + if p.Clicked == 0 { + value = "" + } + } + if p.Submitted() { + submitted++ + } + }, 500, 120) + if err := tt.Click("Site"); err != nil { + t.Fatal(err) + } + tt.Type("mujica") + tt.Key(0, ui.KeyEnter) + if value != "mujica" || submitted != 1 { + t.Fatalf("value %q submitted %d", value, submitted) + } + if err := tt.Click("Go"); err != nil { + t.Fatal(err) + } + if clicked != 1 { + t.Fatalf("clicked %d, want 1", clicked) + } + if err := tt.Click("Clear"); err != nil { + t.Fatal(err) + } + if clicked != 0 || value != "" { + t.Fatalf("clear: clicked %d value %q", clicked, value) + } + if !tt.HasText("https://") || !tt.HasText(".com") { + t.Fatal("addons missing") + } +} + +func TestInputGroupTabOrder(t *testing.T) { + value := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Back", Leading: true}, {Label: "Search"}}}) + }, 500, 120) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Back") { + t.Fatal("leading button is not first") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Query") { + t.Fatal("input is not second") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Search") { + t.Fatal("trailing button is not third") + } +} + +func TestInputGroupBlockedStates(t *testing.T) { + for _, opts := range []InputGroupOptions{{Label: "Code", Disabled: true}, {Label: "Code", ReadOnly: true}} { + value, clicked := "abc", -1 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + opts.Buttons = []InputGroupButton{{Label: "Copy"}} + if p := InputGroup(c, &value, opts); p.Clicked >= 0 { + clicked = p.Clicked + } + }, 400, 100) + _ = tt.Click("Code") + tt.Type("zz") + _ = tt.Click("Copy") + if value != "abc" { + t.Fatalf("%+v: value changed to %q", opts, value) + } + if opts.Disabled && clicked != -1 { + t.Fatal("disabled button clicked") + } + if opts.ReadOnly && clicked != 0 { + t.Fatal("read-only group blocked its button") + } + } +} + +func TestInputGroupSnapshot(t *testing.T) { + a, b, e := "mujica", "", "bad value" + snapshot(t, "input_group", 520, 200, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).Children(func() { + InputGroup(c, &a, InputGroupOptions{Prefix: "https://", Suffix: ".com", Label: "Site"}) + InputGroup(c, &b, InputGroupOptions{Placeholder: "Search the archive", Label: "Search", + Buttons: []InputGroupButton{{Label: "Search", Icon: icons.Must("search"), IconOnly: true, Leading: true}, {Label: "Go"}}}) + InputGroup(c, &e, InputGroupOptions{Invalid: true, Suffix: "kg", Label: "Weight"}) + }) + }) +} diff --git a/internal/display.go b/internal/display.go new file mode 100644 index 0000000..f84e2fc --- /dev/null +++ b/internal/display.go @@ -0,0 +1,27 @@ +package internal + +import ( + "math" + "time" + + "github.com/egoist/mygo/ui" +) + +// Fade animates e's background and border colors over d; zero skips it. +func Fade(e *ui.Element, d time.Duration) *ui.Element { + if d > 0 { + e.Transition(ui.ElementTransition{Colors: true, Duration: d}) + } + return e +} + +// Rule draws a 1 DIP horizontal line from x0 to x1 at y, on device pixels. +func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) { + s := float64(p.Scale()) + px := math.Floor(float64(y) * s) + if int(math.Round(s))%2 == 1 { + px += 0.5 + } + yy := float32(px / s) + p.Line(x0, yy, x1, yy, 1, c) +} diff --git a/kbd.go b/kbd.go new file mode 100644 index 0000000..5e2575d --- /dev/null +++ b/kbd.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "runtime" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// Kbd shows a key or key combination as key caps. It registers nothing. +// "Mod" is Command on macOS and Ctrl elsewhere; Ctrl, Alt, Shift, Enter, +// Esc, Tab, Backspace, Delete, Up, Down, Left and Right follow the platform. +func Kbd(c *ui.Context, keys ...string) *ui.Element { + if len(keys) == 0 { + panic("mujica: Kbd needs at least one key") + } + k := Tokens(c) + mac := runtime.GOOS == "darwin" + spoken := make([]string, len(keys)) + for i, key := range keys { + if key == "" { + panic("mujica: Kbd key must not be empty") + } + _, spoken[i] = kbdKey(key, mac) + } + row := ui.Row(c).Gap(2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleText).Label(strings.Join(spoken, "+")) + row.Children(func() { + for i, key := range keys { + if i > 0 && !mac { + ui.Text(c, "+").FontSize(theme.CaptionSize).TextColor(k.TextMuted) + } + face, _ := kbdKey(key, mac) + ui.Box(c).Key(i).Row().Center().MinWidth(20).Height(20).Padding(0, 5). + Radius(theme.ControlRadius).Background(k.Surface).BorderColor(k.ControlBorder). + BorderWidth(1, 1, 2, 1).Children(func() { + ui.Text(c, face).Font(theme.MonoFont).FontSize(theme.CaptionSize).TextColor(k.Text).SingleLine() + }) + } + }) + return row +} + +// kbdNames maps a key to its macOS cap, macOS word and other platforms' cap. +var kbdNames = map[string]struct{ macCap, macWord, other string }{ + "mod": {"⌘", "Command", "Ctrl"}, + "ctrl": {"⌃", "Control", "Ctrl"}, + "alt": {"⌥", "Option", "Alt"}, + "shift": {"⇧", "Shift", "Shift"}, + "enter": {"↩", "Return", "Enter"}, + "esc": {"esc", "Escape", "Esc"}, + "tab": {"⇥", "Tab", "Tab"}, + "backspace": {"⌫", "Delete", "Backspace"}, + "delete": {"⌦", "Forward Delete", "Delete"}, + "up": {"↑", "Up", "↑"}, + "down": {"↓", "Down", "↓"}, + "left": {"←", "Left", "←"}, + "right": {"→", "Right", "→"}, +} + +// kbdKey returns how key shows and how it is read on the platform. +func kbdKey(key string, mac bool) (shown, spoken string) { + n, ok := kbdNames[strings.ToLower(key)] + if !ok { + if len([]rune(key)) == 1 { + key = strings.ToUpper(key) + } + return key, key + } + if mac { + return n.macCap, n.macWord + } + word := n.other + switch strings.ToLower(key) { + case "up", "down", "left", "right": + word = n.macWord + } + return n.other, word +} diff --git a/kbd_test.go b/kbd_test.go new file mode 100644 index 0000000..35ba0e4 --- /dev/null +++ b/kbd_test.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "runtime" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestKbdKeyPlatforms(t *testing.T) { + for _, tc := range []struct { + key string + mac bool + shown, spoken string + }{ + {"Mod", true, "⌘", "Command"}, + {"Mod", false, "Ctrl", "Ctrl"}, + {"shift", true, "⇧", "Shift"}, + {"Alt", false, "Alt", "Alt"}, + {"Alt", true, "⌥", "Option"}, + {"Up", false, "↑", "Up"}, + {"k", false, "K", "K"}, + {"F5", true, "F5", "F5"}, + } { + shown, spoken := kbdKey(tc.key, tc.mac) + if shown != tc.shown || spoken != tc.spoken { + t.Errorf("%q mac=%v: %q %q", tc.key, tc.mac, shown, spoken) + } + } +} + +func TestKbdShowsPlatformCombo(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Kbd(c, "Mod", "Shift", "p") + }, 300, 100) + tt.Frame() + spoken, mod := "Ctrl+Shift+P", "Ctrl" + if runtime.GOOS == "darwin" { + spoken, mod = "Command+Shift+P", "⌘" + } + if _, ok := tt.Find(spoken); !ok { + t.Fatalf("not read as %q", spoken) + } + if !tt.HasText(mod) || !tt.HasText("P") { + t.Fatalf("texts %v", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + if tt.Focused(spoken) { + t.Fatal("Kbd took focus") + } +} + +func TestKbdEmptyPanics(t *testing.T) { + for _, keys := range [][]string{nil, {"Mod", ""}} { + func() { + defer func() { + if recover() == nil { + t.Fatalf("%q: no panic", keys) + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Kbd(c, keys...) + }, 100, 100) + tt.Frame() + }() + } +} + +func TestKbdSnapshot(t *testing.T) { + snapshot(t, "kbd", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(16).AlignItems(ui.Center).Children(func() { + Kbd(c, "Mod", "K") + Kbd(c, "Mod", "Shift", "P") + Kbd(c, "Esc") + Kbd(c, "Alt", "Up") + }) + }) +} diff --git a/link.go b/link.go new file mode 100644 index 0000000..6b47b66 --- /dev/null +++ b/link.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "fmt" + "log/slog" + "net/url" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// LinkOptions configure a Link. An empty URL makes an in-app action link. +type LinkOptions struct { + // URL opens in the browser, or goes there in a MyGo router for a path. + URL string + // Error is the caller's open failure, shown below the link. + Error string +} + +// Link is underlined text activated by a click or Enter. Clicked reports +// every activation. A malformed URL is not opened and shows an error. +func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element { + if label == "" { + panic("mujica: Link needs a label") + } + k := Tokens(c) + u := Density(c).Unit() + external := false + problem := opts.Error + target := opts.URL + if opts.URL != "" { + ok := false + external, ok = linkCheck(opts.URL) + if !ok { + external, target = false, "" + if problem == "" { + problem = fmt.Sprintf(msg(c, "link.invalid"), opts.URL) + } + } + } + var l *ui.Element + ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() { + ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { + l = ui.Link(c, label, target).Underline().DecorationThickness(1) + internal.Ring(l, k.Focus, theme.ControlRadius) + color := k.AccentText + if l.Pressed() || l.Hovered() { + color = k.Text + } + l.TextColor(color).DecorationColor(color) + if external { + Icon(c, "external-link", IconOptions{Size: 12, Tone: IconAccent}) + } + }) + if problem != "" { + l.Error(problem) + ui.Text(c, problem).TextColor(k.Danger).FontSize(theme.CaptionSize) + } + }) + if l.Clicked() { + slog.Debug("mujica: link activate", "url", opts.URL, "opened", target != "") + } + return l +} + +// linkCheck reports whether raw has a scheme, and whether it can be opened. +func linkCheck(raw string) (external, ok bool) { + p, err := url.Parse(raw) + if err != nil { + return false, false + } + switch p.Scheme { + case "": + return false, p.Path != "" || p.RawQuery != "" + case "http", "https": + return true, p.Host != "" + } + return true, p.Opaque != "" || p.Host != "" || p.Path != "" +} diff --git a/link_test.go b/link_test.go new file mode 100644 index 0000000..3b85ae4 --- /dev/null +++ b/link_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestLinkOpensURL(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Link(c, "Charter", LinkOptions{URL: "https://example.com/charter"}) + }, 300, 100) + if err := tt.Click("Charter"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + want := []string{"https://example.com/charter", "https://example.com/charter"} + if got := tt.OpenedURLs(); !slices.Equal(got, want) { + t.Fatalf("opened %v", got) + } +} + +func TestLinkAction(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Link(c, "Show details", LinkOptions{}).Clicked() { + n++ + } + }, 300, 100) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 1 || len(tt.OpenedURLs()) != 0 { + t.Fatalf("clicks %d, opened %v", n, tt.OpenedURLs()) + } +} + +func TestLinkInvalidURLIsNotOpened(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Link(c, "Broken", LinkOptions{URL: "https://"}).Clicked() { + n++ + } + }, 400, 100) + if err := tt.Click("Broken"); err != nil { + t.Fatal(err) + } + if n != 1 || len(tt.OpenedURLs()) != 0 { + t.Fatalf("clicks %d, opened %v", n, tt.OpenedURLs()) + } + if !tt.HasText("This link cannot be opened: https://") { + t.Fatalf("texts %v", tt.Texts()) + } +} + +func TestLinkCallerError(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + Link(c, "手册", LinkOptions{URL: "https://example.com", Error: "没有可以打开此链接的应用"}) + }, 400, 100) + tt.Frame() + if !tt.HasText("没有可以打开此链接的应用") { + t.Fatalf("texts %v", tt.Texts()) + } +} + +func TestLinkCheck(t *testing.T) { + for raw, want := range map[string][2]bool{ + "https://example.com": {true, true}, + "https://": {true, false}, + "mailto:a@b.c": {true, true}, + "/notes/42": {false, true}, + "?tab=info": {false, true}, + "http://[::1": {false, false}, + } { + ext, ok := linkCheck(raw) + if ext != want[0] || ok != want[1] { + t.Errorf("%q: external %v ok %v", raw, ext, ok) + } + } +} + +func TestLinkSnapshot(t *testing.T) { + snapshot(t, "link", 420, 140, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + Link(c, "Read the court charter", LinkOptions{URL: "https://example.com"}) + Link(c, "Show the guest list", LinkOptions{}) + Link(c, "Broken link", LinkOptions{URL: "https://"}) + }) + }) +} diff --git a/loading_overlay.go b/loading_overlay.go new file mode 100644 index 0000000..6f8ba0d --- /dev/null +++ b/loading_overlay.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "cmp" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// LoadingOverlayOptions configure a LoadingOverlay. +type LoadingOverlayOptions struct { + // Label explains the wait; empty uses "Loading…". + Label string + // Cancel labels a button that reports Canceled; empty shows none. + Cancel string +} + +// LoadingOverlayResult is what a LoadingOverlay built and reported this frame. +type LoadingOverlayResult struct { + Element *ui.Element + // Canceled reports the cancel button or Escape while loading. + Canceled bool +} + +// LoadingOverlay builds content and, while loading, covers it with a +// spinner and label. Covered content takes no pointer, keys or focus; +// the rest of the window stays usable. Focus inside moves to the overlay. +func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult { + if content == nil { + panic("mujica: LoadingOverlay needs content") + } + k := Tokens(c) + u := Density(c).Unit() + label := cmp.Or(opts.Label, msg(c, "loading_overlay.label")) + var res LoadingOverlayResult + box := ui.Box(c) + was := ui.Local(box, "loading_overlay.loading", func() bool { return false }) + if loading != *was { + slog.Debug("mujica: loading_overlay changed", "loading", loading) + if loading { + c.Announce(label) + } + *was = loading + } + box.Children(func() { + inner := ui.Column(c).FillWidth().Disabled(loading) + inner.Children(content) + if !loading { + return + } + held := inner.FocusWithin() + scrim := ui.Column(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Padding(u * 4). + Background(k.Background.Alpha(0.5)).Radius(theme.CardRadius) + scrim.Transition(ui.ElementTransition{Duration: theme.StateDuration, Enter: &ui.Motion{}, Exit: &ui.Motion{}}) + scrim.Children(func() { + panel := ui.Column(c).AlignItems(ui.Center).Gap(u*3).Padding(u*4, u*6).Radius(theme.PopupRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleStatus).Label(label) + if opts.Cancel == "" { + // Without a button the panel keeps the focus in the region. + internal.Ring(panel.Focusable(), k.Focus, theme.PopupRadius) + if held { + panel.Focus() + } + } + panel.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + Spinner(c, SpinnerOptions{Size: 20, Label: label}) + ui.Text(c, label).TextColor(k.Text) + }) + if opts.Cancel == "" { + return + } + b := Button(c, opts.Cancel, ButtonOptions{Variant: Secondary}) + if held { + b.Focus() + } + if b.Clicked() { + res.Canceled = true + } + }) + if opts.Cancel != "" && panel.Shortcut(0, ui.KeyEscape) { + res.Canceled = true + } + }) + }) + if res.Canceled { + slog.Debug("mujica: loading_overlay canceled") + } + res.Element = box + return res +} diff --git a/loading_overlay_test.go b/loading_overlay_test.go new file mode 100644 index 0000000..67afefb --- /dev/null +++ b/loading_overlay_test.go @@ -0,0 +1,119 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +type overlayProbe struct { + loading bool + saves, outside int + cancels int + cancel, label string +} + +func overlayTester(p *overlayProbe) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Gap(12).Children(func() { + r := LoadingOverlay(c, p.loading, LoadingOverlayOptions{Cancel: p.cancel, Label: p.label}, func() { + ui.Column(c).Padding(24).Children(func() { + if Button(c, "Save", ButtonOptions{}).Clicked() { + p.saves++ + } + }) + }) + if r.Canceled { + p.cancels++ + } + if Button(c, "Outside", ButtonOptions{}).Clicked() { + p.outside++ + } + }) + }, 500, 400) +} + +func TestLoadingOverlayBlocksOnlyItsRegion(t *testing.T) { + p := &overlayProbe{} + tt := overlayTester(p) + _ = tt.Click("Save") + p.loading = true + tt.Frame() + r, _ := tt.Find("Save") + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + tt.Key(0, ui.KeyEnter) + if err := tt.Click("Outside"); err != nil { + t.Fatal(err) + } + if p.saves != 1 || p.outside != 1 { + t.Fatalf("saves %d outside %d", p.saves, p.outside) + } + if !tt.HasText("Loading…") { + t.Fatalf("texts %q", tt.Texts()) + } + if a := tt.Announcements(); !slices.Contains(a, "Loading…") { + t.Fatalf("announcements %q", a) + } + p.loading = false + tt.Frame() + _ = tt.Click("Save") + if p.saves != 2 || tt.HasText("Loading…") { + t.Fatalf("after loading saves %d", p.saves) + } +} + +func TestLoadingOverlayCancelAndFocus(t *testing.T) { + p := &overlayProbe{cancel: "Stop", label: "Importing"} + tt := overlayTester(p) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Save") { + t.Fatal("Save not focused") + } + p.loading = true + tt.Frame() + if !tt.Focused("Stop") { + t.Fatal("focus left the region instead of moving to cancel") + } + tt.Key(0, ui.KeyEscape) + if p.cancels != 1 { + t.Fatalf("Escape cancels %d", p.cancels) + } + if err := tt.Click("Stop"); err != nil { + t.Fatal(err) + } + if p.cancels != 2 || p.saves != 0 { + t.Fatalf("cancels %d saves %d", p.cancels, p.saves) + } +} + +func TestLoadingOverlayFocusWithoutCancel(t *testing.T) { + p := &overlayProbe{} + tt := overlayTester(p) + tt.Key(0, ui.KeyTab) + p.loading = true + tt.Frame() + tt.Frame() + if !tt.Focused("Loading…") { + t.Fatal("focus not kept in the region") + } + tt.Key(0, ui.KeyEscape) + if p.cancels != 0 { + t.Fatal("Escape canceled without a cancel action") + } +} + +func TestLoadingOverlaySnapshot(t *testing.T) { + snapshot(t, "loading_overlay", 420, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + LoadingOverlay(c, true, LoadingOverlayOptions{Cancel: "Cancel"}, func() { + ui.Column(c).Padding(16).Gap(8).AlignItems(ui.Start).Background(Tokens(c).Surface).Radius(4).Children(func() { + ui.Text(c, "Quarterly report") + ui.Text(c, "Revenue, churn and pipeline for Q3.") + Button(c, "Export", ButtonOptions{}) + }) + }) + }) + }) +} diff --git a/menubar.go b/menubar.go new file mode 100644 index 0000000..00f63f2 --- /dev/null +++ b/menubar.go @@ -0,0 +1,279 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// MenubarItemKind is what a menu item does. +type MenubarItemKind int + +const ( + // MenubarAction runs a command. + MenubarAction MenubarItemKind = iota + // MenubarCheck toggles a setting the caller keeps in Checked. + MenubarCheck + // MenubarRadio picks one of a group; the caller sets Checked on one. + MenubarRadio + // MenubarSeparator draws a line between groups of items. + MenubarSeparator +) + +// MenubarShortcut is the key hint shown beside an item; Key zero shows none. +type MenubarShortcut struct { + Mods ui.Modifiers + Key ui.Key +} + +// MenubarItem is an item of a menu, or with Items a submenu. +type MenubarItem struct { + // ID is reported by Chosen; required for actions, checks and radios. + ID string + Label string + Kind MenubarItemKind + Checked bool + Disabled bool + // Shortcut is only shown; handle the key with c.Shortcut. + Shortcut MenubarShortcut + Items []MenubarItem +} + +// MenubarMenu is a title of the bar and the menu it opens. +type MenubarMenu struct { + Label string + Items []MenubarItem +} + +// MenubarOptions configure a Menubar. +type MenubarOptions struct { + // Label names the bar; empty uses the built-in name. + Label string +} + +// MenubarResult is the built Menubar. +type MenubarResult struct { + *ui.Element + chosen string +} + +// Chosen returns the ID of the item the user chose in this frame. +func (r MenubarResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" } + +type menubarState struct { + open int + hl []int + focusTo int + ptr [2]float32 +} + +// Menubar is an in-window menu bar drawn by MujicaUI, with submenus and full keyboard control. +func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarResult { + if len(menus) == 0 { + panic("mujica: Menubar needs menus") + } + for _, m := range menus { + if m.Label == "" { + panic("mujica: MenubarMenu needs a Label") + } + menubarCheck(m.Items) + } + k := Tokens(c) + d := Density(c) + u := d.Unit() + label := opts.Label + if label == "" { + label = msg(c, "menubar.label") + } + bar := ui.Row(c).AlignItems(ui.Center).Gap(u * 0.5).Padding(u).FocusGroup(ui.Horizontal). + Role(ui.RoleToolbar).Label(label).Background(k.Surface) + bar.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0) + }) + st := ui.Local(bar, "state", func() *menubarState { return &menubarState{open: -1, focusTo: -1} }) + m := &menubarRun{c: c, menus: menus, st: *st} + res := MenubarResult{Element: bar} + titles := make([]ui.Rect, len(menus)) + bar.Children(func() { + for i, menu := range menus { + t := m.title(i, menu.Label) + titles[i] = t.Bounds() + } + }) + if m.st.open >= 0 { + m.overlay(titles) + } + res.chosen = m.chosen + return res +} + +// menubarCheck panics on items that cannot work. +func menubarCheck(items []MenubarItem) { + for _, it := range items { + if it.Kind == MenubarSeparator { + continue + } + if it.Label == "" { + panic("mujica: MenubarItem needs a Label") + } + if it.Items != nil { + if it.Kind != MenubarAction { + panic("mujica: a submenu item must be of kind MenubarAction") + } + menubarCheck(it.Items) + } else if it.ID == "" { + panic("mujica: MenubarItem " + it.Label + " needs an ID") + } + } +} + +// menubarRun is one frame of a Menubar. +type menubarRun struct { + c *ui.Context + menus []MenubarMenu + st *menubarState + chosen string +} + +func (m *menubarRun) title(i int, label string) *ui.Element { + c, st := m.c, m.st + k := Tokens(c) + d := Density(c) + open := st.open == i + t := ui.ButtonBase(c).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*3).Radius(theme.ControlRadius). + Role(ui.RoleMenuButton).Label(label).TextColor(k.Text) + internal.Ring(t, k.Focus, theme.ControlRadius) + switch { + case open: + t.Background(k.Selection) + case t.Hovered(): + t.Background(k.SurfaceHover) + } + if st.focusTo == i { + t.Focus() + st.focusTo = -1 + } + if st.open < 0 { + switch { + case t.Shortcut(0, ui.KeyRight): + m.focus((i + 1) % len(m.menus)) + case t.Shortcut(0, ui.KeyLeft): + m.focus((i - 1 + len(m.menus)) % len(m.menus)) + case t.Shortcut(0, ui.KeyDown), t.Shortcut(0, ui.KeyUp): + m.openMenu(i, true) + } + } else { + m.keys(t) + } + if t.Clicked() { + if open { + m.close() + } else { + m.openMenu(i, t.FocusVisible()) + } + } + t.Children(func() { ui.Text(c, label).SingleLine() }) + return t +} + +// keys drives an open menu from the focused title. +func (m *menubarRun) keys(t *ui.Element) { + st := m.st + l := len(st.hl) - 1 + items := m.items(l) + h := st.hl[l] + switch { + case t.Shortcut(0, ui.KeyDown): + st.hl[l] = menubarStep(items, h, 1) + case t.Shortcut(0, ui.KeyUp): + st.hl[l] = menubarStep(items, h, -1) + case t.Shortcut(0, ui.KeyHome): + st.hl[l] = menubarStep(items, -1, 1) + case t.Shortcut(0, ui.KeyEnd): + st.hl[l] = menubarStep(items, len(items), -1) + case t.Shortcut(0, ui.KeyRight): + if h >= 0 && items[h].Items != nil { + st.hl = append(st.hl, menubarStep(items[h].Items, -1, 1)) + } else { + m.openMenu((st.open+1)%len(m.menus), true) + } + case t.Shortcut(0, ui.KeyLeft): + if l > 0 { + st.hl = st.hl[:l] + } else { + m.openMenu((st.open-1+len(m.menus))%len(m.menus), true) + } + case t.Shortcut(0, ui.KeyEnter), t.Shortcut(0, ui.KeySpace): + if h >= 0 { + m.activate(l, h) + } + case t.Shortcut(0, ui.KeyEscape): + if l > 0 { + st.hl = st.hl[:l] + } else { + m.close() + } + } +} + +// items returns the items shown at level l of the open menu. +func (m *menubarRun) items(l int) []MenubarItem { + items := m.menus[m.st.open].Items + for i := 0; i < l; i++ { + items = items[m.st.hl[i]].Items + } + return items +} + +func (m *menubarRun) focus(i int) { + m.st.focusTo = i + m.c.Invalidate() +} + +func (m *menubarRun) openMenu(i int, highlight bool) { + first := -1 + if highlight { + first = menubarStep(m.menus[i].Items, -1, 1) + } + m.st.open, m.st.hl = i, []int{first} + m.focus(i) + slog.Debug("mujica: menubar open", "menu", m.menus[i].Label) +} + +func (m *menubarRun) close() { + if m.st.open >= 0 { + m.focus(m.st.open) + } + m.st.open, m.st.hl = -1, nil + slog.Debug("mujica: menubar close") +} + +// activate chooses item h of level l, or opens it when it is a submenu. +func (m *menubarRun) activate(l, h int) { + it := m.items(l)[h] + if it.Disabled || it.Kind == MenubarSeparator { + return + } + if it.Items != nil { + m.st.hl = append(m.st.hl[:l+1], menubarStep(it.Items, -1, 1)) + return + } + m.chosen = it.ID + slog.Debug("mujica: menubar choose", "id", it.ID) + m.close() +} + +// menubarStep returns the next enabled item from h in direction dir, wrapping. +func menubarStep(items []MenubarItem, h, dir int) int { + n := len(items) + for k := 1; k <= n; k++ { + j := ((h+dir*k)%n + n) % n + if it := items[j]; it.Kind != MenubarSeparator && !it.Disabled { + return j + } + } + return -1 +} diff --git a/menubar_panel.go b/menubar_panel.go new file mode 100644 index 0000000..11e7185 --- /dev/null +++ b/menubar_panel.go @@ -0,0 +1,188 @@ +package mujica + +import ( + "runtime" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// overlay draws the open menu and its submenus above the window. +func (m *menubarRun) overlay(titles []ui.Rect) { + c, st := m.c, m.st + ui.Overlay(c, func() { + back := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0) + if back.Clicked() { + m.close() + return + } + // Title hotspots above the backdrop switch menus as the pointer moves. + for i, r := range titles { + hot := ui.Box(c).Absolute().Left(r.X).Top(r.Y).Size(r.W, r.H).Cursor(ui.CursorPointer) + if hot.Clicked() { + if i == st.open { + m.close() + } else { + m.openMenu(i, false) + } + return + } + if hot.Hovered() && i != st.open { + m.openMenu(i, false) + } + } + if st.open < 0 { + return + } + r := titles[st.open] + x, y := r.X, r.Y+r.H+2 + var prev ui.Rect + for l := 0; l < len(st.hl) && st.open >= 0; l++ { + panel, anchor := m.panel(l, x, y, prev) + if anchor == nil || l+1 >= len(st.hl) { + break + } + a := anchor.Bounds() + prev = panel.Bounds() + x, y = prev.X+prev.W-2, a.Y-Density(c).Unit() + } + }) +} + +// panel builds level l at (x, y), flipping left of parent when it does not fit. +// It returns the panel and the row of its highlighted item. +func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *ui.Element) { + c, st := m.c, m.st + k := Tokens(c) + u := Density(c).Unit() + items := m.items(l) + panel := ui.Column(c).Absolute().Padding(u).MinWidth(200).AlignItems(ui.Stretch).Role(ui.RolePopup). + Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Shadow(0, 6, 16, 0, ui.RGBA(0, 0, 0, 0.18)) + pb := panel.Bounds() + ww, wh := c.Size() + if x+pb.W > ww-4 { + x = ww - 4 - pb.W + if l > 0 { + x = parent.X - pb.W + 2 + } + } + if y+pb.H > wh-4 { + y = max(4, wh-4-pb.H) + } + panel.Left(max(4, x)).Top(y) + var anchor *ui.Element + panel.Children(func() { + for j, it := range items { + if it.Kind == MenubarSeparator { + ui.Box(c).Height(1).Margin(u, u*2).Background(k.Border).Role(ui.RoleNone) + continue + } + row := m.item(it, st.hl[l] == j) + if st.hl[l] == j { + anchor = row + } + if row.Clicked() { + st.hl = append(st.hl[:l], j) + m.activate(l, j) + return + } + px, py, over := row.PointerPosition() + b := row.Bounds() + at := [2]float32{b.X + px, b.Y + py} + if over && at != st.ptr && !it.Disabled { + st.ptr = at + if st.hl[l] != j || it.Items != nil && len(st.hl) == l+1 { + st.hl = append(st.hl[:l], j) + if it.Items != nil { + st.hl = append(st.hl, -1) + } + c.Invalidate() + } + } + } + }) + return panel, anchor +} + +func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element { + c := m.c + k := Tokens(c) + d := Density(c) + u := d.Unit() + name := it.Label + if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked { + name += ", " + msg(c, "menubar.checked") + } + row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2). + Radius(theme.ControlRadius).Label(name).TextColor(k.Text).Disabled(it.Disabled).Role(ui.RoleButton) + if hl { + row.Background(k.Selection) + } + row.Children(func() { + mark := ui.Box(c).Size(16, 16).Shrink(0).Center().Role(ui.RoleNone) + if it.Checked && it.Kind == MenubarCheck { + mark.TextColor(k.AccentText).Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14) }) + } + if it.Checked && it.Kind == MenubarRadio { + mark.Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText) + }) + } + ui.Text(c, it.Label).SingleLine() + ui.Box(c).Grow(1).MinWidth(u * 6) + if it.Shortcut.Key != 0 { + ui.Text(c, menubarShortcutText(it.Shortcut)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine() + } + if it.Items != nil { + ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted) + } + }) + return row +} + +var menubarKeyNames = map[ui.Key]string{ + ui.KeyEnter: "Enter", ui.KeyEscape: "Esc", ui.KeyBackspace: "Backspace", ui.KeyTab: "Tab", ui.KeySpace: "Space", + ui.KeyDelete: "Del", ui.KeyHome: "Home", ui.KeyEnd: "End", ui.KeyPageUp: "PgUp", ui.KeyPageDown: "PgDn", + ui.KeyLeft: "←", ui.KeyRight: "→", ui.KeyUp: "↑", ui.KeyDown: "↓", ui.KeyMinus: "-", ui.KeyEqual: "=", + ui.KeyComma: ",", ui.KeyPeriod: ".", ui.KeySlash: "/", ui.KeySemicolon: ";", ui.KeyQuote: "'", + ui.KeyF1: "F1", ui.KeyF2: "F2", ui.KeyF3: "F3", ui.KeyF4: "F4", ui.KeyF5: "F5", ui.KeyF6: "F6", + ui.KeyF7: "F7", ui.KeyF8: "F8", ui.KeyF9: "F9", ui.KeyF10: "F10", ui.KeyF11: "F11", ui.KeyF12: "F12", + ui.KeyBracketLeft: "[", ui.KeyBracketRight: "]", ui.KeyBackslash: "\\", ui.KeyBackquote: "`", +} + +// menubarShortcutText spells a shortcut the platform's way: ⌘S on macOS, Ctrl+S elsewhere. +func menubarShortcutText(s MenubarShortcut) string { + key, ok := menubarKeyNames[s.Key] + switch { + case ok: + case s.Key >= ui.KeyA && s.Key <= ui.KeyZ: + key = string(rune('A' + s.Key - ui.KeyA)) + case s.Key >= ui.Key0 && s.Key <= ui.Key9: + key = string(rune('0' + s.Key - ui.Key0)) + default: + panic("mujica: MenubarShortcut key has no name") + } + mac := runtime.GOOS == "darwin" + var parts []string + for _, m := range []struct { + mod ui.Modifiers + mac, name string + }{{ui.Ctrl, "⌃", "Ctrl"}, {ui.Alt, "⌥", "Alt"}, {ui.Shift, "⇧", "Shift"}, {ui.Super, "⌘", "Win"}} { + if s.Mods&m.mod != 0 { + if mac { + parts = append(parts, m.mac) + } else { + parts = append(parts, m.name) + } + } + } + if mac { + return strings.Join(parts, "") + key + } + return strings.Join(append(parts, key), "+") +} diff --git a/menubar_test.go b/menubar_test.go new file mode 100644 index 0000000..1309072 --- /dev/null +++ b/menubar_test.go @@ -0,0 +1,191 @@ +package mujica + +import ( + "runtime" + "testing" + + "github.com/egoist/mygo/ui" +) + +type menubarApp struct { + wrap bool + zoom string + chosen []string +} + +func (a *menubarApp) menus() []MenubarMenu { + return []MenubarMenu{ + {Label: "File", Items: []MenubarItem{ + {ID: "new", Label: "New", Shortcut: MenubarShortcut{ui.Cmd, ui.KeyN}}, + {ID: "open", Label: "Open…", Shortcut: MenubarShortcut{ui.Cmd, ui.KeyO}}, + {Label: "Export", Items: []MenubarItem{ + {ID: "pdf", Label: "PDF"}, + {ID: "png", Label: "Image", Disabled: true}, + {ID: "svg", Label: "Vector"}, + }}, + {Kind: MenubarSeparator}, + {ID: "quit", Label: "Quit"}, + }}, + {Label: "View", Items: []MenubarItem{ + {ID: "wrap", Label: "Word wrap", Kind: MenubarCheck, Checked: a.wrap}, + {Kind: MenubarSeparator}, + {ID: "zoom-fit", Label: "Fit", Kind: MenubarRadio, Checked: a.zoom == "zoom-fit"}, + {ID: "zoom-100", Label: "Actual size", Kind: MenubarRadio, Checked: a.zoom == "zoom-100"}, + }}, + {Label: "Help", Items: []MenubarItem{{ID: "about", Label: "About"}}}, + } +} + +func (a *menubarApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Fill().Children(func() { + if id, ok := Menubar(c, a.menus(), MenubarOptions{}).Chosen(); ok { + a.chosen = append(a.chosen, id) + switch id { + case "wrap": + a.wrap = !a.wrap + case "zoom-fit", "zoom-100": + a.zoom = id + } + } + }) +} + +func TestMenubarPointer(t *testing.T) { + a := &menubarApp{zoom: "zoom-fit"} + tt := ui.NewTester(a.view, 700, 500) + if err := tt.Click("View"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Word wrap"); err != nil { + t.Fatal(err) + } + if !a.wrap || tt.HasText("Actual size") { + t.Fatalf("check item: wrap %v, menu still open %v", a.wrap, tt.HasText("Actual size")) + } + _ = tt.Click("View") + if _, ok := tt.Find("Word wrap, checked"); !ok { + t.Fatal("checked state not named") + } + if err := tt.Click("Actual size"); err != nil { + t.Fatal(err) + } + if a.zoom != "zoom-100" { + t.Fatalf("radio chose %q", a.zoom) + } + _ = tt.Click("File") + r, _ := tt.Find("Export") + tt.Move(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W/2+1, r.Y+r.H/2) + if !tt.HasText("Vector") { + t.Fatalf("hover did not open submenu: %q", tt.Texts()) + } + _ = tt.Click("Image") + if err := tt.Click("Vector"); err != nil { + t.Fatal(err) + } + if got := a.chosen; len(got) != 3 || got[2] != "svg" { + t.Fatalf("chosen %q", got) + } + _ = tt.Click("Help") + tt.ClickAt(600, 400) + if tt.HasText("About") { + t.Fatal("click outside did not close") + } +} + +func TestMenubarHoverSwitchesMenus(t *testing.T) { + a := &menubarApp{} + tt := ui.NewTester(a.view, 700, 500) + _ = tt.Click("File") + r, _ := tt.Find("Help") + tt.Move(r.X+2, r.Y+2) + if tt.HasText("Quit") || !tt.HasText("About") { + t.Fatalf("hover did not switch: %q", tt.Texts()) + } +} + +func TestMenubarKeyboard(t *testing.T) { + a := &menubarApp{} + tt := ui.NewTester(a.view, 700, 500) + tt.Key(0, ui.KeyTab) + if !tt.Focused("File") { + t.Fatal("Tab did not reach the bar") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyLeft) + if !tt.Focused("File") { + t.Fatal("arrows did not move between titles") + } + tt.Key(0, ui.KeyDown) // opens File on New + tt.Key(0, ui.KeyDown) // Open + tt.Key(0, ui.KeyDown) // Export + tt.Key(0, ui.KeyRight) + if !tt.HasText("Vector") { + t.Fatal("Right did not open the submenu") + } + tt.Key(0, ui.KeyDown) // skips disabled Image to Vector + tt.Key(0, ui.KeyEnter) + if len(a.chosen) != 1 || a.chosen[0] != "svg" { + t.Fatalf("chosen %q", a.chosen) + } + if tt.HasText("Quit") || !tt.Focused("File") { + t.Fatal("menu did not close back to its title") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyUp) // wraps past the separator to Quit + tt.Key(0, ui.KeyRight) + if !tt.HasText("Word wrap") { + t.Fatal("Right on a plain item did not move to the next menu") + } + tt.Key(0, ui.KeySpace) + if !a.wrap { + t.Fatal("Space did not toggle the check item") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEscape) + if tt.HasText("Word wrap") { + t.Fatal("Escape did not close") + } +} + +func TestMenubarShortcutText(t *testing.T) { + got := menubarShortcutText(MenubarShortcut{ui.Ctrl | ui.Shift, ui.KeyF5}) + want := "Ctrl+Shift+F5" + if runtime.GOOS == "darwin" { + want = "⌃⇧F5" + } + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestMenubarInvalid(t *testing.T) { + for name, menus := range map[string][]MenubarMenu{ + "empty": nil, + "title": {{Items: []MenubarItem{{ID: "a", Label: "A"}}}}, + "id": {{Label: "M", Items: []MenubarItem{{Label: "A"}}}}, + "submenu": {{Label: "M", Items: []MenubarItem{{Label: "A", Kind: MenubarCheck, Items: []MenubarItem{}}}}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Menubar(c, menus, MenubarOptions{}) + }, 300, 100, 1) + }) + } +} + +func TestMenubarSnapshot(t *testing.T) { + a := &menubarApp{wrap: true, zoom: "zoom-fit"} + snapshot(t, "menubar", 480, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Menubar(c, a.menus(), MenubarOptions{}) + }) + }) +} diff --git a/messages_datetime.go b/messages_datetime.go new file mode 100644 index 0000000..1d44b95 --- /dev/null +++ b/messages_datetime.go @@ -0,0 +1,86 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "calendar.label": {"Calendar", "日历"}, + "calendar.title": {"%[1]s %[2]d", "%[2]d年%[1]s"}, + "calendar.day_label": {"%[1]s, %[2]s %[3]d, %[4]d", "%[4]d年%[2]s%[3]d日 %[1]s"}, + "calendar.prev_month": {"Previous month", "上个月"}, + "calendar.next_month": {"Next month", "下个月"}, + "calendar.prev_year": {"Previous year", "上一年"}, + "calendar.next_year": {"Next year", "下一年"}, + "calendar.month_1": {"January", "1月"}, + "calendar.month_2": {"February", "2月"}, + "calendar.month_3": {"March", "3月"}, + "calendar.month_4": {"April", "4月"}, + "calendar.month_5": {"May", "5月"}, + "calendar.month_6": {"June", "6月"}, + "calendar.month_7": {"July", "7月"}, + "calendar.month_8": {"August", "8月"}, + "calendar.month_9": {"September", "9月"}, + "calendar.month_10": {"October", "10月"}, + "calendar.month_11": {"November", "11月"}, + "calendar.month_12": {"December", "12月"}, + "calendar.weekday_0": {"Su", "日"}, + "calendar.weekday_1": {"Mo", "一"}, + "calendar.weekday_2": {"Tu", "二"}, + "calendar.weekday_3": {"We", "三"}, + "calendar.weekday_4": {"Th", "四"}, + "calendar.weekday_5": {"Fr", "五"}, + "calendar.weekday_6": {"Sa", "六"}, + "calendar.weekday_long_0": {"Sunday", "星期日"}, + "calendar.weekday_long_1": {"Monday", "星期一"}, + "calendar.weekday_long_2": {"Tuesday", "星期二"}, + "calendar.weekday_long_3": {"Wednesday", "星期三"}, + "calendar.weekday_long_4": {"Thursday", "星期四"}, + "calendar.weekday_long_5": {"Friday", "星期五"}, + "calendar.weekday_long_6": {"Saturday", "星期六"}, + "calendar.before_min": {"Choose %s or later.", "请选择 %s 或之后。"}, + "calendar.after_max": {"Choose %s or earlier.", "请选择 %s 或之前。"}, + "calendar.unavailable": {"%s is not available.", "%s 不可选。"}, + "calendar.clear": {"Clear", "清空"}, + "calendar.confirm": {"Confirm", "确定"}, + "calendar.cancel": {"Cancel", "取消"}, + + "date_picker.placeholder": {"Select date", "选择日期"}, + "date_picker.open": {"Open calendar", "打开日历"}, + "date_picker.invalid": {"Enter a date like %s.", "请按 %s 的格式输入日期。"}, + + "date_range_picker.label": {"Date range", "日期范围"}, + "date_range_picker.placeholder": {"Start date – End date", "开始日期 – 结束日期"}, + "date_range_picker.pick_start": {"Choose the start date.", "请选择开始日期。"}, + "date_range_picker.pick_end": {"From %s, choose the end date.", "自 %s 起,请选择结束日期。"}, + "date_range_picker.summary": {"%s – %s · %d days", "%s – %s · 共 %d 天"}, + "date_range_picker.blocked": {"The range cannot include %s.", "范围不能包含 %s。"}, + + "time_picker.hours": {"Hours", "时"}, + "time_picker.minutes": {"Minutes", "分"}, + "time_picker.seconds": {"Seconds", "秒"}, + "time_picker.period": {"AM/PM", "上午/下午"}, + "time_picker.am": {"AM", "上午"}, + "time_picker.pm": {"PM", "下午"}, + "time_picker.with_period": {"%[1]s %[2]s", "%[2]s %[1]s"}, + "time_picker.open": {"Choose time", "选择时间"}, + "time_picker.minute_step": {"Minutes must be a multiple of %d.", "分钟须为 %d 的倍数。"}, + "time_picker.second_step": {"Seconds must be a multiple of %d.", "秒须为 %d 的倍数。"}, + "time_picker.out_of_range": {"Choose a time from %s to %s.", "请选择 %s 至 %s 之间的时间。"}, + + "time_range_picker.start": {"Start time", "开始时间"}, + "time_range_picker.end": {"End time", "结束时间"}, + "time_range_picker.next_day": {"+1 day", "次日"}, + "time_range_picker.same": {"Start and end must differ.", "开始与结束时间不能相同。"}, + "time_range_picker.order": {"End must be after start; overnight ranges are off.", "结束须晚于开始;未开启跨午夜。"}, + + "date_time_picker.placeholder": {"Select date and time", "选择日期和时间"}, + "date_time_picker.time": {"Time", "时间"}, + "date_time_picker.gap": {"%s does not exist in %s: the clocks skip it.", "%s 在 %s 不存在:该时段因夏令时被跳过。"}, + "date_time_picker.ambiguous": {"%s happens twice in %s: the clocks repeat it.", "%s 在 %s 出现两次:该时段因夏令时重复。"}, + + "month_picker.placeholder": {"Select month", "选择月份"}, + "month_picker.year": {"%d", "%d年"}, + + "year_picker.placeholder": {"Select year", "选择年份"}, + "year_picker.prev": {"Previous decade", "上一个十年"}, + "year_picker.next": {"Next decade", "下一个十年"}, + }) +} diff --git a/messages_display.go b/messages_display.go new file mode 100644 index 0000000..b92688a --- /dev/null +++ b/messages_display.go @@ -0,0 +1,16 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "editable_text.saving": {"Saving", "正在保存"}, + "icon.missing": {"Missing icon: %s", "缺少图标:%s"}, + "image.placeholder": {"No image", "暂无图片"}, + "image.loading": {"Loading image", "正在加载图片"}, + "image.error": {"Image failed to load", "图片加载失败"}, + "image.retry": {"Retry", "重试"}, + "avatar.unnamed": {"Unnamed", "未命名"}, + "avatar_group.more": {"Show all %d people", "显示全部 %d 人"}, + "tag.remove": {"Remove %s", "移除%s"}, + "link.invalid": {"This link cannot be opened: %s", "无法打开此链接:%s"}, + }) +} diff --git a/messages_feedback.go b/messages_feedback.go index c7ec2d0..527754c 100644 --- a/messages_feedback.go +++ b/messages_feedback.go @@ -3,5 +3,49 @@ package mujica func init() { addMessages(map[string][2]string{ "loading": {"Loading", "加载中"}, + + "alert.close": {"Dismiss", "关闭"}, + "alert.info": {"Information", "信息"}, + "alert.success": {"Success", "成功"}, + "alert.warning": {"Warning", "警告"}, + "alert.error": {"Error", "错误"}, + + "toast.close": {"Dismiss notification", "关闭通知"}, + "toast.region": {"Notifications", "通知"}, + + "progress.indeterminate": {"In progress", "进行中"}, + "meter.normal": {"Normal", "正常"}, + "meter.warning": {"High", "偏高"}, + "meter.critical": {"Critical", "严重"}, + "meter.low_warning": {"Low", "偏低"}, + + "empty_state.no_data.title": {"Nothing here yet", "暂无内容"}, + "empty_state.no_data.description": {"Items you add will appear here.", "添加的内容会显示在这里。"}, + "empty_state.no_results.title": {"No results", "没有结果"}, + "empty_state.no_results.description": {"Try other words or clear the filters.", "请换个关键词,或清除筛选条件。"}, + "empty_state.no_results.query": {"Nothing matches “%s”.", "没有与“%s”匹配的内容。"}, + + "result.success.title": {"Done", "操作成功"}, + "result.failure.title": {"Something went wrong", "操作失败"}, + "result.forbidden.title": {"No access", "无访问权限"}, + + "status_indicator.success": {"Healthy", "正常"}, + "status_indicator.warning": {"Degraded", "警告"}, + "status_indicator.error": {"Failing", "错误"}, + "status_indicator.offline": {"Offline", "离线"}, + "status_indicator.busy": {"Working", "处理中"}, + + "notification_center.title": {"Notifications", "通知"}, + "notification_center.unread": {"%d unread", "%d 条未读"}, + "notification_center.unread_mark": {"Unread", "未读"}, + "notification_center.mark_read": {"Mark read", "标为已读"}, + "notification_center.mark_unread": {"Mark unread", "标为未读"}, + "notification_center.mark_all_read": {"Mark all read", "全部已读"}, + "notification_center.clear": {"Clear", "清除"}, + "notification_center.clear_all": {"Clear all", "全部清除"}, + "notification_center.empty": {"You're all caught up", "没有新通知"}, + "notification_center.empty_hint": {"New notifications will appear here.", "新的通知会显示在这里。"}, + + "loading_overlay.label": {"Loading…", "正在加载…"}, }) } diff --git a/messages_form.go b/messages_form.go new file mode 100644 index 0000000..96ca640 --- /dev/null +++ b/messages_form.go @@ -0,0 +1,53 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "rating.label": {"Rating", "评分"}, + "pin_input.label": {"Verification code", "验证码"}, + "pin_input.filled": {"%d of %d digits entered", "已输入 %d / %d 位"}, + "rating.value": {"%s of %d", "%s / %d 分"}, + "tags_input.duplicate": {"“%s” is already added", "“%s”已添加"}, + "tags_input.max": {"At most %d tags", "最多 %d 个标签"}, + "tags_input.remove": {"Remove %s", "移除 %s"}, + "color_picker.label": {"Color", "颜色"}, + "color_picker.red": {"Red", "红"}, + "color_picker.green": {"Green", "绿"}, + "color_picker.blue": {"Blue", "蓝"}, + "color_picker.percent": {"Opacity percent", "不透明度百分比"}, + "color_picker.hue": {"Hue", "色相"}, + "color_picker.alpha": {"Opacity", "不透明度"}, + "color_picker.square": {"Saturation and brightness", "饱和度与亮度"}, + "color_picker.sv": {"%.0f%% saturation, %.0f%% brightness", "饱和度 %.0f%%,亮度 %.0f%%"}, + "color_picker.invalid": {"Use HEX RGB, RRGGBB or RRGGBBAA; channels 0–255; opacity 0–100.", "HEX 须为 RGB、RRGGBB 或 RRGGBBAA;通道 0–255;不透明度 0–100。"}, + "file_picker.failed": {"Could not open the file dialog: %v", "无法打开文件对话框:%v"}, + "file_picker.choose_file": {"Choose file…", "选择文件…"}, + "file_picker.choose_folder": {"Choose folder…", "选择文件夹…"}, + "file_picker.none": {"Nothing chosen", "未选择"}, + "file_drop_zone.hint": {"Drop files here", "将文件拖到此处"}, + "file_drop_zone.browse": {"Browse…", "浏览…"}, + "file_drop_zone.any": {"Any file", "任意文件"}, + "file_drop_zone.max_files": {"up to %d files", "最多 %d 个文件"}, + "file_drop_zone.max_size": {"%s each", "每个不超过 %s"}, + "file_drop_zone.type": {"Only %s files", "仅接受 %s 文件"}, + "file_drop_zone.size": {"Larger than %s", "超过 %s"}, + "file_drop_zone.count": {"More than %d files", "超过 %d 个文件"}, + "file_drop_zone.folder": {"Folders are not accepted", "不接受文件夹"}, + "file_drop_zone.unreadable": {"Cannot read: %v", "无法读取:%v"}, + "file_drop_zone.checking": {"Checking…", "检查中…"}, + "file_drop_zone.remove": {"Remove %s", "移除 %s"}, + "file_drop_zone.dialog": {"File dialog", "文件对话框"}, + "transfer.source": {"Available", "可选"}, + "transfer.target": {"Chosen", "已选"}, + "transfer.to": {"Move to %s", "移到%s"}, + "transfer.all": {"Select all in %s", "全选%s"}, + "transfer.search": {"Search", "搜索"}, + "transfer.search_in": {"Search %s", "搜索%s"}, + "transfer.empty": {"No items", "暂无条目"}, + "transfer.no_match": {"No matches", "无匹配结果"}, + "form.submit": {"Submit", "提交"}, + "form.reset": {"Reset", "重置"}, + "form.required": {"Required", "必填"}, + "form.checking": {"Checking…", "校验中…"}, + "form.failed": {"Some fields need attention", "部分字段需要修改"}, + }) +} diff --git a/messages_input.go b/messages_input.go new file mode 100644 index 0000000..5797897 --- /dev/null +++ b/messages_input.go @@ -0,0 +1,22 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "text_input.clear": {"Clear", "清空"}, + "number_input.increase": {"Increase", "增加"}, + "number_input.decrease": {"Decrease", "减少"}, + "number_input.invalid": {"Enter a number", "请输入数字"}, + "number_input.range": {"Enter a number from %s to %s", "请输入 %s 到 %s 之间的数字"}, + "password_input.show": {"Show password", "显示密码"}, + "password_input.hide": {"Hide password", "隐藏密码"}, + "search_input.search": {"Search", "搜索"}, + "search_input.find": {"Find", "查找"}, + "search_input.previous": {"Previous match", "上一项"}, + "search_input.next": {"Next match", "下一项"}, + "search_input.count": {"%d of %d", "第 %d 项,共 %d 项"}, + "search_input.none": {"No matches", "无匹配"}, + "checkbox.mixed": {"Partly selected", "部分选中"}, + "text_area.count": {"%d of %d characters", "%d / %d 字"}, + "text_area.over": {"%d characters over the limit", "超出 %d 字"}, + }) +} diff --git a/messages_layout.go b/messages_layout.go new file mode 100644 index 0000000..5563c39 --- /dev/null +++ b/messages_layout.go @@ -0,0 +1,12 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "app_shell.collapse": {"Collapse sidebar", "收起侧栏"}, + "app_shell.expand": {"Expand sidebar", "展开侧栏"}, + "status_bar.label": {"Status bar", "状态栏"}, + "status_bar.hidden": {"%d more items", "另有 %d 项"}, + "split_pane.divider": {"Divider", "分隔条"}, + "split_pane.collapsed": {"Collapsed", "已折叠"}, + }) +} diff --git a/messages_navigation.go b/messages_navigation.go new file mode 100644 index 0000000..8474db7 --- /dev/null +++ b/messages_navigation.go @@ -0,0 +1,36 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "breadcrumb.label": {"Breadcrumb", "路径导航"}, + "breadcrumb.current": {"current page", "当前页"}, + "breadcrumb.more": {"Show hidden path", "显示折叠的路径"}, + "pagination.label": {"Pagination", "分页"}, + "pagination.total": {"%d items", "共 %d 条"}, + "pagination.prev": {"Previous page", "上一页"}, + "pagination.next": {"Next page", "下一页"}, + "pagination.page": {"Page %d", "第 %d 页"}, + "pagination.current": {"current", "当前"}, + "pagination.size": {"Page size", "每页条数"}, + "pagination.per_page": {"%d / page", "%d 条/页"}, + "pagination.jump": {"Go to", "跳至"}, + "pagination.field": {"Page number", "页码"}, + "pagination.range": {"1–%d", "1–%d"}, + "steps.item": {"Step %d of %d: %s, %s", "第 %d 步,共 %d 步:%s,%s"}, + "steps.waiting": {"waiting", "等待"}, + "steps.current": {"current", "进行中"}, + "steps.done": {"done", "已完成"}, + "steps.error": {"failed", "出错"}, + "navigation_menu.label": {"Site navigation", "导航菜单"}, + "navigation_menu.back": {"Back", "后退"}, + "navigation_menu.forward": {"Forward", "前进"}, + "menubar.label": {"Menu bar", "菜单栏"}, + "menubar.checked": {"checked", "已勾选"}, + "command_palette.label": {"Command palette", "命令面板"}, + "command_palette.search": {"Search commands", "搜索命令"}, + "command_palette.empty": {"No matching commands", "没有匹配的命令"}, + "command_palette.hint_move": {"↑↓ choose", "↑↓ 选择"}, + "command_palette.hint_run": {"Enter run", "Enter 执行"}, + "command_palette.hint_close": {"Esc close", "Esc 关闭"}, + }) +} diff --git a/messages_overlay.go b/messages_overlay.go new file mode 100644 index 0000000..c2d590c --- /dev/null +++ b/messages_overlay.go @@ -0,0 +1,13 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "dialog.close": {"Close", "关闭"}, + "alert_dialog.cancel": {"Cancel", "取消"}, + "alert_dialog.confirm": {"Delete", "删除"}, + "alert_dialog.type_to_unlock": {"Type “%s” to confirm", "输入“%s”以确认"}, + "drawer.close": {"Close", "关闭"}, + "popconfirm.confirm": {"Confirm", "确定"}, + "popconfirm.cancel": {"Cancel", "取消"}, + }) +} diff --git a/month_picker.go b/month_picker.go new file mode 100644 index 0000000..b88359a --- /dev/null +++ b/month_picker.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" +) + +// YearMonth is a month of a year. The zero value means no month. +type YearMonth struct { + Year int + Month time.Month +} + +// IsZero reports whether m is the empty month. +func (m YearMonth) IsZero() bool { return m == YearMonth{} } + +// String formats m as 2006-01, or "" when empty. +func (m YearMonth) String() string { + if m.IsZero() { + return "" + } + return fmt.Sprintf("%04d-%02d", m.Year, m.Month) +} + +func (m YearMonth) ordinal() int { return m.Year*12 + int(m.Month) - 1 } + +func yearMonthOf(o int) YearMonth { + y := o / 12 + if o < 0 && o%12 != 0 { + y-- + } + return YearMonth{y, time.Month(o-y*12) + 1} +} + +// MonthPickerOptions configure a MonthPicker. +type MonthPickerOptions struct { + // Min and Max bound the months chosen; zero leaves a side open. + Min, Max YearMonth + DisableMonth func(YearMonth) bool + Clearable bool + // Today marks the current month; zero uses the system clock. + Today Date + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// MonthPicker chooses a month into *value from a year page; PageUp and PageDown change the year. +func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult { + for _, m := range []YearMonth{*value, opts.Min, opts.Max} { + if !m.IsZero() && (m.Month < time.January || m.Month > time.December) { + panic(fmt.Sprintf("mujica: MonthPicker month %v is not valid", m)) + } + } + if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.ordinal() > opts.Max.ordinal() { + panic(fmt.Sprintf("mujica: MonthPicker Min %v is after Max %v", opts.Min, opts.Max)) + } + today := datetimeToday(c, opts.Today) + now := YearMonth{today.Year, today.Month} + title := func(m YearMonth) string { return datetimeMonthTitle(c, m.Year, m.Month) } + home := *value + if home.IsZero() { + home = now + } + text := "" + if !value.IsZero() { + text = title(*value) + } + label := opts.Label + if label == "" { + label = msg(c, "month_picker.placeholder") + } + col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{ + text: text, placeholder: msg(c, "month_picker.placeholder"), label: label, + clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, home: home.ordinal(), + }, datetimeGridConfig{ + pageOf: func(o int) (int, int) { return yearMonthOf(o).Year * 12, 12 }, + cols: 3, + page: 12, + title: func(start int) string { return fmt.Sprintf(msg(c, "month_picker.year"), yearMonthOf(start).Year) }, + prev: msg(c, "calendar.prev_year"), + next: msg(c, "calendar.next_year"), + text: func(o int) string { return datetimeMonthName(c, yearMonthOf(o).Month) }, + label: func(o int) string { return title(yearMonthOf(o)) }, + chosen: func(o int) bool { return !value.IsZero() && o == value.ordinal() }, + current: func(o int) bool { return o == now.ordinal() }, + enabled: func(o int) bool { + m := yearMonthOf(o) + return (opts.Min.IsZero() || o >= opts.Min.ordinal()) && (opts.Max.IsZero() || o <= opts.Max.ordinal()) && + (opts.DisableMonth == nil || !opts.DisableMonth(m)) + }, + muted: func(int, int) bool { return false }, + }) + to := *value + switch { + case cleared: + to = YearMonth{} + case ok: + to = yearMonthOf(picked) + } + changed := to != *value + if changed { + slog.Debug("mujica: month_picker change", "from", *value, "to", to) + *value = to + } + return DateTimeResult{Element: col, changed: changed} +} diff --git a/month_picker_test.go b/month_picker_test.go new file mode 100644 index 0000000..0d2f4bd --- /dev/null +++ b/month_picker_test.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func monthPickerTester(value *YearMonth, opts MonthPickerOptions, changes *int) *ui.Tester { + opts.Today = calendarToday + opts.Label = "Billing month" + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(16).Children(func() { + if MonthPicker(c, value, opts).Changed() { + *changes++ + } + }) + }, 420, 420) +} + +func TestMonthPickerClickAndYear(t *testing.T) { + v, n := YearMonth{}, 0 + tt := monthPickerTester(&v, MonthPickerOptions{}, &n) + _ = tt.Click("Billing month") + if !tt.HasText("2026") { + t.Fatalf("popup should open on the current year: %v", tt.Texts()) + } + _ = tt.Click("Next year") + if err := tt.Click("March 2027"); err != nil { + t.Fatal(err) + } + if v != (YearMonth{2027, time.March}) || n != 1 || tt.HasText("April 2027") { + t.Fatalf("picked %v, popup closed %v", v, !tt.HasText("April 2027")) + } +} + +func TestMonthPickerKeyboard(t *testing.T) { + v, n := YearMonth{2026, time.May}, 0 + tt := monthPickerTester(&v, MonthPickerOptions{}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if !tt.Focused("May 2026") { + t.Fatal("popup should focus the chosen month") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyPageUp) + if !tt.Focused("September 2025") { + t.Fatal("arrows and PageUp should move the cursor") + } + tt.Key(0, ui.KeyEnter) + if v != (YearMonth{2025, time.September}) || n != 1 { + t.Fatalf("Enter chose %v", v) + } +} + +func TestMonthPickerLimitsAndClear(t *testing.T) { + v, n := YearMonth{2026, time.June}, 0 + opts := MonthPickerOptions{Min: YearMonth{2026, time.March}, Max: YearMonth{2026, time.October}, Clearable: true, + DisableMonth: func(m YearMonth) bool { return m.Month == time.August }} + tt := monthPickerTester(&v, opts, &n) + _ = tt.Click("Billing month") + for _, m := range []string{"February 2026", "August 2026", "November 2026"} { + _ = tt.Click(m) + } + if v != (YearMonth{2026, time.June}) || n != 0 { + t.Fatalf("disabled month chosen: %v", v) + } + tt.Key(0, ui.KeyEscape) + if err := tt.Click("Clear"); err != nil { + t.Fatal(err) + } + if !v.IsZero() || n != 1 { + t.Fatalf("clear left %v", v) + } +} + +func TestYearMonthOrdinal(t *testing.T) { + for _, m := range []YearMonth{{2026, time.January}, {2026, time.December}, {1, time.March}} { + if got := yearMonthOf(m.ordinal()); got != m { + t.Fatalf("%v round-tripped to %v", m, got) + } + } +} + +func TestMonthPickerSnapshot(t *testing.T) { + a, b := YearMonth{2026, time.October}, YearMonth{} + snapshot(t, "month_picker", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + MonthPicker(c, &b, MonthPickerOptions{Today: calendarToday}) + MonthPicker(c, &a, MonthPickerOptions{Today: calendarToday, Clearable: true}) + }) + }) +} diff --git a/navigation_menu.go b/navigation_menu.go new file mode 100644 index 0000000..da0d3cc --- /dev/null +++ b/navigation_menu.go @@ -0,0 +1,206 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// NavigationLink is a page of a NavigationMenu, or with Items a group of pages. +type NavigationLink struct { + Label string + // Path is pushed on the router; groups need none. + Path string + Icon *ui.SVG + Description string + Items []NavigationLink +} + +// NavigationMenuOptions configure a NavigationMenu. +type NavigationMenuOptions struct { + // History shows back and forward buttons for the router. + History bool + // Label names the menu for assistive technology. + Label string +} + +// NavigationMenu shows links pushing pages on router; the router's path marks the current one. +func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element { + if router == nil { + panic("mujica: NavigationMenu needs a router") + } + if len(links) == 0 { + panic("mujica: NavigationMenu needs links") + } + k := Tokens(c) + u := Density(c).Unit() + path := router.Path() + label := opts.Label + if label == "" { + label = msg(c, "navigation_menu.label") + } + push := func(p string) { + if p != path { + slog.Debug("mujica: navigation menu push", "from", path, "to", p) + router.Push(p) + } + } + row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2) + row.Children(func() { + if opts.History { + ui.Row(c).Gap(u).Children(func() { + navigationMenuHistory(c, ui.BackButton(c, router), msg(c, "navigation_menu.back")) + navigationMenuHistory(c, ui.ForwardButton(c, router), msg(c, "navigation_menu.forward")) + }) + ui.Box(c).Size(1, 20).Background(k.Ornament).Role(ui.RoleNone) + } + ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(label).Children(func() { + for _, l := range links { + navigationMenuTop(c, l, path, push) + } + }) + }) + return row +} + +func navigationMenuHistory(c *ui.Context, b *ui.Element, name string) { + k := Tokens(c) + d := Density(c) + b.Label(name).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Radius(theme.ControlRadius).Background(k.Surface) + internal.Ring(b, k.Focus, theme.ControlRadius) +} + +// navigationMenuActive reports whether l or a link inside it is the page at path. +func navigationMenuActive(l NavigationLink, path string) bool { + // The root page is current only on itself, not under every path. + if l.Path != "" && (path == l.Path || l.Path != "/" && strings.HasPrefix(path, strings.TrimSuffix(l.Path, "/")+"/")) { + return true + } + for _, it := range l.Items { + if navigationMenuActive(it, path) { + return true + } + } + return false +} + +func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(string)) { + if l.Label == "" { + panic("mujica: NavigationLink needs a Label") + } + if l.Items == nil && l.Path == "" { + panic("mujica: NavigationLink needs a Path or Items") + } + k := Tokens(c) + d := Density(c) + u := d.Unit() + on := navigationMenuActive(l, path) + b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius). + Label(l.Label).TextColor(k.TextMuted).Cursor(ui.CursorPointer) + open := ui.Local(b, "open", func() bool { return false }) + if l.Items == nil { + b.Role(ui.RoleLink) + } else { + b.Role(ui.RoleMenuButton) + } + if on { + b.TextColor(k.Text).FontWeight(600) + } + if b.Hovered() || *open { + b.Background(k.SurfaceHover).TextColor(k.Text) + } + b.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { + if on { + p.Fill(ui.Rect{X: r.X + u*3, Y: r.Y + r.H - 2, W: r.W - u*6, H: 2}, k.AccentText, 1) + } + if b.FocusVisible() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + b.Children(func() { + if l.Icon != nil { + ui.Icon(c, l.Icon).Size(16, 16) + } + ui.Text(c, l.Label).SingleLine() + if l.Items != nil { + ui.Icon(c, icons.Must("chevron-down")).Size(12, 12).TextColor(k.TextMuted) + } + }) + if l.Items == nil { + if b.Clicked() { + push(l.Path) + } + return + } + if b.Clicked() { + *open = !*open + } else if b.Shortcut(0, ui.KeyDown) { + *open = true + } + ui.PopoverBase(c, b, open, func(panel *ui.Element) { + panel.Margin(u, 0, 0, 0).Padding(u*2).Gap(u).Radius(theme.PopupRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(240).MaxWidth(360) + panel.FocusGroup(ui.Vertical) + first := true + var list func(items []NavigationLink) + list = func(items []NavigationLink) { + for _, it := range items { + if it.Label == "" { + panic("mujica: NavigationLink needs a Label") + } + if it.Items != nil { + ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(theme.CaptionSize).TextColor(k.Ornament.Mix(k.Text, 0.35)). + Padding(u*2, u*2, u, u*2).SingleLine() + list(it.Items) + continue + } + if it.Path == "" { + panic("mujica: NavigationLink needs a Path or Items") + } + if navigationMenuItem(c, it, it.Path == path, first).Clicked() { + *open = false + push(it.Path) + } + first = false + } + } + list(l.Items) + }) +} + +func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element { + k := Tokens(c) + u := Density(c).Unit() + e := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).AlignItems(ui.Start).Gap(u*3). + Padding(u*2, u*3).Radius(theme.ControlRadius).Label(l.Label).TextColor(k.Text).Cursor(ui.CursorPointer) + if first { + e.AutoFocus() + } + internal.Ring(e, k.Focus, theme.ControlRadius) + switch { + case on: + e.Background(k.Selection).FontWeight(600) + e.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5) + }) + case e.Hovered(): + e.Background(k.SurfaceHover) + } + e.Children(func() { + if l.Icon != nil { + ui.Icon(c, l.Icon).Size(16, 16).Margin(2, 0, 0, 0) + } + ui.Column(c).Gap(2).MinWidth(0).Children(func() { + ui.Text(c, l.Label).SingleLine() + if l.Description != "" { + ui.Text(c, l.Description).FontSize(theme.CaptionSize).TextColor(k.TextMuted).FontWeight(400) + } + }) + }) + return e +} diff --git a/navigation_menu_test.go b/navigation_menu_test.go new file mode 100644 index 0000000..137e42f --- /dev/null +++ b/navigation_menu_test.go @@ -0,0 +1,139 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func navigationMenuSample() []NavigationLink { + return []NavigationLink{ + {Label: "Home", Path: "/", Icon: icons.Must("house")}, + {Label: "Library", Items: []NavigationLink{ + {Label: "Poems", Path: "/library/poems", Description: "Verse of the court"}, + {Label: "Letters", Path: "/library/letters"}, + {Label: "Archive", Items: []NavigationLink{ + {Label: "Older letters", Path: "/archive/letters"}, + }}, + }}, + {Label: "About", Path: "/about"}, + } +} + +func TestNavigationMenuPush(t *testing.T) { + r := ui.NewRouter("/") + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NavigationMenu(c, r, navigationMenuSample(), NavigationMenuOptions{History: true}) + }, 700, 400) + if err := tt.Click("About"); err != nil { + t.Fatal(err) + } + if r.Path() != "/about" { + t.Fatalf("path %q", r.Path()) + } + if err := tt.Click("Library"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Verse of the court") || !tt.HasText("Older letters") { + t.Fatalf("group did not open: %q", tt.Texts()) + } + if err := tt.Click("Older letters"); err != nil { + t.Fatal(err) + } + if r.Path() != "/archive/letters" || tt.HasText("Verse of the court") { + t.Fatalf("path %q, panel open %v", r.Path(), tt.HasText("Verse of the court")) + } + if err := tt.Click("Back"); err != nil { + t.Fatal(err) + } + if r.Path() != "/about" { + t.Fatalf("Back went to %q", r.Path()) + } + if err := tt.Click("Forward"); err != nil { + t.Fatal(err) + } + if r.Path() != "/archive/letters" { + t.Fatalf("Forward went to %q", r.Path()) + } +} + +func TestNavigationMenuKeyboard(t *testing.T) { + r := ui.NewRouter("/") + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NavigationMenu(c, r, navigationMenuSample(), NavigationMenuOptions{}) + }, 700, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Library") { + t.Fatal("Right did not reach Library") + } + tt.Key(0, ui.KeyDown) + if !tt.Focused("Poems") { + t.Fatalf("Down did not open into the panel: %q", tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if r.Path() != "/library/letters" { + t.Fatalf("keyboard went to %q", r.Path()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEscape) + if tt.HasText("Poems") || !tt.Focused("Library") { + t.Fatalf("Escape: open %v, focus on Library %v", tt.HasText("Poems"), tt.Focused("Library")) + } +} + +func TestNavigationMenuActive(t *testing.T) { + l := navigationMenuSample() + for _, tc := range []struct { + path string + want [3]bool + }{ + {"/", [3]bool{true, false, false}}, + {"/library/poems", [3]bool{false, true, false}}, + {"/archive/letters/7", [3]bool{false, true, false}}, + {"/about", [3]bool{false, false, true}}, + {"/aboutx", [3]bool{false, false, false}}, + } { + for i := range l { + got := navigationMenuActive(l[i], tc.path) + if got != tc.want[i] { + t.Errorf("%s: link %s active %v", tc.path, l[i].Label, got) + } + } + } +} + +func TestNavigationMenuInvalid(t *testing.T) { + for name, links := range map[string][]NavigationLink{ + "empty": nil, + "path": {{Label: "X"}}, + "label": {{Path: "/x"}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + NavigationMenu(c, ui.NewRouter("/"), links, NavigationMenuOptions{}) + }, 300, 100, 1) + }) + } +} + +func TestNavigationMenuSnapshot(t *testing.T) { + r := ui.NewRouter("/") + r.Push("/library/poems") + snapshot(t, "navigation_menu", 620, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + NavigationMenu(c, r, navigationMenuSample(), NavigationMenuOptions{History: true}) + }) + }) +} diff --git a/notification_center.go b/notification_center.go new file mode 100644 index 0000000..19a4f93 --- /dev/null +++ b/notification_center.go @@ -0,0 +1,189 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Notification is one in-app message. The caller owns and persists the list. +type Notification struct { + // ID is required and unique within the list. + ID string + Title string + Body string + // Time shows as given, as "5 min ago". + Time string + Read bool + // Action labels a button; empty shows none. + Action string +} + +// NotificationCenterOptions configure a NotificationCenter. +type NotificationCenterOptions struct { + // Title uses the built-in "Notifications" when empty. + Title string +} + +// NotificationCenterResult reports the frame's events by notification ID. +type NotificationCenterResult struct { + Element *ui.Element + Action string + // Toggled changed its Read state; Opened was clicked and is now read. + Toggled string + Opened string + Removed string + // MarkedAllRead and ClearedAll report the header buttons. + MarkedAllRead bool + ClearedAll bool +} + +// NotificationCenter lists *items and edits them in place: read, unread, +// remove, clear. The list is one Tab stop; arrows move among its buttons +// and Delete removes the focused notification. +func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult { + if items == nil { + panic("mujica: NotificationCenter needs items") + } + seen := map[string]bool{} + unread := 0 + for _, n := range *items { + if n.ID == "" || seen[n.ID] { + panic(fmt.Sprintf("mujica: notification ID %q empty or repeated", n.ID)) + } + seen[n.ID] = true + if !n.Read { + unread++ + } + } + k := Tokens(c) + u := Density(c).Unit() + title := cmp.Or(opts.Title, msg(c, "notification_center.title")) + var res NotificationCenterResult + panel := ui.Column(c).FillWidth().Shrink(1).MinHeight(0).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Role(ui.RoleGroup).Label(title).Clip() + panel.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text) + if unread > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText) + } + ui.Spacer(c) + if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() { + for i := range *items { + (*items)[i].Read = true + } + res.MarkedAllRead = true + slog.Debug("mujica: notification_center mark all read", "count", unread) + } + if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() { + slog.Debug("mujica: notification_center clear all", "count", len(*items)) + *items = (*items)[:0] + res.ClearedAll = true + } + }) + // Ornament hairline under the panel title. + ui.Box(c).FillWidth().Height(1).Background(k.Ornament).Role(ui.RoleNone) + if len(*items) == 0 && !res.ClearedAll { + EmptyState(c, EmptyStateOptions{ + Title: msg(c, "notification_center.empty"), + Description: msg(c, "notification_center.empty_hint"), + Icon: icons.Must("bell"), + }) + return + } + list := ui.Scroll(c).Shrink(1).FillWidth().Role(ui.RoleList).FocusGroup(ui.Vertical | ui.Horizontal) + list.Dividers(theme.BorderWidth, k.Border) + list.Children(func() { + for i := range *items { + notificationRow(c, k, u, &(*items)[i], &res) + } + }) + }) + if res.Removed != "" { + for i, n := range *items { + if n.ID == res.Removed { + *items = append((*items)[:i], (*items)[i+1:]...) + break + } + } + } + res.Element = panel + return res +} + +func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, res *NotificationCenterResult) { + name := n.Title + if !n.Read { + name = msg(c, "notification_center.unread_mark") + ": " + n.Title + } + row := ui.Row(c).Key(n.ID).FillWidth().AlignItems(ui.Start).Gap(u*2).Padding(u*3, u*3, u*3, u*2). + Role(ui.RoleListItem) + if row.Hovered() { + row.Background(k.SurfaceHover) + } + row.Children(func() { + // Unread mark: a diamond and bold title, not color alone. + ui.Box(c).Size(12, 20).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + if !n.Read { + p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText) + } + }) + open := ui.ButtonBase(c).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u). + Radius(theme.ControlRadius).Label(name) + internal.Ring(open, k.Focus, theme.ControlRadius) + open.Children(func() { + ui.Row(c).FillWidth().Gap(u * 2).Children(func() { + t := ui.Text(c, n.Title).TextColor(k.Text).Grow(1).Shrink(1) + if !n.Read { + t.Bold() + } + if n.Time != "" { + ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine() + } + }) + if n.Body != "" { + ui.Text(c, n.Body).TextColor(k.TextMuted).LineHeight(theme.LineHeight) + } + }) + if open.Clicked() && !n.Read { + n.Read, res.Opened = true, n.ID + slog.Debug("mujica: notification_center opened", "id", n.ID) + } + ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { + if n.Action != "" && Button(c, n.Action, ButtonOptions{Variant: Outline}).Clicked() { + res.Action = n.ID + slog.Debug("mujica: notification_center action", "id", n.ID) + } + toggle, icon := "notification_center.mark_read", icons.Must("check") + if n.Read { + toggle, icon = "notification_center.mark_unread", icons.Must("circle") + } + if notificationIconButton(c, k, u, icon, msg(c, toggle)).Clicked() { + n.Read, res.Toggled = !n.Read, n.ID + slog.Debug("mujica: notification_center toggled", "id", n.ID, "read", n.Read) + } + if notificationIconButton(c, k, u, icons.Must("x"), msg(c, "notification_center.clear")).Clicked() { + res.Removed = n.ID + } + }) + }) + if row.Shortcut(0, ui.KeyDelete) || row.Shortcut(0, ui.KeyBackspace) { + res.Removed = n.ID + } + if res.Removed == n.ID { + slog.Debug("mujica: notification_center removed", "id", n.ID) + } +} + +func notificationIconButton(c *ui.Context, k theme.Tokens, u float32, icon *ui.SVG, label string) *ui.Element { + b := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icon, Label: label}) + b.TextColor(k.TextMuted).Padding(0, u).Tooltip(label) + return b +} diff --git a/notification_center_test.go b/notification_center_test.go new file mode 100644 index 0000000..20fef80 --- /dev/null +++ b/notification_center_test.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func sampleNotifications() []Notification { + return []Notification{ + {ID: "a", Title: "Build failed", Body: "main · 3 tests failing", Time: "2 min ago", Action: "View log"}, + {ID: "b", Title: "Invite accepted", Body: "Mira joined the workspace", Time: "1 h ago"}, + {ID: "c", Title: "Backup complete", Time: "Yesterday", Read: true}, + } +} + +func notificationTester(items *[]Notification, last *NotificationCenterResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(560).Children(func() { + r := NotificationCenter(c, items, NotificationCenterOptions{}) + r.Element = nil + if r != (NotificationCenterResult{}) { + *last = r + } + }) + }, 640, 600) +} + +func TestNotificationCenterReadAndAction(t *testing.T) { + items := sampleNotifications() + var last NotificationCenterResult + tt := notificationTester(&items, &last) + if !tt.HasText("2 unread") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Unread: Invite accepted"); err != nil { + t.Fatal(err) + } + if !items[1].Read || last.Opened != "b" || !tt.HasText("1 unread") { + t.Fatalf("open: %+v %+v", items[1], last) + } + if err := tt.Click("View log"); err != nil { + t.Fatal(err) + } + if last.Action != "a" || items[0].Read { + t.Fatalf("action: %+v %+v", last, items[0]) + } + if err := tt.Click("Mark unread"); err != nil { + t.Fatal(err) + } + if items[1].Read || last.Toggled != "b" { + t.Fatalf("toggle: %+v %+v", items[1], last) + } + if err := tt.Click("Mark all read"); err != nil { + t.Fatal(err) + } + for _, n := range items { + if !n.Read { + t.Fatalf("mark all: %+v", n) + } + } + if !last.MarkedAllRead || tt.HasText("1 unread") || tt.HasText("2 unread") { + t.Fatalf("mark all: %+v %q", last, tt.Texts()) + } +} + +func TestNotificationCenterRemoveAndClear(t *testing.T) { + items := sampleNotifications() + var last NotificationCenterResult + tt := notificationTester(&items, &last) + if err := tt.Click("Clear"); err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].ID != "b" || last.Removed != "a" { + t.Fatalf("remove: %+v %+v", items, last) + } + if err := tt.Click("Clear all"); err != nil { + t.Fatal(err) + } + if len(items) != 0 || !last.ClearedAll || !tt.HasText("You're all caught up") { + t.Fatalf("clear all: %+v %q", items, tt.Texts()) + } +} + +func TestNotificationCenterKeyboard(t *testing.T) { + items := sampleNotifications() + var last NotificationCenterResult + tt := notificationTester(&items, &last) + tt.Key(0, ui.KeyTab) // Mark all read + tt.Key(0, ui.KeyTab) // Clear all + tt.Key(0, ui.KeyTab) // first notification + if !tt.Focused("Unread: Build failed") { + t.Fatal("Tab did not enter the list") + } + tt.Key(0, ui.KeyEnter) + if !items[0].Read || last.Opened != "a" { + t.Fatalf("Enter: %+v", items[0]) + } + tt.Key(0, ui.KeyDelete) + if len(items) != 2 || items[0].ID != "b" { + t.Fatalf("Delete: %+v", items) + } + tt.Key(0, ui.KeyTab) + if tt.Focused("Unread: Invite accepted") { + t.Fatal("the list takes more than one Tab stop") + } +} + +func TestNotificationCenterPanicsOnRepeatedID(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + items := []Notification{{ID: "x"}, {ID: "x"}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NotificationCenter(c, &items, NotificationCenterOptions{}) + }, 300, 200) +} + +func TestNotificationCenterSnapshot(t *testing.T) { + items := sampleNotifications() + snapshot(t, "notification_center", 520, 400, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(400).Children(func() { + NotificationCenter(c, &items, NotificationCenterOptions{}) + }) + }) +} diff --git a/number_input.go b/number_input.go new file mode 100644 index 0000000..a781378 --- /dev/null +++ b/number_input.go @@ -0,0 +1,227 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// NumberInputOptions configure a NumberInput. +type NumberInputOptions struct { + // Min and Max bound the value; equal bounds (the zero value) mean no bounds. + Min, Max float64 + // Step is what Up, Down and the buttons add; zero means 1. + Step float64 + // Precision is the decimals kept and shown; zero takes the decimals of Step. + Precision int + // AllowEmpty lets the field be empty, which sets the value to NaN. + AllowEmpty bool + // StepperOnly shows the value with step buttons and no typing; it needs bounds. + StepperOnly bool + Placeholder string + // Label names the field for assistive technology. + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// numberInputSpec is NumberInputOptions resolved. +type numberInputSpec struct { + lo, hi, step float64 + decimals int +} + +func (s numberInputSpec) format(v float64) string { + if math.IsNaN(v) { + return "" + } + return strconv.FormatFloat(v, 'f', s.decimals, 64) +} + +// clamp bounds v and rounds it to the precision. +func (s numberInputSpec) clamp(v float64) float64 { + p := math.Pow(10, float64(s.decimals)) + return math.Round(math.Max(s.lo, math.Min(s.hi, v))*p) / p +} + +func numberInputResolve(opts NumberInputOptions) numberInputSpec { + s := numberInputSpec{lo: opts.Min, hi: opts.Max, step: opts.Step, decimals: opts.Precision} + if s.lo > s.hi { + panic("mujica: NumberInput Min is above Max") + } + if s.step < 0 || opts.Precision < 0 { + panic("mujica: NumberInput Step and Precision cannot be negative") + } + if s.lo == s.hi { + if opts.StepperOnly { + panic("mujica: NumberInput StepperOnly needs Min < Max") + } + s.lo, s.hi = math.Inf(-1), math.Inf(1) + } + if s.step == 0 { + s.step = 1 + } + if s.decimals == 0 { + if t := strconv.FormatFloat(s.step, 'f', -1, 64); strings.Contains(t, ".") { + s.decimals = len(t) - strings.IndexByte(t, '.') - 1 + } + } + return s +} + +// NumberInput edits *value as a number; Up and Down step it. +func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputResult { + if value == nil { + panic("mujica: NumberInput needs a value") + } + if opts.StepperOnly && opts.AllowEmpty { + panic("mujica: NumberInput StepperOnly cannot be empty") + } + if math.IsNaN(*value) && !opts.AllowEmpty { + panic("mujica: NumberInput value is NaN without AllowEmpty") + } + s := numberInputResolve(opts) + if opts.StepperOnly { + return numberInputStepper(c, value, s, opts) + } + var r InputResult + set := func(v float64) { + if !math.IsNaN(v) { + v = s.clamp(v) + } + if v != *value && !(math.IsNaN(v) && math.IsNaN(*value)) { + *value = v + r.changed = true + } + } + stepBy := func(n float64) { + base := *value + if math.IsNaN(base) { + base = math.Max(s.lo, math.Min(s.hi, 0)) + } + set(base + n*s.step) + } + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + text := ui.Local(frame, "text", func() string { return s.format(*value) }) + invalid := ui.Local(frame, "invalid", func() string { return "" }) + in := ui.TextInputBase(c, text).Grow(1).MinWidth(Density(c).Unit() * 12). + Placeholder(opts.Placeholder).Label(opts.Label) + if in.Changed() { + *invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set) + } + switch { + case in.Shortcut(0, ui.KeyUp): + stepBy(1) + *text, *invalid = s.format(*value), "" + case in.Shortcut(0, ui.KeyDown): + stepBy(-1) + *text, *invalid = s.format(*value), "" + } + if !in.Focused() { + *text, *invalid = s.format(*value), "" + } + for _, b := range []struct { + name string + icon string + delta float64 + can bool + }{ + {msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo}, + {msg(c, "number_input.increase"), "plus", 1, math.IsNaN(*value) || *value < s.hi}, + } { + if numberInputButton(c, b.icon, b.name, b.can) { + stepBy(b.delta) + *text, *invalid = s.format(*value), "" + in.Focus() + } + } + if look.err == "" { + look.err = *invalid + } + return in + }, nil) + if r.changed { + slog.Debug("mujica: number_input change", "value", *value) + } + return r +} + +// numberInputParse applies typed text and returns why it is invalid, if it is. +func numberInputParse(c *ui.Context, s numberInputSpec, text string, allowEmpty bool, set func(float64)) string { + t := strings.TrimSpace(text) + if t == "" && allowEmpty { + set(math.NaN()) + return "" + } + v, err := strconv.ParseFloat(t, 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + return msg(c, "number_input.invalid") + } + if v < s.lo || v > s.hi { + return fmt.Sprintf(msg(c, "number_input.range"), s.format(s.lo), s.format(s.hi)) + } + set(v) + return "" +} + +// numberInputButton builds a pointer-only step button; Up and Down are its keys. +func numberInputButton(c *ui.Context, icon, name string, can bool) bool { + k := Tokens(c) + b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius). + Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer).Disabled(!can) + fg := k.TextMuted + if b.Pressed() { + b.Background(k.Selection) + fg = k.Text + } else if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(fg) }) + return b.Clicked() +} + +// numberInputStepper shows the value beside MyGo's stepper, restyled. +func numberInputStepper(c *ui.Context, value *float64, s numberInputSpec, opts NumberInputOptions) InputResult { + k := Tokens(c) + d := Density(c) + var r InputResult + r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled) + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).Children(func() { + ui.Text(c, s.format(*value)).FontFeatures("tnum").MinWidth(d.Unit() * 8).TextAlign(ui.End) + // MyGo's stepper arrows ignore Disabled: give them a copy. + target := value + if opts.Disabled { + held := *value + target = &held + } + st := ui.Stepper(c, target, s.lo, s.hi, s.step).Label(opts.Label).Width(d.Unit()*6). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) + if opts.Error != "" { + st.Error(opts.Error).Border(theme.BorderWidth, k.Danger) + } + internal.Ring(st, k.Focus, theme.ControlRadius) + r.Input = st + r.changed = st.Changed() && !opts.Disabled + }) + if opts.Error != "" { + ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize) + } + }) + if r.changed { + *value = s.clamp(*value) + slog.Debug("mujica: number_input step", "value", *value) + } + return r +} diff --git a/number_input_test.go b/number_input_test.go new file mode 100644 index 0000000..5259557 --- /dev/null +++ b/number_input_test.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func numberInputTester(v *float64, changes *int, opts NumberInputOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if NumberInput(c, v, opts).Changed() { + *changes++ + } + Button(c, "Elsewhere", ButtonOptions{}) + }, 400, 300) +} + +func TestNumberInputStepsAndBounds(t *testing.T) { + v, n := 9.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0, Max: 10, Label: "Copies"}) + _ = tt.Click("Copies") + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + if v != 10 || n != 1 { + t.Fatalf("value %v changes %d, want 10 and 1", v, n) + } + _ = tt.Click("Decrease") + _ = tt.Click("Decrease") + if v != 8 || n != 3 { + t.Fatalf("after buttons %v changes %d", v, n) + } + _ = tt.Click("Increase") + _ = tt.Click("Increase") + _ = tt.Click("Increase") + if v != 10 { + t.Fatalf("increase passed Max: %v", v) + } +} + +func TestNumberInputTypingPrecisionAndErrors(t *testing.T) { + v, n := 1.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0, Max: 2, Step: 0.25, Label: "Ratio"}) + _ = tt.Click("Ratio") + tt.Command("selectAll") + tt.Type("1.5") + if v != 1.5 { + t.Fatalf("typed value %v", v) + } + tt.Key(0, ui.KeyDown) + if v != 1.25 { + t.Fatalf("Down from 1.5 by 0.25 gave %v", v) + } + tt.Command("selectAll") + tt.Type("abc") + if v != 1.25 || !tt.HasText("Enter a number") { + t.Fatalf("invalid text changed value to %v or showed no error: %q", v, tt.Texts()) + } + tt.Command("selectAll") + tt.Type("7") + if v != 1.25 || !tt.HasText("Enter a number from 0.00 to 2.00") { + t.Fatalf("out of range: value %v texts %q", v, tt.Texts()) + } + _ = tt.Click("Elsewhere") + if tt.HasText("Enter a number") { + t.Fatal("error stays after leaving the field, which shows the value again") + } +} + +func TestNumberInputEmpty(t *testing.T) { + v, n := 3.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{AllowEmpty: true, Label: "Limit"}) + _ = tt.Click("Limit") + tt.Command("selectAll") + tt.Key(0, ui.KeyBackspace) + if !math.IsNaN(v) { + t.Fatalf("emptied value %v, want NaN", v) + } + tt.Key(0, ui.KeyUp) + if v != 1 { + t.Fatalf("Up from empty gave %v, want 1", v) + } +} + +func TestNumberInputStepperOnly(t *testing.T) { + v, n := 2.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 1, Max: 3, StepperOnly: true, Label: "Guests"}) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + if v != 3 || n != 1 || !tt.HasText("3") { + t.Fatalf("value %v changes %d texts %q", v, n, tt.Texts()) + } + tt.Key(0, ui.KeyHome) + if v != 1 { + t.Fatalf("Home gave %v", v) + } +} + +func TestNumberInputDisabled(t *testing.T) { + for _, stepper := range []bool{false, true} { + v, n := 2.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 1, Max: 3, StepperOnly: stepper, Disabled: true, Label: "Off"}) + _ = tt.Click("Increase") + _ = tt.Click("Off") + tt.Key(0, ui.KeyUp) + if v != 2 || n != 0 { + t.Fatalf("stepper %v: disabled changed to %v (%d changes)", stepper, v, n) + } + } +} + +func TestNumberInputBadOptionsPanic(t *testing.T) { + for _, opts := range []NumberInputOptions{{Min: 3, Max: 1}, {StepperOnly: true}, {Step: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", opts) + } + }() + v := 0.0 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NumberInput(c, &v, opts) + }, 200, 100) + }() + } +} + +func TestNumberInputSnapshot(t *testing.T) { + snapshot(t, "number_input", 420, 260, func(c *ui.Context) { + a, b, e, s := 12.0, math.NaN(), 7.5, 4.0 + ui.Column(c).Padding(24).Gap(12).Width(260).Children(func() { + NumberInput(c, &a, NumberInputOptions{Min: 0, Max: 99, Label: "A"}) + NumberInput(c, &b, NumberInputOptions{AllowEmpty: true, Placeholder: "No limit", Label: "B"}) + NumberInput(c, &e, NumberInputOptions{Step: 0.5, Label: "E", Error: "Must be whole"}) + NumberInput(c, &s, NumberInputOptions{Min: 1, Max: 8, StepperOnly: true, Label: "S"}) + }) + }) +} diff --git a/overlay.go b/overlay.go new file mode 100644 index 0000000..7b4a4a0 --- /dev/null +++ b/overlay.go @@ -0,0 +1,181 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// OverlayPlace is where an Overlay sits in the window. The zero value is OverlayCenter. +type OverlayPlace int + +const ( + OverlayCenter OverlayPlace = iota + OverlayTop + OverlayBottom + OverlayLeft + OverlayRight + OverlayTopLeft + OverlayTopRight + OverlayBottomLeft + OverlayBottomRight +) + +// OverlayOptions configure an Overlay. +type OverlayOptions struct { + Place OverlayPlace + // Inset keeps the panel from the window edges; 16 DIPs when zero. + Inset float32 + // Modal traps the focus, makes the window inert and closes on a backdrop click. + Modal bool + // Dim darkens the window behind a modal overlay. It requires Modal. + Dim bool + // Label names the layer for assistive technology. + Label string +} + +// Overlay shows content in a layer above the window while *open is true. +// Escape closes it; a modal one also closes on a backdrop click. +func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element { + if opts.Dim && !opts.Modal { + panic("mujica: Overlay Dim requires Modal") + } + if opts.Place < OverlayCenter || opts.Place > OverlayBottomRight { + panic("mujica: Overlay Place out of range") + } + if !*open { + return nil + } + k := Tokens(c) + inset := opts.Inset + if inset == 0 { + inset = 16 + } + fx, fy := overlayFractions(opts.Place) + if opts.Modal { + var backClicked bool + shown := true + panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + backClicked = back.Clicked() + p := overlayEnter(c, panel) + back.Padding(inset).Justify(overlayAlign(fy)).AlignItems(overlayAlign(fx)) + if opts.Dim { + back.Background(overlayScrim(k).Alpha(p)) + } + overlayStyle(c, panel, p) + panel.Label(opts.Label).Padding(Density(c).Unit() * 4) + content() + }) + if !shown { + slog.Debug("mujica: overlay close", "backdrop", backClicked) + *open = false + } + return panel + } + var panel *ui.Element + ui.Overlay(c, func() { + w, h := c.Size() + panel = ui.Column(c).Absolute().Role(ui.RoleDialog).Label(opts.Label).Padding(Density(c).Unit() * 4) + b := panel.Bounds() + panel.Left(inset + (w-2*inset-b.W)*fx).Top(inset + (h-2*inset-b.H)*fy) + p := overlayEnter(c, panel) + if b.W == 0 { + p = 0 // not measured yet + c.AnimationFrame() + } + overlayStyle(c, panel, p) + panel.Children(content) + }) + if c.Shortcut(0, ui.KeyEscape) { + slog.Debug("mujica: overlay close", "escape", true) + *open = false + } + return panel +} + +func overlayFractions(p OverlayPlace) (fx, fy float32) { + switch p { + case OverlayTop: + return 0.5, 0 + case OverlayBottom: + return 0.5, 1 + case OverlayLeft: + return 0, 0.5 + case OverlayRight: + return 1, 0.5 + case OverlayTopLeft: + return 0, 0 + case OverlayTopRight: + return 1, 0 + case OverlayBottomLeft: + return 0, 1 + case OverlayBottomRight: + return 1, 1 + } + return 0.5, 0.5 +} + +func overlayAlign(f float32) ui.Align { + switch f { + case 0: + return ui.Start + case 1: + return ui.End + } + return ui.Center +} + +// overlayEnter returns how far, 0 to 1, the popup born with e has entered. +func overlayEnter(c *ui.Context, e *ui.Element) float32 { + born := ui.Local(e, "mujica.overlay.born", func() time.Time { return c.Now() }) + d := Motion(c, theme.PopupDuration) + if d == 0 { + return 1 + } + t := float32(c.Now().Sub(*born)) / float32(d) + if t >= 1 { + return 1 + } + c.AnimationFrame() + return ui.EaseOut(t) +} + +// overlayScrim is the color dimming the window behind a modal layer. +func overlayScrim(k theme.Tokens) ui.Color { + if k.Dark { + return ui.RGBA(0, 0, 0, 0.6) + } + return k.Text.Alpha(0.42) +} + +// overlayStyle gives a popup panel the surface, edge and shadow of the theme. +func overlayStyle(c *ui.Context, panel *ui.Element, p float32) { + k := Tokens(c) + panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius). + TextColor(k.Text).Opacity(p).Shadow(0, 6, 18, 0, ui.RGBA(0, 0, 0, 0.16*p)) +} + +// overlayPlace puts a w×h box below anchor a, above when there is no room, inside the window. +func overlayPlace(c *ui.Context, a ui.Rect, w, h, gap float32) (x, y float32) { + ww, wh := c.Size() + const edge = 4 + x, y = a.X, a.Y+a.H+gap + if y+h > wh-edge && a.Y-gap-h >= edge { + y = a.Y - gap - h + } + x = max(edge, min(x, ww-edge-w)) + y = max(edge, min(y, wh-edge-h)) + return x, y +} + +// overlayAnchored is overlayEnter, held at 0 until the anchor b has been laid out. +func overlayAnchored(c *ui.Context, panel *ui.Element, b ui.Rect) float32 { + if b.W == 0 && b.H == 0 { + c.AnimationFrame() + return 0 + } + return overlayEnter(c, panel) +} diff --git a/overlay_test.go b/overlay_test.go new file mode 100644 index 0000000..c66bc7a --- /dev/null +++ b/overlay_test.go @@ -0,0 +1,201 @@ +package mujica + +import ( + "runtime" + "testing" + + "github.com/egoist/mygo/ui" +) + +type overlayApp struct { + open bool + opts OverlayOptions + behind int + panel ui.Rect +} + +func (a *overlayApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Gap(8).Children(func() { + if Button(c, "Show", ButtonOptions{}).Clicked() { + a.open = true + } + if Button(c, "Behind", ButtonOptions{}).Clicked() { + a.behind++ + } + }) + if p := Overlay(c, &a.open, a.opts, func() { + ui.Text(c, "Layer") + if Button(c, "Done", ButtonOptions{}).Clicked() { + a.open = false + } + }); p != nil { + a.panel = p.Bounds() + } +} + +func newOverlayApp(opts OverlayOptions) (*overlayApp, *ui.Tester) { + a := &overlayApp{opts: opts} + tt := ui.NewTester(a.view, 600, 400) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + _ = tt.Click("Show") + tt.Frame() + return a, tt +} + +func TestOverlayPlaces(t *testing.T) { + for _, modal := range []bool{false, true} { + for _, tc := range []struct { + place OverlayPlace + fx, fy float32 + }{{OverlayCenter, 0.5, 0.5}, {OverlayTopLeft, 0, 0}, {OverlayBottomRight, 1, 1}, {OverlayTop, 0.5, 0}} { + a, _ := newOverlayApp(OverlayOptions{Place: tc.place, Modal: modal}) + p := a.panel + wantX := 16 + (600-32-p.W)*tc.fx + wantY := 16 + (400-32-p.H)*tc.fy + if abs(p.X-wantX) > 1 || abs(p.Y-wantY) > 1 { + t.Errorf("modal %v place %d: panel %+v, want at (%v, %v)", modal, tc.place, p, wantX, wantY) + } + } + } +} + +func abs(v float32) float32 { + if v < 0 { + return -v + } + return v +} + +func TestOverlayModal(t *testing.T) { + a, tt := newOverlayApp(OverlayOptions{Modal: true, Dim: true}) + if !tt.Focused("Done") { + t.Fatal("modal overlay did not take the focus") + } + _ = tt.Click("Behind") + if a.behind != 0 || a.open { + t.Fatalf("backdrop: behind %d open %v", a.behind, a.open) + } + if !tt.Focused("Show") { + t.Fatal("focus not restored") + } +} + +func TestOverlayNonModal(t *testing.T) { + a, tt := newOverlayApp(OverlayOptions{Place: OverlayBottomRight}) + _ = tt.Click("Behind") + if a.behind != 1 || !a.open { + t.Fatalf("behind %d open %v", a.behind, a.open) + } + tt.Key(0, ui.KeyEscape) + if a.open { + t.Fatal("Escape did not close") + } +} + +func TestOverlayDimNeedsModal(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := true + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Overlay(c, &open, OverlayOptions{Dim: true}, func() {}) + }, 200, 200) +} + +// TestOverlayLifecycle opens and closes every overlay 100 times and checks +// that nothing grows: goroutines, heap objects or the elements left behind. +func TestOverlayLifecycle(t *testing.T) { + var open bool + views := map[string]func(c *ui.Context, b *ui.Element){ + "Dialog": func(c *ui.Context, b *ui.Element) { Dialog(c, &open, DialogOptions{Title: "T"}, func() {}) }, + "Alert": func(c *ui.Context, b *ui.Element) { + AlertDialog(c, &open, AlertDialogOptions{Title: "T", ConfirmText: "x"}) + }, + "Drawer": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T"}, nil) }, + "Drawer2": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T", NonModal: true}, nil) }, + "Popover": func(c *ui.Context, b *ui.Element) { Popover(c, b, &open, PopoverOptions{}, func() {}) }, + "Confirm": func(c *ui.Context, b *ui.Element) { Popconfirm(c, b, &open, PopconfirmOptions{Title: "T"}) }, + "Overlay": func(c *ui.Context, b *ui.Element) { Overlay(c, &open, OverlayOptions{Modal: true}, func() {}) }, + "Menu": func(c *ui.Context, b *ui.Element) { + popupMenuOpen(c, b, &open, (&menuApp{}).build) + }, + "Context": nil, + } + for name, view := range views { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + row := ui.Row(c) + var b *ui.Element + row.Children(func() { b = Button(c, "Anchor", ButtonOptions{}) }) + if view == nil { + ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build) + } else { + view(c, b) + } + }, 600, 400) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + cycle := func() { + open = true + if view == nil { + _ = tt.Click("Anchor") + tt.Key(ui.Shift, ui.KeyF10) + if !tt.HasText("Rename") { + t.Fatalf("%s: did not open", name) + } + } + tt.Frame() + if name != "Drawer2" { + tt.Key(0, ui.KeyEscape) + if view == nil { + open = false + } + } else { + open = false + tt.Frame() + } + if open || tt.HasText("Rename") { + t.Fatalf("%s: Escape did not close", name) + } + } + for range 20 { + cycle() + } + texts := len(tt.Texts()) + before := heapObjects() + goroutines := runtime.NumGoroutine() + for range 100 { + cycle() + } + if got := len(tt.Texts()); got != texts { + t.Errorf("%s: texts %d -> %d", name, texts, got) + } + if g := runtime.NumGoroutine(); g > goroutines { + t.Errorf("%s: goroutines %d -> %d", name, goroutines, g) + } + if after := heapObjects(); after > before+2000 { + t.Errorf("%s: heap objects %d -> %d", name, before, after) + } + } +} + +func heapObjects() uint64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.HeapObjects +} + +func TestOverlaySnapshot(t *testing.T) { + snapshot(t, "overlay", 480, 260, func(c *ui.Context) { + open := true + ui.Column(c).Padding(24).Children(func() { ui.Text(c, "Score of the second act") }) + Overlay(c, &open, OverlayOptions{Place: OverlayBottomRight, Label: "Now playing"}, func() { + ui.Text(c, "Now playing").FontWeight(600) + ui.Text(c, "Symbol I: △").TextColor(Tokens(c).TextMuted) + }) + }) +} diff --git a/pagination.go b/pagination.go new file mode 100644 index 0000000..05a6fa3 --- /dev/null +++ b/pagination.go @@ -0,0 +1,247 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PaginationOptions configure a Pagination. +type PaginationOptions struct { + // Total is the number of records; it must not be negative. + Total int + // PageSizes lists the page sizes offered; nil hides the choice. + PageSizes []int + // Jump shows a field going to a typed page. + Jump bool + // Siblings is how many pages show each side of the current; zero means 1. + Siblings int + Disabled bool +} + +// PaginationResult is the built Pagination. +type PaginationResult struct { + *ui.Element + changed bool +} + +// Changed reports a new page or page size in this frame, from the user or a correction. +func (r PaginationResult) Changed() bool { return r.changed } + +// Pagination moves *page (from 1) through Total records, *pageSize per page. +// A page past the end after Total shrinks is corrected to the last page. +func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult { + if opts.Total < 0 { + panic("mujica: Pagination Total is negative") + } + if *pageSize <= 0 { + panic("mujica: Pagination pageSize must be positive") + } + if opts.PageSizes != nil && !slices.Contains(opts.PageSizes, *pageSize) { + panic("mujica: Pagination pageSize is not one of PageSizes") + } + sib := opts.Siblings + if sib == 0 { + sib = 1 + } + if sib < 0 { + panic("mujica: Pagination Siblings is negative") + } + k := Tokens(c) + d := Density(c) + u := d.Unit() + row := ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Wrap().Role(ui.RoleGroup).Label(msg(c, "pagination.label")).Disabled(opts.Disabled) + res := PaginationResult{Element: row} + pages := paginationPages(opts.Total, *pageSize) + if p := max(1, min(*page, pages)); p != *page { + slog.Debug("mujica: pagination correct", "from", *page, "to", p, "pages", pages) + *page, res.changed = p, true + } + set := func(p int) { + if p != *page { + slog.Debug("mujica: pagination page", "from", *page, "to", p) + *page, res.changed = p, true + } + } + row.Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "pagination.total"), opts.Total)).TextColor(k.TextMuted).SingleLine() + ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Children(func() { + if paginationButton(c, "", icons.Must("chevron-left"), msg(c, "pagination.prev"), false, *page <= 1).Clicked() { + set(*page - 1) + } + for _, p := range paginationItems(*page, pages, sib) { + if p == 0 { + ui.Text(c, "…").TextColor(k.TextMuted).Padding(0, u).Role(ui.RoleNone) + continue + } + n := strconv.Itoa(p) + name := fmt.Sprintf(msg(c, "pagination.page"), p) + if p == *page { + name += ", " + msg(c, "pagination.current") + } + if paginationButton(c, n, nil, name, p == *page, false).Clicked() { + set(p) + } + } + if paginationButton(c, "", icons.Must("chevron-right"), msg(c, "pagination.next"), false, *page >= pages).Clicked() { + set(*page + 1) + } + }) + if opts.PageSizes != nil { + paginationSizes(c, page, pageSize, opts.PageSizes, &res) + } + if opts.Jump { + paginationJump(c, pages, set) + } + }) + return res +} + +func paginationPages(total, size int) int { return max(1, (total+size-1)/size) } + +// paginationItems lists the page numbers to show, 0 standing for an ellipsis. +func paginationItems(cur, pages, sib int) []int { + var out []int + if pages <= 2*sib+5 { + for p := 1; p <= pages; p++ { + out = append(out, p) + } + return out + } + start := max(min(cur-sib, pages-2*sib-2), 3) + end := min(max(cur+sib, 2*sib+3), pages-2) + out = append(out, 1) + if start > 3 { + out = append(out, 0) + } else { + out = append(out, 2) + } + for p := start; p <= end; p++ { + out = append(out, p) + } + if end < pages-2 { + out = append(out, 0) + } else { + out = append(out, pages-1) + } + return append(out, pages) +} + +func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on, disabled bool) *ui.Element { + k := Tokens(c) + h := Density(c).ControlHeight() + b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, Density(c).Unit()*2).Radius(theme.ControlRadius). + Label(name).Disabled(disabled) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case on: + b.Background(k.Accent).TextColor(k.OnAccent).FontWeight(600) + case b.Pressed(): + b.Background(k.Selection).TextColor(k.Text) + case b.Hovered(): + b.Background(k.SurfaceHover).TextColor(k.Text).Border(theme.BorderWidth, k.ControlBorder) + default: + b.TextColor(k.Text).Border(theme.BorderWidth, k.Border) + } + if icon != nil { + b.Tooltip(name) + } + b.Children(func() { + if icon != nil { + ui.Icon(c, icon).Size(16, 16) + } else { + ui.Text(c, text).SingleLine().FontFeatures("tnum") + } + }) + return b +} + +func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *PaginationResult) { + k := Tokens(c) + d := Density(c) + u := d.Unit() + old := *pageSize + sel := ui.SelectBase(c, pageSize) + sel.Trigger.MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u*2).Radius(theme.ControlRadius). + Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "pagination.size")) + internal.Ring(sel.Trigger, k.Focus, theme.ControlRadius) + if sel.Trigger.Hovered() { + sel.Trigger.Background(k.SurfaceHover) + } + sel.Trigger.Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), *pageSize)).SingleLine() + ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) + }) + sel.Popup(func(panel *ui.Element) { + panel.Margin(u, 0, 0, 0).Padding(u).Radius(theme.PopupRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)) + for _, s := range sizes { + it := sel.Item(s).Padding(u*1.5, u*3).Radius(theme.ControlRadius).Gap(u * 2) + if it.Highlighted() { + it.Background(k.Selection) + } + it.Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), s)).SingleLine().Grow(1) + if s == *pageSize { + ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText) + } + }) + } + }) + if *pageSize != old { + // Keep the first record of the old page in view. + first := (*page-1)*old + 1 + *page = (first-1) / *pageSize + 1 + res.changed = true + slog.Debug("mujica: pagination size", "from", old, "to", *pageSize, "page", *page) + } +} + +func paginationJump(c *ui.Context, pages int, set func(int)) { + k := Tokens(c) + d := Density(c) + u := d.Unit() + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + ui.Text(c, msg(c, "pagination.jump")).TextColor(k.TextMuted).SingleLine() + box := ui.Row(c).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Width(64).Padding(0, u*2). + Radius(theme.ControlRadius).Background(k.Surface) + text := ui.Local(box, "text", func() string { return "" }) + bad := ui.Local(box, "bad", func() bool { return false }) + var in *ui.Element + box.Children(func() { + in = ui.TextInputBase(c, text).FillWidth().Label(msg(c, "pagination.field")) + }) + if in.Changed() { + *bad = false + } + if in.Submitted() { + p, err := strconv.Atoi(strings.TrimSpace(*text)) + if err != nil || p < 1 || p > pages { + *bad = true + slog.Debug("mujica: pagination jump rejected", "text", *text, "pages", pages) + } else { + *text, *bad = "", false + set(p) + } + } + border := k.ControlBorder + if *bad { + border = k.Danger + in.Error(fmt.Sprintf(msg(c, "pagination.range"), pages)) + } else if in.Focused() { + border = k.Focus + } + box.Border(theme.BorderWidth, border) + if *bad { + ui.Text(c, fmt.Sprintf(msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine() + } + }) +} diff --git a/pagination_test.go b/pagination_test.go new file mode 100644 index 0000000..0e7d427 --- /dev/null +++ b/pagination_test.go @@ -0,0 +1,166 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPaginationItems(t *testing.T) { + for _, tc := range []struct { + cur, pages int + want []int + }{ + {1, 1, []int{1}}, + {3, 7, []int{1, 2, 3, 4, 5, 6, 7}}, + {1, 20, []int{1, 2, 3, 4, 5, 0, 20}}, + {10, 20, []int{1, 0, 9, 10, 11, 0, 20}}, + {20, 20, []int{1, 0, 16, 17, 18, 19, 20}}, + } { + if got := paginationItems(tc.cur, tc.pages, 1); !slices.Equal(got, tc.want) { + t.Errorf("items(%d, %d) = %v, want %v", tc.cur, tc.pages, got, tc.want) + } + } +} + +func TestPaginationNavigate(t *testing.T) { + page, size, changes := 1, 10, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Pagination(c, &page, &size, PaginationOptions{Total: 95}).Changed() { + changes++ + } + }, 700, 120) + // Previous is disabled on page 1, so Tab lands on "1". + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if page != 2 || changes != 1 { + t.Fatalf("keyboard: page %d changes %d", page, changes) + } + if err := tt.Click("Next page"); err != nil { + t.Fatal(err) + } + if page != 3 || changes != 2 { + t.Fatalf("next: page %d changes %d", page, changes) + } + if err := tt.Click("10"); err != nil { + t.Fatal(err) + } + if page != 10 { + t.Fatalf("click 10: page %d", page) + } + _ = tt.Click("Next page") + if page != 10 { + t.Fatalf("next past the end: page %d", page) + } +} + +func TestPaginationCorrectsOnTotalChange(t *testing.T) { + page, size, total, changes := 9, 10, 95, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Pagination(c, &page, &size, PaginationOptions{Total: total}).Changed() { + changes++ + } + }, 700, 120) + total = 31 + tt.Frame() + if page != 4 || changes != 1 { + t.Fatalf("page %d changes %d, want 4 and 1", page, changes) + } + total = 0 + tt.Frame() + if page != 1 { + t.Fatalf("empty total: page %d", page) + } +} + +func TestPaginationPageSizeKeepsRecord(t *testing.T) { + page, size := 3, 10 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Pagination(c, &page, &size, PaginationOptions{Total: 200, PageSizes: []int{10, 20, 50}}) + }, 800, 300) + if err := tt.Click("10 / page"); err != nil { + t.Fatal(err) + } + if err := tt.Click("50 / page"); err != nil { + t.Fatal(err) + } + // Record 21 was first on page 3; with 50 per page it is on page 1. + if size != 50 || page != 1 { + t.Fatalf("size %d page %d", size, page) + } +} + +func TestPaginationJump(t *testing.T) { + page, size := 1, 10 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + Pagination(c, &page, &size, PaginationOptions{Total: 100, Jump: true}) + }, 900, 120) + if err := tt.Click("页码"); err != nil { + t.Fatal(err) + } + tt.Type("42") + tt.Key(0, ui.KeyEnter) + if page != 1 || !tt.HasText("1–10") { + t.Fatalf("out of range accepted: page %d texts %q", page, tt.Texts()) + } + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) + tt.Type("7") + tt.Key(0, ui.KeyEnter) + if page != 7 || tt.HasText("1–10") { + t.Fatalf("jump: page %d texts %q", page, tt.Texts()) + } +} + +func TestPaginationDisabled(t *testing.T) { + page, size := 2, 10 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Pagination(c, &page, &size, PaginationOptions{Total: 50, Disabled: true}) + }, 700, 120) + _ = tt.Click("Next page") + _ = tt.Click("4") + if page != 2 { + t.Fatalf("disabled pagination moved to %d", page) + } +} + +func TestPaginationInvalid(t *testing.T) { + for name, tc := range map[string]struct { + size int + opts PaginationOptions + }{ + "total": {10, PaginationOptions{Total: -1}}, + "size": {0, PaginationOptions{}}, + "sizes": {15, PaginationOptions{PageSizes: []int{10, 20}}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + page := 1 + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Pagination(c, &page, &tc.size, tc.opts) + }, 300, 100, 1) + }) + } +} + +func TestPaginationSnapshot(t *testing.T) { + snapshot(t, "pagination", 760, 140, func(c *ui.Context) { + a, b, s := 10, 1, 20 + ui.Column(c).Padding(16).Gap(16).Children(func() { + Pagination(c, &a, &s, PaginationOptions{Total: 400, PageSizes: []int{10, 20, 50}, Jump: true}) + Pagination(c, &b, &s, PaginationOptions{Total: 45, Disabled: true}) + }) + }) +} diff --git a/password_input.go b/password_input.go new file mode 100644 index 0000000..25d07ca --- /dev/null +++ b/password_input.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PasswordInputOptions configure a PasswordInput. +type PasswordInputOptions struct { + Placeholder string + // Label names the field for assistive technology. + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// PasswordInput is a masked field with a show and hide toggle. +func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) InputResult { + if value == nil { + panic("mujica: PasswordInput needs a value") + } + var r InputResult + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + shown := ui.Local(frame, "shown", func() bool { return false }) + var in *ui.Element + // MyGo's mask cannot be lifted, so each mode keeps its own editor. + mode := "masked" + if *shown { + mode = "shown" + } + ui.Row(c).Key(mode).Grow(1).Children(func() { + in = ui.TextInputBase(c, value).Grow(1).MinWidth(Density(c).Unit() * 10). + Placeholder(opts.Placeholder).Label(opts.Label) + if !*shown { + in.Password() + } + }) + r.changed = in.Changed() + r.submitted = in.Submitted() + if passwordInputToggle(c, *shown) { + *shown = !*shown + slog.Debug("mujica: password_input reveal", "shown", *shown) + } + return in + }, nil) + return r +} + +// passwordInputToggle builds the show or hide button and reports a click. +func passwordInputToggle(c *ui.Context, shown bool) bool { + k := Tokens(c) + name, icon := msg(c, "password_input.show"), "eye" + if shown { + name, icon = msg(c, "password_input.hide"), "eye-off" + } + b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(name).Tooltip(name) + internal.Ring(b, k.Focus, theme.ControlRadius) + fg := k.TextMuted + if b.Pressed() { + b.Background(k.Selection) + fg = k.Text + } else if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(fg) }) + return b.Clicked() +} diff --git a/password_input_test.go b/password_input_test.go new file mode 100644 index 0000000..72f82c0 --- /dev/null +++ b/password_input_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPasswordInputMaskPasteAndReveal(t *testing.T) { + pw, changes, submits := "", 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := PasswordInput(c, &pw, PasswordInputOptions{Label: "Password"}) + if r.Changed() { + changes++ + } + if r.Submitted() { + submits++ + } + }, 400, 200) + _ = tt.Click("Password") + tt.SetClipboard("s3cr") + tt.Command("paste") + tt.Type("et") + if pw != "s3cret" || changes != 2 { + t.Fatalf("value %q changes %d", pw, changes) + } + tt.SetClipboard("") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "" { + t.Fatalf("masked field copied %q", tt.Clipboard()) + } + for _, s := range tt.Texts() { + if s == "s3cret" { + t.Fatal("masked password shows in the texts") + } + } + if err := tt.Click("Show password"); err != nil { + t.Fatal(err) + } + if _, ok := tt.Find("Hide password"); !ok || pw != "s3cret" { + t.Fatalf("after reveal: value %q, texts %q", pw, tt.Texts()) + } + _ = tt.Click("Password") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "s3cret" { + t.Fatalf("revealed field copied %q", tt.Clipboard()) + } + tt.Key(0, ui.KeyEnter) + if submits != 1 { + t.Fatalf("submits %d", submits) + } + _ = tt.Click("Hide password") + if _, ok := tt.Find("Show password"); !ok { + t.Fatal("hide did not mask again") + } +} + +func TestPasswordInputToggleByKeyboard(t *testing.T) { + pw := "x" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PasswordInput(c, &pw, PasswordInputOptions{Label: "PIN"}) + }, 400, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if _, ok := tt.Find("Hide password"); !ok { + t.Fatal("Space on the toggle did not reveal") + } +} + +func TestPasswordInputDisabled(t *testing.T) { + pw := "locked" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PasswordInput(c, &pw, PasswordInputOptions{Label: "Off", Disabled: true}) + }, 400, 200) + _ = tt.Click("Show password") + _ = tt.Click("Off") + tt.Type("zz") + if pw != "locked" { + t.Fatalf("disabled value %q", pw) + } + if _, ok := tt.Find("Show password"); !ok { + t.Fatal("disabled toggle revealed") + } +} + +func TestPasswordInputSnapshot(t *testing.T) { + snapshot(t, "password_input", 420, 220, func(c *ui.Context) { + a, b, e := "", "versailles", "short" + ui.Column(c).Padding(24).Gap(12).Width(320).Children(func() { + PasswordInput(c, &a, PasswordInputOptions{Label: "A", Placeholder: "Password"}) + PasswordInput(c, &b, PasswordInputOptions{Label: "B"}) + PasswordInput(c, &e, PasswordInputOptions{Label: "E", Error: "At least 8 characters"}) + }) + }) +} diff --git a/pin_input.go b/pin_input.go new file mode 100644 index 0000000..99f05e0 --- /dev/null +++ b/pin_input.go @@ -0,0 +1,165 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// PinInputOptions configure a PinInput. Length is 6 when zero. +type PinInputOptions struct { + Length int + // Label names the input; empty uses the built-in "Verification code". + Label string + Disabled bool + // Invalid draws the cells in the danger color. + Invalid bool +} + +// PinInputParts are the parts of a PinInput. +type PinInputParts struct { + Element *ui.Element + changed, completed bool +} + +// Changed reports whether the user changed the code this frame. +func (p PinInputParts) Changed() bool { return p.changed } + +// Completed reports whether the user filled the last cell this frame. +func (p PinInputParts) Completed() bool { return p.completed } + +// pinInputEvent is input taken between frames, applied in the next one. +type pinInputEvent struct { + text string + backspace bool + paste bool +} + +type pinInputState struct { + queue []pinInputEvent + compose string +} + +// PinInput edits a fixed-length code of digits, one per cell, keeping leading zeros. +// Typing fills the next cell, Backspace clears the last, and paste fills them all. +func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts { + n := opts.Length + if n == 0 { + n = 6 + } + if n < 0 || len(*value) > n || strings.Trim(*value, "0123456789") != "" { + panic(fmt.Sprintf("mujica: PinInput value %q does not fit %d digits", *value, n)) + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "pin_input.label") + } + parts := PinInputParts{} + e := ui.Row(c).Gap(d.Unit() * 2).Shrink(0).Role(ui.RoleTextField).Label(label).Disabled(opts.Disabled) + parts.Element = e + st := ui.Local(e, "pin", func() pinInputState { return pinInputState{} }) + put := func(s string) { + var b strings.Builder + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + next := b.String() + if len(next) > n { + next = next[:n] + } + if next == *value { + return + } + was := len(*value) + *value = next + parts.changed = true + if len(next) == n && was < n { + parts.completed = true + slog.Debug("mujica: pin input completed", "length", n) + } + } + for _, ev := range st.queue { + switch { + case ev.paste: + put(c.ReadClipboard()) + case ev.backspace && *value != "": + put((*value)[:len(*value)-1]) + case ev.text != "": + put(*value + ev.text) + } + } + st.queue = st.queue[:0] + if opts.Disabled { + st.compose = "" + } else { + e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool { + switch { + case ev.Kind == ui.InputText: + st.queue = append(st.queue, pinInputEvent{text: ev.Text}) + st.compose = "" + case ev.Kind == ui.InputCompose: + st.compose = ev.Text + case ev.Kind == ui.InputCommand && ev.Text == "paste", + ev.Kind == ui.InputKeyDown && ev.Mods == ui.Cmd && ev.Key == ui.KeyV: + st.queue = append(st.queue, pinInputEvent{paste: true}) + case ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace: + st.queue = append(st.queue, pinInputEvent{backspace: true}) + default: + return false + } + return true + }) + } + size := d.ControlHeight() + d.Unit()*2 + active := min(len(*value), n-1) + focused := e.Focused() + compose := st.compose + e.Description(fmt.Sprintf(msg(c, "pin_input.filled"), len(*value), n)) + e.TextCaret(ui.Rect{X: float32(active) * (size + d.Unit()*2), Y: 0, W: size, H: size}) + e.Children(func() { + for i := range n { + cell := ui.Box(c).Size(size, size).Center().Radius(theme.ControlRadius).Background(k.Surface).Role(ui.RoleNone) + border := k.ControlBorder + switch { + case opts.Invalid: + border = k.Danger + case focused && i == active: + border = k.Focus + } + cell.Border(theme.BorderWidth, border) + ch := "" + if i < len(*value) { + ch = (*value)[i : i+1] + } + showCompose := focused && i == active && compose != "" + cell.Children(func() { + switch { + case showCompose: + ui.Text(c, compose).FontSize(theme.H3Size).Underline().SingleLine() + case ch != "": + ui.Text(c, ch).FontSize(theme.H3Size).FontFeatures("tnum").SingleLine() + } + }) + caret := focused && i == active && len(*value) < n && !showCompose + cell.DrawOver(func(p *ui.Painter, r ui.Rect) { + if focused && i == active { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + if caret { + h := r.H * 0.4 + p.Fill(ui.Rect{X: r.X + r.W/2 - 0.5, Y: r.Y + (r.H-h)/2, W: 1, H: h}, k.Text, 0) + } + }) + } + }) + return parts +} diff --git a/pin_input_test.go b/pin_input_test.go new file mode 100644 index 0000000..07bcf9f --- /dev/null +++ b/pin_input_test.go @@ -0,0 +1,104 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type pinInputProbe struct { + value string + changes, completes int +} + +func (p *pinInputProbe) tester(opts PinInputOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := PinInput(c, &p.value, opts) + if r.Changed() { + p.changes++ + } + if r.Completed() { + p.completes++ + } + }, 480, 120) +} + +func TestPinInputTypingAndBackspace(t *testing.T) { + p := &pinInputProbe{} + tt := p.tester(PinInputOptions{Length: 4, Label: "Code"}) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Code") { + t.Fatal("not focusable") + } + tt.TypeKey(0, ui.Key0, "0") + tt.Type("a") + tt.Type("07") + if p.value != "007" || p.completes != 0 { + t.Fatalf("value %q completes %d", p.value, p.completes) + } + tt.Type("9") + tt.Type("5") + if p.value != "0079" || p.completes != 1 { + t.Fatalf("full: value %q completes %d", p.value, p.completes) + } + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) + if p.value != "00" { + t.Fatalf("backspace: %q", p.value) + } + tt.Type("12") + if p.value != "0012" || p.completes != 2 { + t.Fatalf("refill: %q completes %d", p.value, p.completes) + } +} + +func TestPinInputPasteAndCompose(t *testing.T) { + p := &pinInputProbe{value: "1"} + tt := p.tester(PinInputOptions{Label: "Code"}) + tt.Key(0, ui.KeyTab) + tt.SetClipboard(" 012-345 9") + tt.Command("paste") + if p.value != "012345" || p.completes != 1 || p.changes != 1 { + t.Fatalf("paste: %q completes %d changes %d", p.value, p.completes, p.changes) + } + tt.Key(0, ui.KeyBackspace) + tt.Compose("4", 1) + if p.value != "01234" || !tt.HasText("4") { + t.Fatalf("compose committed early: %q", p.value) + } + tt.Type("8") + if p.value != "012348" { + t.Fatalf("commit: %q", p.value) + } +} + +func TestPinInputDisabled(t *testing.T) { + p := &pinInputProbe{value: "12"} + tt := p.tester(PinInputOptions{Label: "Code", Disabled: true}) + tt.Key(0, ui.KeyTab) + tt.Type("3") + if tt.Focused("Code") || p.value != "12" { + t.Fatalf("disabled took input: %q", p.value) + } +} + +func TestPinInputRejectsBadValue(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("letters accepted") + } + }() + p := &pinInputProbe{value: "12a"} + p.tester(PinInputOptions{}) +} + +func TestPinInputSnapshot(t *testing.T) { + a, b := "0420", "13" + snapshot(t, "pin_input", 420, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).Children(func() { + PinInput(c, &a, PinInputOptions{}).Element.Focus() + PinInput(c, &b, PinInputOptions{Length: 4, Invalid: true}) + }) + }) +} diff --git a/popconfirm.go b/popconfirm.go new file mode 100644 index 0000000..5f2c9b8 --- /dev/null +++ b/popconfirm.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// PopconfirmOptions configure a Popconfirm. Title is required. +type PopconfirmOptions struct { + Title string + Description string + // ConfirmLabel is "Confirm" and CancelLabel "Cancel" when empty. + ConfirmLabel string + CancelLabel string + // Danger paints the confirm button as destructive. + Danger bool +} + +// PopconfirmResult reports the choice made in this frame. +type PopconfirmResult struct { + Confirmed bool + Cancelled bool +} + +// Popconfirm asks for a light confirmation beside anchor while *open is +// true. Confirm takes the focus; Cancel, Escape and an outside click cancel. +// Use AlertDialog for high-risk actions. +func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult { + if anchor == nil { + panic("mujica: Popconfirm needs an anchor") + } + if opts.Title == "" { + panic("mujica: Popconfirm needs a Title") + } + var res PopconfirmResult + if !*open { + return res + } + k := Tokens(c) + u := Density(c).Unit() + confirm, cancel := opts.ConfirmLabel, opts.CancelLabel + if confirm == "" { + confirm = msg(c, "popconfirm.confirm") + } + if cancel == "" { + cancel = msg(c, "popconfirm.cancel") + } + variant := Primary + if opts.Danger { + variant = Danger + } + b := anchor.Bounds() + shown := true + ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { + overlayStyle(c, panel, overlayAnchored(c, panel, b)) + panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(320).Role(ui.RoleAlertDialog). + Label(opts.Title).Description(opts.Description) + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + color := k.Warning + if opts.Danger { + color = k.Danger + } + ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0) + ui.Column(c).Gap(u).Shrink(1).Children(func() { + ui.Text(c, opts.Title).FontWeight(600) + if opts.Description != "" { + ui.Text(c, opts.Description).TextColor(k.TextMuted) + } + }) + }) + ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + if Button(c, cancel, ButtonOptions{Variant: Secondary}).Clicked() { + res.Cancelled = true + } + if Button(c, confirm, ButtonOptions{Variant: variant}).AutoFocus().Clicked() { + res.Confirmed = true + } + }) + }) + if !shown { + res.Cancelled = true + } + if res.Confirmed || res.Cancelled { + slog.Debug("mujica: popconfirm close", "confirmed", res.Confirmed) + *open = false + } + return res +} diff --git a/popconfirm_test.go b/popconfirm_test.go new file mode 100644 index 0000000..32aef2a --- /dev/null +++ b/popconfirm_test.go @@ -0,0 +1,70 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type popconfirmApp struct { + open bool + confirmed, cancelled int +} + +func (a *popconfirmApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(30).AlignItems(ui.Start).Children(func() { + b := Button(c, "Archive", ButtonOptions{Variant: Secondary}) + if b.Clicked() { + a.open = true + } + r := Popconfirm(c, b, &a.open, PopconfirmOptions{Title: "Archive this letter?", Description: "It leaves the inbox."}) + if r.Confirmed { + a.confirmed++ + } + if r.Cancelled { + a.cancelled++ + } + }) +} + +func TestPopconfirmConfirmByKeyboard(t *testing.T) { + a := &popconfirmApp{} + tt := ui.NewTester(a.view, 400, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !a.open || !tt.Focused("Confirm") { + t.Fatalf("open %v, Confirm focused %v", a.open, tt.Focused("Confirm")) + } + tt.Key(0, ui.KeyEnter) + if a.confirmed != 1 || a.cancelled != 0 || a.open { + t.Fatalf("confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open) + } + if !tt.Focused("Archive") { + t.Fatal("focus not restored to the anchor") + } +} + +func TestPopconfirmCancelPaths(t *testing.T) { + a := &popconfirmApp{} + tt := ui.NewTester(a.view, 400, 300) + _ = tt.Click("Archive") + _ = tt.Click("Cancel") + _ = tt.Click("Archive") + tt.Key(0, ui.KeyEscape) + _ = tt.Click("Archive") + tt.ClickAt(390, 290) + if a.cancelled != 3 || a.confirmed != 0 || a.open { + t.Fatalf("cancelled %d confirmed %d open %v", a.cancelled, a.confirmed, a.open) + } +} + +func TestPopconfirmSnapshot(t *testing.T) { + snapshot(t, "popconfirm", 420, 220, func(c *ui.Context) { + open := true + ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { + b := Button(c, "Dismiss the guard", ButtonOptions{Variant: Secondary}) + Popconfirm(c, b, &open, PopconfirmOptions{Title: "Dismiss the guard?", Description: "The east gate stays unwatched tonight.", Danger: true}) + }) + }) +} diff --git a/popover.go b/popover.go new file mode 100644 index 0000000..aef1a18 --- /dev/null +++ b/popover.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// PopoverOptions configure a Popover. +type PopoverOptions struct { + // Title shows in serif at the top and names the panel. + Title string + // Width fixes the panel width; zero fits the content, at least the anchor's width. + Width float32 + // Label names an untitled panel for assistive technology. + Label string +} + +// Popover shows content in a panel below anchor while *open is true. The +// panel flips above where there is no room, stays in the window and scrolls +// when taller than the room; an outside click or Escape closes it. +func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element { + if anchor == nil { + panic("mujica: Popover needs an anchor") + } + if !*open { + return nil + } + u := Density(c).Unit() + b := anchor.Bounds() + label := opts.Label + if opts.Title != "" { + label = opts.Title + } + panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + overlayStyle(c, panel, overlayAnchored(c, panel, b)) + panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(b.W).MaxHeight(popoverRoom(c, b)).Label(label) + if opts.Width > 0 { + panel.Width(opts.Width) + } else { + panel.MaxWidth(360) + } + if opts.Title != "" { + ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).FontWeight(600).Shrink(0) + } + ui.Scroll(c).Shrink(1).Children(content) + }) + if !*open { + slog.Debug("mujica: popover close") + } + return panel +} + +// popoverRoom is the tallest a panel below or above anchor b may be. +func popoverRoom(c *ui.Context, b ui.Rect) float32 { + _, h := c.Size() + return max(b.Y, h-b.Y-b.H) - 16 +} diff --git a/popover_test.go b/popover_test.go new file mode 100644 index 0000000..a5226b5 --- /dev/null +++ b/popover_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type popoverApp struct { + open bool + top float32 + panel, anchor ui.Rect + behind, applied int +} + +func (a *popoverApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(a.top, 0, 0, 40).Gap(8).Children(func() { + b := Button(c, "Filter", ButtonOptions{}) + if b.Clicked() { + a.open = !a.open + } + a.anchor = b.Bounds() + if Button(c, "Behind", ButtonOptions{}).Clicked() { + a.behind++ + } + if p := Popover(c, b, &a.open, PopoverOptions{Title: "Filter rows"}, func() { + if Button(c, "Apply", ButtonOptions{}).Clicked() { + a.applied++ + a.open = false + } + }); p != nil { + a.panel = p.Bounds() + } + }) +} + +func TestPopoverOpensBelowAndFlips(t *testing.T) { + a := &popoverApp{top: 20} + tt := ui.NewTester(a.view, 400, 300) + _ = tt.Click("Filter") + tt.Frame() + if !a.open || a.panel.Y < a.anchor.Y+a.anchor.H { + t.Fatalf("below: open %v panel %+v anchor %+v", a.open, a.panel, a.anchor) + } + a = &popoverApp{top: 240} + tt = ui.NewTester(a.view, 400, 300) + _ = tt.Click("Filter") + tt.Frame() + if a.panel.Y+a.panel.H > a.anchor.Y { + t.Fatalf("no flip: panel %+v anchor %+v", a.panel, a.anchor) + } +} + +func TestPopoverOutsideClickAndEscape(t *testing.T) { + a := &popoverApp{top: 20} + tt := ui.NewTester(a.view, 400, 300) + _ = tt.Click("Filter") + tt.ClickAt(390, 290) + if a.open || a.behind != 0 { + t.Fatalf("outside click: open %v behind %d", a.open, a.behind) + } + _ = tt.Click("Filter") + tt.Key(0, ui.KeyTab) // focus follows the anchor into the panel + if !tt.Focused("Apply") { + t.Fatal("Tab did not enter the popover") + } + tt.Key(0, ui.KeyEscape) + if a.open || !tt.Focused("Filter") { + t.Fatalf("Escape: open %v anchor focused %v", a.open, tt.Focused("Filter")) + } + _ = tt.Click("Filter") + _ = tt.Click("Apply") + if a.applied != 1 || a.open { + t.Fatalf("applied %d open %v", a.applied, a.open) + } +} + +func TestPopoverNeedsAnchor(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := true + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Popover(c, nil, &open, PopoverOptions{}, nil) + }, 200, 200) +} + +func TestPopoverSnapshot(t *testing.T) { + snapshot(t, "popover", 420, 260, func(c *ui.Context) { + open := true + ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { + b := Button(c, "Guests", ButtonOptions{Variant: Secondary}) + Popover(c, b, &open, PopoverOptions{Title: "Guest list", Width: 240}, func() { + ui.Text(c, "Twelve guests confirmed; three await a reply.").TextColor(Tokens(c).TextMuted) + }) + }) + }) +} diff --git a/popup_menu.go b/popup_menu.go new file mode 100644 index 0000000..0b3db9e --- /dev/null +++ b/popup_menu.go @@ -0,0 +1,245 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PopupMenuItemOptions configure one item of a PopupMenu. +type PopupMenuItemOptions struct { + Disabled bool + // KeepOpen keeps the menu open after the item is chosen. + KeepOpen bool +} + +// PopupMenu builds the items of a DropdownMenu or ContextMenu. Its methods +// add items in order and report choices in the frame they happen. +type PopupMenu struct { + c *ui.Context + root *popupMenuRoot + level *popupMenuLevel + subs []popupMenuSub + seq, rows int + autofocus bool +} + +// popupMenuRoot carries what every level of one open menu shares in a frame. +type popupMenuRoot struct{ close bool } + +// popupMenuLevel is what one menu level keeps while it shows. +type popupMenuLevel struct { + open int // seq of the open submenu, 0 for none + byKey bool // the open submenu was opened from the keyboard +} + +type popupMenuSub struct { + item *ui.Element + seq int + build func(m *PopupMenu) +} + +// Item adds an item showing label and reports whether it was chosen. +// Choosing it closes the menu unless KeepOpen is set. +func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool { + it := m.row(ui.ButtonBase(m.c), opts.Disabled, false) + m.content(it, label, false, false) + if !it.Clicked() { + return false + } + slog.Debug("mujica: popup_menu choose", "item", label) + m.root.close = m.root.close || !opts.KeepOpen + return true +} + +// Check adds an item toggling *checked and reports whether it changed. +func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions) bool { + it := m.row(ui.CheckboxBase(m.c, checked), opts.Disabled, false) + m.content(it, label, *checked, false) + if !it.Changed() { + return false + } + slog.Debug("mujica: popup_menu check", "item", label, "checked", *checked) + m.root.close = m.root.close || !opts.KeepOpen + return true +} + +// Submenu adds an item opening a nested menu that build fills. Hover, +// click, Enter, Space or Right opens it; Left or Escape closes it. +func (m *PopupMenu) Submenu(label string, opts PopupMenuItemOptions, build func(m *PopupMenu)) { + if build == nil { + panic("mujica: PopupMenu Submenu needs a build function") + } + it := m.row(ui.ButtonBase(m.c), opts.Disabled, true) + seq := m.seq + m.content(it, label, false, true) + if opts.Disabled { + return + } + if it.Clicked() || it.Shortcut(0, ui.KeyRight) { + m.level.open, m.level.byKey = seq, it.FocusVisible() + slog.Debug("mujica: popup_menu submenu", "item", label) + } + m.subs = append(m.subs, popupMenuSub{it, seq, build}) +} + +// Group adds a captioned group of items, separated from what comes before. +func (m *PopupMenu) Group(label string, fn func()) { + k := Tokens(m.c) + u := Density(m.c).Unit() + if m.rows > 0 { + m.Separator() + } + if label != "" { + ui.Text(m.c, label).FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0) + } + fn() +} + +// Separator adds a line between items. +func (m *PopupMenu) Separator() { + u := Density(m.c).Unit() + ui.Box(m.c).Height(theme.BorderWidth).Margin(u, u*2).Background(Tokens(m.c).Border).Shrink(0) +} + +// row styles an item element and runs the hover, focus and highlight logic. +func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { + c := m.c + k := Tokens(c) + d := Density(c) + m.seq++ + m.rows++ + seq := m.seq + it.Justify(ui.Start).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). + Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).FocusRing(false).TextColor(k.Text) + if disabled { + return it + } + if m.autofocus { + it.AutoFocus() + m.autofocus = false + } + hovered := it.Hovered() + was := ui.Local(it, "mujica.menu.hover", func() bool { return false }) + if hovered && !*was { + it.Focus() // focus follows the pointer + if sub { + m.level.open, m.level.byKey = seq, false + } else { + m.level.open = 0 + } + } + *was = hovered + if hovered || it.FocusVisible() || m.level.open == seq { + it.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) + }) + } + return it +} + +// content lays out the check column, label and submenu chevron of an item. +func (m *PopupMenu) content(it *ui.Element, label string, checked, sub bool) { + c := m.c + k := Tokens(c) + it.Children(func() { + mark := ui.Box(c).Size(16, 16).Shrink(0) + if checked { + mark.Children(func() { ui.Icon(c, icons.Must("check")).Size(16, 16).TextColor(k.AccentText) }) + } + ui.Text(c, label).SingleLine().Grow(1) + if sub { + ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted).Shrink(0) + } + }) +} + +// popupMenuOpen shows the menu build fills below anchor while *open is true. +func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m *PopupMenu)) *ui.Element { + if !*open { + return nil + } + b := anchor.Bounds() + root := &popupMenuRoot{} + panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + overlayStyle(c, panel, overlayAnchored(c, panel, b)) + panel.Margin(4, 0, 0, 0).MinWidth(max(b.W, 200)).MaxWidth(360).MaxHeight(popoverRoom(c, b)) + items := popupMenuLevelBuild(c, panel, root, build, true) + if items.Shortcut(0, ui.KeyTab) || items.Shortcut(ui.Shift, ui.KeyTab) { + root.close = true + } + }) + if root.close { + *open = false + } + if !*open { + slog.Debug("mujica: popup_menu close") + } + return panel +} + +// popupMenuLevelBuild builds one level's items in outer, then its open submenu. +func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) *ui.Element { + u := Density(c).Unit() + items := ui.Scroll(c).Shrink(1).Padding(u).FocusGroup(ui.Vertical) + m := &PopupMenu{c: c, root: root, autofocus: autofocus} + m.level = ui.Local(items, "mujica.menu.level", func() popupMenuLevel { return popupMenuLevel{} }) + outer.Role(ui.RolePopup) + items.Children(func() { build(m) }) + for _, s := range m.subs { + if m.level.open == s.seq { + popupMenuSubmenu(c, outer, root, m.level, s) + } + } + return items +} + +// popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window. +func popupMenuSubmenu(c *ui.Context, outer *ui.Element, root *popupMenuRoot, lv *popupMenuLevel, s popupMenuSub) { + u := Density(c).Unit() + ob, ib := outer.Bounds(), s.item.Bounds() + box := ui.Column(c).Absolute().Key(s.seq).MinWidth(180).MaxWidth(360) + sb := box.Bounds() + ww, wh := c.Size() + left, top := ib.X+ib.W+u-ob.X, ib.Y-u-ob.Y + if ob.X+left+sb.W > ww-4 { + left = ib.X - u - sb.W - ob.X + } + top = min(top, wh-4-sb.H-ob.Y) + box.Left(left).Top(top).MaxHeight(wh - 8) + p := overlayEnter(c, box) + if sb.W == 0 || ob.W == 0 { + p = 0 // not measured yet + c.AnimationFrame() + } + overlayStyle(c, box, p) + box.Children(func() { popupMenuLevelBuild(c, box, root, s.build, lv.byKey) }) + if box.Shortcut(0, ui.KeyLeft) || box.Shortcut(0, ui.KeyEscape) { + lv.open = 0 + s.item.Focus() + } +} + +// popupMenuTrigger styles a menu trigger button with a trailing chevron. +func popupMenuTrigger(c *ui.Context, b *ui.Element, label string, icon *ui.SVG, open bool) { + k := Tokens(c) + d := Density(c) + b.MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3, 0, d.Unit()*4). + Gap(d.Unit() * 2).Radius(theme.ControlRadius) + internal.Ring(b, k.Focus, theme.ControlRadius) + bg, fg, border := buttonColors(k, Secondary, b.Hovered() && !b.IsDisabled(), b.Pressed() || open) + b.Background(bg).TextColor(fg).Border(theme.BorderWidth, border) + b.Children(func() { + if icon != nil { + ui.Icon(c, icon).Size(16, 16) + } + if label != "" { + ui.Text(c, label).SingleLine() + } + ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) + }) +} diff --git a/progress.go b/progress.go new file mode 100644 index 0000000..85d09dc --- /dev/null +++ b/progress.go @@ -0,0 +1,272 @@ +package mujica + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ProgressShape is how a progress indicator draws. The zero value is ProgressLinear. +type ProgressShape int + +const ( + ProgressLinear ProgressShape = iota + ProgressRing +) + +// ProgressOptions configure a Progress. +type ProgressOptions struct { + Shape ProgressShape + // Indeterminate shows work of unknown length; the value is ignored. + Indeterminate bool + // Label names the indicator and shows beside it; empty reads "Loading". + Label string + ShowValue bool + // Size is the ring diameter (48 DIP when zero) or the bar width (fills when zero). + Size float32 +} + +// Progress shows value, from 0 to 1, of work done. It panics outside that range. +// Indeterminate indicators move only while painted and hold still under reduced motion. +func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { + if !opts.Indeterminate && (math.IsNaN(value) || value < 0 || value > 1) { + panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value)) + } + if opts.Size < 0 { + panic("mujica: negative Progress Size") + } + k := Tokens(c) + u := Density(c).Unit() + still := c.Preferences().ReduceMotion + name := opts.Label + if name == "" { + name = msg(c, "loading") + } + acc := value + if opts.Indeterminate { + acc = -1 + } + caption := "" + switch { + case opts.Indeterminate && still: + caption = msg(c, "progress.indeterminate") + case !opts.Indeterminate && opts.ShowValue: + caption = strconv.Itoa(int(math.Round(value*100))) + "%" + } + if opts.Shape == ProgressRing { + return progressRing(c, k, u, acc, name, caption, opts, still) + } + col := ui.Column(c).Gap(u * 1.5) + if opts.Size > 0 { + col.Width(opts.Size) + } else { + col.FillWidth() + } + col.Children(func() { + if opts.Label != "" || caption != "" { + ui.Row(c).Gap(u * 2).Children(func() { + ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1) + ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine() + }) + } + bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border) + shown := float32(value) + if !opts.Indeterminate { + shown = bar.Animate("progress.value", shown, theme.StateDuration) + } + bar.Draw(func(p *ui.Painter, r ui.Rect) { + switch { + case !opts.Indeterminate: + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius) + case still: + p.Fill(r, k.AccentText.Alpha(0.4), theme.ControlRadius) + default: + p.AnimationFrame() + phase := float32(p.Now().UnixMilli()%1400) / 1400 + w := r.W * 0.3 + x := r.X - w + (r.W+w)*phase + p.Clip(r, theme.ControlRadius, func() { + p.Fill(ui.Rect{X: x, Y: r.Y, W: w, H: r.H}, k.AccentText, theme.ControlRadius) + }) + } + }) + }) + return col +} + +func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, caption string, opts ProgressOptions, still bool) *ui.Element { + d := opts.Size + if d == 0 { + d = 48 + } + row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2) + row.Children(func() { + ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent) + shown := float32(acc) + if !opts.Indeterminate { + shown = ring.Animate("progress.value", shown, theme.StateDuration) + } + stroke := max(3, d/10) + ring.Draw(func(p *ui.Painter, r ui.Rect) { + cx, cy, rad := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, (r.W-stroke)/2) + var track ui.Path + track.Circle(cx, cy, rad) + p.StrokePath(&track, stroke, k.Border) + switch { + case !opts.Indeterminate: + if shown > 0 { + p.StrokePath(progressArc(p, cx, cy, rad, 0, shown), stroke, k.AccentText) + } + case still: + p.StrokePath(&track, stroke, k.AccentText.Alpha(0.4)) + default: + p.AnimationFrame() + start := float32(p.Now().UnixMilli()%1200) / 1200 + p.StrokePath(progressArc(p, cx, cy, rad, start, start+0.25), stroke, k.AccentText) + } + }) + if caption != "" && d >= 44 && !opts.Indeterminate { + ring.Children(func() { + ui.Text(c, caption).FontSize(theme.CaptionSize).FontFeatures("tnum").TextColor(k.Text) + }) + ring.Center() + } + if opts.Label != "" || (caption != "" && (d < 44 || opts.Indeterminate)) { + ui.Column(c).Children(func() { + if opts.Label != "" { + ui.Text(c, opts.Label).TextColor(k.Text) + } + if caption != "" && (d < 44 || opts.Indeterminate) { + ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize) + } + }) + } + }) + return row +} + +// progressArc is the arc from turn a to turn b, clockwise from twelve o'clock. +func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path { + var p ui.Path + n := max(2, int(math.Ceil(float64((b-a)*64)))) + for i := 0; i <= n; i++ { + t := float64(a + (b-a)*float32(i)/float32(n)) + x := cx + r*float32(math.Sin(2*math.Pi*t)) + y := cy - r*float32(math.Cos(2*math.Pi*t)) + if i == 0 { + p.MoveTo(internal.Snap(pt, x), internal.Snap(pt, y)) + } else { + p.LineTo(internal.Snap(pt, x), internal.Snap(pt, y)) + } + } + return &p +} + +// MeterOptions configure a Meter. +type MeterOptions struct { + // Min and Max bound the value; both zero mean 0 to 1. + Min, Max float64 + // Levels mark the value High from Warning and Critical from Critical; + // Critical below Warning flags low values instead. Nil marks none. + Levels *ui.MeterLevels + Label string + ShowValue bool + // Width is the bar width; zero fills. + Width float32 +} + +// meterLevel is 0 normal, 1 warning, 2 critical. +func meterLevel(v float64, l *ui.MeterLevels) int { + if l == nil || l.Warning == l.Critical { + return 0 + } + low := l.Critical < l.Warning + switch { + case !low && v >= l.Critical, low && v <= l.Critical: + return 2 + case !low && v >= l.Warning, low && v <= l.Warning: + return 1 + } + return 0 +} + +// Meter shows a level within a range, as disk use. Thresholds show as +// ticks and the level as a word as well as a color. It panics on a value outside the range. +func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element { + lo, hi := opts.Min, opts.Max + if lo == 0 && hi == 0 { + hi = 1 + } + if !(hi > lo) { + panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi)) + } + if math.IsNaN(value) || value < lo || value > hi { + panic(fmt.Sprintf("mujica: Meter value %v outside %v..%v", value, lo, hi)) + } + if l := opts.Levels; l != nil && (l.Warning < lo || l.Warning > hi || l.Critical < lo || l.Critical > hi) { + panic("mujica: Meter levels outside the range") + } + k := Tokens(c) + u := Density(c).Unit() + level := meterLevel(value, opts.Levels) + tone := []ui.Color{k.Success, k.Warning, k.Danger}[level] + var word string + if opts.Levels != nil { + low := opts.Levels.Critical < opts.Levels.Warning + word = msg(c, "meter.normal") + switch { + case level == 2: + word = msg(c, "meter.critical") + case level == 1 && low: + word = msg(c, "meter.low_warning") + case level == 1: + word = msg(c, "meter.warning") + } + } + frac := func(v float64) float32 { return float32((v - lo) / (hi - lo)) } + col := ui.Column(c).Gap(u * 1.5) + if opts.Width > 0 { + col.Width(opts.Width) + } else { + col.FillWidth() + } + col.Children(func() { + caption := word + if opts.ShowValue { + caption = strconv.FormatFloat(value, 'f', -1, 64) + " / " + strconv.FormatFloat(hi, 'f', -1, 64) + if word != "" { + caption += " · " + word + } + } + if opts.Label != "" || caption != "" { + ui.Row(c).Gap(u * 2).Children(func() { + ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1) + ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine() + }) + } + name := opts.Label + if word != "" { + name += " (" + word + ")" + } + bar := ui.Meter(c, value, lo, hi, opts.Levels).Label(name).FillWidth().Height(u * 2). + Radius(theme.ControlRadius).Background(k.Border) + bar.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * frac(value), H: r.H}, tone, theme.ControlRadius) + }) + if l := opts.Levels; l != nil { + // Ticks at the thresholds, over the fill. + bar.DrawOver(func(p *ui.Painter, r ui.Rect) { + for _, v := range []float64{l.Warning, l.Critical} { + x := float32(math.Round(float64(r.X + r.W*frac(v)))) + p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Text.Alpha(0.6), 0) + } + }) + } + }) + return col +} diff --git a/progress_test.go b/progress_test.go new file mode 100644 index 0000000..78c5bdf --- /dev/null +++ b/progress_test.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestProgressValueText(t *testing.T) { + v := 0.424 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Progress(c, v, ProgressOptions{Label: "Uploading", ShowValue: true}) + Progress(c, 1, ProgressOptions{Shape: ProgressRing, Label: "Indexing", ShowValue: true}) + }, 400, 300) + if !tt.HasText("42%") || !tt.HasText("100%") { + t.Fatalf("texts %q", tt.Texts()) + } + if r, ok := tt.Find("Indexing"); !ok || r.H < 48 { + t.Fatalf("ring %v %v", r, ok) + } + v = 0 + tt.Frame() + if !tt.HasText("0%") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestProgressIndeterminateReducedMotion(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Progress(c, math.NaN(), ProgressOptions{Indeterminate: true, Label: "Connecting"}) + }, 400, 300) + if tt.HasText("In progress") { + t.Fatal("static note shown while moving") + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("In progress") { + t.Fatal("no static note under reduced motion") + } + a := encode(t, tt.Image()) + tt.Frame() + if string(a) != string(encode(t, tt.Image())) { + t.Fatal("indeterminate bar moved under reduced motion") + } +} + +func TestProgressRingSize(t *testing.T) { + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + w = Progress(c, 0.5, ProgressOptions{Shape: ProgressRing, Size: 24}).Bounds().W + }) + }, 400, 300) + tt.Frame() + if w != 24 { + t.Fatalf("ring width %v", w) + } +} + +func TestMeterLevels(t *testing.T) { + disk := &ui.MeterLevels{Warning: 80, Critical: 95} + battery := &ui.MeterLevels{Warning: 20, Critical: 10} + for _, tc := range []struct { + v float64 + levels *ui.MeterLevels + want int + }{ + {50, disk, 0}, {80, disk, 1}, {94.9, disk, 1}, {95, disk, 2}, {100, disk, 2}, + {50, battery, 0}, {20, battery, 1}, {10, battery, 2}, {0, battery, 2}, {99, nil, 0}, + } { + if got := meterLevel(tc.v, tc.levels); got != tc.want { + t.Errorf("meterLevel(%v, %+v) = %d, want %d", tc.v, tc.levels, got, tc.want) + } + } +} + +func TestMeterWords(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + Meter(c, 90, MeterOptions{Max: 100, Levels: &ui.MeterLevels{Warning: 80, Critical: 95}, Label: "磁盘", ShowValue: true}) + Meter(c, 15, MeterOptions{Max: 100, Levels: &ui.MeterLevels{Warning: 20, Critical: 10}, Label: "电量"}) + }, 400, 300) + if !tt.HasText("90 / 100 · 偏高") || !tt.HasText("偏低") { + t.Fatalf("texts %q", tt.Texts()) + } + if _, ok := tt.Find("磁盘 (偏高)"); !ok { + t.Fatal("meter name lacks its level") + } +} + +func TestProgressAndMeterPanics(t *testing.T) { + for name, f := range map[string]func(c *ui.Context){ + "progress above 1": func(c *ui.Context) { Progress(c, 1.2, ProgressOptions{}) }, + "progress NaN": func(c *ui.Context) { Progress(c, math.NaN(), ProgressOptions{}) }, + "meter empty range": func(c *ui.Context) { Meter(c, 5, MeterOptions{Min: 10, Max: 10}) }, + "meter outside": func(c *ui.Context) { Meter(c, 2, MeterOptions{}) }, + "meter levels": func(c *ui.Context) { + Meter(c, 5, MeterOptions{Max: 10, Levels: &ui.MeterLevels{Warning: 8, Critical: 12}}) + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestProgressSnapshot(t *testing.T) { + snapshot(t, "progress", 520, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).Children(func() { + Progress(c, 0.62, ProgressOptions{Label: "Uploading 3 files", ShowValue: true}) + Progress(c, 0, ProgressOptions{Indeterminate: true, Label: "Connecting"}) + ui.Row(c).Gap(24).AlignItems(ui.Center).Children(func() { + Progress(c, 0.35, ProgressOptions{Shape: ProgressRing, ShowValue: true}) + Progress(c, 0.8, ProgressOptions{Shape: ProgressRing, Size: 24, Label: "Indexing", ShowValue: true}) + Progress(c, 0, ProgressOptions{Shape: ProgressRing, Indeterminate: true, Label: "Waiting"}) + }) + Meter(c, 42, MeterOptions{Max: 100, Label: "Memory", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}}) + Meter(c, 93, MeterOptions{Max: 100, Label: "Disk", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}}) + }) + }) +} diff --git a/radio_group.go b/radio_group.go new file mode 100644 index 0000000..df0b3d4 --- /dev/null +++ b/radio_group.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RadioItem is one choice of a RadioGroup; Value is its stable identity. +type RadioItem[T comparable] struct { + Value T + Label string + Disabled bool +} + +// RadioGroupOptions configure a RadioGroup. +type RadioGroupOptions struct { + // Horizontal lays the choices in a row; the default is a column. + Horizontal bool + // Label names the group for assistive technology. + Label string + Disabled bool +} + +// RadioGroup chooses one item's Value into *value; arrows move the choice. +func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opts RadioGroupOptions) InputResult { + if value == nil { + panic("mujica: RadioGroup needs a value") + } + if len(items) == 0 { + panic("mujica: RadioGroup needs items") + } + seen := make(map[T]bool, len(items)) + for _, it := range items { + if seen[it.Value] { + panic("mujica: RadioGroup has a duplicate value") + } + seen[it.Value] = true + } + k := Tokens(c) + d := Density(c) + var r InputResult + r.Element = ui.RadioGroup(c, func() { + for _, it := range items { + if radioGroupItem(c, k, d, value, it) { + r.changed = true + slog.Debug("mujica: radio_group choose", "label", it.Label) + } + } + }).Label(opts.Label).Disabled(opts.Disabled).Gap(0) + if opts.Horizontal { + r.Element.Row().Gap(d.Unit() * 6) + } + return r +} + +// radioGroupItem builds one radio and reports whether it was chosen. +func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T]) bool { + row := ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false).Disabled(it.Disabled) + on := *value == it.Value + hover, pressed := row.Hovered(), row.Pressed() + row.Children(func() { + dot := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(checkboxSize / 2) + switch { + case on: + dot.Background(k.Accent).Border(theme.BorderWidth, k.AccentText) + case pressed: + dot.Background(k.Selection).Border(theme.BorderWidth, k.AccentText) + case hover: + dot.Background(k.SurfaceHover).Border(theme.BorderWidth, k.AccentText) + default: + dot.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) + } + dot.Transition(textInputFade) + dot.DrawOver(func(p *ui.Painter, r ui.Rect) { + if on { + s := r.W * 0.375 + p.Fill(ui.Rect{X: r.X + (r.W-s)/2, Y: r.Y + (r.H-s)/2, W: s, H: s}, k.OnAccent, s/2) + } + if row.FocusVisible() { + internal.FocusRing(p, r, checkboxSize/2, k.Focus) + } + }) + ui.Text(c, it.Label) + }) + return row.Changed() +} diff --git a/radio_group_test.go b/radio_group_test.go new file mode 100644 index 0000000..e4a2099 --- /dev/null +++ b/radio_group_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var radioGroupTestItems = []RadioItem[string]{ + {Value: "s", Label: "Small"}, + {Value: "m", Label: "Medium", Disabled: true}, + {Value: "l", Label: "Large"}, +} + +func radioGroupTester(v *string, changes *int, opts RadioGroupOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if RadioGroup(c, v, radioGroupTestItems, opts).Changed() { + *changes++ + } + Button(c, "After", ButtonOptions{}) + }, 400, 300) +} + +func TestRadioGroupClickAndArrows(t *testing.T) { + v, n := "s", 0 + tt := radioGroupTester(&v, &n, RadioGroupOptions{Label: "Size"}) + _ = tt.Click("Medium") + if v != "s" || n != 0 { + t.Fatalf("disabled item chosen: %q", v) + } + _ = tt.Click("Large") + if v != "l" || n != 1 { + t.Fatalf("click: %q changes %d", v, n) + } + tt.Key(0, ui.KeyDown) + if v != "s" || n != 2 { + t.Fatalf("Down wraps past the disabled item to Small, got %q changes %d", v, n) + } + tt.Key(0, ui.KeyUp) + if v != "l" { + t.Fatalf("Up skipped to %q", v) + } + tt.Key(0, ui.KeyHome) + if v != "s" { + t.Fatalf("Home chose %q", v) + } +} + +func TestRadioGroupTabStopsAtChosen(t *testing.T) { + v, n := "l", 0 + tt := radioGroupTester(&v, &n, RadioGroupOptions{Horizontal: true}) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Large") { + t.Fatal("Tab did not go to the chosen radio") + } + tt.Key(0, ui.KeyLeft) + if v != "s" { + t.Fatalf("Left in a row chose %q", v) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("After") { + t.Fatal("Tab did not leave the group") + } +} + +func TestRadioGroupDisabledGroup(t *testing.T) { + v, n := "s", 0 + tt := radioGroupTester(&v, &n, RadioGroupOptions{Disabled: true}) + _ = tt.Click("Large") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if v != "s" || n != 0 { + t.Fatalf("disabled group chose %q", v) + } +} + +func TestRadioGroupDuplicateValuePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for duplicate values") + } + }() + v := 1 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RadioGroup(c, &v, []RadioItem[int]{{Value: 1, Label: "A"}, {Value: 1, Label: "B"}}, RadioGroupOptions{}) + }, 200, 100) +} + +func TestRadioGroupSnapshot(t *testing.T) { + snapshot(t, "radio_group", 460, 220, func(c *ui.Context) { + v, h := "m", 2 + ui.Column(c).Padding(24).Gap(16).Children(func() { + RadioGroup(c, &v, []RadioItem[string]{{Value: "s", Label: "Sonnet"}, {Value: "m", Label: "Madrigal"}, {Value: "o", Label: "Ode", Disabled: true}}, RadioGroupOptions{}) + RadioGroup(c, &h, []RadioItem[int]{{Value: 1, Label: "Matins"}, {Value: 2, Label: "Vespers"}, {Value: 3, Label: "Compline"}}, RadioGroupOptions{Horizontal: true}) + }) + }) +} diff --git a/rating.go b/rating.go new file mode 100644 index 0000000..ccf7942 --- /dev/null +++ b/rating.go @@ -0,0 +1,144 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RatingOptions configure a Rating. Max is 5 and Step 1 when zero. +type RatingOptions struct { + Max int + // Step is the smallest change, such as 0.5 for half stars; it must divide 1. + Step float64 + ReadOnly bool + Disabled bool + // Label names the rating; empty uses the built-in "Rating". + Label string + // ShowValue prints "3.5 / 5" beside the stars. + ShowValue bool +} + +// RatingParts are the parts of a Rating. +type RatingParts struct { + Element *ui.Element + changed bool +} + +// Changed reports whether the user set a new value this frame. +func (p RatingParts) Changed() bool { return p.changed } + +// Rating shows *value as stars and lets the pointer and arrows set it in steps. +func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { + n, step := opts.Max, opts.Step + if n == 0 { + n = 5 + } + if step == 0 { + step = 1 + } + if n < 0 || step < 0 || step > 1 || math.Abs(1/step-math.Round(1/step)) > 1e-9 { + panic(fmt.Sprintf("mujica: Rating Max %d, Step %v: Max must be positive and Step divide 1", opts.Max, opts.Step)) + } + k := Tokens(c) + size := Density(c).ControlHeight() * 0.6 + label := opts.Label + if label == "" { + label = msg(c, "rating.label") + } + parts := RatingParts{} + set := func(v float64) { + v = math.Max(0, math.Min(float64(n), math.Round(v/step)*step)) + if v != *value { + slog.Debug("mujica: rating changed", "from", *value, "to", v) + *value = v + parts.changed = true + } + } + e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0) + parts.Element = e + e.Label(label) + interactive := !opts.ReadOnly && !opts.Disabled + if opts.Disabled { + e.Disabled(true) + } else { + e.Focusable() + internal.Ring(e, k.Focus, theme.ControlRadius) + } + if interactive { + e.Role(ui.RoleSlider).Cursor(ui.CursorPointer) + switch { + case e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyUp): + set(*value + step) + case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown): + set(*value - step) + case e.Shortcut(0, ui.KeyHome): + set(0) + case e.Shortcut(0, ui.KeyEnd): + set(float64(n)) + } + } + e.Children(func() { + stars := ui.Box(c).Size(size*float32(n), size).Shrink(0) + shown, fill := *value, k.AccentText + if interactive { + at := func(x float32) float64 { return math.Ceil(float64(x/size)/step-1e-9) * step } + if _, _, ok := stars.Dragged(); ok { + x, _, _ := stars.PointerPosition() + set(at(x)) + shown = *value + } else if x, _, over := stars.PointerPosition(); over { + shown, fill = math.Max(step, math.Min(float64(n), at(x))), k.AccentText.Alpha(0.6) + } + } + stars.Draw(func(p *ui.Painter, r ui.Rect) { + for i := range n { + cell := ui.Rect{X: r.X + float32(i)*size, Y: r.Y, W: size, H: size} + path := ratingStar(cell) + p.StrokePath(path, 1.25, k.ControlBorder) + part := float32(math.Max(0, math.Min(1, shown-float64(i)))) + if part > 0 { + p.Clip(ui.Rect{X: cell.X, Y: cell.Y, W: cell.W * part, H: cell.H}, 0, func() { + p.FillPath(path, fill) + p.StrokePath(path, 1.25, fill) + }) + } + } + }) + if opts.ShowValue { + ui.Text(c, fmt.Sprintf("%s / %d", strconv.FormatFloat(*value, 'f', -1, 64), n)). + TextColor(k.TextMuted).FontFeatures("tnum").SingleLine() + } + }) + e.Description(fmt.Sprintf(msg(c, "rating.value"), strconv.FormatFloat(*value, 'f', -1, 64), n)) + return parts +} + +// ratingStar returns a five-pointed star inset in r. +func ratingStar(r ui.Rect) *ui.Path { + var p ui.Path + cx, cy := r.X+r.W/2, r.Y+r.H*0.54 + outer := r.W * 0.42 + inner := outer * 0.45 + for i := range 10 { + rad := outer + if i%2 == 1 { + rad = inner + } + a := -math.Pi/2 + float64(i)*math.Pi/5 + x, y := cx+rad*float32(math.Cos(a)), cy+rad*float32(math.Sin(a)) + if i == 0 { + p.MoveTo(x, y) + } else { + p.LineTo(x, y) + } + } + p.Close() + return &p +} diff --git a/rating_test.go b/rating_test.go new file mode 100644 index 0000000..b814fc2 --- /dev/null +++ b/rating_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func ratingTester(v *float64, opts RatingOptions, 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 Rating(c, v, opts).Changed() { + *changes++ + } + }) + }, 300, 100) +} + +func TestRatingKeyboardSteps(t *testing.T) { + v, n := 2.0, 0 + tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Taste") { + t.Fatal("rating not focusable") + } + tt.Key(0, ui.KeyRight) + if v != 2.5 { + t.Fatalf("right: %v", v) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + if v != 1.5 { + t.Fatalf("down twice: %v", v) + } + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) + if v != 5 { + t.Fatalf("end then right: %v", v) + } + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyLeft) + if v != 0 || n != 5 { + t.Fatalf("home then left: %v, %d changes", v, n) + } +} + +func TestRatingPointer(t *testing.T) { + v, n := 0.0, 0 + tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n) + size := float32(36) * 0.6 + // The left half of the third star. + tt.ClickAt(10+2*size+size*0.25, 10+size/2) + if v != 2.5 || n != 1 { + t.Fatalf("click: %v, %d changes", v, n) + } + tt.ClickAt(10+2*size+size*0.25, 10+size/2) + if n != 1 { + t.Fatalf("same value reported a change") + } +} + +func TestRatingReadOnlyAndDisabled(t *testing.T) { + for _, opts := range []RatingOptions{{ReadOnly: true, Label: "Score"}, {Disabled: true, Label: "Score"}} { + v, n := 3.0, 0 + tt := ratingTester(&v, opts, &n) + tt.ClickAt(12, 20) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if v != 3 || n != 0 { + t.Fatalf("%+v: value %v changes %d", opts, v, n) + } + if opts.ReadOnly != tt.Focused("Score") { + t.Fatalf("%+v: focus %v", opts, tt.Focused("Score")) + } + } +} + +func TestRatingShowsValueAndPanics(t *testing.T) { + v, n := 3.5, 0 + tt := ratingTester(&v, RatingOptions{Step: 0.5, ShowValue: true}, &n) + if !tt.HasText("3.5 / 5") { + t.Fatalf("texts %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("Step 0.3 accepted") + } + }() + ratingTester(&v, RatingOptions{Step: 0.3}, &n) +} + +func TestRatingSnapshot(t *testing.T) { + a, b, d := 3.5, 4.0, 2.0 + snapshot(t, "rating", 360, 170, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(14).AlignItems(ui.Start).Children(func() { + Rating(c, &a, RatingOptions{Step: 0.5, ShowValue: true}) + Rating(c, &b, RatingOptions{ReadOnly: true, Max: 7, ShowValue: true}) + Rating(c, &d, RatingOptions{Disabled: true}) + }) + }) +} diff --git a/result.go b/result.go new file mode 100644 index 0000000..70814d0 --- /dev/null +++ b/result.go @@ -0,0 +1,97 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ResultKind is the outcome a Result page reports. The zero value is ResultSuccess. +type ResultKind int + +const ( + ResultSuccess ResultKind = iota + ResultFailure + ResultForbidden +) + +// ResultOptions configure a Result. +type ResultOptions struct { + Kind ResultKind + // Title uses the kind's built-in copy when empty. + Title string + Description string + // Actions label the next steps; the first is the primary button. + Actions []string +} + +// ResultResult is what a Result built and reported this frame. +type ResultResult struct { + Element *ui.Element + // Action is the index of the clicked action, -1 for none. + Action int +} + +// Result reports the outcome of a finished task and offers next steps. +// It only reports the chosen step; the caller navigates. +func Result(c *ui.Context, opts ResultOptions) ResultResult { + k := Tokens(c) + u := Density(c).Unit() + var tone ui.Color + var icon *ui.SVG + var key string + switch opts.Kind { + case ResultSuccess: + tone, icon, key = k.Success, icons.Must("circle-check"), "result.success.title" + case ResultFailure: + tone, icon, key = k.Danger, icons.Must("circle-x"), "result.failure.title" + case ResultForbidden: + tone, icon, key = k.Warning, icons.Must("shield-off"), "result.forbidden.title" + default: + panic(fmt.Sprintf("mujica: unknown ResultKind %d", int(opts.Kind))) + } + title := cmp.Or(opts.Title, msg(c, key)) + res := ResultResult{Action: -1} + res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*3).Padding(u*10, u*6).Role(ui.RoleGroup).Label(title) + res.Element.Children(func() { + ui.Icon(c, icon).Size(48, 48).TextColor(tone) + ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H2Size).TextColor(k.Text).TextAlign(ui.Center) + if opts.Description != "" { + ui.Text(c, opts.Description).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(420).LineHeight(theme.LineHeight) + } + resultOrnament(c, k) + if len(opts.Actions) == 0 { + return + } + ui.Row(c).Gap(u * 3).Wrap().Justify(ui.Center).Children(func() { + for i, a := range opts.Actions { + v := Secondary + if i == 0 { + v = Primary + } + if Button(c, a, ButtonOptions{Variant: v}).Key(i).Clicked() { + slog.Debug("mujica: result action", "kind", opts.Kind, "index", i) + res.Action = i + } + } + }) + }) + return res +} + +// resultOrnament draws two hairlines meeting a diamond, in ornament gold. +func resultOrnament(c *ui.Context, k theme.Tokens) { + ui.Box(c).Size(160, 12).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + cy := float32(int(r.Y+r.H/2)) + 0.5 + cx := r.X + r.W/2 + p.Line(r.X, cy, cx-9, cy, 1, k.Ornament) + p.Line(cx+9, cy, r.X+r.W, cy, 1, k.Ornament) + p.FillPath(internal.Diamond(cx, cy, 8), k.Ornament) + }) +} diff --git a/result_test.go b/result_test.go new file mode 100644 index 0000000..b7ec69f --- /dev/null +++ b/result_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestResultActions(t *testing.T) { + got := []int{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if a := Result(c, ResultOptions{Kind: ResultFailure, Description: "The payment was declined.", Actions: []string{"Try again", "Back to cart"}}).Action; a >= 0 { + got = append(got, a) + } + }, 600, 500) + if !tt.HasText("Something went wrong") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Back to cart"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(got) != 2 || got[0] != 1 || got[1] != 0 { + t.Fatalf("actions %v", got) + } +} + +func TestResultTitles(t *testing.T) { + for kind, want := range map[ResultKind]string{ResultSuccess: "操作成功", ResultFailure: "操作失败", ResultForbidden: "无访问权限"} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + Result(c, ResultOptions{Kind: kind}) + }, 500, 400) + if !tt.HasText(want) { + t.Errorf("%d: texts %q", kind, tt.Texts()) + } + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Result(c, ResultOptions{Title: "Order placed"}) + }, 500, 400) + if !tt.HasText("Order placed") || tt.HasText("Done") { + t.Fatalf("custom title: %q", tt.Texts()) + } +} + +func TestResultPanicsOnUnknownKind(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Result(c, ResultOptions{Kind: 7}) }, 300, 200) +} + +func TestResultSnapshot(t *testing.T) { + snapshot(t, "result", 520, 340, func(c *ui.Context) { + Result(c, ResultOptions{ + Kind: ResultForbidden, + Description: "You need the Editor role to change billing settings.", + Actions: []string{"Request access", "Go back"}, + }) + }) +} diff --git a/scroll_area.go b/scroll_area.go new file mode 100644 index 0000000..1802827 --- /dev/null +++ b/scroll_area.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ScrollDirection is the way a ScrollArea scrolls. The zero value is vertical. +type ScrollDirection int + +const ( + ScrollVertical ScrollDirection = iota + ScrollHorizontal + ScrollBoth +) + +// ScrollAreaOptions configure a ScrollArea. +type ScrollAreaOptions struct { + Direction ScrollDirection + // Label names the region for assistive technology. + Label string +} + +// ScrollArea scrolls its children with the wheel, its scroll bars and the keyboard. +// state keeps the offset across frames: set X or Y to scroll there, math.MaxFloat32 for the end. +// A nil state keeps the offset by the area's place in the view. +// The area takes the focus while its content overflows, so the keys reach it. +func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element { + if children == nil { + panic("mujica: ScrollArea needs children") + } + var e *ui.Element + switch opts.Direction { + case ScrollVertical: + e = ui.Scroll(c) + case ScrollHorizontal: + e = ui.ScrollHorizontal(c) + case ScrollBoth: + e = ui.ScrollBoth(c) + default: + panic("mujica: invalid ScrollDirection") + } + if state == nil { + state = ui.Local(e, "scroll_area.state", func() ui.ScrollState { return ui.ScrollState{} }) + } + e.TrackScroll(state) + if state.MaxX > 0 || state.MaxY > 0 { + focus := Tokens(c).Focus + // The ring goes inside: areas often fill a clipped pane edge to edge. + e.Focusable().FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { + if e.FocusVisible() { + in := theme.FocusWidth / 2 + p.Stroke(ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, focus, theme.ControlRadius, theme.FocusWidth) + } + }) + } + if opts.Label != "" { + e.Label(opts.Label) + } + return e.Children(children) +} diff --git a/scroll_area_test.go b/scroll_area_test.go new file mode 100644 index 0000000..4beb683 --- /dev/null +++ b/scroll_area_test.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func scrollAreaView(st *ui.ScrollState, opts ScrollAreaOptions, rows int) func(c *ui.Context) { + return func(c *ui.Context) { + Use(c, Settings{}) + ScrollArea(c, st, opts, func() { + for i := range rows { + ui.Text(c, fmt.Sprintf("Row %d", i)).Height(20).Width(float32(100 + 40*i)) + } + }).Size(200, 100) + } +} + +func TestScrollAreaScrollToEnd(t *testing.T) { + st := &ui.ScrollState{} + tt := ui.NewTester(scrollAreaView(st, ScrollAreaOptions{}, 20), 300, 300) + if st.MaxY != 300 { + t.Fatalf("MaxY %v, want 300", st.MaxY) + } + st.Y = math.MaxFloat32 + tt.Frame() + if st.Y != 300 { + t.Fatalf("Y %v after scrolling to the end, want 300", st.Y) + } + st.Y = 40 + tt.Frame() + if r, _ := tt.Find("Row 2"); r.Y != 0 { + t.Fatalf("Row 2 at %v after scrolling to 40, want 0", r.Y) + } +} + +func TestScrollAreaWheelAndKeys(t *testing.T) { + st := &ui.ScrollState{} + tt := ui.NewTester(scrollAreaView(st, ScrollAreaOptions{Label: "Log"}, 20), 300, 300) + tt.Scroll(50, 50, 0, 60) + if st.Y != 60 { + t.Fatalf("wheel moved to %v, want 60", st.Y) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Log") { + t.Fatal("overflowing area did not take the focus") + } + tt.Key(0, ui.KeyDown) + if st.Y != 100 { + t.Fatalf("Down moved to %v, want 100", st.Y) + } + tt.Key(0, ui.KeyEnd) + if st.Y != 300 { + t.Fatalf("End moved to %v, want 300", st.Y) + } + tt.Key(0, ui.KeyHome) + if st.Y != 0 { + t.Fatalf("Home moved to %v, want 0", st.Y) + } +} + +func TestScrollAreaNoOverflowSkipsFocus(t *testing.T) { + tt := ui.NewTester(scrollAreaView(&ui.ScrollState{}, ScrollAreaOptions{Label: "Log"}, 2), 300, 300) + tt.Key(0, ui.KeyTab) + if tt.Focused("Log") { + t.Fatal("area without overflow took the focus") + } +} + +func TestScrollAreaHorizontal(t *testing.T) { + st := &ui.ScrollState{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ScrollArea(c, st, ScrollAreaOptions{Direction: ScrollHorizontal}, func() { + ui.Box(c).Size(500, 50) + }).Size(200, 60) + }, 300, 300) + tt.Scroll(50, 30, 120, 0) + if st.X != 120 || st.Y != 0 || st.MaxX != 300 { + t.Fatalf("offset %v,%v max %v, want 120,0 max 300", st.X, st.Y, st.MaxX) + } +} + +func TestScrollAreaKeepsPlaceWithoutState(t *testing.T) { + tt := ui.NewTester(scrollAreaView(nil, ScrollAreaOptions{Direction: ScrollBoth}, 20), 300, 300) + tt.Scroll(50, 50, 0, 80) + tt.Frame() + tt.Frame() + if r, _ := tt.Find("Row 4"); r.Y != 0 { + t.Fatalf("Row 4 at %v after frames, want 0", r.Y) + } +} + +func TestScrollAreaInvalidDirectionPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("invalid direction did not panic") + } + }() + ui.NewTester(scrollAreaView(nil, ScrollAreaOptions{Direction: 7}, 1), 100, 100) +} + +func TestScrollAreaSnapshot(t *testing.T) { + snapshot(t, "scroll_area", 300, 160, func(c *ui.Context) { + k := Tokens(c) + st := ui.Local(c.Root(), "snap", func() ui.ScrollState { return ui.ScrollState{Y: 30} }) + ui.Column(c).Padding(16).Children(func() { + ScrollArea(c, st, ScrollAreaOptions{}, func() { + for i := range 12 { + ui.Text(c, fmt.Sprintf("Verse %d", i+1)).Padding(4, 12) + } + }).Height(128).Background(k.Surface).Border(1, k.Border).Radius(4) + }) + }) +} diff --git a/search_input.go b/search_input.go new file mode 100644 index 0000000..7a1af8f --- /dev/null +++ b/search_input.go @@ -0,0 +1,140 @@ +package mujica + +import ( + "fmt" + "log/slog" + "runtime" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SearchInputOptions configure a SearchInput. +type SearchInputOptions struct { + // Placeholder is empty for the built-in "Search" or "Find". + Placeholder string + // Label names the field; empty uses the placeholder. + Label string + Disabled bool + // Find turns on find-bar mode: match count, previous and next. + Find bool + // Matches is how many matches the caller found for the query. + Matches int + // Current is the chosen match index; Find mode requires it. + Current *int +} + +// SearchInputResult adds find-bar moves to InputResult. +type SearchInputResult struct { + InputResult + moved bool +} + +// Moved reports that the user went to another match this frame. +func (r SearchInputResult) Moved() bool { return r.moved } + +// SearchInput is a search field with clear, or a find bar with match navigation. +func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchInputResult { + if query == nil { + panic("mujica: SearchInput needs a query") + } + if opts.Find && opts.Current == nil { + panic("mujica: SearchInput Find mode needs Current") + } + if opts.Matches < 0 { + panic("mujica: SearchInput Matches is negative") + } + k := Tokens(c) + place := opts.Placeholder + if place == "" { + place = msg(c, "search_input.search") + if opts.Find { + place = msg(c, "search_input.find") + } + } + name := opts.Label + if name == "" { + name = place + } + var r SearchInputResult + step := func(d int) { + if opts.Matches == 0 { + return + } + *opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches + r.moved = true + slog.Debug("mujica: search_input move", "current", *opts.Current) + } + if opts.Find { + *opts.Current = max(0, min(*opts.Current, opts.Matches-1)) + if !opts.Disabled { + mac := runtime.GOOS == "darwin" + switch { + case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3): + step(1) + case mac && c.Shortcut(ui.Shift|ui.Cmd, ui.KeyG), !mac && c.Shortcut(ui.Shift, ui.KeyF3): + step(-1) + } + } + } + look := &textInputLook{disabled: opts.Disabled} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted) + back := ui.Local(frame, "back", func() bool { return false }) + in := ui.TextInputBase(c, query).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) + r.changed = in.Changed() + if opts.Find { + // Shift+Enter would reach the editor as a plain submit. + in.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyEnter && ev.Mods == ui.Shift { + *back = true + return true + } + return false + }) + switch { + case *back: + *back = false + step(-1) + case in.Submitted(): + step(1) + } + } else { + r.submitted = in.Submitted() + } + if *query != "" && textInputClear(c, in) { + *query = "" + r.changed = true + slog.Debug("mujica: search_input clear") + } + if opts.Find { + searchInputFindControls(c, *query, opts.Matches, *opts.Current, step) + } + return in + }, nil) + return r +} + +// searchInputFindControls shows the match count with previous and next buttons. +func searchInputFindControls(c *ui.Context, query string, matches, current int, step func(int)) { + k := Tokens(c) + status := msg(c, "search_input.none") + switch { + case query == "": + status = "" + case matches > 0: + status = fmt.Sprintf(msg(c, "search_input.count"), current+1, matches) + } + ui.Text(c, status).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum"). + SingleLine().Shrink(0).Role(ui.RoleStatus) + ui.Box(c).Width(theme.BorderWidth).Height(16).Background(k.Border).Shrink(0) + opts := IconButtonOptions{Variant: Ghost, Disabled: matches == 0} + if IconButton(c, icons.Must("chevron-up"), msg(c, "search_input.previous"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() { + step(-1) + } + if IconButton(c, icons.Must("chevron-down"), msg(c, "search_input.next"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() { + step(1) + } +} diff --git a/search_input_test.go b/search_input_test.go new file mode 100644 index 0000000..dc6cfb3 --- /dev/null +++ b/search_input_test.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "runtime" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSearchInputSearchClearSubmit(t *testing.T) { + q, changes, submits := "", 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := SearchInput(c, &q, SearchInputOptions{}) + if r.Changed() { + changes++ + } + if r.Submitted() { + submits++ + } + }, 400, 200) + _ = tt.Click("Search") + tt.Type("rose") + tt.Key(0, ui.KeyEnter) + if q != "rose" || changes != 1 || submits != 1 { + t.Fatalf("query %q changes %d submits %d", q, changes, submits) + } + _ = tt.Click("Clear") + if q != "" || changes != 2 { + t.Fatalf("after clear %q changes %d", q, changes) + } + tt.Type("lys") + tt.Key(0, ui.KeyEscape) + if q != "" || changes != 4 { + t.Fatalf("after Escape %q changes %d", q, changes) + } +} + +// searchInputFindTester counts matches of the query in a fixed text. +func searchInputFindTester(q *string, cur, moves *int, locale Locale) *ui.Tester { + const text = "rose lys rose crown rose" + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: locale}) + n := 0 + if *q != "" { + n = strings.Count(text, *q) + } + if SearchInput(c, q, SearchInputOptions{Find: true, Matches: n, Current: cur, Label: "Find in page"}).Moved() { + *moves++ + } + }, 500, 200) +} + +func TestSearchInputFindBarNavigation(t *testing.T) { + q, cur, moves := "", 0, 0 + tt := searchInputFindTester(&q, &cur, &moves, "") + _ = tt.Click("Find in page") + tt.Type("rose") + if !tt.HasText("1 of 3") { + t.Fatalf("count missing: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEnter) + if cur != 2 || !tt.HasText("3 of 3") { + t.Fatalf("after two Enters current %d texts %q", cur, tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if cur != 0 { + t.Fatalf("Enter did not wrap, current %d", cur) + } + tt.Key(ui.Shift, ui.KeyEnter) + if cur != 2 || q != "rose" { + t.Fatalf("Shift+Enter: current %d query %q", cur, q) + } + _ = tt.Click("Previous match") + _ = tt.Click("Next match") + _ = tt.Click("Next match") + if cur != 0 || moves != 7 { + t.Fatalf("buttons: current %d moves %d", cur, moves) + } + if runtime.GOOS == "darwin" { + tt.Key(ui.Cmd, ui.KeyG) + } else { + tt.Key(0, ui.KeyF3) + } + if cur != 1 { + t.Fatalf("find-next shortcut: current %d", cur) + } +} + +func TestSearchInputFindNoMatches(t *testing.T) { + q, cur, moves := "", 0, 0 + tt := searchInputFindTester(&q, &cur, &moves, ZhCN) + _ = tt.Click("Find in page") + tt.Type("tulip") + if !tt.HasText("无匹配") { + t.Fatalf("no-match status missing: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + _ = tt.Click("下一项") + if cur != 0 || moves != 0 { + t.Fatalf("moved without matches: current %d moves %d", cur, moves) + } +} + +func TestSearchInputFindNeedsCurrent(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without Current") + } + }() + q := "" + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SearchInput(c, &q, SearchInputOptions{Find: true}) + }, 200, 100) +} + +func TestSearchInputSnapshot(t *testing.T) { + snapshot(t, "search_input", 460, 220, func(c *ui.Context) { + a, b, f, cur := "", "Marie", "rose", 1 + ui.Column(c).Padding(24).Gap(12).Width(400).Children(func() { + SearchInput(c, &a, SearchInputOptions{}) + SearchInput(c, &b, SearchInputOptions{}) + SearchInput(c, &f, SearchInputOptions{Find: true, Matches: 3, Current: &cur}) + }) + }) +} diff --git a/segmented_control.go b/segmented_control.go new file mode 100644 index 0000000..f20cfe9 --- /dev/null +++ b/segmented_control.go @@ -0,0 +1,97 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// Segment is one choice of a SegmentedControl. +type Segment struct { + // Label is required: it is shown, or with HideLabel names the icon. + Label string + Icon *ui.SVG + HideLabel bool +} + +// SegmentedControlOptions configure a SegmentedControl. +type SegmentedControlOptions struct { + // Equal gives every segment the width of the widest. + Equal bool + // Label names the group for assistive technology. + Label string + Disabled bool +} + +// SegmentedControl chooses one of segments into *selected; Changed reports a new choice. +func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element { + if len(segments) == 0 { + panic("mujica: SegmentedControl needs at least one segment") + } + if *selected < 0 || *selected >= len(segments) { + panic("mujica: SegmentedControl selected index out of range") + } + k := Tokens(c) + d := Density(c) + u := d.Unit() + inset := float32(2) + prev := *selected + parts := ui.SegmentedBase(c, selected, len(segments)) + track := parts.Track.Padding(inset).Gap(inset).Radius(theme.ControlRadius+inset). + Background(k.Background).Border(theme.BorderWidth, k.ControlBorder).AlignItems(ui.Stretch). + MinHeight(d.ControlHeight()).Disabled(opts.Disabled) + if opts.Equal { + track.Grid().Columns(len(segments)) + } + if opts.Label != "" { + track.Label(opts.Label) + } + track.Children(func() { + for i, s := range segments { + if s.Label == "" { + panic("mujica: Segment needs a Label") + } + if s.HideLabel && s.Icon == nil { + panic("mujica: Segment with HideLabel needs an Icon") + } + on := i == *selected + seg := parts.Segment(i).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius). + MinWidth(theme.MinHitSize).Label(s.Label) + if !opts.Disabled { + seg.Cursor(ui.CursorPointer) + } + fg := k.TextMuted + switch { + case on: + seg.Background(k.Accent).FontWeight(600) + fg = k.OnAccent + case seg.Pressed(): + seg.Background(k.Selection) + fg = k.Text + case seg.Hovered(): + seg.Background(k.SurfaceHover) + fg = k.Text + } + seg.TextColor(fg) + internal.Ring(seg, k.Focus, theme.ControlRadius) + if s.HideLabel { + seg.Tooltip(s.Label) + } + seg.Children(func() { + if s.Icon != nil { + ui.Icon(c, s.Icon).Size(16, 16) + } + if !s.HideLabel { + ui.Text(c, s.Label).SingleLine() + } + }) + } + }) + if track.Changed() { + slog.Debug("mujica: segmented control change", "from", prev, "to", *selected) + } + return track +} diff --git a/segmented_control_test.go b/segmented_control_test.go new file mode 100644 index 0000000..56ea8c2 --- /dev/null +++ b/segmented_control_test.go @@ -0,0 +1,112 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestSegmentedControlClickAndKeys(t *testing.T) { + sel, changes := 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if SegmentedControl(c, &sel, []Segment{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}, SegmentedControlOptions{Label: "Range"}).Changed() { + changes++ + } + }, 400, 100) + if err := tt.Click("Month"); err != nil { + t.Fatal(err) + } + if sel != 2 || changes != 1 { + t.Fatalf("click: sel %d changes %d", sel, changes) + } + tt.Key(0, ui.KeyRight) + if sel != 0 || changes != 2 { + t.Fatalf("Right wraps to %d, changes %d", sel, changes) + } + tt.Key(0, ui.KeyLeft) + if sel != 2 { + t.Fatalf("Left wraps to %d", sel) + } + tt.Key(0, ui.KeyHome) + if sel != 0 || !tt.Focused("Day") { + t.Fatalf("Home chose %d", sel) + } +} + +func TestSegmentedControlIconsAndDisabled(t *testing.T) { + sel := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SegmentedControl(c, &sel, []Segment{ + {Label: "List", Icon: icons.Must("list"), HideLabel: true}, + {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true}, + }, SegmentedControlOptions{Disabled: true}) + }, 400, 100) + if _, ok := tt.Find("Grid"); !ok { + t.Fatal("icon segment has no name") + } + _ = tt.Click("Grid") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if sel != 0 { + t.Fatalf("disabled control changed to %d", sel) + } +} + +func TestSegmentedControlEqualWidths(t *testing.T) { + sel := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + SegmentedControl(c, &sel, []Segment{{Label: "A"}, {Label: "Much longer"}}, SegmentedControlOptions{Equal: true}) + }) + }, 400, 100) + a, _ := tt.Find("A") + b, _ := tt.Find("Much longer") + // Equal segments center their text: A's center sits a segment width before the other's. + da := (b.X + b.W/2) - (a.X + a.W/2) + if da < b.W { + t.Fatalf("segments not equal: A %+v, long %+v", a, b) + } +} + +func TestSegmentedControlInvalid(t *testing.T) { + for name, segs := range map[string][]Segment{ + "empty": nil, + "label": {{Icon: icons.Must("list")}}, + "noicon": {{Label: "X", HideLabel: true}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + sel := 0 + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + SegmentedControl(c, &sel, segs, SegmentedControlOptions{}) + }, 300, 100, 1) + }) + } +} + +func TestSegmentedControlSnapshot(t *testing.T) { + snapshot(t, "segmented_control", 480, 180, func(c *ui.Context) { + a, b, e := 1, 0, 2 + ui.Column(c).Padding(16).Gap(16).AlignItems(ui.Start).Children(func() { + SegmentedControl(c, &a, []Segment{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}, SegmentedControlOptions{}) + ui.Row(c).Gap(16).Children(func() { + SegmentedControl(c, &b, []Segment{ + {Label: "List", Icon: icons.Must("list"), HideLabel: true}, + {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true}, + }, SegmentedControlOptions{}) + SegmentedControl(c, &b, []Segment{{Label: "Light", Icon: icons.Must("sun")}, {Label: "Dark", Icon: icons.Must("moon")}}, SegmentedControlOptions{Disabled: true}) + }) + SegmentedControl(c, &e, []Segment{{Label: "S"}, {Label: "Medium"}, {Label: "XL"}}, SegmentedControlOptions{Equal: true}) + }) + }) +} diff --git a/sidebar.go b/sidebar.go new file mode 100644 index 0000000..339400e --- /dev/null +++ b/sidebar.go @@ -0,0 +1,182 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// SidebarItem is a destination of a Sidebar, or with Items a nested group. +type SidebarItem struct { + // ID is what choosing the item stores; unused for groups. + ID string + Label string + Icon *ui.SVG + // Count shows beside the label when positive. + Count int + // Items nest under the item, which then expands and collapses. + Items []SidebarItem +} + +// SidebarSection is a titled group of a Sidebar; its title collapses it. +type SidebarSection struct { + Title string + Items []SidebarItem +} + +// SidebarOptions configure a Sidebar. +type SidebarOptions struct { + // Compact shows only icons, labels as tooltips; every item needs an Icon. + Compact bool + // Width is 240 DIPs when zero, 56 when Compact. + Width float32 +} + +// SidebarResult is the built Sidebar. +type SidebarResult struct { + *ui.Element + changed bool +} + +// Changed reports a new choice in this frame. +func (r SidebarResult) Changed() bool { return r.changed } + +// Sidebar is a source list choosing an item ID into *selected, built on MyGo's Sidebar. +func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts SidebarOptions) SidebarResult { + k := Tokens(c) + width := opts.Width + if width == 0 { + width = 240 + if opts.Compact { + width = 56 + } + } + wrap := ui.Column(c).Width(width).FillHeight().AlignItems(ui.Stretch).Shrink(0) + closed := ui.Local(wrap, "closed", func() map[string]*bool { return map[string]*bool{} }) + res := SidebarResult{Element: wrap} + prev := *selected + wrap.Children(func() { + // MyGo draws icons in Accent: swap in readable state colors for the sidebar. + saved := c.Theme() + t := *saved + t.Accent, t.AccentText, t.Surface = k.AccentText, k.AccentText, k.Surface + c.SetTheme(&t) + sb := ui.Sidebar(c, selected, func() { + for si, s := range sections { + if opts.Compact { + if si > 0 { + sidebarRule(c) + } + sidebarFlat(c, *selected, s.Items) + continue + } + if s.Title == "" { + panic("mujica: SidebarSection needs a Title") + } + ui.SidebarSection(c, s.Title, sidebarOpen(*closed, "s:"+s.Title), func() { + sidebarItems(c, *selected, s.Items, *closed, 0) + }) + } + }).FillHeight().FillWidth() + sb.DrawOver(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, k.Border, 0) + }) + c.SetTheme(saved) + if sb.Changed() { + res.changed = true + slog.Debug("mujica: sidebar choose", "from", prev, "to", *selected) + } + }) + return res +} + +// sidebarOpen returns the shown state of a collapsible part, open at first. +func sidebarOpen(closed map[string]*bool, key string) *bool { + o, ok := closed[key] + if !ok { + o = new(bool) + *o = true + closed[key] = o + } + return o +} + +func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed map[string]*bool, depth int) { + u := Density(c).Unit() + for _, it := range items { + if it.Label == "" { + panic("mujica: SidebarItem needs a Label") + } + if it.Items != nil { + ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() { + ui.SidebarSection(c, it.Label, sidebarOpen(closed, fmt.Sprintf("g%d:%s", depth, it.Label)), func() { + sidebarItems(c, selected, it.Items, closed, depth+1) + }) + }) + continue + } + ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() { + sidebarItem(c, selected, it, false) + }) + } +} + +// sidebarFlat lists every leaf for the compact rail. +func sidebarFlat(c *ui.Context, selected string, items []SidebarItem) { + for _, it := range items { + if it.Items != nil { + sidebarFlat(c, selected, it.Items) + continue + } + if it.Icon == nil { + panic("mujica: compact Sidebar items need an Icon") + } + sidebarItem(c, selected, it, true) + } +} + +func sidebarItem(c *ui.Context, selected string, it SidebarItem, compact bool) { + if it.ID == "" { + panic("mujica: SidebarItem needs an ID") + } + k := Tokens(c) + d := Density(c) + on := it.ID == selected + label := it.Label + if compact { + label = "" + } + e := ui.SidebarItem(c, it.ID, it.Icon, label).MinHeight(d.ControlHeight()).Radius(theme.ControlRadius) + name := it.Label + if it.Count > 0 { + name = fmt.Sprintf("%s (%d)", it.Label, it.Count) + } + if compact { + e.Tooltip(name).Label(name) + } else if it.Count > 0 { + e.Label(name).Children(func() { + ui.Text(c, sidebarCount(it.Count)).FontSize(theme.CaptionSize).FontFeatures("tnum"). + TextColor(k.TextMuted).Padding(0, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background) + }) + } + if on { + e.Background(k.Selection).TextColor(k.Text).FontWeight(600) + e.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5) + }) + } +} + +func sidebarCount(n int) string { + if n > 999 { + return "999+" + } + return fmt.Sprint(n) +} + +func sidebarRule(c *ui.Context) { + ui.Box(c).Height(1).Margin(Density(c).Unit()*2, 4).Background(Tokens(c).Ornament).Role(ui.RoleNone) +} diff --git a/sidebar_test.go b/sidebar_test.go new file mode 100644 index 0000000..9eb9f63 --- /dev/null +++ b/sidebar_test.go @@ -0,0 +1,135 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func sidebarSample() []SidebarSection { + return []SidebarSection{ + {Title: "Library", Items: []SidebarItem{ + {ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 12}, + {ID: "starred", Label: "Starred", Icon: icons.Must("star")}, + {Label: "Projects", Items: []SidebarItem{ + {ID: "opera", Label: "Opera", Icon: icons.Must("folder")}, + {ID: "ballet", Label: "Ballet", Icon: icons.Must("folder")}, + }}, + }}, + {Title: "System", Items: []SidebarItem{ + {ID: "settings", Label: "Settings", Icon: icons.Must("settings")}, + }}, + } +} + +func TestSidebarChooseAndKeys(t *testing.T) { + sel, changes := "inbox", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Sidebar(c, &sel, sidebarSample(), SidebarOptions{}).Changed() { + changes++ + } + }, 400, 500) + if err := tt.Click("Opera"); err != nil { + t.Fatal(err) + } + if sel != "opera" || changes != 1 { + t.Fatalf("click: %q %d", sel, changes) + } + tt.Key(0, ui.KeyDown) + if sel != "ballet" { + t.Fatalf("Down chose %q", sel) + } + tt.Key(0, ui.KeyEnd) + if sel != "settings" { + t.Fatalf("End chose %q", sel) + } + tt.Key(0, ui.KeyHome) + if sel != "inbox" || changes != 4 { + t.Fatalf("Home chose %q, changes %d", sel, changes) + } +} + +func TestSidebarCollapse(t *testing.T) { + sel := "inbox" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Sidebar(c, &sel, sidebarSample(), SidebarOptions{}) + }, 400, 500) + if err := tt.Click("Projects"); err != nil { + t.Fatal(err) + } + if tt.HasText("Opera") { + t.Fatal("nested group did not collapse") + } + if err := tt.Click("Library"); err != nil { + t.Fatal(err) + } + if tt.HasText("Starred") || !tt.HasText("Settings") { + t.Fatalf("section did not collapse: %q", tt.Texts()) + } + _ = tt.Click("Library") + if !tt.HasText("Starred") || tt.HasText("Opera") { + t.Fatalf("collapse state lost: %q", tt.Texts()) + } +} + +func TestSidebarCompact(t *testing.T) { + sel := "inbox" + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).FillHeight().Children(func() { + w = Sidebar(c, &sel, sidebarSample(), SidebarOptions{Compact: true}).Bounds().W + }) + }, 400, 500) + tt.Frame() + if w != 56 { + t.Fatalf("compact width %v", w) + } + if err := tt.Click("Ballet"); err != nil { + t.Fatal(err) + } + if sel != "ballet" { + t.Fatalf("compact click chose %q", sel) + } + if _, ok := tt.Find("Inbox (12)"); !ok { + t.Fatalf("compact item lost its name: %q", tt.Texts()) + } +} + +func TestSidebarInvalid(t *testing.T) { + for name, tc := range map[string]struct { + s []SidebarSection + opts SidebarOptions + }{ + "title": {[]SidebarSection{{Items: []SidebarItem{{ID: "a", Label: "A"}}}}, SidebarOptions{}}, + "id": {[]SidebarSection{{Title: "T", Items: []SidebarItem{{Label: "A"}}}}, SidebarOptions{}}, + "compact": {[]SidebarSection{{Title: "T", Items: []SidebarItem{{ID: "a", Label: "A"}}}}, SidebarOptions{Compact: true}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + sel := "" + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Sidebar(c, &sel, tc.s, tc.opts) + }, 300, 300, 1) + }) + } +} + +func TestSidebarSnapshot(t *testing.T) { + snapshot(t, "sidebar", 420, 360, func(c *ui.Context) { + a, b := "opera", "starred" + ui.Row(c).Fill().Gap(24).Children(func() { + Sidebar(c, &a, sidebarSample(), SidebarOptions{}) + Sidebar(c, &b, sidebarSample(), SidebarOptions{Compact: true}) + }) + }) +} diff --git a/skeleton.go b/skeleton.go new file mode 100644 index 0000000..eefa1b5 --- /dev/null +++ b/skeleton.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// SkeletonShape is the placeholder's form. The zero value is SkeletonText. +type SkeletonShape int + +const ( + SkeletonText SkeletonShape = iota + SkeletonCircle + SkeletonRect +) + +// SkeletonOptions configure a Skeleton. Sizes are fixed so content does not shift. +type SkeletonOptions struct { + Shape SkeletonShape + // Width fills the parent when zero; a circle uses it as its diameter (40 DIP when zero). + Width float32 + // Height is the rect height (ControlHeight when zero); text derives it from Lines. + Height float32 + // Lines is the text line count, 1 when zero; the last of several is shorter. + Lines int +} + +// Skeleton stands in for content that is loading. It pulses while painted +// and shows a static fill under reduced motion. +func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element { + if opts.Width < 0 || opts.Height < 0 || opts.Lines < 0 { + panic("mujica: negative Skeleton size") + } + k := Tokens(c) + still := c.Preferences().ReduceMotion + fill := k.SurfaceHover + if k.Dark { + fill = k.Border + } + paint := func(p *ui.Painter, r ui.Rect, radius float32) { + a := float32(1) + if !still { + p.AnimationFrame() + t := float64(p.Now().UnixMilli()%1600) / 1600 + a = 0.55 + 0.45*float32(0.5+0.5*math.Cos(2*math.Pi*t)) + } + p.Fill(r, fill.Alpha(a), radius) + } + e := ui.Box(c).Role(ui.RoleNone) + if opts.Width > 0 { + e.Width(opts.Width) + } else if opts.Shape != SkeletonCircle { + e.FillWidth() + } + switch opts.Shape { + case SkeletonCircle: + d := opts.Width + if d == 0 { + d = 40 + } + e.Size(d, d).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, r.W/2) }) + case SkeletonRect: + h := opts.Height + if h == 0 { + h = Density(c).ControlHeight() + } + e.Height(h).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, theme.CardRadius) }) + default: + lines := max(opts.Lines, 1) + line := theme.BodySize * theme.LineHeight + e.Height(line * float32(lines)).Draw(func(p *ui.Painter, r ui.Rect) { + bar := theme.BodySize * 0.8 + for i := range lines { + w := r.W + if lines > 1 && i == lines-1 { + w *= 0.6 + } + y := r.Y + float32(i)*line + (line-bar)/2 + paint(p, ui.Rect{X: r.X, Y: y, W: w, H: bar}, theme.ControlRadius) + } + }) + } + return e +} diff --git a/skeleton_test.go b/skeleton_test.go new file mode 100644 index 0000000..a9b2661 --- /dev/null +++ b/skeleton_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestSkeletonSizes(t *testing.T) { + var text, circle, rect, fixed ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ui.Column(c).Width(300).AlignItems(ui.Start).Children(func() { + text = Skeleton(c, SkeletonOptions{Lines: 3}).Bounds() + circle = Skeleton(c, SkeletonOptions{Shape: SkeletonCircle}).Bounds() + rect = Skeleton(c, SkeletonOptions{Shape: SkeletonRect}).Bounds() + fixed = Skeleton(c, SkeletonOptions{Shape: SkeletonRect, Width: 120, Height: 80}).Bounds() + }) + }, 400, 400) + tt.Frame() + if text.W != 300 || text.H != 63 { + t.Errorf("text %v", text) + } + if circle.W != 40 || circle.H != 40 { + t.Errorf("circle %v", circle) + } + if rect.W != 300 || rect.H != 28 { + t.Errorf("rect %v", rect) + } + if fixed.W != 120 || fixed.H != 80 { + t.Errorf("fixed %v", fixed) + } +} + +func TestSkeletonStaticUnderReducedMotion(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Skeleton(c, SkeletonOptions{Shape: SkeletonRect}) + }, 200, 100) + a := encode(t, tt.Image()) + time.Sleep(300 * time.Millisecond) + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) { + t.Fatal("skeleton did not pulse") + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + a = encode(t, tt.Image()) + time.Sleep(300 * time.Millisecond) + tt.Frame() + if !bytes.Equal(a, encode(t, tt.Image())) { + t.Fatal("skeleton moved under reduced motion") + } +} + +func TestSkeletonPanicsOnNegativeSize(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Skeleton(c, SkeletonOptions{Lines: -1}) + }, 200, 100) +} + +func TestSkeletonSnapshot(t *testing.T) { + snapshot(t, "skeleton", 420, 200, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + Skeleton(c, SkeletonOptions{Shape: SkeletonCircle}) + ui.Column(c).Grow(1).Children(func() { + Skeleton(c, SkeletonOptions{Lines: 2}) + }) + }) + Skeleton(c, SkeletonOptions{Shape: SkeletonRect, Height: 72}) + }) + }) +} diff --git a/spinner.go b/spinner.go index 990ba09..2f1e9dd 100644 --- a/spinner.go +++ b/spinner.go @@ -19,6 +19,7 @@ type SpinnerOptions struct { } // Spinner draws four diamonds turning around a center while it shows. +// It repaints only while painted: hidden, it stops. Reduced motion holds it still. func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element { k := Tokens(c) size := opts.Size @@ -34,26 +35,31 @@ func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element { color = k.AccentText } still := c.Preferences().ReduceMotion - row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleProgress).Label(label) + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center) row.Children(func() { - ui.Box(c).Key(still).Size(size, size).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { - turn := 0.0 - if !still { - turn = float64(p.Now().UnixMilli()%1200) / 1200 - p.AnimationFrame() - } - cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.32 - for i := range 4 { - a := 2 * math.Pi * (turn + float64(i)/4) - x := cx + rad*float32(math.Cos(a)) - y := cy + rad*float32(math.Sin(a)) - alpha := 1 - float32(i)*0.22 - p.FillPath(internal.Diamond(internal.Snap(p, x), internal.Snap(p, y), internal.Snap(p, r.W*0.3)), color.Alpha(alpha)) - } - }) + // Keyed by motion so a flip repaints; MyGo's spinner carries the progress role. + ui.Box(c).Key(still).Shrink(0).Children(func() { spinnerMark(c, label, size, color, still) }) if opts.ShowLabel { ui.Text(c, label).TextColor(k.TextMuted).SingleLine() } }) return row } + +func spinnerMark(c *ui.Context, label string, size float32, color ui.Color, still bool) { + ui.Spinner(c).Label(label).Size(size, size).Draw(func(p *ui.Painter, r ui.Rect) { + turn := 0.0 + if !still { + turn = float64(p.Now().UnixMilli()%1200) / 1200 + p.AnimationFrame() + } + cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.32 + for i := range 4 { + a := 2 * math.Pi * (turn + float64(i)/4) + x := cx + rad*float32(math.Cos(a)) + y := cy + rad*float32(math.Sin(a)) + alpha := 1 - float32(i)*0.22 + p.FillPath(internal.Diamond(internal.Snap(p, x), internal.Snap(p, y), internal.Snap(p, r.W*0.3)), color.Alpha(alpha)) + } + }) +} diff --git a/spinner_test.go b/spinner_test.go new file mode 100644 index 0000000..436ad18 --- /dev/null +++ b/spinner_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "bytes" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestSpinnerLabelAndSize(t *testing.T) { + var mark ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + Spinner(c, SpinnerOptions{}) + Spinner(c, SpinnerOptions{Size: 32, Label: "Fetching mail", ShowLabel: true}) + }, 400, 300) + r, ok := tt.Find("加载中") + if !ok || r.W != 16 || r.H != 16 { + t.Fatalf("default mark %v %v", r, ok) + } + if n := len(slices.DeleteFunc(tt.Texts(), func(s string) bool { return s != "加载中" })); n != 1 { + t.Fatalf("hidden label painted: %d", n) + } + mark, ok = tt.Find("Fetching mail") + if !ok || mark.W != 32 { + t.Fatalf("sized mark %v %v", mark, ok) + } + n := 0 + for _, s := range tt.Texts() { + if s == "Fetching mail" { + n++ + } + } + if n != 2 { + t.Fatalf("label as name and text: %d of %q", n, tt.Texts()) + } +} + +func TestSpinnerMotion(t *testing.T) { + show := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if show { + Spinner(c, SpinnerOptions{Size: 40}) + } + }, 100, 100) + a := encode(t, tt.Image()) + time.Sleep(150 * time.Millisecond) + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) { + t.Fatal("spinner did not turn") + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + a = encode(t, tt.Image()) + time.Sleep(150 * time.Millisecond) + tt.Frame() + if !bytes.Equal(a, encode(t, tt.Image())) { + t.Fatal("spinner turned under reduced motion") + } + show = false + tt.Frame() + if _, ok := tt.Find("Loading"); ok { + t.Fatal("hidden spinner still built") + } +} + +func TestSpinnerSnapshot(t *testing.T) { + snapshot(t, "spinner", 360, 100, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(24).AlignItems(ui.Center).Children(func() { + Spinner(c, SpinnerOptions{}) + Spinner(c, SpinnerOptions{Size: 24}) + Spinner(c, SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true}) + Button(c, "Saving", ButtonOptions{Loading: true}) + }) + }) +} diff --git a/split_pane.go b/split_pane.go new file mode 100644 index 0000000..5d0dc32 --- /dev/null +++ b/split_pane.go @@ -0,0 +1,264 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SplitPaneSide names a pane of a SplitPane. +type SplitPaneSide int + +const ( + SplitPaneNeither SplitPaneSide = iota + SplitPaneFirst + SplitPaneSecond +) + +// SplitPaneState is the app-held layout of a SplitPane. +type SplitPaneState struct { + // Size is the first pane's width, or height when vertical, in DIPs; zero starts at half. + Size float32 + // Collapsed hides the Collapsible pane; Size is kept for when it opens again. + Collapsed bool +} + +// SplitPaneOptions configure a SplitPane. +type SplitPaneOptions struct { + // Vertical puts the first pane above the second. + Vertical bool + // MinFirst and MinSecond bound the panes in DIPs; zero is 10 spacing units. + MinFirst, MinSecond float32 + // Collapsible lets that pane collapse: Enter on the divider, a double click, + // dragging it under half its minimum, or an arrow key past its minimum. + Collapsible SplitPaneSide +} + +// SplitPaneResult is a SplitPane and its events in this frame. +type SplitPaneResult struct { + *ui.Element + Resized bool + Toggled bool +} + +type splitPaneDrag struct { + held bool + // want is where the pointer asks the divider to be, before clamping. + want float32 +} + +// SplitPane lays two panes side by side with a divider the pointer drags and the arrow keys move. +func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, first, second func()) SplitPaneResult { + if state == nil || first == nil || second == nil { + panic("mujica: SplitPane needs a state and two panes") + } + if opts.MinFirst < 0 || opts.MinSecond < 0 { + panic("mujica: SplitPane minimums must not be negative") + } + if opts.Collapsible < SplitPaneNeither || opts.Collapsible > SplitPaneSecond { + panic("mujica: invalid SplitPane Collapsible") + } + if state.Collapsed && opts.Collapsible == SplitPaneNeither { + panic("mujica: SplitPane collapsed without a Collapsible pane") + } + k := Tokens(c) + u := Density(c).Unit() + lo, minSecond := opts.MinFirst, opts.MinSecond + if lo == 0 { + lo = 10 * u + } + if minSecond == 0 { + minSecond = 10 * u + } + e := ui.Row(c).AlignItems(ui.Stretch) + if opts.Vertical { + e.Column() + } + total := e.Bounds().W + if opts.Vertical { + total = e.Bounds().H + } + hi := max(lo, total-theme.BorderWidth-minSecond) + clamp := func(v float32) float32 { return max(lo, min(v, hi)) } + if state.Size <= 0 && total > 0 { + state.Size = clamp((total - theme.BorderWidth) / 2) + } else if total > 0 { + state.Size = clamp(state.Size) + } else if first := ui.Local(e, "split_pane.first", func() bool { return true }); *first { + // Not laid out yet: measure, then size in the next frame. + *first = false + c.Invalidate() + } + var res SplitPaneResult + resize := func(v float32) { + if v = clamp(v); v != state.Size { + state.Size = v + res.Resized = true + } + } + toggle := func() { + state.Collapsed = !state.Collapsed + res.Toggled = true + slog.Debug("mujica: split_pane toggled", "collapsed", state.Collapsed) + } + firstShown := !(state.Collapsed && opts.Collapsible == SplitPaneFirst) + secondShown := !(state.Collapsed && opts.Collapsible == SplitPaneSecond) + along := func(el *ui.Element, v float32) { + if opts.Vertical { + el.Height(v) + } else { + el.Width(v) + } + } + e.Children(func() { + if firstShown { + a := ui.Box(c).Key("first").Shrink(0).Clip() + switch { + case !secondShown: + a.Grow(1).Shrink(1) + case state.Size <= 0 && opts.Vertical: + a.HeightPercent(50) + case state.Size <= 0: + a.WidthPercent(50) + default: + along(a, state.Size) + } + a.Children(first) + } + div := ui.Box(c).Key("divider").Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).Label(msg(c, "split_pane.divider")) + along(div, theme.BorderWidth) + if state.Collapsed { + div.Description(msg(c, "split_pane.collapsed")) + } else if total > 0 { + div.Description(fmt.Sprintf("%.0f%%", 100*state.Size/total)) + } + splitPaneKeys(c, div, state, opts, lo, hi, u, resize, toggle) + if secondShown { + b := ui.Box(c).Key("second").Grow(1).Shrink(1).Clip() + if opts.Vertical { + b.MinHeight(0) + } else { + b.MinWidth(0) + } + b.Children(second) + } + at := state.Size + switch { + case !firstShown: + at = 0 + case !secondShown: + at = total - theme.BorderWidth + } + grip := 1.5 * u + handle := ui.Box(c).Key("handle").Absolute() + if opts.Vertical { + handle.Left(0).Right(0).Top(at - (grip-1)/2).Height(grip).Cursor(ui.CursorResizeNS) + } else { + handle.Top(0).Bottom(0).Left(at - (grip-1)/2).Width(grip).Cursor(ui.CursorResizeEW) + } + drag := ui.Local(handle, "split_pane.drag", func() splitPaneDrag { return splitPaneDrag{} }) + dx, dy, held := handle.Dragged() + if held { + div.Focus() + if !drag.held { + drag.want = at + } + delta := dx + if opts.Vertical { + delta = dy + } + if delta != 0 { + drag.want += delta + splitPaneFollow(state, opts, drag.want, lo, minSecond, total, resize, toggle) + } + } + drag.held = held + if opts.Collapsible != SplitPaneNeither && handle.DoubleClicked() { + toggle() + } + active := held || handle.Hovered() + div.Draw(func(p *ui.Painter, r ui.Rect) { + line := k.ControlBorder + if active { + line = k.AccentText + } + p.Fill(r, line, 0) + if state.Collapsed { + // A short tab marks the edge a collapsed pane opens from. + if opts.Vertical { + p.Fill(ui.Rect{X: r.X + r.W/2 - 12, Y: r.Y - 1, W: 24, H: 3}, line, 1.5) + } else { + p.Fill(ui.Rect{X: r.X - 1, Y: r.Y + r.H/2 - 12, W: 3, H: 24}, line, 1.5) + } + } + }) + handle.Draw(func(p *ui.Painter, r ui.Rect) { + if div.FocusVisible() { + internal.FocusRing(p, r, 0, k.Focus) + } + }) + }) + if res.Resized || res.Toggled { + c.Invalidate() + } + res.Element = e + return res +} + +// splitPaneFollow moves the divider where the pointer wants it; past half its minimum, a collapsible pane closes. +func splitPaneFollow(state *SplitPaneState, opts SplitPaneOptions, want, lo, minSecond, total float32, resize func(float32), toggle func()) { + var past bool + switch opts.Collapsible { + case SplitPaneFirst: + past = want < lo/2 + case SplitPaneSecond: + past = total-theme.BorderWidth-want < minSecond/2 + } + if past != state.Collapsed { + toggle() + } + if !state.Collapsed { + resize(want) + } +} + +// splitPaneKeys moves the divider with the arrows, Home and End, and toggles the collapsible pane with Enter. +func splitPaneKeys(c *ui.Context, div *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, hi, u float32, resize func(float32), toggle func()) { + back, forth := ui.KeyLeft, ui.KeyRight + if opts.Vertical { + back, forth = ui.KeyUp, ui.KeyDown + } + step := 4 * u + switch { + case div.Shortcut(0, back): + switch { + case state.Collapsed && opts.Collapsible == SplitPaneSecond: + toggle() + case state.Collapsed: + case opts.Collapsible == SplitPaneFirst && state.Size <= lo: + toggle() + default: + resize(state.Size - step) + } + case div.Shortcut(0, forth): + switch { + case state.Collapsed && opts.Collapsible == SplitPaneFirst: + toggle() + case state.Collapsed: + case opts.Collapsible == SplitPaneSecond && state.Size >= hi: + toggle() + default: + resize(state.Size + step) + } + case div.Shortcut(0, ui.KeyHome) && !state.Collapsed: + resize(lo) + case div.Shortcut(0, ui.KeyEnd) && !state.Collapsed: + resize(hi) + case opts.Collapsible != SplitPaneNeither && div.Shortcut(0, ui.KeyEnter): + toggle() + } +} diff --git a/split_pane_test.go b/split_pane_test.go new file mode 100644 index 0000000..463c883 --- /dev/null +++ b/split_pane_test.go @@ -0,0 +1,190 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type splitPaneProbe struct { + resized, toggled int +} + +func splitPaneTester(st *SplitPaneState, opts SplitPaneOptions, p *splitPaneProbe) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := SplitPane(c, st, opts, func() { ui.Text(c, "Files") }, func() { ui.Text(c, "Editor") }) + r.Fill() + if r.Resized { + p.resized++ + } + if r.Toggled { + p.toggled++ + } + }, 401, 200) +} + +func TestSplitPaneStartsAtHalf(t *testing.T) { + st := &SplitPaneState{} + tt := splitPaneTester(st, SplitPaneOptions{}, &splitPaneProbe{}) + if st.Size != 200 { + t.Fatalf("size %v, want 200", st.Size) + } + if r, _ := tt.Find("Editor"); r.X != 201 { + t.Fatalf("second pane at %v, want 201", r.X) + } +} + +func TestSplitPaneKeyboard(t *testing.T) { + st := &SplitPaneState{Size: 150} + p := &splitPaneProbe{} + tt := splitPaneTester(st, SplitPaneOptions{MinFirst: 100, MinSecond: 120}, p) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Divider") { + t.Fatal("Tab did not focus the divider") + } + tt.Key(0, ui.KeyRight) + if st.Size != 166 { + t.Fatalf("Right moved to %v, want 166", st.Size) + } + tt.Key(0, ui.KeyHome) + if st.Size != 100 { + t.Fatalf("Home moved to %v, want the minimum 100", st.Size) + } + tt.Key(0, ui.KeyLeft) + if st.Size != 100 || st.Collapsed { + t.Fatalf("Left past the minimum gave %v collapsed=%v", st.Size, st.Collapsed) + } + tt.Key(0, ui.KeyEnd) + if st.Size != 401-1-120 { + t.Fatalf("End moved to %v, want 280", st.Size) + } + tt.Key(0, ui.KeyEnter) + if st.Collapsed || p.toggled != 0 { + t.Fatal("Enter collapsed a pane that is not collapsible") + } + if p.resized != 3 { + t.Fatalf("resized %d times, want 3", p.resized) + } +} + +func TestSplitPaneDragClampsToMinimum(t *testing.T) { + st := &SplitPaneState{Size: 200} + tt := splitPaneTester(st, SplitPaneOptions{MinFirst: 80}, &splitPaneProbe{}) + tt.Press(200, 100) + tt.Move(240, 100) + if st.Size != 240 { + t.Fatalf("dragged to %v, want 240", st.Size) + } + tt.Move(10, 100) + tt.Release(10, 100) + if st.Size != 80 || st.Collapsed { + t.Fatalf("dragged past the minimum to %v collapsed=%v, want 80", st.Size, st.Collapsed) + } + if !tt.Focused("Divider") { + t.Fatal("drag did not focus the divider") + } +} + +func TestSplitPaneCollapseFirst(t *testing.T) { + st := &SplitPaneState{Size: 200} + p := &splitPaneProbe{} + tt := splitPaneTester(st, SplitPaneOptions{MinFirst: 100, Collapsible: SplitPaneFirst}, p) + // Dragging under half the minimum collapses; back over it opens again. + tt.Press(200, 100) + tt.Move(120, 100) + if st.Collapsed || st.Size != 120 { + t.Fatalf("at 120: size %v collapsed=%v", st.Size, st.Collapsed) + } + tt.Move(40, 100) + if !st.Collapsed || tt.HasText("Files") { + t.Fatalf("under half the minimum: collapsed=%v files shown=%v", st.Collapsed, tt.HasText("Files")) + } + if r, _ := tt.Find("Editor"); r.X != 1 { + t.Fatalf("second pane at %v, want the whole width from 1", r.X) + } + tt.Move(160, 100) + tt.Release(160, 100) + if st.Collapsed || st.Size != 160 { + t.Fatalf("dragged back: size %v collapsed=%v", st.Size, st.Collapsed) + } + // Keyboard: Left at the minimum collapses, Right opens, Enter toggles. + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyLeft) + if !st.Collapsed { + t.Fatal("Left at the minimum did not collapse") + } + tt.Key(0, ui.KeyRight) + if st.Collapsed || st.Size != 100 { + t.Fatalf("Right did not reopen at 100: size %v collapsed=%v", st.Size, st.Collapsed) + } + tt.Key(0, ui.KeyEnter) + if !st.Collapsed { + t.Fatal("Enter did not collapse") + } + tt.Key(0, ui.KeyEnter) + if st.Collapsed || !tt.HasText("Files") { + t.Fatal("Enter did not reopen") + } + if p.toggled != 6 { + t.Fatalf("toggled %d times, want 6", p.toggled) + } +} + +func TestSplitPaneCollapseSecondVertical(t *testing.T) { + st := &SplitPaneState{Size: 100} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SplitPane(c, st, SplitPaneOptions{Vertical: true, MinSecond: 60, Collapsible: SplitPaneSecond}, + func() { ui.Text(c, "Editor") }, func() { ui.Text(c, "Console") }).Fill() + }, 300, 201) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if st.Size != 116 { + t.Fatalf("Down moved to %v, want 116", st.Size) + } + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyDown) + if !st.Collapsed || tt.HasText("Console") { + t.Fatal("Down past the second minimum did not collapse it") + } + tt.Key(0, ui.KeyUp) + if st.Collapsed || st.Size != 140 { + t.Fatalf("Up did not reopen at 140: size %v collapsed=%v", st.Size, st.Collapsed) + } +} + +func TestSplitPaneDoubleClickToggles(t *testing.T) { + st := &SplitPaneState{Size: 200} + tt := splitPaneTester(st, SplitPaneOptions{Collapsible: SplitPaneFirst}, &splitPaneProbe{}) + tt.ClickAt(200, 100) + tt.ClickAt(200, 100) + if !st.Collapsed || st.Size != 200 { + t.Fatalf("double click: collapsed=%v size %v, want collapsed keeping 200", st.Collapsed, st.Size) + } +} + +func TestSplitPaneRejectsCollapsedWithoutSide(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("collapsed state without a Collapsible pane did not panic") + } + }() + splitPaneTester(&SplitPaneState{Collapsed: true}, SplitPaneOptions{}, &splitPaneProbe{}) +} + +func TestSplitPaneSnapshot(t *testing.T) { + snapshot(t, "split_pane", 420, 160, func(c *ui.Context) { + k := Tokens(c) + st := ui.Local(c.Root(), "snap", func() SplitPaneState { return SplitPaneState{Size: 140} }) + pane := func(s string, bg ui.Color) func() { + return func() { + ui.Box(c).Fill().Padding(12).Background(bg).Children(func() { ui.Text(c, s) }) + } + } + ui.Column(c).Padding(16).Children(func() { + SplitPane(c, st, SplitPaneOptions{}, pane("Files", k.Surface), pane("Editor", k.Background)). + Height(128).Border(1, k.Border).Radius(4).Clip() + }) + }) +} diff --git a/stack.go b/stack.go new file mode 100644 index 0000000..1914350 --- /dev/null +++ b/stack.go @@ -0,0 +1,69 @@ +package mujica + +import "github.com/egoist/mygo/ui" + +// StackAlign places children across a Stack's direction. Zero stretches a vertical stack and centers a horizontal one. +type StackAlign int + +const ( + StackAlignAuto StackAlign = iota + StackAlignStart + StackAlignCenter + StackAlignEnd + StackAlignStretch +) + +// StackOptions configure a Stack. +type StackOptions struct { + // Horizontal lays children left to right; otherwise top to bottom. + Horizontal bool + // Gap defaults to SpaceS. + Gap Space + Align StackAlign + // Justify places children along the direction; zero is ui.Start. + Justify ui.Align + // Wrap moves children of a horizontal stack onto more lines. + Wrap bool +} + +// Stack arranges children in a row or column. Build children with StackItem to hide them without leaving a gap. +func Stack(c *ui.Context, opts StackOptions, children func()) *ui.Element { + if children == nil { + panic("mujica: Stack needs children") + } + if opts.Wrap && !opts.Horizontal { + panic("mujica: Stack Wrap needs Horizontal") + } + e := ui.Column(c) + if opts.Horizontal { + e.Row() + } + e.Gap(containerSpace(Density(c), opts.Gap, SpaceS)).Justify(opts.Justify) + switch opts.Align { + case StackAlignAuto: + case StackAlignStart: + e.AlignItems(ui.Start) + case StackAlignCenter: + e.AlignItems(ui.Center) + case StackAlignEnd: + e.AlignItems(ui.End) + case StackAlignStretch: + e.AlignItems(ui.Stretch) + default: + panic("mujica: invalid StackAlign") + } + if opts.Wrap { + e.Wrap() + } + return e.Children(children) +} + +// StackItem builds one child of a Stack unless hidden; a hidden item takes no room and no gap. +func StackItem(hidden bool, build func()) { + if build == nil { + panic("mujica: StackItem needs build") + } + if !hidden { + build() + } +} diff --git a/stack_test.go b/stack_test.go new file mode 100644 index 0000000..d9c2053 --- /dev/null +++ b/stack_test.go @@ -0,0 +1,114 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestStackVerticalGap(t *testing.T) { + var a, b ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Stack(c, StackOptions{}, func() { + a = ui.Box(c).Height(20).Bounds() + b = ui.Box(c).Height(20).Bounds() + }) + }, 300, 300).Frame() + if b.Y-(a.Y+a.H) != 8 { + t.Fatalf("gap %v, want 8", b.Y-(a.Y+a.H)) + } + if a.W != 300 { + t.Fatalf("vertical stack did not stretch: width %v", a.W) + } +} + +func TestStackHiddenItemTakesNoGap(t *testing.T) { + for _, hide := range []bool{false, true} { + var a, last ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Stack(c, StackOptions{Horizontal: true, Gap: SpaceL}, func() { + a = ui.Box(c).Size(30, 10).Bounds() + StackItem(hide, func() { ui.Box(c).Size(30, 10) }) + last = ui.Box(c).Size(30, 10).Bounds() + }) + }, 300, 300).Frame() + want := a.X + 30 + 16 + if !hide { + want += 30 + 16 + } + if last.X != want { + t.Errorf("hidden=%v: last at %v, want %v", hide, last.X, want) + } + } +} + +func TestStackAlignEnd(t *testing.T) { + var r ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(200).Children(func() { + Stack(c, StackOptions{Align: StackAlignEnd}, func() { + r = ui.Box(c).Size(50, 10).Bounds() + }) + }) + }, 300, 300).Frame() + if r.X != 150 { + t.Fatalf("end-aligned child at %v, want 150", r.X) + } +} + +func TestStackJustifyAndWrap(t *testing.T) { + var first, third ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(250).Children(func() { + Stack(c, StackOptions{Horizontal: true, Wrap: true, Gap: SpaceNone, Justify: ui.End}, func() { + first = ui.Box(c).Size(100, 20).Bounds() + ui.Box(c).Size(100, 20) + third = ui.Box(c).Size(100, 20).Bounds() + }) + }) + }, 300, 300).Frame() + if third.Y != first.Y+20 { + t.Fatalf("third at y=%v, want the next line at %v", third.Y, first.Y+20) + } + if first.X != 50 || third.X != 150 { + t.Fatalf("end-justified lines start at %v and %v, want 50 and 150", first.X, third.X) + } +} + +func TestStackWrapNeedsHorizontal(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("vertical Wrap did not panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Stack(c, StackOptions{Wrap: true}, func() {}) + }, 100, 100) +} + +func TestStackSnapshot(t *testing.T) { + snapshot(t, "stack", 420, 150, func(c *ui.Context) { + k := Tokens(c) + chip := func(s string) { + ui.Box(c).Padding(6, 12).Background(k.Surface).Border(1, k.Border).Radius(2).Children(func() { ui.Text(c, s) }) + } + ui.Column(c).Padding(16).Gap(16).Children(func() { + Stack(c, StackOptions{Horizontal: true}, func() { + for _, s := range []string{"Overture", "Aria", "Recitative"} { + chip(s) + } + }) + Stack(c, StackOptions{Horizontal: true, Justify: ui.SpaceBetween}, func() { + chip("Start") + StackItem(true, func() { chip("Hidden") }) + chip("Between") + chip("End") + }) + }) + }) +} diff --git a/status_bar.go b/status_bar.go new file mode 100644 index 0000000..c12f496 --- /dev/null +++ b/status_bar.go @@ -0,0 +1,237 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// StatusItemTone colors a status item's text and icon. The zero value is muted text. +type StatusItemTone int + +const ( + StatusItemNeutral StatusItemTone = iota + StatusItemSuccess + StatusItemWarning + StatusItemDanger + StatusItemInfo +) + +// StatusItem is one entry of a StatusBar. +type StatusItem struct { + // ID is the item's stable key and the value StatusBarResult.Clicked reports. + ID string + Text string + Icon *ui.SVG + Tone StatusItemTone + // ShowProgress draws a short bar filled to Progress, from 0 to 1. + ShowProgress bool + Progress float32 + // Action makes the item a button. + Action bool + // Label names an item without Text for assistive technology. + Label string + // Secondary items hide first, last first, when the bar is too narrow. + Secondary bool +} + +// StatusBarOptions configure a StatusBar. +type StatusBarOptions struct { + Left, Right []StatusItem +} + +// StatusBarResult is a StatusBar and its event in this frame. +type StatusBarResult struct { + *ui.Element + // Clicked is the ID of the action item clicked, or empty. + Clicked string +} + +const statusBarProgressWidth float32 = 64 + +// StatusBar is a window's bottom strip with left and right groups of status items. +func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { + k := Tokens(c) + d := Density(c) + statusBarCheck(opts) + h := max(theme.MinHitSize, d.ControlHeight()-8) + gap := d.Unit() * 2 + e := ui.Row(c).Height(h).Shrink(0).PaddingX(d.Unit()*2).Gap(gap). + Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border). + Label(msg(c, "status_bar.label")) + hidden := statusBarHide(c, e, opts, e.Bounds().W-d.Unit()*4, gap) + var res StatusBarResult + group := func(items []StatusItem) { + ui.Row(c).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() { + for _, it := range items { + if hidden[it.ID] { + continue + } + if statusBarItem(c, it) { + res.Clicked = it.ID + slog.Debug("mujica: status_bar clicked", "id", it.ID) + } + } + }) + } + e.Children(func() { + group(opts.Left) + ui.Spacer(c) + if len(hidden) > 0 { + var names []string + for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) { + if hidden[it.ID] { + names = append(names, statusBarName(it)) + } + } + label := fmt.Sprintf(msg(c, "status_bar.hidden"), len(hidden)) + ui.Text(c, fmt.Sprintf("+%d", len(hidden))).FontSize(theme.CaptionSize).TextColor(k.TextMuted). + Label(label).Tooltip(strings.Join(names, "\n")).Shrink(0) + } + group(opts.Right) + }) + res.Element = e + return res +} + +func statusBarCheck(opts StatusBarOptions) { + seen := map[string]bool{} + for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) { + switch { + case it.ID == "": + panic("mujica: StatusItem needs an ID") + case seen[it.ID]: + panic("mujica: duplicate StatusItem ID " + it.ID) + case it.Progress < 0 || it.Progress > 1: + panic("mujica: StatusItem Progress must be within 0 and 1") + case it.Text == "" && it.Label == "": + panic("mujica: StatusItem " + it.ID + " needs Text or Label") + } + seen[it.ID] = true + } +} + +func statusBarName(it StatusItem) string { + if it.Text != "" { + return it.Text + } + return it.Label +} + +// statusBarHide returns the secondary items to hide so the rest fit in room DIPs. +func statusBarHide(c *ui.Context, bar *ui.Element, opts StatusBarOptions, room, gap float32) map[string]bool { + hidden := map[string]bool{} + if room <= 0 { + // Not laid out yet: show all, and measure in the next frame. + if first := ui.Local(bar, "status_bar.first", func() bool { return true }); *first { + *first = false + c.Invalidate() + } + return hidden + } + all := append(append([]StatusItem{}, opts.Left...), opts.Right...) + need := func() float32 { + var w float32 + for _, it := range all { + if !hidden[it.ID] { + w += statusBarWidth(c, it) + gap + } + } + if len(hidden) > 0 { + mw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: theme.CaptionSize}) + w += mw + gap + } + return w + } + for i := len(all) - 1; i >= 0 && need() > room; i-- { + if all[i].Secondary { + hidden[all[i].ID] = true + } + } + return hidden +} + +// statusBarWidth estimates the width an item takes. +func statusBarWidth(c *ui.Context, it StatusItem) float32 { + d := Density(c) + w := d.Unit() * 2 + if it.Icon != nil { + w += 14 + d.Unit() + } + if it.Text != "" { + tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: theme.CaptionSize}) + w += tw + d.Unit() + } + if it.ShowProgress { + w += statusBarProgressWidth + d.Unit() + } + return w +} + +// statusBarItem builds one item and reports whether its action was clicked. +func statusBarItem(c *ui.Context, it StatusItem) bool { + k := Tokens(c) + d := Density(c) + fg := k.TextMuted + if it.Action { + fg = k.Text + } + switch it.Tone { + case StatusItemNeutral: + case StatusItemSuccess: + fg = k.Success + case StatusItemWarning: + fg = k.Warning + case StatusItemDanger: + fg = k.Danger + case StatusItemInfo: + fg = k.Info + default: + panic("mujica: invalid StatusItemTone") + } + var e *ui.Element + if it.Action { + e = ui.ButtonBase(c).Key(it.ID).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Radius(theme.ControlRadius) + internal.Ring(e, k.Focus, theme.ControlRadius) + if e.Pressed() { + e.Background(k.Selection) + } else if e.Hovered() { + e.Background(k.SurfaceHover) + } + } else { + e = ui.Row(c).Key(it.ID).MinHeight(theme.MinHitSize) + } + e.PaddingX(d.Unit()).Gap(d.Unit()).TextColor(fg).Shrink(0) + if it.Label != "" { + e.Label(it.Label) + } + e.Children(func() { + if it.Icon != nil { + ui.Icon(c, it.Icon).Size(14, 14) + } + if it.Text != "" { + ui.Text(c, it.Text).FontSize(theme.CaptionSize).SingleLine() + } + if it.ShowProgress { + statusBarProgress(c, it.Progress) + } + }) + return it.Action && e.Clicked() +} + +func statusBarProgress(c *ui.Context, v float32) { + k := Tokens(c) + ui.Box(c).Size(statusBarProgressWidth, 4).Shrink(0).Role(ui.RoleProgress). + Description(fmt.Sprintf("%.0f%%", v*100)). + Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.Border, 2) + if v > 0 { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: max(r.W*v, r.H), H: r.H}, k.AccentText, 2) + } + }) +} diff --git a/status_bar_test.go b/status_bar_test.go new file mode 100644 index 0000000..7c5f1e3 --- /dev/null +++ b/status_bar_test.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func statusBarSample() StatusBarOptions { + return StatusBarOptions{ + Left: []StatusItem{ + {ID: "branch", Text: "main", Icon: icons.Must("folder")}, + {ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}, + {ID: "lint", Text: "2 warnings", Tone: StatusItemWarning, Secondary: true}, + }, + Right: []StatusItem{ + {ID: "enc", Text: "UTF-8", Secondary: true}, + {ID: "pos", Text: "Ln 12, Col 4", Secondary: true}, + {ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}, + }, + } +} + +func TestStatusBarActionClick(t *testing.T) { + var clicked []string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if r := StatusBar(c, statusBarSample()); r.Clicked != "" { + clicked = append(clicked, r.Clicked) + } + }, 800, 100) + if err := tt.Click("Notifications"); err != nil { + t.Fatal(err) + } + _ = tt.Click("main") + if len(clicked) != 1 || clicked[0] != "bell" { + t.Fatalf("clicked %v, want [bell]", clicked) + } +} + +func TestStatusBarActionKeyboard(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if StatusBar(c, statusBarSample()).Clicked == "bell" { + n++ + } + }, 800, 100) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Notifications") { + t.Fatal("Tab did not reach the action") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 2 { + t.Fatalf("keyboard activations %d, want 2", n) + } +} + +func TestStatusBarCollapsesSecondary(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusBar(c, statusBarSample()) + }, 800, 100) + for _, s := range []string{"main", "Syncing", "2 warnings", "UTF-8", "Ln 12, Col 4"} { + if !tt.HasText(s) { + t.Fatalf("wide bar lost %q", s) + } + } + tt.SetSize(330, 100) + tt.Frame() + for _, s := range []string{"Ln 12, Col 4", "UTF-8", "2 warnings"} { + if tt.HasText(s) { + t.Fatalf("narrow bar still shows secondary %q; texts %q", s, tt.Texts()) + } + } + if !tt.HasText("main") || !tt.HasText("Syncing") { + t.Fatalf("narrow bar hid primary items: %q", tt.Texts()) + } + if !tt.HasText("3 more items") { + t.Fatalf("no hidden count named: %q", tt.Texts()) + } + // Medium width drops only the last secondary items. + tt.SetSize(460, 100) + tt.Frame() + if !tt.HasText("2 warnings") || tt.HasText("Ln 12, Col 4") { + t.Fatalf("medium bar hid the wrong items: %q", tt.Texts()) + } + if _, ok := tt.Find("Notifications"); !ok { + t.Fatal("primary action hidden") + } + tt.SetSize(800, 100) + tt.Frame() + if !tt.HasText("UTF-8") { + t.Fatal("secondary items did not come back when wide") + } +} + +func TestStatusBarRejectsBadItems(t *testing.T) { + for _, o := range []StatusBarOptions{ + {Left: []StatusItem{{Text: "no id"}}}, + {Left: []StatusItem{{ID: "a", Text: "a"}}, Right: []StatusItem{{ID: "a", Text: "b"}}}, + {Left: []StatusItem{{ID: "p", Text: "p", ShowProgress: true, Progress: 1.5}}}, + {Left: []StatusItem{{ID: "n", Icon: icons.Must("bell")}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", o) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusBar(c, o) + }, 400, 100) + }() + } +} + +func TestStatusBarSnapshot(t *testing.T) { + snapshot(t, "status_bar", 560, 60, func(c *ui.Context) { + ui.Column(c).Fill().Justify(ui.End).Children(func() { + o := statusBarSample() + o.Left = append(o.Left, StatusItem{ID: "ok", Text: "Saved", Tone: StatusItemSuccess, Icon: icons.Must("check")}) + StatusBar(c, o) + }) + }) +} diff --git a/status_indicator.go b/status_indicator.go new file mode 100644 index 0000000..045c642 --- /dev/null +++ b/status_indicator.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Status is a system or item state. The zero value is StatusSuccess. +type Status int + +const ( + StatusSuccess Status = iota + StatusWarning + StatusError + StatusOffline + StatusBusy +) + +func (s Status) String() string { + switch s { + case StatusSuccess: + return "success" + case StatusWarning: + return "warning" + case StatusError: + return "error" + case StatusOffline: + return "offline" + case StatusBusy: + return "busy" + } + panic(fmt.Sprintf("mujica: unknown Status %d", int(s))) +} + +// StatusIndicatorOptions configure a StatusIndicator. +type StatusIndicatorOptions struct { + // Label replaces the status's built-in word, as "Database online". + Label string +} + +// StatusIndicator shows a status by its own shape and a word, never by color alone. +func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element { + k := Tokens(c) + u := Density(c).Unit() + label := opts.Label + if label == "" { + label = msg(c, "status_indicator."+status.String()) + } + var tone ui.Color + var icon *ui.SVG + switch status { + case StatusSuccess: + tone, icon = k.Success, icons.Must("circle-check") + case StatusWarning: + tone, icon = k.Warning, icons.Must("triangle-alert") + case StatusError: + tone, icon = k.Danger, icons.Must("circle-x") + case StatusOffline: + tone, icon = k.TextMuted, icons.Must("wifi-off") + case StatusBusy: + tone = k.Info + } + row := ui.Row(c).AlignItems(ui.Center).Gap(u * 1.5).Role(ui.RoleStatus).Label(label) + row.Children(func() { + if status == StatusBusy { + Spinner(c, SpinnerOptions{Size: 16, Color: tone, Label: label}) + } else { + ui.Icon(c, icon).Size(16, 16).TextColor(tone) + } + ui.Text(c, label).TextColor(k.Text).FontSize(theme.BodySize).SingleLine() + }) + return row +} diff --git a/status_indicator_test.go b/status_indicator_test.go new file mode 100644 index 0000000..b47df01 --- /dev/null +++ b/status_indicator_test.go @@ -0,0 +1,69 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestStatusIndicatorWords(t *testing.T) { + want := map[Status]string{ + StatusSuccess: "Healthy", StatusWarning: "Degraded", StatusError: "Failing", + StatusOffline: "Offline", StatusBusy: "Working", + } + for s, word := range want { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusIndicator(c, s, StatusIndicatorOptions{}) + }, 300, 100) + if !tt.HasText(word) { + t.Errorf("%v: texts %q", s, tt.Texts()) + } + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + StatusIndicator(c, StatusOffline, StatusIndicatorOptions{}) + StatusIndicator(c, StatusSuccess, StatusIndicatorOptions{Label: "Database online"}) + }, 300, 100) + if !tt.HasText("离线") || !tt.HasText("Database online") || tt.HasText("正常") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestStatusIndicatorShapesDiffer(t *testing.T) { + // The same tone would still differ by shape: render each in one color. + var imgs [][]byte + for _, s := range []Status{StatusSuccess, StatusWarning, StatusError, StatusOffline} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusIndicator(c, s, StatusIndicatorOptions{Label: "Same"}) + }, 120, 40) + imgs = append(imgs, encode(t, tt.Image())) + } + for i := range imgs { + for j := i + 1; j < len(imgs); j++ { + if string(imgs[i]) == string(imgs[j]) { + t.Errorf("statuses %d and %d draw alike", i, j) + } + } + } +} + +func TestStatusIndicatorPanicsOnUnknown(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); StatusIndicator(c, 42, StatusIndicatorOptions{}) }, 300, 100) +} + +func TestStatusIndicatorSnapshot(t *testing.T) { + snapshot(t, "status_indicator", 560, 80, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(20).Children(func() { + for _, s := range []Status{StatusSuccess, StatusWarning, StatusError, StatusOffline, StatusBusy} { + StatusIndicator(c, s, StatusIndicatorOptions{}) + } + }) + }) +} diff --git a/steps.go b/steps.go new file mode 100644 index 0000000..8971b9a --- /dev/null +++ b/steps.go @@ -0,0 +1,181 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Step is one stage of Steps. +type Step struct { + Title string + Description string + // Error marks the step as failed, whatever its place. + Error bool + // Clickable lets the user go to the step. + Clickable bool +} + +// StepsOptions configure Steps. +type StepsOptions struct { + Vertical bool + // Label names the steps for assistive technology. + Label string +} + +// StepsResult is the built Steps. +type StepsResult struct { + *ui.Element + changed bool +} + +// Changed reports that the user chose a clickable step in this frame. +func (r StepsResult) Changed() bool { return r.changed } + +type stepsState int + +const ( + stepsWaiting stepsState = iota + stepsCurrent + stepsDone + stepsFailed +) + +// Steps shows a process whose step *current is under way: earlier steps are done. +func Steps(c *ui.Context, current *int, steps []Step, opts StepsOptions) StepsResult { + if len(steps) == 0 { + panic("mujica: Steps needs at least one step") + } + if *current < 0 || *current > len(steps) { + panic("mujica: Steps current out of range") + } + u := Density(c).Unit() + root := ui.Row(c).AlignItems(ui.Start).Gap(u * 2).FocusGroup(ui.Horizontal | ui.Vertical).Role(ui.RoleList) + if opts.Vertical { + root.Column().AlignItems(ui.Stretch).Gap(0) + } + if opts.Label != "" { + root.Label(opts.Label) + } + res := StepsResult{Element: root} + root.Children(func() { + for i, s := range steps { + if s.Title == "" { + panic("mujica: Step needs a Title") + } + st := stepsWaiting + switch { + case s.Error: + st = stepsFailed + case i < *current: + st = stepsDone + case i == *current: + st = stepsCurrent + } + if !opts.Vertical && i > 0 { + stepsLine(c, steps, i-1, *current) + } + if stepsItem(c, i, len(steps), s, st, opts.Vertical, i < len(steps)-1 && opts.Vertical, i < *current) && i != *current { + slog.Debug("mujica: steps choose", "from", *current, "to", i) + *current, res.changed = i, true + } + } + }) + return res +} + +// stepsItem builds one step and reports a click on it. +func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, doneLine bool) bool { + k := Tokens(c) + u := Density(c).Unit() + var e *ui.Element + if s.Clickable { + e = ui.ButtonBase(c).Cursor(ui.CursorPointer) + internal.Ring(e, k.Focus, theme.ControlRadius) + if e.Hovered() { + e.Background(k.SurfaceHover) + } + } else { + e = ui.Row(c).Shrink(0).Role(ui.RoleListItem) + } + e.AlignItems(ui.Stretch).Justify(ui.Start).Gap(u * 3).Padding(u).Radius(theme.ControlRadius). + Label(fmt.Sprintf(msg(c, "steps.item"), i+1, n, s.Title, msg(c, stepsStateKey[st]))) + e.Children(func() { + ui.Column(c).AlignItems(ui.Center).Gap(u).Shrink(0).Children(func() { + stepsMarker(c, i, st) + if tail { + ui.Box(c).Width(2).Grow(1).MinHeight(u * 4).Background(stepsLineColor(k, doneLine)).Radius(1) + } + }) + texts := ui.Column(c).Gap(u*0.5).MinWidth(0).Padding(2, 0, 0, 0) + if vertical { + texts.Padding(2, 0, u*4, 0) + } + texts.Children(func() { + title := ui.Text(c, s.Title).SingleLine() + switch st { + case stepsCurrent: + title.FontWeight(600).TextColor(k.Text) + case stepsFailed: + title.FontWeight(600).TextColor(k.Danger) + case stepsDone: + title.TextColor(k.Text) + default: + title.TextColor(k.TextMuted) + } + if s.Description != "" { + ui.Text(c, s.Description).FontSize(theme.CaptionSize).TextColor(k.TextMuted).MaxWidth(240) + } + }) + }) + return s.Clickable && e.Clicked() +} + +var stepsStateKey = map[stepsState]string{ + stepsWaiting: "steps.waiting", stepsCurrent: "steps.current", stepsDone: "steps.done", stepsFailed: "steps.error", +} + +func stepsMarker(c *ui.Context, i int, st stepsState) { + k := Tokens(c) + m := ui.Box(c).Size(24, 24).Radius(12).Center().Shrink(0).Role(ui.RoleNone) + var icon string + switch st { + case stepsDone: + m.Background(k.Accent).TextColor(k.OnAccent) + icon = "check" + case stepsCurrent: + m.Background(k.Surface).Border(2, k.AccentText).TextColor(k.AccentText).FontWeight(700) + case stepsFailed: + m.Background(k.Surface).Border(2, k.Danger).TextColor(k.Danger) + icon = "x" + default: + m.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.TextMuted) + } + m.Children(func() { + if icon != "" { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + } else { + ui.Text(c, strconv.Itoa(i+1)).FontSize(theme.CaptionSize).SingleLine() + } + }) +} + +// stepsLine joins step i to the next in the horizontal layout. +func stepsLine(c *ui.Context, steps []Step, i, current int) { + k := Tokens(c) + ui.Box(c).Height(2).Grow(1).MinWidth(24).Margin(Density(c).Unit()+11, 0, 0, 0).Radius(1). + Background(stepsLineColor(k, i < current && !steps[i].Error)).Role(ui.RoleNone) +} + +func stepsLineColor(k theme.Tokens, done bool) ui.Color { + if done { + return k.AccentText + } + return k.Border +} diff --git a/steps_test.go b/steps_test.go new file mode 100644 index 0000000..201e346 --- /dev/null +++ b/steps_test.go @@ -0,0 +1,109 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func stepsSample() []Step { + return []Step{ + {Title: "Account", Clickable: true}, + {Title: "Profile", Clickable: true}, + {Title: "Payment"}, + {Title: "Confirm"}, + } +} + +func TestStepsClickable(t *testing.T) { + cur, changes := 2, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Steps(c, &cur, stepsSample(), StepsOptions{}).Changed() { + changes++ + } + }, 700, 120) + _ = tt.Click("Confirm") + if cur != 2 || changes != 0 { + t.Fatalf("non-clickable step chosen: %d", cur) + } + if err := tt.Click("Account"); err != nil { + t.Fatal(err) + } + if cur != 0 || changes != 1 { + t.Fatalf("click: cur %d changes %d", cur, changes) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if cur != 1 || changes != 2 { + t.Fatalf("keyboard: cur %d changes %d", cur, changes) + } +} + +func TestStepsStatesNamed(t *testing.T) { + cur := 1 + steps := stepsSample() + steps[2].Error = true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Steps(c, &cur, steps, StepsOptions{Vertical: true}) + }, 400, 400) + for _, name := range []string{ + "Step 1 of 4: Account, done", + "Step 2 of 4: Profile, current", + "Step 3 of 4: Payment, failed", + "Step 4 of 4: Confirm, waiting", + } { + if _, ok := tt.Find(name); !ok { + t.Errorf("missing %q in %q", name, tt.Texts()) + } + } + // All done: current past the last step. + cur = 4 + tt.Frame() + if _, ok := tt.Find("Step 4 of 4: Confirm, done"); !ok { + t.Errorf("finished steps: %q", tt.Texts()) + } +} + +func TestStepsInvalid(t *testing.T) { + for name, tc := range map[string]struct { + cur int + steps []Step + }{ + "empty": {0, nil}, + "range": {5, stepsSample()}, + "title": {0, []Step{{}}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Steps(c, &tc.cur, tc.steps, StepsOptions{}) + }, 300, 100, 1) + }) + } +} + +func TestStepsSnapshot(t *testing.T) { + snapshot(t, "steps", 640, 380, func(c *ui.Context) { + a, b := 2, 1 + steps := []Step{ + {Title: "Account", Description: "Name and seal"}, + {Title: "Profile", Description: "Titles and house"}, + {Title: "Payment", Description: "Treasury"}, + {Title: "Confirm"}, + } + ui.Column(c).Padding(16).Gap(24).Children(func() { + Steps(c, &a, steps, StepsOptions{}) + v := append([]Step(nil), steps...) + v[2].Error = true + v[2].Description = "The card was declined" + Steps(c, &b, v, StepsOptions{Vertical: true}) + }) + }) +} diff --git a/tabs.go b/tabs.go new file mode 100644 index 0000000..c3b022e --- /dev/null +++ b/tabs.go @@ -0,0 +1,174 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// Tab is one tab of Tabs. +type Tab struct { + // Label is required; it names the tab and its panel. + Label string + Icon *ui.SVG + Disabled bool + // Panel builds the tab's content while it is chosen; nil shows none. + Panel func() +} + +// TabsOptions configure Tabs. +type TabsOptions struct { + // Vertical stacks the tabs at the start with the panel beside them. + Vertical bool + // Label names the tab list for assistive technology. + Label string +} + +// TabsResult is the built Tabs: the element holding tabs and panel. +type TabsResult struct { + *ui.Element + changed bool +} + +// Changed reports a new choice made by the user in this frame. +func (r TabsResult) Changed() bool { return r.changed } + +// Tabs shows tabs choosing *selected and the chosen tab's panel. Disabled tabs are skipped by the arrows. +func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) TabsResult { + if len(tabs) == 0 { + panic("mujica: Tabs needs at least one tab") + } + if *selected < 0 || *selected >= len(tabs) { + panic("mujica: Tabs selected index out of range") + } + if tabs[*selected].Disabled { + panic("mujica: Tabs selected tab is disabled") + } + k := Tokens(c) + d := Density(c) + u := d.Unit() + pad := theme.FocusGap + theme.FocusWidth + // TabsBase only sees enabled tabs, so the arrows skip disabled ones. + var enabled []int + at := 0 + for i, t := range tabs { + if !t.Disabled { + if i == *selected { + at = len(enabled) + } + enabled = append(enabled, i) + } + } + root := ui.Column(c).AlignItems(ui.Stretch) + if opts.Vertical { + root.Row() + } + res := TabsResult{Element: root} + root.Children(func() { + var strip *ui.Element + if opts.Vertical { + strip = ui.Scroll(c).Shrink(0).Padding(pad) + } else { + strip = ui.ScrollHorizontal(c).Shrink(0).MinWidth(0).Padding(pad, pad, 0, pad) + } + strip.Draw(func(p *ui.Painter, r ui.Rect) { + if opts.Vertical { + p.Fill(ui.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, k.Border, 0) + } else { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0) + } + }) + strip.Children(func() { + base := ui.TabsBase(c, &at, len(enabled)) + list := base.List.Gap(u) + if opts.Vertical { + list.Column().AlignItems(ui.Stretch) + } + if opts.Label != "" { + list.Label(opts.Label) + } + built := make([]*ui.Element, len(tabs)) + list.Children(func() { + n := 0 + for i, t := range tabs { + var tab *ui.Element + if t.Disabled { + tab = ui.Row(c).Shrink(0).Role(ui.RoleTab).Disabled(true) + } else { + tab = base.Tab(n) + n++ + } + tabsStyle(c, tab, t, i == enabled[at], opts.Vertical) + built[i] = tab + } + }) + if list.Changed() { + prev := *selected + *selected = enabled[at] + res.changed = true + built[*selected].ScrollIntoView() + slog.Debug("mujica: tabs change", "from", prev, "to", *selected) + } + }) + panel := ui.Column(c).MinWidth(0).Padding(u * 4).Role(ui.RoleGroup).Label(tabs[*selected].Label) + if opts.Vertical { + panel.Grow(1) + } + if p := tabs[*selected].Panel; p != nil { + panel.Children(p) + } + }) + return res +} + +func tabsStyle(c *ui.Context, tab *ui.Element, t Tab, on, vertical bool) { + k := Tokens(c) + d := Density(c) + u := d.Unit() + tab.MinHeight(d.ControlHeight()).Padding(0, u*4).Gap(u * 2).AlignItems(ui.Center).Cursor(ui.CursorPointer) + if vertical { + tab.Radius(theme.ControlRadius, 0, 0, theme.ControlRadius) + } else { + tab.Radius(theme.ControlRadius, theme.ControlRadius, 0, 0) + } + if t.Disabled { + tab.Cursor(ui.CursorNotAllowed) + } + if t.Label == "" { + panic("mujica: Tab needs a Label") + } + fg := k.TextMuted + switch { + case on: + tab.Background(k.Selection).FontWeight(600) + fg = k.Text + case !t.Disabled && tab.Pressed(): + tab.Background(k.Selection) + fg = k.Text + case !t.Disabled && tab.Hovered(): + tab.Background(k.SurfaceHover) + fg = k.Text + } + tab.TextColor(fg) + tab.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { + if on { + if vertical { + p.Fill(ui.Rect{X: r.X + r.W - 2, Y: r.Y, W: 2, H: r.H}, k.AccentText, 0) + } else { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, k.AccentText, 0) + } + } + if tab.FocusVisible() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + tab.Children(func() { + if t.Icon != nil { + ui.Icon(c, t.Icon).Size(16, 16) + } + ui.Text(c, t.Label).SingleLine() + }) +} diff --git a/tabs_test.go b/tabs_test.go new file mode 100644 index 0000000..f5ba7f2 --- /dev/null +++ b/tabs_test.go @@ -0,0 +1,162 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +func tabsSample() []Tab { + return []Tab{ + {Label: "General", Panel: func() {}}, + {Label: "Fonts", Disabled: true}, + {Label: "Colors"}, + {Label: "Advanced"}, + } +} + +func TestTabsClickAndPanel(t *testing.T) { + sel, changes := 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ts := tabsSample() + ts[0].Panel = func() { ui.Text(c, "General page") } + ts[2].Panel = func() { ui.Text(c, "Colors page") } + if Tabs(c, &sel, ts, TabsOptions{Label: "Settings"}).Changed() { + changes++ + } + }, 500, 200) + if !tt.HasText("General page") || tt.HasText("Colors page") { + t.Fatalf("panel of tab 0 not shown: %q", tt.Texts()) + } + if err := tt.Click("Colors"); err != nil { + t.Fatal(err) + } + if sel != 2 || changes != 1 || !tt.HasText("Colors page") { + t.Fatalf("click Colors: sel %d changes %d texts %q", sel, changes, tt.Texts()) + } + _ = tt.Click("Fonts") + if sel != 2 || changes != 1 { + t.Fatalf("disabled tab chosen: sel %d changes %d", sel, changes) + } +} + +func TestTabsKeyboardSkipsDisabled(t *testing.T) { + for _, vertical := range []bool{false, true} { + sel := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Tabs(c, &sel, tabsSample(), TabsOptions{Vertical: vertical}) + }, 500, 300) + tt.Key(0, ui.KeyTab) + if !tt.Focused("General") { + t.Fatalf("vertical=%v: Tab did not focus the chosen tab", vertical) + } + next := ui.KeyRight + if vertical { + next = ui.KeyDown + } + tt.Key(0, next) + if sel != 2 || !tt.Focused("Colors") { + t.Fatalf("vertical=%v: arrow chose %d, want 2 skipping disabled", vertical, sel) + } + tt.Key(0, ui.KeyEnd) + if sel != 3 { + t.Fatalf("vertical=%v: End chose %d", vertical, sel) + } + tt.Key(0, next) + if sel != 0 { + t.Fatalf("vertical=%v: wrap chose %d", vertical, sel) + } + } +} + +func TestTabsOverflowScrolls(t *testing.T) { + sel := 0 + var tabs []Tab + for _, l := range []string{"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel"} { + tabs = append(tabs, Tab{Label: l}) + } + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(240).Children(func() { + w = Tabs(c, &sel, tabs, TabsOptions{}).Bounds().W + }) + }, 400, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + tt.Frame() + r, ok := tt.Find("Hotel") + if sel != 7 || !ok || r.X+r.W > 240 { + t.Fatalf("last tab not scrolled into view: sel %d rect %+v", sel, r) + } + if w > 240 { + t.Fatalf("tabs widened their parent to %v", w) + } +} + +func TestTabsInvalid(t *testing.T) { + for name, tc := range map[string]struct { + sel int + tabs []Tab + }{ + "empty": {0, nil}, + "range": {4, tabsSample()}, + "disabled": {1, tabsSample()}, + "label": {0, []Tab{{Icon: icons.Must("star")}}}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Tabs(c, &tc.sel, tc.tabs, TabsOptions{}) + }, 300, 100, 1) + }) + } +} + +func tabsView(c *ui.Context) { + sel, vsel := 0, 2 + ui.Column(c).Padding(16).Gap(16).Children(func() { + Tabs(c, &sel, []Tab{ + {Label: "Overview", Icon: icons.Must("house"), Panel: func() { + ui.Text(c, "The chosen tab's panel.") + }}, + {Label: "Members", Icon: icons.Must("user")}, + {Label: "Archive", Disabled: true}, + {Label: "Settings", Icon: icons.Must("settings")}, + }, TabsOptions{}) + Tabs(c, &vsel, []Tab{ + {Label: "Profile"}, + {Label: "Security", Disabled: true}, + {Label: "Billing", Panel: func() { ui.Text(c, "Vertical layout.") }}, + }, TabsOptions{Vertical: true}) + }) +} + +func TestTabsSnapshot(t *testing.T) { + snapshot(t, "tabs", 520, 300, tabsView) + for _, d := range []theme.Density{theme.Compact, theme.Comfortable} { + snapshot(t, "tabs-"+strings.ToLower(d.String()), 520, 340, func(c *ui.Context) { + Use(c, Settings{Mode: tabsMode(c), Density: d}) + tabsView(c) + }) + } +} + +// tabsMode recovers the mode snapshot applied, to add a density. +func tabsMode(c *ui.Context) Mode { + if Tokens(c).Dark { + return Dark + } + return Light +} diff --git a/tag.go b/tag.go new file mode 100644 index 0000000..3ba3161 --- /dev/null +++ b/tag.go @@ -0,0 +1,109 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TagOptions configure a Tag. The zero value is a plain label. +type TagOptions struct { + // Icon names an icon of the icons package shown before the label. + Icon string + // Clickable makes the label a button reported by TagView.Clicked. + Clickable bool + // Closable adds a remove button reported by TagView.Closed. + Closable bool + Disabled bool +} + +// TagView is a shown Tag and what the user did to it this frame. +type TagView struct { + Element *ui.Element + // Clicked reports the label was activated; never set by the remove button. + Clicked bool + // Closed reports the remove button, or Delete or Backspace on the label. + Closed bool +} + +// Tag shows a short label. Clicking the label and removing the tag are +// separate actions with separate focus stops. +func Tag(c *ui.Context, label string, opts TagOptions) TagView { + if label == "" { + panic("mujica: Tag needs a label") + } + k := Tokens(c) + u := Density(c).Unit() + var v TagView + box := ui.Row(c).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Shrink(0).Disabled(opts.Disabled) + v.Element = box + box.Children(func() { + var main *ui.Element + if opts.Clickable { + main = ui.ButtonBase(c).Label(label) + internal.Ring(main, k.Focus, theme.ControlRadius) + } else { + main = ui.Row(c).AlignItems(ui.Center) + } + main.Gap(u*1.5).Padding(0, u*2).FillHeight().Radius(theme.ControlRadius).TextColor(k.Text) + if opts.Clickable && !opts.Disabled { + if main.Pressed() { + main.Background(k.Selection) + } else if main.Hovered() { + main.Background(k.SurfaceHover) + } + internal.Fade(main, Motion(c, theme.StateDuration)) + } + if opts.Closable { + main.Padding(0, u, 0, u*2) + } + main.Children(func() { + if opts.Icon != "" { + Icon(c, opts.Icon, IconOptions{Size: 14}) + } + ui.Text(c, label).FontSize(theme.BodySize - 1).SingleLine() + }) + if opts.Clickable && main.Clicked() { + v.Clicked = true + } + if opts.Closable { + if opts.Clickable && !opts.Disabled && (main.Shortcut(0, ui.KeyDelete) || main.Shortcut(0, ui.KeyBackspace)) { + v.Closed = true + } + if tagClose(c, k, label).Clicked() { + v.Closed = true + } + } + }) + if v.Clicked { + slog.Debug("mujica: tag click", "label", label) + } + if v.Closed { + slog.Debug("mujica: tag close", "label", label) + } + return v +} + +func tagClose(c *ui.Context, k theme.Tokens, label string) *ui.Element { + b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize). + Radius(theme.ControlRadius).Label(fmt.Sprintf(msg(c, "tag.remove"), label)) + fg := k.TextMuted + if b.Pressed() { + b.Background(k.Selection) + fg = k.Text + } else if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + internal.Ring(b, k.Focus, theme.ControlRadius) + internal.Fade(b, Motion(c, theme.StateDuration)) + b.TextColor(fg).Children(func() { + Icon(c, "x", IconOptions{Size: 12}) + }) + return b +} diff --git a/tag_test.go b/tag_test.go new file mode 100644 index 0000000..f2b76d0 --- /dev/null +++ b/tag_test.go @@ -0,0 +1,92 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type tagCounts struct{ clicked, closed int } + +func tagTester(opts TagOptions, n *tagCounts) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + v := Tag(c, "Velvet", opts) + if v.Clicked { + n.clicked++ + } + if v.Closed { + n.closed++ + } + }) + }, 300, 100) +} + +func TestTagClickAndCloseAreSeparate(t *testing.T) { + var n tagCounts + tt := tagTester(TagOptions{Clickable: true, Closable: true, Icon: "star"}, &n) + if err := tt.Click("Remove Velvet"); err != nil { + t.Fatal(err) + } + if n != (tagCounts{0, 1}) { + t.Fatalf("after close click %+v", n) + } + if err := tt.Click("Velvet"); err != nil { + t.Fatal(err) + } + if n != (tagCounts{1, 1}) { + t.Fatalf("after label click %+v", n) + } +} + +func TestTagKeyboard(t *testing.T) { + var n tagCounts + tt := tagTester(TagOptions{Clickable: true, Closable: true}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDelete) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Remove Velvet") { + t.Fatal("remove button not second focus stop") + } + tt.Key(0, ui.KeySpace) + if n != (tagCounts{1, 2}) { + t.Fatalf("counts %+v", n) + } +} + +func TestTagPlainTakesNoFocus(t *testing.T) { + var n tagCounts + tt := tagTester(TagOptions{}, &n) + _ = tt.Click("Velvet") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != (tagCounts{}) || tt.Focused("Velvet") { + t.Fatalf("plain tag reacted: %+v", n) + } +} + +func TestTagDisabled(t *testing.T) { + var n tagCounts + tt := tagTester(TagOptions{Clickable: true, Closable: true, Disabled: true}, &n) + _ = tt.Click("Velvet") + _ = tt.Click("Remove Velvet") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDelete) + if n != (tagCounts{}) { + t.Fatalf("disabled tag reacted: %+v", n) + } +} + +func TestTagSnapshot(t *testing.T) { + snapshot(t, "tag", 480, 80, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(8).AlignItems(ui.Center).Children(func() { + Tag(c, "Baroque", TagOptions{}) + Tag(c, "Favorite", TagOptions{Icon: "star", Clickable: true}) + Tag(c, "Archive", TagOptions{Closable: true}) + Tag(c, "Locked", TagOptions{Icon: "lock", Closable: true, Disabled: true}) + }) + }) +} diff --git a/tags_input.go b/tags_input.go new file mode 100644 index 0000000..3669bf0 --- /dev/null +++ b/tags_input.go @@ -0,0 +1,178 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TagsInputOptions configure a TagsInput. +type TagsInputOptions struct { + // Max caps the number of tags; zero allows any number. + Max int + // Separators split typed and pasted text into tags; empty uses ",;,;" and line breaks. + Separators string + // Validate checks a tag before it is added and returns an error message, or "". + Validate func(tag string) string + Placeholder string + // Label names the input when it is not inside a FormField. + Label string + Disabled bool + // Invalid draws the border in the danger color. + Invalid bool +} + +// TagsInputParts are the parts of a TagsInput. +type TagsInputParts struct { + Element *ui.Element + // Input takes the focus. + Input *ui.Element + changed bool +} + +// Changed reports whether tags were added or removed this frame. +func (p TagsInputParts) Changed() bool { return p.changed } + +type tagsInputState struct { + query string + err string + backspace bool +} + +// TagsInput edits a list of unique tags shown as chips before a text input. +// Enter or a separator adds the text; Backspace in the empty input removes the last tag. +func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputParts { + if opts.Max < 0 { + panic("mujica: TagsInput Max must not be negative") + } + k := Tokens(c) + d := Density(c) + seps := opts.Separators + if seps == "" { + seps = ",;,;\n\r\t" + } + parts := TagsInputParts{} + outer := ui.Column(c).Gap(d.Unit()) + parts.Element = outer + st := ui.Local(outer, "tags", func() tagsInputState { return tagsInputState{} }) + add := func(s string) bool { + s = strings.TrimSpace(s) + switch { + case s == "": + return true + case slices.Contains(*tags, s): + st.err = fmt.Sprintf(msg(c, "tags_input.duplicate"), s) + return false + case opts.Max > 0 && len(*tags) >= opts.Max: + st.err = fmt.Sprintf(msg(c, "tags_input.max"), opts.Max) + return false + } + if opts.Validate != nil { + if e := opts.Validate(s); e != "" { + st.err = e + return false + } + } + *tags = append(*tags, s) + parts.changed = true + slog.Debug("mujica: tags input added", "count", len(*tags)) + return true + } + remove := func(i int) { + *tags = slices.Delete(slices.Clone(*tags), i, i+1) + parts.changed, st.err = true, "" + slog.Debug("mujica: tags input removed", "count", len(*tags)) + } + if st.backspace { + st.backspace = false + if len(*tags) > 0 && st.query == "" { + remove(len(*tags) - 1) + } + } + var box *ui.Element + outer.Children(func() { + box = ui.Row(c).Wrap().AlignItems(ui.Center).Gap(d.Unit()*1.5).MinHeight(d.ControlHeight()). + Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled) + box.Children(func() { + for i, tag := range *tags { + chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Gap(d.Unit()).Padding(0, d.Unit(), 0, d.Unit()*2). + MinHeight(d.ControlHeight() - d.Unit()*3).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0) + chip.Children(func() { + ui.Text(c, tag).SingleLine() + x := ui.ButtonBase(c).Size(20, 20).Radius(theme.ControlRadius).TextColor(k.TextMuted). + Label(fmt.Sprintf(msg(c, "tags_input.remove"), tag)) + internal.Ring(x, k.Focus, theme.ControlRadius) + if x.Hovered() { + x.Background(k.SurfaceHover).TextColor(k.Text) + } + x.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14) }) + if x.Clicked() { + remove(i) + } + }) + } + // The input keeps its identity whatever chips come before it. + ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24).Children(func() { + in := ui.TextInputBase(c, &st.query).FocusRing(false).FillWidth().Placeholder(opts.Placeholder) + parts.Input = in + if opts.Label != "" { + in.Label(opts.Label) + } + in.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" { + st.backspace = true + return true + } + return false + }) + }) + }) + if st.err != "" { + ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus) + } + }) + in := parts.Input + if in.Changed() { + st.err = "" + } + if strings.ContainsAny(st.query, seps) { + pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) }) + last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00") + var kept []string + for _, p := range pieces[:len(pieces)-1] { + if !add(p) { + kept = append(kept, strings.TrimSpace(p)) + } + } + st.query = strings.Join(append(kept, last), string([]rune(seps)[0])) + } + if in.Submitted() && add(st.query) { + st.query = "" + } + if box.Clicked() { + in.Focus() + } + border := k.ControlBorder + switch { + case opts.Invalid || st.err != "": + border = k.Danger + case in.Focused(): + border = k.Focus + case box.Hovered() && !opts.Disabled: + border = k.ControlBorder.Mix(k.Text, 0.25) + } + box.Border(theme.BorderWidth, border) + box.DrawOver(func(p *ui.Painter, r ui.Rect) { + if in.Focused() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + return parts +} diff --git a/tags_input_test.go b/tags_input_test.go new file mode 100644 index 0000000..2d2dae2 --- /dev/null +++ b/tags_input_test.go @@ -0,0 +1,117 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func tagsInputTester(tags *[]string, opts TagsInputOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Children(func() { + if TagsInput(c, tags, opts).Changed() { + *changes++ + } + }) + }, 520, 200) +} + +func TestTagsInputEnterSeparatorsAndBackspace(t *testing.T) { + tags, n := []string{"rose"}, 0 + tt := tagsInputTester(&tags, TagsInputOptions{Label: "Tags"}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Tags") { + t.Fatal("input not after the chip's remove button") + } + tt.Type("thorn") + tt.Key(0, ui.KeyEnter) + tt.Type("lily, ivy;moss") + if !slices.Equal(tags, []string{"rose", "thorn", "lily", "ivy"}) { + t.Fatalf("tags %q", tags) + } + tt.Key(0, ui.KeyEnter) + if tags[4] != "moss" || n != 3 { + t.Fatalf("tags %q changes %d", tags, n) + } + tt.Key(0, ui.KeyBackspace) + if !slices.Equal(tags, []string{"rose", "thorn", "lily", "ivy"}) { + t.Fatalf("backspace: %q", tags) + } +} + +func TestTagsInputPasteDedupeAndMax(t *testing.T) { + tags, n := []string{"a"}, 0 + tt := tagsInputTester(&tags, TagsInputOptions{Label: "Tags", Max: 3}, &n) + _ = tt.Click("Tags") + tt.SetClipboard("b,a,c,d,") + tt.Command("paste") + if !slices.Equal(tags, []string{"a", "b", "c"}) { + t.Fatalf("tags %q", tags) + } + if !tt.HasText("“a” is already added") && !tt.HasText("At most 3 tags") { + t.Fatalf("no error shown: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if len(tags) != 3 { + t.Fatalf("max exceeded: %q", tags) + } +} + +func TestTagsInputValidateKeepsText(t *testing.T) { + tags, n, seen := []string{}, 0, "" + opts := TagsInputOptions{Label: "Tags", Validate: func(s string) string { + seen = s + if strings.ContainsAny(s, " ") { + return "No spaces" + } + return "" + }} + tt := tagsInputTester(&tags, opts, &n) + _ = tt.Click("Tags") + tt.Type("two words") + tt.Key(0, ui.KeyEnter) + if len(tags) != 0 || !tt.HasText("No spaces") { + t.Fatalf("tags %q texts %q", tags, tt.Texts()) + } + tt.Type("x") + if tt.HasText("No spaces") { + t.Fatal("error stays after editing") + } + tt.Key(0, ui.KeyEnter) + if seen != "two wordsx" { + t.Fatalf("typed text lost: %q", seen) + } +} + +func TestTagsInputRemoveButtonAndDisabled(t *testing.T) { + tags, n := []string{"rose", "ivy"}, 0 + tt := tagsInputTester(&tags, TagsInputOptions{}, &n) + if err := tt.Click("Remove rose"); err != nil { + t.Fatal(err) + } + if !slices.Equal(tags, []string{"ivy"}) || n != 1 { + t.Fatalf("tags %q", tags) + } + tags = []string{"rose"} + tt = tagsInputTester(&tags, TagsInputOptions{Disabled: true}, &n) + _ = tt.Click("Remove rose") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyBackspace) + if len(tags) != 1 { + t.Fatal("disabled input removed a tag") + } +} + +func TestTagsInputSnapshot(t *testing.T) { + a, b := []string{"Ave Mujica", "Oblivionis", "Doloris"}, []string{"rose"} + snapshot(t, "tags_input", 520, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).Children(func() { + TagsInput(c, &a, TagsInputOptions{Placeholder: "Add a member"}) + TagsInput(c, &b, TagsInputOptions{Invalid: true, Placeholder: "Add a flower"}) + }) + }) +} diff --git a/testdata/snapshots/darwin/alert-dark.png b/testdata/snapshots/darwin/alert-dark.png new file mode 100644 index 0000000..f1af51d Binary files /dev/null and b/testdata/snapshots/darwin/alert-dark.png differ diff --git a/testdata/snapshots/darwin/alert-light.png b/testdata/snapshots/darwin/alert-light.png new file mode 100644 index 0000000..7db185f Binary files /dev/null and b/testdata/snapshots/darwin/alert-light.png differ diff --git a/testdata/snapshots/darwin/alert_dialog-dark.png b/testdata/snapshots/darwin/alert_dialog-dark.png new file mode 100644 index 0000000..48cc493 Binary files /dev/null and b/testdata/snapshots/darwin/alert_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/alert_dialog-light.png b/testdata/snapshots/darwin/alert_dialog-light.png new file mode 100644 index 0000000..b108d13 Binary files /dev/null and b/testdata/snapshots/darwin/alert_dialog-light.png differ diff --git a/testdata/snapshots/darwin/app_shell-dark.png b/testdata/snapshots/darwin/app_shell-dark.png new file mode 100644 index 0000000..d646dbe Binary files /dev/null and b/testdata/snapshots/darwin/app_shell-dark.png differ diff --git a/testdata/snapshots/darwin/app_shell-light.png b/testdata/snapshots/darwin/app_shell-light.png new file mode 100644 index 0000000..e6834ac Binary files /dev/null and b/testdata/snapshots/darwin/app_shell-light.png differ diff --git a/testdata/snapshots/darwin/aspect_ratio-dark.png b/testdata/snapshots/darwin/aspect_ratio-dark.png new file mode 100644 index 0000000..65015aa Binary files /dev/null and b/testdata/snapshots/darwin/aspect_ratio-dark.png differ diff --git a/testdata/snapshots/darwin/aspect_ratio-light.png b/testdata/snapshots/darwin/aspect_ratio-light.png new file mode 100644 index 0000000..97bf958 Binary files /dev/null and b/testdata/snapshots/darwin/aspect_ratio-light.png differ diff --git a/testdata/snapshots/darwin/avatar-dark.png b/testdata/snapshots/darwin/avatar-dark.png new file mode 100644 index 0000000..07be84f Binary files /dev/null and b/testdata/snapshots/darwin/avatar-dark.png differ diff --git a/testdata/snapshots/darwin/avatar-light.png b/testdata/snapshots/darwin/avatar-light.png new file mode 100644 index 0000000..5882699 Binary files /dev/null and b/testdata/snapshots/darwin/avatar-light.png differ diff --git a/testdata/snapshots/darwin/avatar_group-dark.png b/testdata/snapshots/darwin/avatar_group-dark.png new file mode 100644 index 0000000..14b0a78 Binary files /dev/null and b/testdata/snapshots/darwin/avatar_group-dark.png differ diff --git a/testdata/snapshots/darwin/avatar_group-light.png b/testdata/snapshots/darwin/avatar_group-light.png new file mode 100644 index 0000000..4cd5593 Binary files /dev/null and b/testdata/snapshots/darwin/avatar_group-light.png differ diff --git a/testdata/snapshots/darwin/badge-dark.png b/testdata/snapshots/darwin/badge-dark.png new file mode 100644 index 0000000..d97c0e4 Binary files /dev/null and b/testdata/snapshots/darwin/badge-dark.png differ diff --git a/testdata/snapshots/darwin/badge-light.png b/testdata/snapshots/darwin/badge-light.png new file mode 100644 index 0000000..1b5c029 Binary files /dev/null and b/testdata/snapshots/darwin/badge-light.png differ diff --git a/testdata/snapshots/darwin/breadcrumb-dark.png b/testdata/snapshots/darwin/breadcrumb-dark.png new file mode 100644 index 0000000..7ad11a0 Binary files /dev/null and b/testdata/snapshots/darwin/breadcrumb-dark.png differ diff --git a/testdata/snapshots/darwin/breadcrumb-light.png b/testdata/snapshots/darwin/breadcrumb-light.png new file mode 100644 index 0000000..f6d94e3 Binary files /dev/null and b/testdata/snapshots/darwin/breadcrumb-light.png differ diff --git a/testdata/snapshots/darwin/button-comfortable-dark.png b/testdata/snapshots/darwin/button-comfortable-dark.png new file mode 100644 index 0000000..6167924 Binary files /dev/null and b/testdata/snapshots/darwin/button-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/button-comfortable-light.png b/testdata/snapshots/darwin/button-comfortable-light.png new file mode 100644 index 0000000..cf71679 Binary files /dev/null and b/testdata/snapshots/darwin/button-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/button-compact-dark.png b/testdata/snapshots/darwin/button-compact-dark.png new file mode 100644 index 0000000..73e1a88 Binary files /dev/null and b/testdata/snapshots/darwin/button-compact-dark.png differ diff --git a/testdata/snapshots/darwin/button-compact-light.png b/testdata/snapshots/darwin/button-compact-light.png new file mode 100644 index 0000000..6eab1a0 Binary files /dev/null and b/testdata/snapshots/darwin/button-compact-light.png differ diff --git a/testdata/snapshots/darwin/button-dark.png b/testdata/snapshots/darwin/button-dark.png index 2653916..df1f5e4 100644 Binary files a/testdata/snapshots/darwin/button-dark.png and b/testdata/snapshots/darwin/button-dark.png differ diff --git a/testdata/snapshots/darwin/button-light.png b/testdata/snapshots/darwin/button-light.png index b85d2e5..c977763 100644 Binary files a/testdata/snapshots/darwin/button-light.png and b/testdata/snapshots/darwin/button-light.png differ diff --git a/testdata/snapshots/darwin/button_group-dark.png b/testdata/snapshots/darwin/button_group-dark.png new file mode 100644 index 0000000..e8eb370 Binary files /dev/null and b/testdata/snapshots/darwin/button_group-dark.png differ diff --git a/testdata/snapshots/darwin/button_group-light.png b/testdata/snapshots/darwin/button_group-light.png new file mode 100644 index 0000000..db5d436 Binary files /dev/null and b/testdata/snapshots/darwin/button_group-light.png differ diff --git a/testdata/snapshots/darwin/calendar-dark.png b/testdata/snapshots/darwin/calendar-dark.png new file mode 100644 index 0000000..8a00542 Binary files /dev/null and b/testdata/snapshots/darwin/calendar-dark.png differ diff --git a/testdata/snapshots/darwin/calendar-light.png b/testdata/snapshots/darwin/calendar-light.png new file mode 100644 index 0000000..8a54f5a Binary files /dev/null and b/testdata/snapshots/darwin/calendar-light.png differ diff --git a/testdata/snapshots/darwin/checkbox-comfortable-dark.png b/testdata/snapshots/darwin/checkbox-comfortable-dark.png new file mode 100644 index 0000000..f50031b Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/checkbox-comfortable-light.png b/testdata/snapshots/darwin/checkbox-comfortable-light.png new file mode 100644 index 0000000..4d2cfdd Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/checkbox-compact-dark.png b/testdata/snapshots/darwin/checkbox-compact-dark.png new file mode 100644 index 0000000..6d34b57 Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-compact-dark.png differ diff --git a/testdata/snapshots/darwin/checkbox-compact-light.png b/testdata/snapshots/darwin/checkbox-compact-light.png new file mode 100644 index 0000000..546718d Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-compact-light.png differ diff --git a/testdata/snapshots/darwin/checkbox-dark.png b/testdata/snapshots/darwin/checkbox-dark.png new file mode 100644 index 0000000..8a1d8c7 Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-dark.png differ diff --git a/testdata/snapshots/darwin/checkbox-light.png b/testdata/snapshots/darwin/checkbox-light.png new file mode 100644 index 0000000..116207f Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-light.png differ diff --git a/testdata/snapshots/darwin/color_picker-dark.png b/testdata/snapshots/darwin/color_picker-dark.png new file mode 100644 index 0000000..494495e Binary files /dev/null and b/testdata/snapshots/darwin/color_picker-dark.png differ diff --git a/testdata/snapshots/darwin/color_picker-light.png b/testdata/snapshots/darwin/color_picker-light.png new file mode 100644 index 0000000..233bb64 Binary files /dev/null and b/testdata/snapshots/darwin/color_picker-light.png differ diff --git a/testdata/snapshots/darwin/command_palette-dark.png b/testdata/snapshots/darwin/command_palette-dark.png new file mode 100644 index 0000000..7587260 Binary files /dev/null and b/testdata/snapshots/darwin/command_palette-dark.png differ diff --git a/testdata/snapshots/darwin/command_palette-light.png b/testdata/snapshots/darwin/command_palette-light.png new file mode 100644 index 0000000..f76ffd4 Binary files /dev/null and b/testdata/snapshots/darwin/command_palette-light.png differ diff --git a/testdata/snapshots/darwin/container-dark.png b/testdata/snapshots/darwin/container-dark.png new file mode 100644 index 0000000..09215ad Binary files /dev/null and b/testdata/snapshots/darwin/container-dark.png differ diff --git a/testdata/snapshots/darwin/container-light.png b/testdata/snapshots/darwin/container-light.png new file mode 100644 index 0000000..604f644 Binary files /dev/null and b/testdata/snapshots/darwin/container-light.png differ diff --git a/testdata/snapshots/darwin/context_menu-dark.png b/testdata/snapshots/darwin/context_menu-dark.png new file mode 100644 index 0000000..9cda9f7 Binary files /dev/null and b/testdata/snapshots/darwin/context_menu-dark.png differ diff --git a/testdata/snapshots/darwin/context_menu-light.png b/testdata/snapshots/darwin/context_menu-light.png new file mode 100644 index 0000000..6699812 Binary files /dev/null and b/testdata/snapshots/darwin/context_menu-light.png differ diff --git a/testdata/snapshots/darwin/date_picker-dark.png b/testdata/snapshots/darwin/date_picker-dark.png new file mode 100644 index 0000000..7dd35a5 Binary files /dev/null and b/testdata/snapshots/darwin/date_picker-dark.png differ diff --git a/testdata/snapshots/darwin/date_picker-light.png b/testdata/snapshots/darwin/date_picker-light.png new file mode 100644 index 0000000..88cd8ee Binary files /dev/null and b/testdata/snapshots/darwin/date_picker-light.png differ diff --git a/testdata/snapshots/darwin/date_range_picker-dark.png b/testdata/snapshots/darwin/date_range_picker-dark.png new file mode 100644 index 0000000..baf0016 Binary files /dev/null and b/testdata/snapshots/darwin/date_range_picker-dark.png differ diff --git a/testdata/snapshots/darwin/date_range_picker-light.png b/testdata/snapshots/darwin/date_range_picker-light.png new file mode 100644 index 0000000..e273300 Binary files /dev/null and b/testdata/snapshots/darwin/date_range_picker-light.png differ diff --git a/testdata/snapshots/darwin/date_time_picker-dark.png b/testdata/snapshots/darwin/date_time_picker-dark.png new file mode 100644 index 0000000..c72db7b Binary files /dev/null and b/testdata/snapshots/darwin/date_time_picker-dark.png differ diff --git a/testdata/snapshots/darwin/date_time_picker-light.png b/testdata/snapshots/darwin/date_time_picker-light.png new file mode 100644 index 0000000..d4d703b Binary files /dev/null and b/testdata/snapshots/darwin/date_time_picker-light.png differ diff --git a/testdata/snapshots/darwin/dialog-comfortable-dark.png b/testdata/snapshots/darwin/dialog-comfortable-dark.png new file mode 100644 index 0000000..81d0edd Binary files /dev/null and b/testdata/snapshots/darwin/dialog-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/dialog-comfortable-light.png b/testdata/snapshots/darwin/dialog-comfortable-light.png new file mode 100644 index 0000000..958125c Binary files /dev/null and b/testdata/snapshots/darwin/dialog-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/dialog-compact-dark.png b/testdata/snapshots/darwin/dialog-compact-dark.png new file mode 100644 index 0000000..64d6e33 Binary files /dev/null and b/testdata/snapshots/darwin/dialog-compact-dark.png differ diff --git a/testdata/snapshots/darwin/dialog-compact-light.png b/testdata/snapshots/darwin/dialog-compact-light.png new file mode 100644 index 0000000..7b8f989 Binary files /dev/null and b/testdata/snapshots/darwin/dialog-compact-light.png differ diff --git a/testdata/snapshots/darwin/dialog-dark.png b/testdata/snapshots/darwin/dialog-dark.png new file mode 100644 index 0000000..ef9ee76 Binary files /dev/null and b/testdata/snapshots/darwin/dialog-dark.png differ diff --git a/testdata/snapshots/darwin/dialog-light.png b/testdata/snapshots/darwin/dialog-light.png new file mode 100644 index 0000000..9e7901f Binary files /dev/null and b/testdata/snapshots/darwin/dialog-light.png differ diff --git a/testdata/snapshots/darwin/divider-dark.png b/testdata/snapshots/darwin/divider-dark.png new file mode 100644 index 0000000..52e1187 Binary files /dev/null and b/testdata/snapshots/darwin/divider-dark.png differ diff --git a/testdata/snapshots/darwin/divider-light.png b/testdata/snapshots/darwin/divider-light.png new file mode 100644 index 0000000..edf9be8 Binary files /dev/null and b/testdata/snapshots/darwin/divider-light.png differ diff --git a/testdata/snapshots/darwin/drawer-dark.png b/testdata/snapshots/darwin/drawer-dark.png new file mode 100644 index 0000000..c609f48 Binary files /dev/null and b/testdata/snapshots/darwin/drawer-dark.png differ diff --git a/testdata/snapshots/darwin/drawer-light.png b/testdata/snapshots/darwin/drawer-light.png new file mode 100644 index 0000000..3eadef3 Binary files /dev/null and b/testdata/snapshots/darwin/drawer-light.png differ diff --git a/testdata/snapshots/darwin/dropdown_menu-dark.png b/testdata/snapshots/darwin/dropdown_menu-dark.png new file mode 100644 index 0000000..4213dd3 Binary files /dev/null and b/testdata/snapshots/darwin/dropdown_menu-dark.png differ diff --git a/testdata/snapshots/darwin/dropdown_menu-light.png b/testdata/snapshots/darwin/dropdown_menu-light.png new file mode 100644 index 0000000..426e388 Binary files /dev/null and b/testdata/snapshots/darwin/dropdown_menu-light.png differ diff --git a/testdata/snapshots/darwin/empty_state-dark.png b/testdata/snapshots/darwin/empty_state-dark.png new file mode 100644 index 0000000..44beced Binary files /dev/null and b/testdata/snapshots/darwin/empty_state-dark.png differ diff --git a/testdata/snapshots/darwin/empty_state-light.png b/testdata/snapshots/darwin/empty_state-light.png new file mode 100644 index 0000000..debfc6b Binary files /dev/null and b/testdata/snapshots/darwin/empty_state-light.png differ diff --git a/testdata/snapshots/darwin/file_drop_zone-dark.png b/testdata/snapshots/darwin/file_drop_zone-dark.png new file mode 100644 index 0000000..e6ffa9a Binary files /dev/null and b/testdata/snapshots/darwin/file_drop_zone-dark.png differ diff --git a/testdata/snapshots/darwin/file_drop_zone-light.png b/testdata/snapshots/darwin/file_drop_zone-light.png new file mode 100644 index 0000000..c3594c6 Binary files /dev/null and b/testdata/snapshots/darwin/file_drop_zone-light.png differ diff --git a/testdata/snapshots/darwin/file_picker-dark.png b/testdata/snapshots/darwin/file_picker-dark.png new file mode 100644 index 0000000..331fec8 Binary files /dev/null and b/testdata/snapshots/darwin/file_picker-dark.png differ diff --git a/testdata/snapshots/darwin/file_picker-light.png b/testdata/snapshots/darwin/file_picker-light.png new file mode 100644 index 0000000..4677604 Binary files /dev/null and b/testdata/snapshots/darwin/file_picker-light.png differ diff --git a/testdata/snapshots/darwin/form-dark.png b/testdata/snapshots/darwin/form-dark.png new file mode 100644 index 0000000..64b96f9 Binary files /dev/null and b/testdata/snapshots/darwin/form-dark.png differ diff --git a/testdata/snapshots/darwin/form-light.png b/testdata/snapshots/darwin/form-light.png new file mode 100644 index 0000000..7690b2a Binary files /dev/null and b/testdata/snapshots/darwin/form-light.png differ diff --git a/testdata/snapshots/darwin/form_field-dark.png b/testdata/snapshots/darwin/form_field-dark.png new file mode 100644 index 0000000..d6b2a7e Binary files /dev/null and b/testdata/snapshots/darwin/form_field-dark.png differ diff --git a/testdata/snapshots/darwin/form_field-light.png b/testdata/snapshots/darwin/form_field-light.png new file mode 100644 index 0000000..c330d79 Binary files /dev/null and b/testdata/snapshots/darwin/form_field-light.png differ diff --git a/testdata/snapshots/darwin/grid-dark.png b/testdata/snapshots/darwin/grid-dark.png new file mode 100644 index 0000000..122e08b Binary files /dev/null and b/testdata/snapshots/darwin/grid-dark.png differ diff --git a/testdata/snapshots/darwin/grid-light.png b/testdata/snapshots/darwin/grid-light.png new file mode 100644 index 0000000..d578fbe Binary files /dev/null and b/testdata/snapshots/darwin/grid-light.png differ diff --git a/testdata/snapshots/darwin/heading-dark.png b/testdata/snapshots/darwin/heading-dark.png new file mode 100644 index 0000000..34991e4 Binary files /dev/null and b/testdata/snapshots/darwin/heading-dark.png differ diff --git a/testdata/snapshots/darwin/heading-light.png b/testdata/snapshots/darwin/heading-light.png new file mode 100644 index 0000000..03c5e0a Binary files /dev/null and b/testdata/snapshots/darwin/heading-light.png differ diff --git a/testdata/snapshots/darwin/hover_card-dark.png b/testdata/snapshots/darwin/hover_card-dark.png new file mode 100644 index 0000000..8de2e5e Binary files /dev/null and b/testdata/snapshots/darwin/hover_card-dark.png differ diff --git a/testdata/snapshots/darwin/hover_card-light.png b/testdata/snapshots/darwin/hover_card-light.png new file mode 100644 index 0000000..1efdfc9 Binary files /dev/null and b/testdata/snapshots/darwin/hover_card-light.png differ diff --git a/testdata/snapshots/darwin/icon-dark.png b/testdata/snapshots/darwin/icon-dark.png new file mode 100644 index 0000000..b86991b Binary files /dev/null and b/testdata/snapshots/darwin/icon-dark.png differ diff --git a/testdata/snapshots/darwin/icon-light.png b/testdata/snapshots/darwin/icon-light.png new file mode 100644 index 0000000..65c3fec Binary files /dev/null and b/testdata/snapshots/darwin/icon-light.png differ diff --git a/testdata/snapshots/darwin/icon_button-dark.png b/testdata/snapshots/darwin/icon_button-dark.png new file mode 100644 index 0000000..ff1961b Binary files /dev/null and b/testdata/snapshots/darwin/icon_button-dark.png differ diff --git a/testdata/snapshots/darwin/icon_button-light.png b/testdata/snapshots/darwin/icon_button-light.png new file mode 100644 index 0000000..6a4ca9b Binary files /dev/null and b/testdata/snapshots/darwin/icon_button-light.png differ diff --git a/testdata/snapshots/darwin/image-dark.png b/testdata/snapshots/darwin/image-dark.png new file mode 100644 index 0000000..286963c Binary files /dev/null and b/testdata/snapshots/darwin/image-dark.png differ diff --git a/testdata/snapshots/darwin/image-light.png b/testdata/snapshots/darwin/image-light.png new file mode 100644 index 0000000..9b207d6 Binary files /dev/null and b/testdata/snapshots/darwin/image-light.png differ diff --git a/testdata/snapshots/darwin/input_group-dark.png b/testdata/snapshots/darwin/input_group-dark.png new file mode 100644 index 0000000..480ff99 Binary files /dev/null and b/testdata/snapshots/darwin/input_group-dark.png differ diff --git a/testdata/snapshots/darwin/input_group-light.png b/testdata/snapshots/darwin/input_group-light.png new file mode 100644 index 0000000..5eb37e7 Binary files /dev/null and b/testdata/snapshots/darwin/input_group-light.png differ diff --git a/testdata/snapshots/darwin/kbd-dark.png b/testdata/snapshots/darwin/kbd-dark.png new file mode 100644 index 0000000..119484d Binary files /dev/null and b/testdata/snapshots/darwin/kbd-dark.png differ diff --git a/testdata/snapshots/darwin/kbd-light.png b/testdata/snapshots/darwin/kbd-light.png new file mode 100644 index 0000000..d38c7dd Binary files /dev/null and b/testdata/snapshots/darwin/kbd-light.png differ diff --git a/testdata/snapshots/darwin/link-dark.png b/testdata/snapshots/darwin/link-dark.png new file mode 100644 index 0000000..9d8705f Binary files /dev/null and b/testdata/snapshots/darwin/link-dark.png differ diff --git a/testdata/snapshots/darwin/link-light.png b/testdata/snapshots/darwin/link-light.png new file mode 100644 index 0000000..8147619 Binary files /dev/null and b/testdata/snapshots/darwin/link-light.png differ diff --git a/testdata/snapshots/darwin/loading_overlay-dark.png b/testdata/snapshots/darwin/loading_overlay-dark.png new file mode 100644 index 0000000..9f0ba2e Binary files /dev/null and b/testdata/snapshots/darwin/loading_overlay-dark.png differ diff --git a/testdata/snapshots/darwin/loading_overlay-light.png b/testdata/snapshots/darwin/loading_overlay-light.png new file mode 100644 index 0000000..fbfc45a Binary files /dev/null and b/testdata/snapshots/darwin/loading_overlay-light.png differ diff --git a/testdata/snapshots/darwin/menubar-dark.png b/testdata/snapshots/darwin/menubar-dark.png new file mode 100644 index 0000000..f68dcae Binary files /dev/null and b/testdata/snapshots/darwin/menubar-dark.png differ diff --git a/testdata/snapshots/darwin/menubar-light.png b/testdata/snapshots/darwin/menubar-light.png new file mode 100644 index 0000000..d2e0343 Binary files /dev/null and b/testdata/snapshots/darwin/menubar-light.png differ diff --git a/testdata/snapshots/darwin/month_picker-dark.png b/testdata/snapshots/darwin/month_picker-dark.png new file mode 100644 index 0000000..79c9a4d Binary files /dev/null and b/testdata/snapshots/darwin/month_picker-dark.png differ diff --git a/testdata/snapshots/darwin/month_picker-light.png b/testdata/snapshots/darwin/month_picker-light.png new file mode 100644 index 0000000..18979ce Binary files /dev/null and b/testdata/snapshots/darwin/month_picker-light.png differ diff --git a/testdata/snapshots/darwin/navigation_menu-dark.png b/testdata/snapshots/darwin/navigation_menu-dark.png new file mode 100644 index 0000000..a45b9ca Binary files /dev/null and b/testdata/snapshots/darwin/navigation_menu-dark.png differ diff --git a/testdata/snapshots/darwin/navigation_menu-light.png b/testdata/snapshots/darwin/navigation_menu-light.png new file mode 100644 index 0000000..bc992d2 Binary files /dev/null and b/testdata/snapshots/darwin/navigation_menu-light.png differ diff --git a/testdata/snapshots/darwin/notification_center-dark.png b/testdata/snapshots/darwin/notification_center-dark.png new file mode 100644 index 0000000..2ffba8f Binary files /dev/null and b/testdata/snapshots/darwin/notification_center-dark.png differ diff --git a/testdata/snapshots/darwin/notification_center-light.png b/testdata/snapshots/darwin/notification_center-light.png new file mode 100644 index 0000000..2c570cc Binary files /dev/null and b/testdata/snapshots/darwin/notification_center-light.png differ diff --git a/testdata/snapshots/darwin/number_input-dark.png b/testdata/snapshots/darwin/number_input-dark.png new file mode 100644 index 0000000..959270c Binary files /dev/null and b/testdata/snapshots/darwin/number_input-dark.png differ diff --git a/testdata/snapshots/darwin/number_input-light.png b/testdata/snapshots/darwin/number_input-light.png new file mode 100644 index 0000000..74f4781 Binary files /dev/null and b/testdata/snapshots/darwin/number_input-light.png differ diff --git a/testdata/snapshots/darwin/overlay-dark.png b/testdata/snapshots/darwin/overlay-dark.png new file mode 100644 index 0000000..48724cb Binary files /dev/null and b/testdata/snapshots/darwin/overlay-dark.png differ diff --git a/testdata/snapshots/darwin/overlay-light.png b/testdata/snapshots/darwin/overlay-light.png new file mode 100644 index 0000000..28c3060 Binary files /dev/null and b/testdata/snapshots/darwin/overlay-light.png differ diff --git a/testdata/snapshots/darwin/pagination-dark.png b/testdata/snapshots/darwin/pagination-dark.png new file mode 100644 index 0000000..25c2935 Binary files /dev/null and b/testdata/snapshots/darwin/pagination-dark.png differ diff --git a/testdata/snapshots/darwin/pagination-light.png b/testdata/snapshots/darwin/pagination-light.png new file mode 100644 index 0000000..e8f1911 Binary files /dev/null and b/testdata/snapshots/darwin/pagination-light.png differ diff --git a/testdata/snapshots/darwin/password_input-dark.png b/testdata/snapshots/darwin/password_input-dark.png new file mode 100644 index 0000000..e893138 Binary files /dev/null and b/testdata/snapshots/darwin/password_input-dark.png differ diff --git a/testdata/snapshots/darwin/password_input-light.png b/testdata/snapshots/darwin/password_input-light.png new file mode 100644 index 0000000..cec5085 Binary files /dev/null and b/testdata/snapshots/darwin/password_input-light.png differ diff --git a/testdata/snapshots/darwin/pin_input-dark.png b/testdata/snapshots/darwin/pin_input-dark.png new file mode 100644 index 0000000..715fd3c Binary files /dev/null and b/testdata/snapshots/darwin/pin_input-dark.png differ diff --git a/testdata/snapshots/darwin/pin_input-light.png b/testdata/snapshots/darwin/pin_input-light.png new file mode 100644 index 0000000..32a9913 Binary files /dev/null and b/testdata/snapshots/darwin/pin_input-light.png differ diff --git a/testdata/snapshots/darwin/popconfirm-dark.png b/testdata/snapshots/darwin/popconfirm-dark.png new file mode 100644 index 0000000..4f35c90 Binary files /dev/null and b/testdata/snapshots/darwin/popconfirm-dark.png differ diff --git a/testdata/snapshots/darwin/popconfirm-light.png b/testdata/snapshots/darwin/popconfirm-light.png new file mode 100644 index 0000000..0d99f7c Binary files /dev/null and b/testdata/snapshots/darwin/popconfirm-light.png differ diff --git a/testdata/snapshots/darwin/popover-dark.png b/testdata/snapshots/darwin/popover-dark.png new file mode 100644 index 0000000..9cc92e8 Binary files /dev/null and b/testdata/snapshots/darwin/popover-dark.png differ diff --git a/testdata/snapshots/darwin/popover-light.png b/testdata/snapshots/darwin/popover-light.png new file mode 100644 index 0000000..65cb03b Binary files /dev/null and b/testdata/snapshots/darwin/popover-light.png differ diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png new file mode 100644 index 0000000..28c32d3 Binary files /dev/null and b/testdata/snapshots/darwin/progress-dark.png differ diff --git a/testdata/snapshots/darwin/progress-light.png b/testdata/snapshots/darwin/progress-light.png new file mode 100644 index 0000000..ffa4b25 Binary files /dev/null and b/testdata/snapshots/darwin/progress-light.png differ diff --git a/testdata/snapshots/darwin/radio_group-dark.png b/testdata/snapshots/darwin/radio_group-dark.png new file mode 100644 index 0000000..6f77a86 Binary files /dev/null and b/testdata/snapshots/darwin/radio_group-dark.png differ diff --git a/testdata/snapshots/darwin/radio_group-light.png b/testdata/snapshots/darwin/radio_group-light.png new file mode 100644 index 0000000..609f18c Binary files /dev/null and b/testdata/snapshots/darwin/radio_group-light.png differ diff --git a/testdata/snapshots/darwin/rating-dark.png b/testdata/snapshots/darwin/rating-dark.png new file mode 100644 index 0000000..7c28431 Binary files /dev/null and b/testdata/snapshots/darwin/rating-dark.png differ diff --git a/testdata/snapshots/darwin/rating-light.png b/testdata/snapshots/darwin/rating-light.png new file mode 100644 index 0000000..cf87bd9 Binary files /dev/null and b/testdata/snapshots/darwin/rating-light.png differ diff --git a/testdata/snapshots/darwin/result-dark.png b/testdata/snapshots/darwin/result-dark.png new file mode 100644 index 0000000..d3a950a Binary files /dev/null and b/testdata/snapshots/darwin/result-dark.png differ diff --git a/testdata/snapshots/darwin/result-light.png b/testdata/snapshots/darwin/result-light.png new file mode 100644 index 0000000..788a99f Binary files /dev/null and b/testdata/snapshots/darwin/result-light.png differ diff --git a/testdata/snapshots/darwin/scroll_area-dark.png b/testdata/snapshots/darwin/scroll_area-dark.png new file mode 100644 index 0000000..7dce19f Binary files /dev/null and b/testdata/snapshots/darwin/scroll_area-dark.png differ diff --git a/testdata/snapshots/darwin/scroll_area-light.png b/testdata/snapshots/darwin/scroll_area-light.png new file mode 100644 index 0000000..551db14 Binary files /dev/null and b/testdata/snapshots/darwin/scroll_area-light.png differ diff --git a/testdata/snapshots/darwin/search_input-dark.png b/testdata/snapshots/darwin/search_input-dark.png new file mode 100644 index 0000000..fb6d0e4 Binary files /dev/null and b/testdata/snapshots/darwin/search_input-dark.png differ diff --git a/testdata/snapshots/darwin/search_input-light.png b/testdata/snapshots/darwin/search_input-light.png new file mode 100644 index 0000000..0bfc594 Binary files /dev/null and b/testdata/snapshots/darwin/search_input-light.png differ diff --git a/testdata/snapshots/darwin/segmented_control-dark.png b/testdata/snapshots/darwin/segmented_control-dark.png new file mode 100644 index 0000000..6d9110c Binary files /dev/null and b/testdata/snapshots/darwin/segmented_control-dark.png differ diff --git a/testdata/snapshots/darwin/segmented_control-light.png b/testdata/snapshots/darwin/segmented_control-light.png new file mode 100644 index 0000000..ebbd3df Binary files /dev/null and b/testdata/snapshots/darwin/segmented_control-light.png differ diff --git a/testdata/snapshots/darwin/sidebar-dark.png b/testdata/snapshots/darwin/sidebar-dark.png new file mode 100644 index 0000000..df7abfc Binary files /dev/null and b/testdata/snapshots/darwin/sidebar-dark.png differ diff --git a/testdata/snapshots/darwin/sidebar-light.png b/testdata/snapshots/darwin/sidebar-light.png new file mode 100644 index 0000000..3cd875d Binary files /dev/null and b/testdata/snapshots/darwin/sidebar-light.png differ diff --git a/testdata/snapshots/darwin/skeleton-dark.png b/testdata/snapshots/darwin/skeleton-dark.png new file mode 100644 index 0000000..f1d5134 Binary files /dev/null and b/testdata/snapshots/darwin/skeleton-dark.png differ diff --git a/testdata/snapshots/darwin/skeleton-light.png b/testdata/snapshots/darwin/skeleton-light.png new file mode 100644 index 0000000..5539f61 Binary files /dev/null and b/testdata/snapshots/darwin/skeleton-light.png differ diff --git a/testdata/snapshots/darwin/spinner-dark.png b/testdata/snapshots/darwin/spinner-dark.png new file mode 100644 index 0000000..6c3d1a1 Binary files /dev/null and b/testdata/snapshots/darwin/spinner-dark.png differ diff --git a/testdata/snapshots/darwin/spinner-light.png b/testdata/snapshots/darwin/spinner-light.png new file mode 100644 index 0000000..be8951a Binary files /dev/null and b/testdata/snapshots/darwin/spinner-light.png differ diff --git a/testdata/snapshots/darwin/split_pane-dark.png b/testdata/snapshots/darwin/split_pane-dark.png new file mode 100644 index 0000000..b70c0f9 Binary files /dev/null and b/testdata/snapshots/darwin/split_pane-dark.png differ diff --git a/testdata/snapshots/darwin/split_pane-light.png b/testdata/snapshots/darwin/split_pane-light.png new file mode 100644 index 0000000..217d065 Binary files /dev/null and b/testdata/snapshots/darwin/split_pane-light.png differ diff --git a/testdata/snapshots/darwin/stack-dark.png b/testdata/snapshots/darwin/stack-dark.png new file mode 100644 index 0000000..fbe69a3 Binary files /dev/null and b/testdata/snapshots/darwin/stack-dark.png differ diff --git a/testdata/snapshots/darwin/stack-light.png b/testdata/snapshots/darwin/stack-light.png new file mode 100644 index 0000000..01bc250 Binary files /dev/null and b/testdata/snapshots/darwin/stack-light.png differ diff --git a/testdata/snapshots/darwin/status_bar-dark.png b/testdata/snapshots/darwin/status_bar-dark.png new file mode 100644 index 0000000..4c7acee Binary files /dev/null and b/testdata/snapshots/darwin/status_bar-dark.png differ diff --git a/testdata/snapshots/darwin/status_bar-light.png b/testdata/snapshots/darwin/status_bar-light.png new file mode 100644 index 0000000..169e081 Binary files /dev/null and b/testdata/snapshots/darwin/status_bar-light.png differ diff --git a/testdata/snapshots/darwin/status_indicator-dark.png b/testdata/snapshots/darwin/status_indicator-dark.png new file mode 100644 index 0000000..40c2973 Binary files /dev/null and b/testdata/snapshots/darwin/status_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/status_indicator-light.png b/testdata/snapshots/darwin/status_indicator-light.png new file mode 100644 index 0000000..c256f18 Binary files /dev/null and b/testdata/snapshots/darwin/status_indicator-light.png differ diff --git a/testdata/snapshots/darwin/steps-dark.png b/testdata/snapshots/darwin/steps-dark.png new file mode 100644 index 0000000..cd38011 Binary files /dev/null and b/testdata/snapshots/darwin/steps-dark.png differ diff --git a/testdata/snapshots/darwin/steps-light.png b/testdata/snapshots/darwin/steps-light.png new file mode 100644 index 0000000..7381891 Binary files /dev/null and b/testdata/snapshots/darwin/steps-light.png differ diff --git a/testdata/snapshots/darwin/tabs-comfortable-dark.png b/testdata/snapshots/darwin/tabs-comfortable-dark.png new file mode 100644 index 0000000..8e995c0 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-comfortable-light.png b/testdata/snapshots/darwin/tabs-comfortable-light.png new file mode 100644 index 0000000..a366b76 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/tabs-compact-dark.png b/testdata/snapshots/darwin/tabs-compact-dark.png new file mode 100644 index 0000000..d2f037c Binary files /dev/null and b/testdata/snapshots/darwin/tabs-compact-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-compact-light.png b/testdata/snapshots/darwin/tabs-compact-light.png new file mode 100644 index 0000000..c69e387 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-compact-light.png differ diff --git a/testdata/snapshots/darwin/tabs-dark.png b/testdata/snapshots/darwin/tabs-dark.png new file mode 100644 index 0000000..41bd90d Binary files /dev/null and b/testdata/snapshots/darwin/tabs-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-light.png b/testdata/snapshots/darwin/tabs-light.png new file mode 100644 index 0000000..01b2495 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-light.png differ diff --git a/testdata/snapshots/darwin/tag-dark.png b/testdata/snapshots/darwin/tag-dark.png new file mode 100644 index 0000000..71ce0bf Binary files /dev/null and b/testdata/snapshots/darwin/tag-dark.png differ diff --git a/testdata/snapshots/darwin/tag-light.png b/testdata/snapshots/darwin/tag-light.png new file mode 100644 index 0000000..c2b19ab Binary files /dev/null and b/testdata/snapshots/darwin/tag-light.png differ diff --git a/testdata/snapshots/darwin/tags_input-dark.png b/testdata/snapshots/darwin/tags_input-dark.png new file mode 100644 index 0000000..12ed2db Binary files /dev/null and b/testdata/snapshots/darwin/tags_input-dark.png differ diff --git a/testdata/snapshots/darwin/tags_input-light.png b/testdata/snapshots/darwin/tags_input-light.png new file mode 100644 index 0000000..a349c5e Binary files /dev/null and b/testdata/snapshots/darwin/tags_input-light.png differ diff --git a/testdata/snapshots/darwin/text-dark.png b/testdata/snapshots/darwin/text-dark.png new file mode 100644 index 0000000..a6d1fb0 Binary files /dev/null and b/testdata/snapshots/darwin/text-dark.png differ diff --git a/testdata/snapshots/darwin/text-light.png b/testdata/snapshots/darwin/text-light.png new file mode 100644 index 0000000..28658ca Binary files /dev/null and b/testdata/snapshots/darwin/text-light.png differ diff --git a/testdata/snapshots/darwin/text_area-dark.png b/testdata/snapshots/darwin/text_area-dark.png new file mode 100644 index 0000000..d6c2290 Binary files /dev/null and b/testdata/snapshots/darwin/text_area-dark.png differ diff --git a/testdata/snapshots/darwin/text_area-light.png b/testdata/snapshots/darwin/text_area-light.png new file mode 100644 index 0000000..b474615 Binary files /dev/null and b/testdata/snapshots/darwin/text_area-light.png differ diff --git a/testdata/snapshots/darwin/text_input-comfortable-dark.png b/testdata/snapshots/darwin/text_input-comfortable-dark.png new file mode 100644 index 0000000..943f914 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/text_input-comfortable-light.png b/testdata/snapshots/darwin/text_input-comfortable-light.png new file mode 100644 index 0000000..281d895 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/text_input-compact-dark.png b/testdata/snapshots/darwin/text_input-compact-dark.png new file mode 100644 index 0000000..3ddd459 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-compact-dark.png differ diff --git a/testdata/snapshots/darwin/text_input-compact-light.png b/testdata/snapshots/darwin/text_input-compact-light.png new file mode 100644 index 0000000..30cc1d1 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-compact-light.png differ diff --git a/testdata/snapshots/darwin/text_input-dark.png b/testdata/snapshots/darwin/text_input-dark.png new file mode 100644 index 0000000..18bde1f Binary files /dev/null and b/testdata/snapshots/darwin/text_input-dark.png differ diff --git a/testdata/snapshots/darwin/text_input-light.png b/testdata/snapshots/darwin/text_input-light.png new file mode 100644 index 0000000..d600c90 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-light.png differ diff --git a/testdata/snapshots/darwin/time_picker-dark.png b/testdata/snapshots/darwin/time_picker-dark.png new file mode 100644 index 0000000..12a4377 Binary files /dev/null and b/testdata/snapshots/darwin/time_picker-dark.png differ diff --git a/testdata/snapshots/darwin/time_picker-light.png b/testdata/snapshots/darwin/time_picker-light.png new file mode 100644 index 0000000..de6c31a Binary files /dev/null and b/testdata/snapshots/darwin/time_picker-light.png differ diff --git a/testdata/snapshots/darwin/time_range_picker-dark.png b/testdata/snapshots/darwin/time_range_picker-dark.png new file mode 100644 index 0000000..d3f8038 Binary files /dev/null and b/testdata/snapshots/darwin/time_range_picker-dark.png differ diff --git a/testdata/snapshots/darwin/time_range_picker-light.png b/testdata/snapshots/darwin/time_range_picker-light.png new file mode 100644 index 0000000..e32d8f1 Binary files /dev/null and b/testdata/snapshots/darwin/time_range_picker-light.png differ diff --git a/testdata/snapshots/darwin/title_bar-dark.png b/testdata/snapshots/darwin/title_bar-dark.png new file mode 100644 index 0000000..cfae176 Binary files /dev/null and b/testdata/snapshots/darwin/title_bar-dark.png differ diff --git a/testdata/snapshots/darwin/title_bar-light.png b/testdata/snapshots/darwin/title_bar-light.png new file mode 100644 index 0000000..e516140 Binary files /dev/null and b/testdata/snapshots/darwin/title_bar-light.png differ diff --git a/testdata/snapshots/darwin/toast-dark.png b/testdata/snapshots/darwin/toast-dark.png new file mode 100644 index 0000000..b7888e8 Binary files /dev/null and b/testdata/snapshots/darwin/toast-dark.png differ diff --git a/testdata/snapshots/darwin/toast-light.png b/testdata/snapshots/darwin/toast-light.png new file mode 100644 index 0000000..108f5fc Binary files /dev/null and b/testdata/snapshots/darwin/toast-light.png differ diff --git a/testdata/snapshots/darwin/toolbar-dark.png b/testdata/snapshots/darwin/toolbar-dark.png new file mode 100644 index 0000000..77b97e4 Binary files /dev/null and b/testdata/snapshots/darwin/toolbar-dark.png differ diff --git a/testdata/snapshots/darwin/toolbar-light.png b/testdata/snapshots/darwin/toolbar-light.png new file mode 100644 index 0000000..7472177 Binary files /dev/null and b/testdata/snapshots/darwin/toolbar-light.png differ diff --git a/testdata/snapshots/darwin/tooltip-dark.png b/testdata/snapshots/darwin/tooltip-dark.png new file mode 100644 index 0000000..b91ffc5 Binary files /dev/null and b/testdata/snapshots/darwin/tooltip-dark.png differ diff --git a/testdata/snapshots/darwin/tooltip-light.png b/testdata/snapshots/darwin/tooltip-light.png new file mode 100644 index 0000000..1e1c840 Binary files /dev/null and b/testdata/snapshots/darwin/tooltip-light.png differ diff --git a/testdata/snapshots/darwin/transfer-dark.png b/testdata/snapshots/darwin/transfer-dark.png new file mode 100644 index 0000000..c9c50e0 Binary files /dev/null and b/testdata/snapshots/darwin/transfer-dark.png differ diff --git a/testdata/snapshots/darwin/transfer-light.png b/testdata/snapshots/darwin/transfer-light.png new file mode 100644 index 0000000..1030c96 Binary files /dev/null and b/testdata/snapshots/darwin/transfer-light.png differ diff --git a/testdata/snapshots/darwin/year_picker-dark.png b/testdata/snapshots/darwin/year_picker-dark.png new file mode 100644 index 0000000..e171caa Binary files /dev/null and b/testdata/snapshots/darwin/year_picker-dark.png differ diff --git a/testdata/snapshots/darwin/year_picker-light.png b/testdata/snapshots/darwin/year_picker-light.png new file mode 100644 index 0000000..e3f2cff Binary files /dev/null and b/testdata/snapshots/darwin/year_picker-light.png differ diff --git a/text.go b/text.go new file mode 100644 index 0000000..52cea10 --- /dev/null +++ b/text.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TextOptions configure a Text. The zero value wraps every line. +type TextOptions struct { + // MaxLines cuts the text with an ellipsis after that many lines; zero never cuts. + MaxLines int + // Selectable lets the user select and copy the text. + Selectable bool +} + +// Text shows body text that wraps at the width it gets. +func Text(c *ui.Context, s string, opts TextOptions) *ui.Element { + if opts.MaxLines < 0 { + panic("mujica: Text MaxLines must not be negative") + } + e := ui.Text(c, s).LineHeight(theme.LineHeight) + switch { + case opts.MaxLines == 1: + e.SingleLine() + case opts.MaxLines > 1: + e.MaxLines(opts.MaxLines) + } + if opts.Selectable { + e.Selectable() + } + return e +} + +// EditableTextOptions configure the editable mode of Text. +type EditableTextOptions struct { + // Saving shows the caller's save in progress and blocks editing. + Saving bool + // Error is the caller's save failure, shown below the text. + Error string + // Label names the text for assistive technology. + Label string +} + +// EditableText is Text's editable mode. Double click or Enter edits; Enter +// or leaving confirms, Escape cancels. Changed reports a confirmed new value. +func EditableText(c *ui.Context, value *string, opts EditableTextOptions) *ui.Element { + k := Tokens(c) + u := Density(c).Unit() + line := k.ControlBorder + if opts.Error != "" { + line = k.Danger + } + var box *ui.Element + ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Saving).Children(func() { + box = ui.EditableText(c, value) + pad := u / 2 + box.Padding(pad+1, pad*2+1).MinWidth(theme.MinHitSize*2).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius). + BorderWidth(0, 0, 1, 0).BorderColor(line).BorderStyle(ui.BorderDashed) + internal.Ring(box, k.Focus, theme.ControlRadius) + if box.Hovered() { + box.Background(k.SurfaceHover) + } + internal.Fade(box, Motion(c, theme.StateDuration)) + if opts.Label != "" { + box.Label(opts.Label) + } + if opts.Saving { + Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "editable_text.saving")}) + } + }) + if opts.Error != "" { + box.Error(opts.Error) + ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize) + } + }) + if box.Changed() { + slog.Debug("mujica: editabletext changed", "len", len(*value)) + } + return box +} diff --git a/text_area.go b/text_area.go new file mode 100644 index 0000000..e87274b --- /dev/null +++ b/text_area.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "fmt" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TextAreaOptions configure a TextArea. +type TextAreaOptions struct { + Placeholder string + // Label names the field for assistive technology. + Label string + // MinHeight is the least height in DIPs; zero fits three lines. + MinHeight float32 + // MaxHeight caps the height in DIPs, then the text scrolls; zero grows freely. + MaxHeight float32 + // MaxLength shows a character count against it; zero hides the count. + MaxLength int + // SubmitKey with SubmitMods submits; a zero key means Cmd+Enter. + SubmitKey ui.Key + SubmitMods ui.Modifiers + Disabled bool + ReadOnly bool + // Error marks the value invalid and shows the message below. + Error string +} + +// TextArea is a multi-line field editing *value. +func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { + if value == nil { + panic("mujica: TextArea needs a value") + } + if opts.MinHeight < 0 || opts.MaxHeight < 0 || opts.MaxHeight > 0 && opts.MaxHeight < opts.MinHeight { + panic("mujica: TextArea needs 0 <= MinHeight <= MaxHeight") + } + if opts.MaxLength < 0 { + panic("mujica: TextArea MaxLength is negative") + } + key, mods := opts.SubmitKey, opts.SubmitMods + if key == 0 { + key, mods = ui.KeyEnter, ui.Cmd + } + d := Density(c) + n := utf8.RuneCountInString(*value) + over := opts.MaxLength > 0 && n > opts.MaxLength + errMsg := opts.Error + if errMsg == "" && over { + errMsg = fmt.Sprintf(msg(c, "text_area.over"), n-opts.MaxLength) + } + count := textAreaCount(c, n, opts.MaxLength) + var r InputResult + look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: errMsg, area: true} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + in := textInputEditor(c, frame, value, opts.ReadOnly, true).Grow(1).Padding(d.Unit()*2, d.Unit()*3). + Placeholder(opts.Placeholder).Label(opts.Label) + if opts.MinHeight > 0 { + in.MinHeight(opts.MinHeight) + } + if opts.MaxHeight > 0 { + in.MaxHeight(opts.MaxHeight) + } + r.changed = in.Changed() && !opts.ReadOnly + r.submitted = in.Shortcut(mods, key) + return in + }, count) + return r +} + +// textAreaCount builds the character count against max, or nil without one. +func textAreaCount(c *ui.Context, n, max int) func() { + if max == 0 { + return nil + } + return func() { + k := Tokens(c) + color := k.TextMuted + over := n > max + if over { + color = k.Danger + } + ui.Text(c, fmt.Sprintf("%d / %d", n, max)).TextColor(color).FontSize(theme.CaptionSize). + FontFeatures("tnum").Shrink(0).Label(fmt.Sprintf(msg(c, "text_area.count"), n, max)) + } +} diff --git a/text_area_test.go b/text_area_test.go new file mode 100644 index 0000000..4e51b59 --- /dev/null +++ b/text_area_test.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTextAreaNewlinesAndSubmit(t *testing.T) { + s, submits := "", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TextArea(c, &s, TextAreaOptions{Label: "Notes"}).Submitted() { + submits++ + } + }, 400, 300) + _ = tt.Click("Notes") + tt.Type("one") + tt.Key(0, ui.KeyEnter) + tt.Type("two") + if s != "one\ntwo" || submits != 0 { + t.Fatalf("value %q submits %d", s, submits) + } + tt.Key(ui.Cmd, ui.KeyEnter) + if submits != 1 || s != "one\ntwo" { + t.Fatalf("Cmd+Enter: value %q submits %d", s, submits) + } +} + +func TestTextAreaCustomSubmitKey(t *testing.T) { + s, submits := "", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TextArea(c, &s, TextAreaOptions{Label: "Chat", SubmitKey: ui.KeyEnter, SubmitMods: ui.Ctrl | ui.Shift}).Submitted() { + submits++ + } + }, 400, 300) + _ = tt.Click("Chat") + tt.Type("hi") + tt.Key(ui.Cmd, ui.KeyEnter) + tt.Key(ui.Ctrl|ui.Shift, ui.KeyEnter) + if submits != 1 { + t.Fatalf("submits %d", submits) + } +} + +func TestTextAreaHeightsAndCount(t *testing.T) { + s := strings.Repeat("line\n", 40) + "end" + var h float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Width(300).Children(func() { + r := TextArea(c, &s, TextAreaOptions{Label: "Log", MinHeight: 60, MaxHeight: 120, MaxLength: 100}) + h = r.Input.Bounds().H + }) + }, 400, 400) + tt.Frame() + if h != 120 { + t.Fatalf("height %v, want the 120 cap", h) + } + if !tt.HasText("203 of 100 characters") || !tt.HasText("103 characters over the limit") { + t.Fatalf("count missing: %q", tt.Texts()) + } + s = "ok" + tt.Frame() + if h < 60 || h >= 120 || !tt.HasText("2 of 100 characters") { + t.Fatalf("height %v texts %q", h, tt.Texts()) + } +} + +func TestTextAreaBadOptionsPanic(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for MaxHeight below MinHeight") + } + }() + s := "" + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextArea(c, &s, TextAreaOptions{MinHeight: 100, MaxHeight: 50}) + }, 300, 200) +} + +func TestTextAreaSnapshot(t *testing.T) { + snapshot(t, "text_area", 420, 360, func(c *ui.Context) { + a, b, e := "", "Les lys d’or sur fond d’azur.\nUne cour, un bal, un serment.", "Trop long" + ui.Column(c).Padding(24).Gap(12).Width(360).Children(func() { + TextArea(c, &a, TextAreaOptions{Label: "A", Placeholder: "Write a dedication…"}) + TextArea(c, &b, TextAreaOptions{Label: "B", MaxLength: 120}) + TextArea(c, &e, TextAreaOptions{Label: "E", MinHeight: 40, MaxLength: 5}) + }) + }) +} diff --git a/text_input.go b/text_input.go new file mode 100644 index 0000000..07107cc --- /dev/null +++ b/text_input.go @@ -0,0 +1,172 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// InputResult is a value input's outer element and its events. +type InputResult struct { + *ui.Element + // Input is the text field inside, for focus and keys. + Input *ui.Element + changed bool + submitted bool +} + +// Changed reports that the user changed the value this frame. +func (r InputResult) Changed() bool { return r.changed } + +// Submitted reports Enter, or the submit shortcut of a TextArea. +func (r InputResult) Submitted() bool { return r.submitted } + +// TextInputOptions configure a TextInput. +type TextInputOptions struct { + Placeholder string + // Label names the field for assistive technology. + Label string + // Leading and Trailing build content inside the field's edges. + Leading func() + Trailing func() + // Clearable shows a clear button while there is text; Escape clears too. + Clearable bool + Disabled bool + // ReadOnly keeps the text selectable and copyable but not editable. + ReadOnly bool + // Error marks the value invalid and shows the message below. + Error string +} + +// TextInput is a single-line field editing *value. +func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult { + if value == nil { + panic("mujica: TextInput needs a value") + } + var r InputResult + look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + if opts.Leading != nil { + opts.Leading() + } + in := textInputEditor(c, frame, value, opts.ReadOnly, false).Grow(1).MinWidth(Density(c).Unit() * 10). + Placeholder(opts.Placeholder).Label(opts.Label) + r.changed = in.Changed() && !opts.ReadOnly + r.submitted = in.Submitted() + if opts.Clearable && *value != "" && !opts.ReadOnly && !opts.Disabled { + if textInputClear(c, in) { + *value = "" + r.changed = true + slog.Debug("mujica: text_input clear") + } + } + if opts.Trailing != nil { + opts.Trailing() + } + return in + }, nil) + return r +} + +// textInputFade eases hover, focus and error colors. +var textInputFade = ui.ElementTransition{Colors: true, Duration: theme.StateDuration} + +// textInputLook is the state a field frame shows. +type textInputLook struct { + disabled, readOnly, area bool + err string +} + +// textInputFrame builds the bordered field around what build returns as the input. +func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Element) *ui.Element, footer func()) (outer, in *ui.Element) { + k := Tokens(c) + d := Density(c) + outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled) + outer.Children(func() { + frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText) + if look.area { + frame.AlignItems(ui.Stretch) + } else { + frame.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3) + } + frame.Children(func() { in = build(frame) }) + in.FocusRing(false) + if look.err != "" { + in.Error(look.err) + } + if frame.Clicked() { + in.Focus() + } + bg, border := k.Surface, k.ControlBorder + switch { + case look.readOnly: + bg, border = k.Background, k.Border + case frame.Hovered(): + border = k.ControlBorder.Mix(k.Text, 0.35) + } + switch { + case look.err != "": + border = k.Danger + case in.Focused() && !look.readOnly: + border = k.Focus + } + frame.Background(bg).Border(theme.BorderWidth, border).Transition(textInputFade) + frame.DrawOver(func(p *ui.Painter, r ui.Rect) { + if in.FocusVisible() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + if look.err == "" && footer == nil { + return + } + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() { + if look.err != "" { + ui.Text(c, look.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Grow(1) + } else { + ui.Spacer(c) + } + if footer != nil { + footer() + } + }) + }) + return outer, in +} + +// textInputEditor builds the MyGo editor; read-only edits are undone. +func textInputEditor(c *ui.Context, frame *ui.Element, value *string, readOnly, area bool) *ui.Element { + base := ui.TextInputBase + if area { + base = ui.TextAreaBase + } + if !readOnly { + return base(c, value) + } + shown := ui.Local(frame, "readonly", func() string { return *value }) + *shown = *value + in := base(c, shown) + *shown = *value + return in +} + +// textInputClear builds the clear button and reports a clear by click or Escape. +func textInputClear(c *ui.Context, in *ui.Element) bool { + k := Tokens(c) + b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius). + Role(ui.RoleButton).Label(msg(c, "text_input.clear")).Cursor(ui.CursorPointer) + fg := k.TextMuted + if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + b.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14).TextColor(fg) }) + if b.Clicked() || in.Shortcut(0, ui.KeyEscape) { + in.Focus() + return true + } + return false +} diff --git a/text_input_test.go b/text_input_test.go new file mode 100644 index 0000000..8758dbf --- /dev/null +++ b/text_input_test.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestTextInputTypingAndSubmit(t *testing.T) { + name, changes, submits := "", 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := TextInput(c, &name, TextInputOptions{Label: "Name", Placeholder: "Your name"}) + if r.Changed() { + changes++ + } + if r.Submitted() { + submits++ + } + }, 400, 200) + if err := tt.Click("Name"); err != nil { + t.Fatal(err) + } + tt.Type("Ada") + tt.Key(0, ui.KeyEnter) + if name != "Ada" || changes == 0 || submits != 1 { + t.Fatalf("name %q changes %d submits %d", name, changes, submits) + } +} + +func TestTextInputUndoClipboardIME(t *testing.T) { + s := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextInput(c, &s, TextInputOptions{Label: "Note"}) + }, 400, 200) + _ = tt.Click("Note") + tt.Compose("にほ", 2) + if s != "" { + t.Fatalf("composition leaked into value: %q", s) + } + tt.Type("日本") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "日本" { + t.Fatalf("clipboard %q", tt.Clipboard()) + } + tt.Type("x") + tt.Command("undo") + if s != "日本" { + t.Fatalf("after undo %q", s) + } +} + +func TestTextInputClear(t *testing.T) { + s, changes := "draft", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TextInput(c, &s, TextInputOptions{Label: "Draft", Clearable: true}).Changed() { + changes++ + } + }, 400, 200) + if err := tt.Click("Clear"); err != nil { + t.Fatal(err) + } + if s != "" || changes != 1 { + t.Fatalf("after clear %q, changes %d", s, changes) + } + if _, ok := tt.Find("Clear"); ok { + t.Fatal("clear button stays on an empty field") + } + tt.Type("again") + tt.Key(0, ui.KeyEscape) + if s != "" || changes != 3 { + t.Fatalf("after Escape %q, changes %d", s, changes) + } +} + +func TestTextInputReadOnlyAndDisabled(t *testing.T) { + ro, off := "fixed", "locked" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Gap(8).Children(func() { + TextInput(c, &ro, TextInputOptions{Label: "RO", ReadOnly: true, Clearable: true}) + TextInput(c, &off, TextInputOptions{Label: "Off", Disabled: true}) + }) + }, 400, 200) + _ = tt.Click("RO") + tt.Command("selectAll") + tt.Command("copy") + tt.Type("zz") + tt.Key(0, ui.KeyBackspace) + if ro != "fixed" || tt.Clipboard() != "fixed" { + t.Fatalf("read-only value %q clipboard %q", ro, tt.Clipboard()) + } + if _, ok := tt.Find("Clear"); ok { + t.Fatal("read-only field offers clear") + } + _ = tt.Click("Off") + tt.Type("zz") + if off != "locked" || tt.Focused("Off") { + t.Fatalf("disabled value %q focused %v", off, tt.Focused("Off")) + } +} + +func TestTextInputSlotsAndError(t *testing.T) { + s := "x" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextInput(c, &s, TextInputOptions{ + Label: "Price", + Leading: func() { ui.Text(c, "¥") }, + Trailing: func() { ui.Text(c, "CNY") }, + Error: "Not a price", + }) + }, 400, 200) + for _, want := range []string{"¥", "CNY", "Not a price"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } +} + +func TestTextInputSnapshot(t *testing.T) { + view := func(c *ui.Context) { + a, b, e, ro, dis := "", "Elisabeth", "1789-13-01", "Read only", "Disabled" + ui.Column(c).Padding(24).Gap(12).Width(360).Children(func() { + TextInput(c, &a, TextInputOptions{Placeholder: "Placeholder", Label: "A"}) + TextInput(c, &b, TextInputOptions{Label: "B", Clearable: true, Leading: func() { + ui.Icon(c, icons.Must("user")).Size(16, 16).TextColor(Tokens(c).TextMuted) + }}) + TextInput(c, &e, TextInputOptions{Label: "E", Error: "No such date"}) + TextInput(c, &ro, TextInputOptions{Label: "R", ReadOnly: true}) + TextInput(c, &dis, TextInputOptions{Label: "D", Disabled: true}) + }) + } + snapshot(t, "text_input", 420, 320, view) + buttonDensitySnapshots(t, "text_input", 420, 360, view) +} diff --git a/text_test.go b/text_test.go new file mode 100644 index 0000000..6b12e7d --- /dev/null +++ b/text_test.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTextSelectAndCopy(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Text(c, "ID-4821", TextOptions{Selectable: true}) + }, 300, 100) + if err := tt.Click("ID-4821"); err != nil { + t.Fatal(err) + } + tt.Command("selectAll") + tt.Command("copy") + if got := tt.Clipboard(); got != "ID-4821" { + t.Fatalf("clipboard %q", got) + } +} + +func TestTextNotSelectableByDefault(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Text(c, "plain", TextOptions{}) + }, 300, 100) + _ = tt.Click("plain") + tt.Command("selectAll") + tt.Command("copy") + if got := tt.Clipboard(); got != "" { + t.Fatalf("clipboard %q, want empty", got) + } +} + +func TestTextMaxLinesCuts(t *testing.T) { + long := "The court gathers in the hall of mirrors while candles burn low and the music fades away." + var free, cut float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(120).Children(func() { + free = Text(c, long, TextOptions{}).Bounds().H + cut = Text(c, long, TextOptions{MaxLines: 1}).Bounds().H + }) + }, 300, 400) + tt.Frame() + if cut <= 0 || free < cut*3 { + t.Fatalf("free %v, one line %v", free, cut) + } +} + +func TestTextNegativeMaxLinesPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Text(c, "x", TextOptions{MaxLines: -1}) + }, 100, 100) + tt.Frame() +} + +func TestEditableTextConfirm(t *testing.T) { + name := "report.pdf" + changed := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if EditableText(c, &name, EditableTextOptions{Label: "File name"}).Changed() { + changed++ + } + }, 300, 120) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Type("summary") + tt.Key(0, ui.KeyEnter) + if name != "summary.pdf" || changed != 1 { + t.Fatalf("name %q, changed %d", name, changed) + } +} + +func TestEditableTextCancel(t *testing.T) { + name := "report.pdf" + changed := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if EditableText(c, &name, EditableTextOptions{}).Changed() { + changed++ + } + }, 300, 120) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Type("draft") + tt.Key(0, ui.KeyEscape) + if name != "report.pdf" || changed != 0 { + t.Fatalf("name %q, changed %d", name, changed) + } + if !tt.HasText("report.pdf") { + t.Fatal("text not shown after cancel") + } +} + +func TestEditableTextSavingBlocksAndErrorShows(t *testing.T) { + name := "notes" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + EditableText(c, &name, EditableTextOptions{Saving: true, Error: "disk full"}) + }, 300, 120) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Type("x") + tt.Key(0, ui.KeyEnter) + if name != "notes" { + t.Fatalf("edited while saving: %q", name) + } + if !tt.HasText("disk full") { + t.Fatal("save error not shown") + } + if _, ok := tt.Find("Saving"); !ok { + t.Fatal("saving spinner not labeled") + } +} + +func TestTextSnapshot(t *testing.T) { + name := "Coronation.md" + snapshot(t, "text", 420, 280, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).Width(380).Children(func() { + Text(c, "Velvet curtains fall over the long gallery, and every candle leans toward the open door.", TextOptions{}) + Text(c, "A single line that is far too long to fit and so ends in an ellipsis.", TextOptions{MaxLines: 1}) + Text(c, "Selectable: ID-4821", TextOptions{Selectable: true}) + EditableText(c, &name, EditableTextOptions{Error: "The name is already taken."}) + EditableText(c, &name, EditableTextOptions{Saving: true}) + }) + }) +} diff --git a/theme.go b/theme.go index a7f15fa..3a5b690 100644 --- a/theme.go +++ b/theme.go @@ -82,10 +82,7 @@ func Use(c *ui.Context, s Settings) { if p.HighContrast { k = k.HighContrast() } - size := theme.BodySize - if p.TextScale > 0 { - size *= p.TextScale - } + size := FontSize(c, theme.BodySize) c.SetTheme(&ui.Theme{ Dark: k.Dark, Background: k.Background, @@ -134,3 +131,11 @@ func Motion(c *ui.Context, d time.Duration) time.Duration { } return d } + +// FontSize scales a design size by the desktop's text scale. +func FontSize(c *ui.Context, size float32) float32 { + if s := c.Preferences().TextScale; s > 0 { + return size * s + } + return size +} diff --git a/theme_test.go b/theme_test.go index 59c0bb0..cd9718c 100644 --- a/theme_test.go +++ b/theme_test.go @@ -41,3 +41,15 @@ func TestTokensWithoutUsePanics(t *testing.T) { }() ui.NewTester(func(c *ui.Context) { Tokens(c) }, 100, 100) } + +func TestFontSizeFollowsTextScale(t *testing.T) { + var got float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + got = FontSize(c, theme.CaptionSize) + }, 100, 100) + tt.SetPreferences(ui.Preferences{TextScale: 2}) + if got != 2*theme.CaptionSize { + t.Fatalf("FontSize = %v, want %v", got, 2*theme.CaptionSize) + } +} diff --git a/time_picker.go b/time_picker.go new file mode 100644 index 0000000..12a4391 --- /dev/null +++ b/time_picker.go @@ -0,0 +1,426 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TimeOfDay is a wall-clock time with no date and no location. +type TimeOfDay struct { + Hour, Minute, Second int +} + +// Valid reports whether t is a time between 00:00:00 and 23:59:59. +func (t TimeOfDay) Valid() bool { + return t.Hour >= 0 && t.Hour < 24 && t.Minute >= 0 && t.Minute < 60 && t.Second >= 0 && t.Second < 60 +} + +// String formats t as 15:04:05. +func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second) } + +// Compare returns -1, 0 or 1 as t is before, equal to or after o. +func (t TimeOfDay) Compare(o TimeOfDay) int { + a, b := t.seconds(), o.seconds() + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +func (t TimeOfDay) seconds() int { return t.Hour*3600 + t.Minute*60 + t.Second } + +// TimePickerOptions configure a TimePicker. +type TimePickerOptions struct { + // Seconds shows and edits the seconds. + Seconds bool + // Hour12 shows a 12-hour clock with AM and PM. + Hour12 bool + // MinuteStep and SecondStep must divide 60; zero means 1. + MinuteStep, SecondStep int + // Min and Max bound the time; a zero Max means 23:59:59. + Min, Max TimeOfDay + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// TimePicker edits *value by typing into hour, minute and second segments or by choosing in a popup. +// Times outside the range or off the step are reported and not written. +func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult { + cfg := timePickerConfig("TimePicker", opts) + if !value.Valid() { + panic(fmt.Sprintf("mujica: TimePicker value %v is not a time of day", *value)) + } + col := ui.Column(c).Gap(Density(c).Unit()).AlignItems(ui.Start).Disabled(opts.Disabled) + draft := ui.Local(col, "time-picker-draft", func() TimeOfDay { return *value }) + seen := ui.Local(col, "time-picker-seen", func() TimeOfDay { return *value }) + problem := ui.Local(col, "time-picker-error", func() string { return "" }) + if *seen != *value { + *draft, *seen, *problem = *value, *value, "" + } + changed := false + col.Children(func() { + cfg.invalid = *problem != "" + if _, edited := timeEditor(c, draft, cfg); edited { + *problem = timePickerProblem(c, *draft, cfg) + if *problem == "" && *draft != *value { + slog.Debug("mujica: time_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true + } + } + datetimeError(c, *problem) + }) + return DateTimeResult{Element: col, changed: changed} +} + +// timeConfig is the resolved behavior of a time editor. +type timeConfig struct { + seconds, hour12 bool + minuteStep, secondStep int + min, max TimeOfDay + label string + invalid bool +} + +// timePickerConfig checks opts and fills in defaults. +func timePickerConfig(name string, opts TimePickerOptions) timeConfig { + cfg := timeConfig{seconds: opts.Seconds, hour12: opts.Hour12, minuteStep: max(opts.MinuteStep, 1), + secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label} + for _, s := range []int{opts.MinuteStep, opts.SecondStep} { + if s < 0 || s > 0 && 60%s != 0 { + panic(fmt.Sprintf("mujica: %s step %d does not divide 60", name, s)) + } + } + if cfg.max == (TimeOfDay{}) { + cfg.max = TimeOfDay{23, 59, 59} + } + if !cfg.min.Valid() || !cfg.max.Valid() || cfg.min.Compare(cfg.max) > 0 { + panic(fmt.Sprintf("mujica: %s range %v..%v is not valid", name, cfg.min, cfg.max)) + } + return cfg +} + +// timePickerProblem explains why t cannot be written, or returns "". +func timePickerProblem(c *ui.Context, t TimeOfDay, cfg timeConfig) string { + switch { + case t.Minute%cfg.minuteStep != 0: + return fmt.Sprintf(msg(c, "time_picker.minute_step"), cfg.minuteStep) + case cfg.seconds && t.Second%cfg.secondStep != 0: + return fmt.Sprintf(msg(c, "time_picker.second_step"), cfg.secondStep) + case t.Compare(cfg.min) < 0 || t.Compare(cfg.max) > 0: + return fmt.Sprintf(msg(c, "time_picker.out_of_range"), timeText(c, cfg.min, cfg), timeText(c, cfg.max, cfg)) + } + return "" +} + +// timeText formats t for messages, honoring the 12-hour clock and seconds. +func timeText(c *ui.Context, t TimeOfDay, cfg timeConfig) string { + s := fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) + if cfg.hour12 { + s = fmt.Sprintf("%d:%02d", timeHour12(t.Hour), t.Minute) + } + if cfg.seconds { + s += fmt.Sprintf(":%02d", t.Second) + } + if cfg.hour12 { + s = fmt.Sprintf(msg(c, "time_picker.with_period"), s, timePeriod(c, t.Hour)) + } + return s +} + +func timeHour12(h int) int { + if h%12 == 0 { + return 12 + } + return h % 12 +} + +func timePeriod(c *ui.Context, h int) string { + if h < 12 { + return msg(c, "time_picker.am") + } + return msg(c, "time_picker.pm") +} + +// timePart is one segment of a time editor. +type timePart int + +const ( + timeHourPart timePart = iota + timeMinutePart + timeSecondPart + timePeriodPart +) + +var timePartNames = [...]string{"time_picker.hours", "time_picker.minutes", "time_picker.seconds", "time_picker.period"} + +func timeParts(cfg timeConfig) []timePart { + parts := []timePart{timeHourPart, timeMinutePart} + if cfg.seconds { + parts = append(parts, timeSecondPart) + } + if cfg.hour12 { + parts = append(parts, timePeriodPart) + } + return parts +} + +// timeEditor draws a segmented time field with a popup and reports whether the user edited *draft. +func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool) { + k := Tokens(c) + d := Density(c) + field = datetimeField(c, ui.Row(c), cfg.invalid).Gap(d.Unit() * 2) + open := ui.Local(field, "time-open", func() bool { return false }) + fresh := ui.Local(field, "time-fresh", func() bool { return false }) + field.Children(func() { + segs := ui.Row(c).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label) + segs.Children(func() { + focusNext := false + for i, part := range timeParts(cfg) { + if i > 0 && part != timePeriodPart { + ui.Text(c, ":").TextColor(k.TextMuted).Role(ui.RoleNone) + } + if part == timePeriodPart { + ui.Box(c).Width(d.Unit()) + } + seg, done := timeSegment(c, draft, part, cfg, &edited) + if focusNext { + seg.Focus() + focusNext = false + } + focusNext = done + } + }) + b := datetimeIconButton(c, "clock", msg(c, "time_picker.open")) + if b.Clicked() { + *open, *fresh = !*open, true + } + }) + if !*open && field.Shortcut(ui.Alt, ui.KeyDown) { + *open, *fresh = true, true + } + datetimePopup(c, field, open, func() { + if timeColumns(c, draft, cfg, *fresh) { + edited = true + } + *fresh = false + }) + return field, edited +} + +// timeSegment draws one focusable segment; done reports a fully typed value. +func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, edited *bool) (seg *ui.Element, done bool) { + k := Tokens(c) + d := Density(c) + name := msg(c, timePartNames[part]) + if cfg.label != "" { + name = cfg.label + ": " + name + } + seg = ui.Box(c).Focusable().FocusRing(false).Role(ui.RoleStepper).Label(name). + Padding(d.Unit()/2, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Center() + typed := ui.Local(seg, "time-typed", func() string { return "" }) + if !seg.Focused() { + *typed = "" + } + set := func(t TimeOfDay) { + if t != *draft { + *draft = t + *edited = true + } + } + t := *draft + step := func(dir int) { + switch part { + case timeHourPart: + t.Hour = (t.Hour + dir + 24) % 24 + case timeMinutePart: + t.Minute = timeStep(t.Minute, cfg.minuteStep, dir) + case timeSecondPart: + t.Second = timeStep(t.Second, cfg.secondStep, dir) + case timePeriodPart: + t.Hour = (t.Hour + 12) % 24 + } + set(t) + } + switch { + case seg.Shortcut(0, ui.KeyUp): + step(1) + case seg.Shortcut(0, ui.KeyDown): + step(-1) + } + if part == timePeriodPart { + if seg.Shortcut(0, ui.KeyA) && t.Hour >= 12 || seg.Shortcut(0, ui.KeyP) && t.Hour < 12 { + t.Hour = (t.Hour + 12) % 24 + set(t) + } + } else { + for n := range 10 { + if seg.Shortcut(0, ui.Key0+ui.Key(n)) { + done = timeType(typed, n, part, cfg, &t) + set(t) + } + } + } + text := "" + switch part { + case timeHourPart: + text = fmt.Sprintf("%02d", t.Hour) + if cfg.hour12 { + text = fmt.Sprintf("%2d", timeHour12(t.Hour)) + } + case timeMinutePart: + text = fmt.Sprintf("%02d", t.Minute) + case timeSecondPart: + text = fmt.Sprintf("%02d", t.Second) + case timePeriodPart: + text = timePeriod(c, t.Hour) + } + fg := k.Text + switch { + case seg.Focused(): + seg.Background(k.Accent) + fg = k.OnAccent + case seg.Hovered(): + seg.Background(k.SurfaceHover) + } + seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() }) + return seg, done +} + +// timeStep moves v to the next multiple of step in dir, wrapping within an hour. +func timeStep(v, step, dir int) int { + if dir > 0 { + return (v/step*step + step) % 60 + } + if v%step != 0 { + return v / step * step + } + return (v - step + 60) % 60 +} + +// timeType adds digit n to a segment's typed digits and reports when the segment is complete. +func timeType(typed *string, n int, part timePart, cfg timeConfig, t *TimeOfDay) bool { + lo, hi := 0, 59 + if part == timeHourPart { + hi = 23 + if cfg.hour12 { + lo, hi = 1, 12 + } + } + s := *typed + fmt.Sprint(n) + v, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + if len(s) > 2 || v > hi { + s, v = fmt.Sprint(n), n + } + *typed = s + if v >= lo { + switch part { + case timeHourPart: + if cfg.hour12 { + v = v%12 + t.Hour/12*12 + } + t.Hour = v + case timeMinutePart: + t.Minute = v + case timeSecondPart: + t.Second = v + } + } + if len(s) == 2 || n*10 > hi && len(s) == 1 { + *typed = "" + return true + } + return false +} + +// timeColumns lists choosable values per segment and reports whether a choice edited *draft. +func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (edited bool) { + k := Tokens(c) + d := Density(c) + h := d.ControlHeight() - 4 + within := func(lo, hi TimeOfDay) bool { return hi.Compare(cfg.min) >= 0 && lo.Compare(cfg.max) <= 0 } + ui.Row(c).Gap(d.Unit()).Children(func() { + for _, part := range timeParts(cfg) { + t := *draft + var options []TimeOfDay + switch part { + case timeHourPart: + for v := range 24 { + if !cfg.hour12 || v/12 == t.Hour/12 { + options = append(options, TimeOfDay{v, t.Minute, t.Second}) + } + } + case timeMinutePart: + for v := 0; v < 60; v += cfg.minuteStep { + options = append(options, TimeOfDay{t.Hour, v, t.Second}) + } + case timeSecondPart: + for v := 0; v < 60; v += cfg.secondStep { + options = append(options, TimeOfDay{t.Hour, t.Minute, v}) + } + case timePeriodPart: + options = []TimeOfDay{{t.Hour % 12, t.Minute, t.Second}, {t.Hour%12 + 12, t.Minute, t.Second}} + } + name := msg(c, timePartNames[part]) + ui.Scroll(c).Height(h * 6).Width(d.ControlHeight() * 1.5).Children(func() { + ui.Column(c).FocusGroup(ui.Vertical).Role(ui.RoleRadioGroup).Label(name).Children(func() { + for _, o := range options { + var lo, hi TimeOfDay + text := "" + switch part { + case timeHourPart: + lo, hi = TimeOfDay{o.Hour, 0, 0}, TimeOfDay{o.Hour, 59, 59} + text = fmt.Sprintf("%02d", o.Hour) + if cfg.hour12 { + text = fmt.Sprintf("%d", timeHour12(o.Hour)) + } + case timeMinutePart: + lo, hi = TimeOfDay{o.Hour, o.Minute, 0}, TimeOfDay{o.Hour, o.Minute, 59} + text = fmt.Sprintf("%02d", o.Minute) + case timeSecondPart: + lo, hi = o, o + text = fmt.Sprintf("%02d", o.Second) + case timePeriodPart: + lo, hi = TimeOfDay{o.Hour / 12 * 12, 0, 0}, TimeOfDay{o.Hour/12*12 + 11, 59, 59} + text = timePeriod(c, o.Hour) + } + sel := t + item := ui.RadioBase(c, &sel, o).Height(h).FillWidth().Center().Radius(theme.ControlRadius). + Disabled(!within(lo, hi)).Label(text) + fg := k.Text + switch { + case o == t: + item.Background(k.Accent) + fg = k.OnAccent + if fresh { + item.ScrollIntoView() + } + case item.Pressed(): + item.Background(k.Selection) + case item.Hovered(): + item.Background(k.SurfaceHover) + } + internal.Ring(item, k.Focus, theme.ControlRadius) + item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") }) + if item.Clicked() && o != *draft { + *draft, edited = o, true + } + } + }) + }) + } + }) + return edited +} diff --git a/time_picker_test.go b/time_picker_test.go new file mode 100644 index 0000000..a5eb6a8 --- /dev/null +++ b/time_picker_test.go @@ -0,0 +1,153 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func timePickerTester(value *TimeOfDay, opts TimePickerOptions, changes *int) *ui.Tester { + opts.Label = "Alarm" + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(16).Children(func() { + if TimePicker(c, value, opts).Changed() { + *changes++ + } + }) + }, 420, 420) +} + +func TestTimePickerArrowsAndTyping(t *testing.T) { + v, n := TimeOfDay{9, 30, 0}, 0 + tt := timePickerTester(&v, TimePickerOptions{}, &n) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Alarm: Hours") { + t.Fatal("Tab should focus the hours") + } + tt.Key(0, ui.KeyUp) + if v != (TimeOfDay{10, 30, 0}) || n != 1 { + t.Fatalf("Up gave %v", v) + } + tt.Key(0, ui.Key1) + tt.Key(0, ui.Key7) + if v != (TimeOfDay{17, 30, 0}) || !tt.Focused("Alarm: Minutes") { + t.Fatalf("typed hours %v, focus moved %v", v, tt.Focused("Alarm: Minutes")) + } + tt.Key(0, ui.Key0) + tt.Key(0, ui.Key5) + if v != (TimeOfDay{17, 5, 0}) { + t.Fatalf("typed minutes %v", v) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyDown) + if v != (TimeOfDay{16, 5, 0}) { + t.Fatalf("Left then Down gave %v", v) + } + tt.Key(0, ui.Key0) + tt.Key(0, ui.KeyDown) + if v != (TimeOfDay{23, 5, 0}) { + t.Fatalf("Down should wrap round the clock: %v", v) + } +} + +func TestTimePickerHour12AndSeconds(t *testing.T) { + v, n := TimeOfDay{9, 0, 0}, 0 + tt := timePickerTester(&v, TimePickerOptions{Hour12: true, Seconds: true}, &n) + if !tt.HasText("AM") || !tt.HasText(" 9") { + t.Fatalf("12-hour display: %v", tt.Texts()) + } + _ = tt.Click("Alarm: AM/PM") + tt.Key(0, ui.KeyP) + if v != (TimeOfDay{21, 0, 0}) { + t.Fatalf("P gave %v", v) + } + _ = tt.Click("Alarm: Hours") + tt.Key(0, ui.Key1) + tt.Key(0, ui.Key2) + if v != (TimeOfDay{12, 0, 0}) { + t.Fatalf("12 PM is noon, got %v", v) + } + _ = tt.Click("Alarm: Seconds") + tt.Key(0, ui.Key4) + tt.Key(0, ui.Key5) + if v != (TimeOfDay{12, 0, 45}) { + t.Fatalf("seconds %v", v) + } +} + +func TestTimePickerRangeAndStep(t *testing.T) { + v, n := TimeOfDay{9, 0, 0}, 0 + opts := TimePickerOptions{Min: TimeOfDay{9, 0, 0}, Max: TimeOfDay{17, 0, 0}, MinuteStep: 15} + tt := timePickerTester(&v, opts, &n) + _ = tt.Click("Alarm: Hours") + tt.Key(0, ui.KeyDown) + if v != (TimeOfDay{9, 0, 0}) || n != 0 || !tt.HasText("Choose a time from 09:00 to 17:00.") { + t.Fatalf("out of range accepted: %v %v", v, tt.Texts()) + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + if v != (TimeOfDay{10, 0, 0}) || tt.HasText("Choose a time") { + t.Fatalf("back in range: %v", v) + } + _ = tt.Click("Alarm: Minutes") + tt.Key(0, ui.KeyUp) + if v != (TimeOfDay{10, 15, 0}) { + t.Fatalf("step 15 gave %v", v) + } + tt.Key(0, ui.Key0) + tt.Key(0, ui.Key7) + if v != (TimeOfDay{10, 0, 0}) || !tt.HasText("Minutes must be a multiple of 15.") { + t.Fatalf("off-step minute accepted: %v", v) + } +} + +func TestTimePickerPopup(t *testing.T) { + v, n := TimeOfDay{9, 30, 0}, 0 + opts := TimePickerOptions{Min: TimeOfDay{9, 0, 0}, MinuteStep: 5} + tt := timePickerTester(&v, opts, &n) + if err := tt.Click("Choose time"); err != nil { + t.Fatal(err) + } + if err := tt.Click("25"); err != nil { + t.Fatal(err) + } + if v != (TimeOfDay{9, 25, 0}) || n != 1 { + t.Fatalf("minute pick %v", v) + } + if err := tt.Click("08"); err != nil { + t.Fatal(err) + } + if v.Hour != 9 || n != 1 { + t.Fatalf("hour before Min was chosen: %v", v) + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("05") { + t.Fatal("Escape should close the popup") + } +} + +func TestTimePickerRejectsBadOptions(t *testing.T) { + for _, opts := range []TimePickerOptions{{MinuteStep: 7}, {Min: TimeOfDay{10, 0, 0}, Max: TimeOfDay{9, 0, 0}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", opts) + } + }() + v := TimeOfDay{} + timePickerTester(&v, opts, new(int)) + }() + } +} + +func TestTimePickerSnapshot(t *testing.T) { + a, b := TimeOfDay{9, 30, 0}, TimeOfDay{21, 5, 40} + snapshot(t, "time_picker", 480, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + TimePicker(c, &a, TimePickerOptions{}) + TimePicker(c, &b, TimePickerOptions{Hour12: true, Seconds: true}) + TimePicker(c, &a, TimePickerOptions{Disabled: true}) + }) + }) +} diff --git a/time_range_picker.go b/time_range_picker.go new file mode 100644 index 0000000..4776748 --- /dev/null +++ b/time_range_picker.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TimeRange is a start and an end time of day. End before Start means it ends the next day. +type TimeRange struct { + Start, End TimeOfDay +} + +// Overnight reports whether r crosses midnight. +func (r TimeRange) Overnight() bool { return r.End.Compare(r.Start) < 0 } + +// TimeRangePickerOptions configure a TimeRangePicker. +type TimeRangePickerOptions struct { + Seconds bool + Hour12 bool + // MinuteStep and SecondStep must divide 60; zero means 1. + MinuteStep, SecondStep int + // Min and Max bound both ends; a zero Max means 23:59:59. + Min, Max TimeOfDay + // AllowOvernight accepts an end before the start, meaning the next day. + AllowOvernight bool + Disabled bool + // Label names the pair for assistive technology. + Label string +} + +// TimeRangePicker edits a start and an end time. It writes *value only when both ends are valid +// and the end follows the start, or crosses midnight with AllowOvernight. +func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOptions) DateTimeResult { + cfg := timePickerConfig("TimeRangePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12, + MinuteStep: opts.MinuteStep, SecondStep: opts.SecondStep, Min: opts.Min, Max: opts.Max}) + if !value.Start.Valid() || !value.End.Valid() { + panic(fmt.Sprintf("mujica: TimeRangePicker value %v is not a pair of times", *value)) + } + if value.Overnight() && !opts.AllowOvernight { + panic(fmt.Sprintf("mujica: TimeRangePicker value %v crosses midnight without AllowOvernight", *value)) + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit()).AlignItems(ui.Start).Disabled(opts.Disabled).Role(ui.RoleGroup) + if opts.Label != "" { + col.Label(opts.Label) + } + draft := ui.Local(col, "time-range-draft", func() TimeRange { return *value }) + seen := ui.Local(col, "time-range-seen", func() TimeRange { return *value }) + problem := ui.Local(col, "time-range-error", func() string { return "" }) + if *seen != *value { + *draft, *seen, *problem = *value, *value, "" + } + changed := false + col.Children(func() { + edited := false + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + start, end := cfg, cfg + start.label, end.label = msg(c, "time_range_picker.start"), msg(c, "time_range_picker.end") + start.invalid, end.invalid = *problem != "", *problem != "" + if _, e := timeEditor(c, &draft.Start, start); e { + edited = true + } + ui.Text(c, "–").TextColor(k.TextMuted).Role(ui.RoleNone) + if _, e := timeEditor(c, &draft.End, end); e { + edited = true + } + if draft.Overnight() && opts.AllowOvernight { + ui.Text(c, msg(c, "time_range_picker.next_day")).FontSize(theme.CaptionSize).TextColor(k.AccentText) + } + }) + if edited { + *problem = timeRangeProblem(c, *draft, cfg, opts.AllowOvernight) + if *problem == "" && *draft != *value { + slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true + } + } + datetimeError(c, *problem) + }) + return DateTimeResult{Element: col, changed: changed} +} + +// timeRangeProblem explains why r cannot be written, or returns "". +func timeRangeProblem(c *ui.Context, r TimeRange, cfg timeConfig, overnight bool) string { + for _, t := range []TimeOfDay{r.Start, r.End} { + if p := timePickerProblem(c, t, cfg); p != "" { + return p + } + } + switch { + case r.Start == r.End: + return msg(c, "time_range_picker.same") + case r.Overnight() && !overnight: + return msg(c, "time_range_picker.order") + } + return "" +} diff --git a/time_range_picker_test.go b/time_range_picker_test.go new file mode 100644 index 0000000..44f3f2b --- /dev/null +++ b/time_range_picker_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func timeRangeTester(value *TimeRange, opts TimeRangePickerOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(16).Children(func() { + if TimeRangePicker(c, value, opts).Changed() { + *changes++ + } + }) + }, 520, 420) +} + +func TestTimeRangePickerOrder(t *testing.T) { + v, n := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}, 0 + tt := timeRangeTester(&v, TimeRangePickerOptions{}, &n) + _ = tt.Click("End time: Hours") + tt.Key(0, ui.Key0) + tt.Key(0, ui.Key8) + if v.End != (TimeOfDay{17, 0, 0}) || n != 0 { + t.Fatalf("end before start accepted: %v", v) + } + if !tt.HasText("End must be after start; overnight ranges are off.") { + t.Fatalf("missing order error: %v", tt.Texts()) + } + _ = tt.Click("Start time: Hours") + tt.Key(0, ui.Key0) + tt.Key(0, ui.Key7) + if v != (TimeRange{TimeOfDay{7, 0, 0}, TimeOfDay{8, 0, 0}}) || n != 2 || tt.HasText("End must be") { + t.Fatalf("fixing the start should commit both: %v", v) + } + _ = tt.Click("End time: Hours") + tt.Key(0, ui.KeyDown) + if v.End != (TimeOfDay{8, 0, 0}) || !tt.HasText("Start and end must differ.") { + t.Fatalf("equal ends accepted: %v", v) + } +} + +func TestTimeRangePickerOvernight(t *testing.T) { + v, n := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{23, 0, 0}}, 0 + tt := timeRangeTester(&v, TimeRangePickerOptions{AllowOvernight: true}, &n) + _ = tt.Click("End time: Hours") + tt.Key(0, ui.Key0) + tt.Key(0, ui.Key6) + if v.End != (TimeOfDay{6, 0, 0}) || n != 2 || !v.Overnight() || !tt.HasText("+1 day") { + t.Fatalf("overnight not accepted: %v %v", v, tt.Texts()) + } +} + +func TestTimeRangePickerRejectsOvernightValue(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + v := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{6, 0, 0}} + timeRangeTester(&v, TimeRangePickerOptions{}, new(int)) +} + +func TestTimeRangePickerSnapshot(t *testing.T) { + a := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 30, 0}} + b := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{6, 0, 0}} + snapshot(t, "time_range_picker", 420, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + TimeRangePicker(c, &a, TimeRangePickerOptions{}) + TimeRangePicker(c, &b, TimeRangePickerOptions{AllowOvernight: true}) + }) + }) +} diff --git a/title_bar.go b/title_bar.go new file mode 100644 index 0000000..af6e1e5 --- /dev/null +++ b/title_bar.go @@ -0,0 +1,38 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TitleBarOptions configure a TitleBar. +type TitleBarOptions struct { + // Leading holds tools before the title, such as a sidebar toggle. + Leading func() + // Trailing holds tools after the title. + Trailing func() +} + +// TitleBar is a window's title row. Its empty parts drag the window, and it keeps clear of the system window buttons +// reported by c.TitleBar() in a window with a hidden title bar. Controls in the slots stay clickable. +func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element { + k := Tokens(c) + d := Density(c) + bar := c.TitleBar() + pad := d.Unit() * 3 + e := ui.Row(c).Height(max(bar.Height, d.ControlHeight()+d.Unit()*2)).Shrink(0). + Padding(0, bar.Right+pad, 0, bar.Left+pad).Gap(d.Unit()*2). + Background(k.Surface).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border). + DragWindow() + return e.Children(func() { + if opts.Leading != nil { + ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Leading) + } + ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.BodySize + 1).FontWeight(600). + SingleLine().Grow(1).Shrink(1).MinWidth(0) + if opts.Trailing != nil { + ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Trailing) + } + }) +} diff --git a/title_bar_test.go b/title_bar_test.go new file mode 100644 index 0000000..18202bb --- /dev/null +++ b/title_bar_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTitleBarDefaultHeight(t *testing.T) { + var r ui.Rect + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r = TitleBar(c, "Archive", TitleBarOptions{}).Bounds() + }, 400, 300).Frame() + if r.H != 44 || r.W != 400 { + t.Fatalf("bar %vx%v, want 400x44", r.W, r.H) + } +} + +func TestTitleBarClearsWindowControls(t *testing.T) { + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r = TitleBar(c, "Archive", TitleBarOptions{ + Trailing: func() { Button(c, "Share", ButtonOptions{Variant: Ghost}) }, + }).Bounds() + }, 400, 300) + tt.SetTitleBar(ui.TitleBar{Height: 52, Left: 78, Right: 40}) + tt.Frame() + if r.H != 52 { + t.Fatalf("bar height %v, want the controls' 52", r.H) + } + title, _ := tt.Find("Archive") + if title.X != 78+12 { + t.Fatalf("title at %v, want 90 past the controls", title.X) + } + share, _ := tt.Find("Share") + if share.X+share.W > 400-40-12+0.5 { + t.Fatalf("trailing tool ends at %v, inside the right controls", share.X+share.W) + } +} + +func TestTitleBarSlotsStayClickable(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TitleBar(c, "Archive", TitleBarOptions{ + Leading: func() { + if Button(c, "Menu", ButtonOptions{Variant: Ghost}).Clicked() { + n++ + } + }, + }) + }, 400, 300) + if err := tt.Click("Menu"); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("leading tool clicked %d times, want 1", n) + } +} + +func TestTitleBarLongTitleKeepsTools(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TitleBar(c, "A very long window title that cannot possibly fit in the bar", TitleBarOptions{ + Trailing: func() { Button(c, "Share", ButtonOptions{Variant: Ghost}) }, + }) + }, 240, 100) + share, ok := tt.Find("Share") + if !ok || share.X+share.W > 240-12+0.5 { + t.Fatalf("tool pushed out: %+v", share) + } +} + +func TestTitleBarSnapshot(t *testing.T) { + snapshot(t, "title_bar", 420, 60, func(c *ui.Context) { + TitleBar(c, "Chronicle of the Court", TitleBarOptions{ + Trailing: func() { Button(c, "Share", ButtonOptions{Variant: Ghost}) }, + }) + }) +} diff --git a/toast.go b/toast.go new file mode 100644 index 0000000..e15a6d4 --- /dev/null +++ b/toast.go @@ -0,0 +1,257 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Toast timing and stacking per window. +const ( + ToastDuration = 5 * time.Second + ToastVisible = 3 +) + +// ToastMessage is one toast. Message is required. +type ToastMessage struct { + // ID dedupes: showing an ID already queued replaces it. Empty uses Message. + ID string + Severity Severity + Message string + // Action labels a button; Toaster reports its click by ID. + Action string + // Duration is ToastDuration when zero; errors then stay until closed. + Duration time.Duration +} + +// ToasterResult reports the toast events of a frame by message ID. +type ToasterResult struct { + Action string + Closed string +} + +type toastItem struct { + msg ToastMessage + life time.Duration // zero stays until closed + spent time.Duration + runFrom time.Time // zero while paused or queued + visible bool + announced bool +} + +// toastQueue holds a window's toasts, visible ones first in order shown. +type toastQueue struct{ items []toastItem } + +type toastKey struct{} + +func toastLife(m ToastMessage) time.Duration { + switch { + case m.Duration < 0: + panic("mujica: negative toast Duration") + case m.Duration > 0: + return m.Duration + case m.Severity == SeverityError: + return 0 + } + return ToastDuration +} + +func (q *toastQueue) push(m ToastMessage, now time.Time) { + if m.Message == "" { + panic("mujica: toast needs a Message") + } + if m.ID == "" { + m.ID = m.Message + } + for i := range q.items { + it := &q.items[i] + if it.msg.ID != m.ID { + continue + } + it.msg, it.life, it.spent, it.announced = m, toastLife(m), 0, false + if it.visible && !it.runFrom.IsZero() { + it.runFrom = now + } + slog.Debug("mujica: toast replaced", "id", m.ID) + return + } + q.items = append(q.items, toastItem{msg: m, life: toastLife(m)}) + slog.Debug("mujica: toast queued", "id", m.ID) + q.promote(now) +} + +// promote shows queued toasts while fewer than ToastVisible show. +func (q *toastQueue) promote(now time.Time) { + n := 0 + for i := range q.items { + it := &q.items[i] + if !it.visible && n < ToastVisible { + it.visible, it.runFrom = true, now + slog.Debug("mujica: toast shown", "id", it.msg.ID) + } + if it.visible { + n++ + } + } +} + +func (it *toastItem) elapsed(now time.Time) time.Duration { + if it.runFrom.IsZero() { + return it.spent + } + return it.spent + now.Sub(it.runFrom) +} + +// expire drops toasts whose time ran out and returns the wait until the next does. +func (q *toastQueue) expire(now time.Time) (next time.Duration) { + live := q.items[:0] + for _, it := range q.items { + if it.visible && it.life > 0 && it.elapsed(now) >= it.life { + slog.Debug("mujica: toast expired", "id", it.msg.ID) + continue + } + live = append(live, it) + } + q.items = live + q.promote(now) + for _, it := range q.items { + if it.visible && it.life > 0 && !it.runFrom.IsZero() { + left := it.life - it.elapsed(now) + if next == 0 || left < next { + next = left + } + } + } + return next +} + +func (q *toastQueue) pause(id string, paused bool, now time.Time) { + for i := range q.items { + it := &q.items[i] + if it.msg.ID != id || !it.visible { + continue + } + switch { + case paused && !it.runFrom.IsZero(): + it.spent, it.runFrom = it.elapsed(now), time.Time{} + slog.Debug("mujica: toast paused", "id", id) + case !paused && it.runFrom.IsZero(): + it.runFrom = now + slog.Debug("mujica: toast resumed", "id", id) + } + } +} + +func (q *toastQueue) remove(id string, now time.Time) { + for i, it := range q.items { + if it.msg.ID == id { + q.items = append(q.items[:i], q.items[i+1:]...) + break + } + } + q.promote(now) +} + +func toasts(c *ui.Context) *toastQueue { + return ui.Local(c.Root(), toastKey{}, func() toastQueue { return toastQueue{} }) +} + +// ShowToast queues m in the window. Call it on the UI thread. +func ShowToast(c *ui.Context, m ToastMessage) { + toasts(c).push(m, c.Now()) + c.Invalidate() +} + +// DismissToast closes the toast with id, shown or queued. +func DismissToast(c *ui.Context, id string) { + toasts(c).remove(id, c.Now()) + c.Invalidate() +} + +// Toaster draws the window's toasts at its bottom right. Call it once per +// frame, last in the view. Toasts never take the focus; Tab reaches their +// buttons. Hover or focus inside a toast pauses its time. +func Toaster(c *ui.Context) ToasterResult { + q := toasts(c) + now := c.Now() + if next := q.expire(now); next > 0 { + c.After(next) + } + var res ToasterResult + if len(q.items) == 0 { + return res + } + k := Tokens(c) + u := Density(c).Unit() + w, _ := c.Size() + ui.Overlay(c, func() { + stack := ui.Column(c).Absolute().Right(u * 4).Bottom(u * 4).Gap(u * 2).AlignItems(ui.End). + PassThrough().Role(ui.RoleGroup).Label(msg(c, "toast.region")) + stack.Children(func() { + for i := range q.items { + it := q.items[i] + if !it.visible { + continue + } + if !it.announced { + q.items[i].announced = true + c.Announce(it.msg.Message) + } + action, closed, held := toastCard(c, k, u, min(360, w-u*8), it.msg) + q.pause(it.msg.ID, held, now) + switch { + case action: + res.Action = it.msg.ID + slog.Debug("mujica: toast action", "id", it.msg.ID) + case closed: + res.Closed = it.msg.ID + slog.Debug("mujica: toast closed", "id", it.msg.ID) + } + } + }) + }) + for _, id := range []string{res.Action, res.Closed} { + if id != "" { + q.remove(id, now) + c.Invalidate() + } + } + return res +} + +// toastCard builds one toast and reports its events and whether hover or focus holds it. +func toastCard(c *ui.Context, k theme.Tokens, u, width float32, m ToastMessage) (action, closed, held bool) { + tone, icon := severityLook(k, m.Severity) + card := ui.Row(c).Key(m.ID).Width(width).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4). + Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder). + Role(ui.RoleStatus).Label(m.Message) + card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14)) + card.Transition(ui.ElementTransition{ + Duration: theme.PopupDuration, + Enter: &ui.Motion{Y: 8}, + Exit: &ui.Motion{}, + }) + card.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + theme.PopupRadius, W: 3, H: r.H - 2*theme.PopupRadius}, tone, 0) + }) + card.Children(func() { + ui.Icon(c, icon).Size(20, 20).TextColor(tone) + ui.Text(c, m.Message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight) + if m.Action != "" && Button(c, m.Action, ButtonOptions{Variant: Ghost}).Clicked() { + action = true + } + x := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Label: msg(c, "toast.close")}) + x.TextColor(k.TextMuted).Padding(0, u) + if x.Clicked() { + closed = true + } + }) + if card.Shortcut(0, ui.KeyEscape) { + closed = true + } + return action, closed, card.Hovered() || card.FocusWithin() +} diff --git a/toast_test.go b/toast_test.go new file mode 100644 index 0000000..d4425ba --- /dev/null +++ b/toast_test.go @@ -0,0 +1,193 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func toastIDs(q *toastQueue, visible bool) []string { + var ids []string + for _, it := range q.items { + if it.visible == visible { + ids = append(ids, it.msg.ID) + } + } + return ids +} + +func TestToastQueueLimitsVisible(t *testing.T) { + var q toastQueue + t0 := time.Unix(0, 0) + for _, id := range []string{"a", "b", "c", "d", "e"} { + q.push(ToastMessage{ID: id, Message: id}, t0) + } + if v, w := toastIDs(&q, true), toastIDs(&q, false); !slices.Equal(v, []string{"a", "b", "c"}) || !slices.Equal(w, []string{"d", "e"}) { + t.Fatalf("visible %v waiting %v", v, w) + } + // d starts its 5 s only when it shows. + if next := q.expire(t0.Add(ToastDuration)); next != ToastDuration { + t.Fatalf("next wake %v", next) + } + if v := toastIDs(&q, true); !slices.Equal(v, []string{"d", "e"}) { + t.Fatalf("after expiry visible %v", v) + } + q.expire(t0.Add(2*ToastDuration - time.Millisecond)) + if len(q.items) != 2 { + t.Fatalf("expired early: %v", toastIDs(&q, true)) + } + q.expire(t0.Add(2 * ToastDuration)) + if len(q.items) != 0 { + t.Fatalf("left %v", toastIDs(&q, true)) + } +} + +func TestToastQueueDedupesByID(t *testing.T) { + var q toastQueue + t0 := time.Unix(0, 0) + q.push(ToastMessage{ID: "sync", Message: "Syncing 1 file"}, t0) + q.push(ToastMessage{Message: "Saved"}, t0) + q.push(ToastMessage{ID: "sync", Message: "Syncing 2 files"}, t0.Add(4*time.Second)) + q.push(ToastMessage{Message: "Saved"}, t0.Add(4*time.Second)) + if len(q.items) != 2 || q.items[0].msg.Message != "Syncing 2 files" || q.items[1].msg.ID != "Saved" { + t.Fatalf("items %+v", q.items) + } + // The replaced toast restarted its time. + q.expire(t0.Add(6 * time.Second)) + if len(q.items) != 2 { + t.Fatalf("replacement kept the old time: %d left", len(q.items)) + } +} + +func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) { + var q toastQueue + t0 := time.Unix(0, 0) + q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0) + q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0) + q.pause("w", true, t0.Add(4*time.Second)) + if next := q.expire(t0.Add(time.Hour)); next != 0 { + t.Fatalf("paused toast still counts down: %v", next) + } + if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) { + t.Fatalf("visible %v", toastIDs(&q, true)) + } + q.pause("w", false, t0.Add(time.Hour)) + if next := q.expire(t0.Add(time.Hour)); next != time.Second { + t.Fatalf("resumed toast left %v, want 1s", next) + } + q.expire(t0.Add(time.Hour + time.Second)) + if !slices.Equal(toastIDs(&q, true), []string{"e"}) { + t.Fatalf("visible %v", toastIDs(&q, true)) + } +} + +func TestToastQueuePanics(t *testing.T) { + for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", m) + } + }() + var q toastQueue + q.push(m, time.Now()) + }() + } +} + +func toastTester(m ToastMessage, res *ToasterResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Notify", ButtonOptions{}).Clicked() { + ShowToast(c, m) + } + if r := Toaster(c); r != (ToasterResult{}) { + *res = r + } + }, 600, 400) +} + +func TestToastActionAndClose(t *testing.T) { + var res ToasterResult + tt := toastTester(ToastMessage{ID: "del", Message: "Note deleted", Action: "Undo"}, &res) + if err := tt.Click("Notify"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Note deleted") { + t.Fatal("toast not shown") + } + if err := tt.Click("Undo"); err != nil { + t.Fatal(err) + } + if res.Action != "del" || tt.HasText("Note deleted") { + t.Fatalf("after undo %+v, still shown %v", res, tt.HasText("Note deleted")) + } + res = ToasterResult{} + _ = tt.Click("Notify") + if err := tt.Click("Dismiss notification"); err != nil { + t.Fatal(err) + } + if res.Closed != "del" || tt.HasText("Note deleted") { + t.Fatalf("after close %+v", res) + } +} + +func TestToastKeepsFocusAndEscapeCloses(t *testing.T) { + var res ToasterResult + tt := toastTester(ToastMessage{ID: "s", Message: "Saved"}, &res) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("Saved") || !tt.Focused("Notify") { + t.Fatalf("shown %v, focus kept %v", tt.HasText("Saved"), tt.Focused("Notify")) + } + if a := tt.Announcements(); !slices.Contains(a, "Saved") { + t.Fatalf("announcements %q", a) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Dismiss notification") { + t.Fatal("Tab does not reach the toast") + } + tt.Key(0, ui.KeyEscape) + if res.Closed != "s" || tt.HasText("Saved") { + t.Fatalf("Escape: %+v", res) + } +} + +func TestToastExpiresAndHoverPauses(t *testing.T) { + var res ToasterResult + tt := toastTester(ToastMessage{Message: "Copied", Duration: 40 * time.Millisecond}, &res) + _ = tt.Click("Notify") + time.Sleep(60 * time.Millisecond) + tt.Frame() + if tt.HasText("Copied") { + t.Fatal("toast outlived its duration") + } + _ = tt.Click("Notify") + r, _ := tt.Find("Copied") + tt.Move(r.X+r.W/2, r.Y+r.H/2) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if !tt.HasText("Copied") { + t.Fatal("hovered toast expired") + } + tt.Move(5, 395) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if tt.HasText("Copied") { + t.Fatal("toast stayed after the pointer left") + } +} + +func TestToastSnapshot(t *testing.T) { + snapshot(t, "toast", 480, 300, func(c *ui.Context) { + if len(toasts(c).items) == 0 { + ShowToast(c, ToastMessage{ID: "1", Message: "Settings saved", Severity: SeveritySuccess}) + ShowToast(c, ToastMessage{ID: "2", Message: "Note moved to trash", Action: "Undo"}) + ShowToast(c, ToastMessage{ID: "3", Message: "Upload failed: network lost", Severity: SeverityError}) + ShowToast(c, ToastMessage{ID: "4", Message: "Queued and not shown"}) + } + Toaster(c) + }) +} diff --git a/toolbar.go b/toolbar.go new file mode 100644 index 0000000..712fefd --- /dev/null +++ b/toolbar.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ToolbarOptions configure a Toolbar. +type ToolbarOptions struct { + // Label names the toolbar for assistive technology. + Label string +} + +// Toolbar is a row of actions built by fn, one Tab stop with arrow keys inside. +// Actions that do not fit move, from the last, into an overflow menu. +func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element { + k := Tokens(c) + tb := ui.Toolbar(c, fn).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). + MinHeight(Density(c).ControlHeight() + 8) + if opts.Label != "" { + tb.Label(opts.Label) + } + return tb +} + +// ToolbarButtonOptions configure a ToolbarButton. +type ToolbarButtonOptions struct { + Icon *ui.SVG + // ShowLabel shows the label beside the icon; without an icon it always shows. + ShowLabel bool + Disabled bool +} + +// ToolbarButton is an action of a Toolbar; label names it and its overflow menu item. +func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.Element { + if label == "" { + panic("mujica: ToolbarButton needs a label") + } + k := Tokens(c) + d := Density(c) + u := d.Unit() + text := opts.ShowLabel || opts.Icon == nil + b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Gap(u * 2). + Radius(theme.ControlRadius).Label(label).Disabled(opts.Disabled).TextColor(k.Text) + if text { + b.Padding(0, u*3) + } + if !text { + b.Tooltip(label) + } + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Children(func() { + if opts.Icon != nil { + ui.Icon(c, opts.Icon).Size(16, 16) + } + if text { + ui.Text(c, label).SingleLine() + } + }) + return b +} + +// ToolbarGroup keeps related actions together; the overflow menu sets groups apart. +func ToolbarGroup(c *ui.Context, fn func()) *ui.Element { + return ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 0.5).Shrink(0).Role(ui.RoleGroup).Children(fn) +} + +// ToolbarSeparator draws a thin rule between actions. +func ToolbarSeparator(c *ui.Context) { + ui.Box(c).Size(1, Density(c).ControlHeight()-12).Margin(0, Density(c).Unit()).Shrink(0). + Background(Tokens(c).Ornament).Role(ui.RoleNone) +} diff --git a/toolbar_test.go b/toolbar_test.go new file mode 100644 index 0000000..125e65f --- /dev/null +++ b/toolbar_test.go @@ -0,0 +1,105 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +type toolbarLog struct{ clicks []string } + +func (l *toolbarLog) view(width float32) func(c *ui.Context) { + return func(c *ui.Context) { + ui.Column(c).Width(width).Children(func() { + Toolbar(c, ToolbarOptions{Label: "Format"}, func() { + ToolbarGroup(c, func() { + for _, n := range []string{"Bold", "Italic", "Underline"} { + if ToolbarButton(c, n, ToolbarButtonOptions{Icon: icons.Must(map[string]string{"Bold": "bold", "Italic": "italic", "Underline": "underline"}[n])}).Clicked() { + l.clicks = append(l.clicks, n) + } + } + }) + ToolbarSeparator(c) + if ToolbarButton(c, "Copy", ToolbarButtonOptions{Icon: icons.Must("copy"), ShowLabel: true}).Clicked() { + l.clicks = append(l.clicks, "Copy") + } + if ToolbarButton(c, "Delete", ToolbarButtonOptions{Icon: icons.Must("trash-2"), Disabled: true}).Clicked() { + l.clicks = append(l.clicks, "Delete") + } + if ToolbarButton(c, "Share", ToolbarButtonOptions{}).Clicked() { + l.clicks = append(l.clicks, "Share") + } + }) + }) + } +} + +func TestToolbarClickAndArrows(t *testing.T) { + l := &toolbarLog{} + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); l.view(600)(c) }, 700, 100) + if err := tt.Click("Italic"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyRight) // from Italic to Underline + tt.Key(0, ui.KeyRight) // Copy + tt.Key(0, ui.KeyRight) // Share, skipping disabled Delete + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeySpace) + _ = tt.Click("Delete") + if want := []string{"Italic", "Share", "Bold"}; !slices.Equal(l.clicks, want) { + t.Fatalf("clicks %q, want %q", l.clicks, want) + } +} + +func TestToolbarOverflow(t *testing.T) { + l := &toolbarLog{} + // The snapshot's layout: MyGo caches the overflow chevrons by position. + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Gap(16).Children(func() { l.view(180)(c) }) + }, 520, 300) + tt.Frame() + if r, ok := tt.Find("Share"); ok && r.W > 0 { + t.Fatalf("Share should overflow, at %+v", r) + } + if err := tt.Click("More"); err != nil { + t.Fatal(err) + } + menu := tt.Menu() + if !slices.Contains(menu, "Share") || !slices.Contains(menu, "Copy") { + t.Fatalf("overflow menu %q", menu) + } + if err := tt.ChooseMenuItem("Share"); err != nil { + t.Fatal(err) + } + tt.Frame() + if !slices.Equal(l.clicks, []string{"Share"}) { + t.Fatalf("overflow choice clicked %q", l.clicks) + } +} + +func TestToolbarButtonNeedsLabel(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ToolbarButton(c, "", ToolbarButtonOptions{Icon: icons.Must("copy")}) + }, 200, 100, 1) +} + +func TestToolbarSnapshot(t *testing.T) { + l := &toolbarLog{} + snapshot(t, "toolbar", 520, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(16).Children(func() { + l.view(180)(c) + l.view(420)(c) + }) + }) +} diff --git a/tooltip.go b/tooltip.go new file mode 100644 index 0000000..ee82404 --- /dev/null +++ b/tooltip.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TooltipOptions configure a Tooltip. +type TooltipOptions struct { + // Delay is how long hover or focus rests before the tip shows: 500 ms when zero. + Delay time.Duration +} + +// tooltipState tracks when the anchor became hot and whether Escape hid the tip. +type tooltipState struct { + since time.Time + dismissed bool +} + +const tooltipMaxWidth = 240 + +// Tooltip shows text near anchor once the pointer rests on it or it has +// the keyboard focus. The tip holds no interactive content, passes the +// pointer through, stays in the window and hides on leave, blur or Escape. +// It also describes anchor to assistive technology. +func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element { + if anchor == nil { + panic("mujica: Tooltip needs an anchor") + } + if opts.Delay < 0 { + panic("mujica: Tooltip Delay is negative") + } + if text == "" { + return anchor + } + anchor.Description(text) + st := ui.Local(anchor, "mujica.tooltip", func() tooltipState { return tooltipState{} }) + hot := anchor.Hovered() || anchor.FocusVisible() + if !hot { + st.since, st.dismissed = time.Time{}, false + return anchor + } + if st.dismissed { + return anchor + } + if st.since.IsZero() { + st.since = c.Now() + } + delay := opts.Delay + if delay == 0 { + delay = 500 * time.Millisecond + } + if wait := delay - c.Now().Sub(st.since); wait > 0 { + c.After(wait) + return anchor + } + if c.Shortcut(0, ui.KeyEscape) { + slog.Debug("mujica: tooltip dismiss") + st.dismissed = true + return anchor + } + tooltipShow(c, anchor.Bounds(), text) + return anchor +} + +// tooltipShow draws the tip below a, or above it when there is no room. +func tooltipShow(c *ui.Context, a ui.Rect, text string) { + k := Tokens(c) + u := Density(c).Unit() + size := theme.CaptionSize + 1 + tw, th := c.MeasureText(tooltipMaxWidth, ui.Span{Text: text, Size: size, Font: c.Theme().Font}) + padX, padY := u*2.5, u*1.5 + w, h := tw+2*(padX+theme.BorderWidth)+1, th+2*(padY+theme.BorderWidth) + x, y := overlayPlace(c, ui.Rect{X: a.X + a.W/2 - w/2, Y: a.Y, W: w, H: a.H}, w, h, 6) + ui.Overlay(c, func() { + ui.Box(c).Absolute().Left(x).Top(y).Width(w).Padding(padY, padX).Radius(theme.ControlRadius). + Background(k.Text).TextColor(k.Background).Border(theme.BorderWidth, k.Text). + PassThrough().Role(ui.RoleTooltip).Children(func() { + ui.Text(c, text).FontSize(size) + }) + }) +} diff --git a/tooltip_test.go b/tooltip_test.go new file mode 100644 index 0000000..190da23 --- /dev/null +++ b/tooltip_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +const tooltipTestDelay = 20 * time.Millisecond + +func tooltipTester(x, y float32) (*ui.Tester, *int) { + clicks := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(y, 0, 0, x).Children(func() { + b := Button(c, "Share", ButtonOptions{}) + if b.Clicked() { + clicks++ + } + Tooltip(c, b, "Share with the court", TooltipOptions{Delay: tooltipTestDelay}) + }) + }, 400, 300) + return tt, &clicks +} + +func waitTooltip(tt *ui.Tester) { + time.Sleep(tooltipTestDelay + 10*time.Millisecond) + tt.Frame() +} + +func TestTooltipHoverDelay(t *testing.T) { + tt, clicks := tooltipTester(40, 40) + r, _ := tt.Find("Share") + tt.Move(r.X+4, r.Y+4) + if tt.HasText("Share with the court") { + t.Fatal("tip showed before the delay") + } + waitTooltip(tt) + tip, ok := tt.Find("Share with the court") + if !ok || tip.Y < r.Y+r.H { + t.Fatalf("tip %+v ok %v below %+v", tip, ok, r) + } + // The tip passes the pointer through: clicking it clicks what is below. + tt.Move(400, 300) + tt.Frame() + if tt.HasText("Share with the court") { + t.Fatal("tip stayed after leave") + } + _ = tt.Click("Share") + if *clicks != 1 { + t.Fatalf("clicks %d", *clicks) + } +} + +func TestTooltipFocusAndEscape(t *testing.T) { + tt, _ := tooltipTester(40, 40) + tt.Key(0, ui.KeyTab) + waitTooltip(tt) + if !tt.HasText("Share with the court") { + t.Fatal("no tip on keyboard focus") + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Share with the court") { + t.Fatal("Escape did not hide the tip") + } + waitTooltip(tt) + if tt.HasText("Share with the court") { + t.Fatal("tip came back while still focused") + } +} + +func TestTooltipEdgeFlips(t *testing.T) { + tt, _ := tooltipTester(330, 262) + r, _ := tt.Find("Share") + tt.Move(r.X+4, r.Y+4) + waitTooltip(tt) + tip, ok := tt.Find("Share with the court") + if !ok || tip.Y > r.Y || tip.X+tip.W > 400 { + t.Fatalf("tip %+v not above and inside for anchor %+v", tip, r) + } +} + +func TestTooltipSnapshot(t *testing.T) { + snapshot(t, "tooltip", 360, 140, func(c *ui.Context) { + ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { + Button(c, "Seal", ButtonOptions{Variant: Secondary}) + tooltipShow(c, ui.Rect{X: 24, Y: 24, W: 62, H: 36}, "Seal the letter with wax") + }) + }) +} diff --git a/transfer.go b/transfer.go new file mode 100644 index 0000000..47a3fc1 --- /dev/null +++ b/transfer.go @@ -0,0 +1,225 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TransferItem is one entry of a Transfer; ID must be unique and stable. +type TransferItem struct { + ID string + Label string + Disabled bool +} + +// TransferOptions configure a Transfer. +type TransferOptions struct { + // SourceTitle and TargetTitle replace the built-in "Available" and "Chosen". + SourceTitle, TargetTitle string + // Height of each list in DIPs; 240 when zero. + Height float32 + Disabled bool +} + +// TransferParts are the parts of a Transfer. +type TransferParts struct { + Element *ui.Element + changed bool +} + +// Changed reports whether items moved this frame. +func (p TransferParts) Changed() bool { return p.changed } + +type transferState struct { + checked map[string]bool + query [2]string +} + +// Transfer moves items between a source list and *target, the IDs chosen, in order. +// Each side has search, check boxes with select-all, and move buttons between them. +func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) TransferParts { + byID := make(map[string]TransferItem, len(items)) + for _, it := range items { + if _, dup := byID[it.ID]; dup || it.ID == "" { + panic(fmt.Sprintf("mujica: Transfer item ID %q is empty or repeated", it.ID)) + } + byID[it.ID] = it + } + for _, id := range *target { + if _, ok := byID[id]; !ok { + panic(fmt.Sprintf("mujica: Transfer target ID %q is not an item", id)) + } + } + d := Density(c) + parts := TransferParts{} + row := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Disabled(opts.Disabled) + parts.Element = row + st := ui.Local(row, "transfer", func() transferState { return transferState{checked: map[string]bool{}} }) + for id := range st.checked { + if it, ok := byID[id]; !ok || it.Disabled { + delete(st.checked, id) + } + } + var source, chosen []TransferItem + for _, it := range items { + if !slices.Contains(*target, it.ID) { + source = append(source, it) + } + } + for _, id := range *target { + chosen = append(chosen, byID[id]) + } + move := func(from []TransferItem, right bool) { + var ids []string + for _, it := range from { + if st.checked[it.ID] && !it.Disabled { + ids = append(ids, it.ID) + delete(st.checked, it.ID) + } + } + if len(ids) == 0 { + return + } + if right { + *target = append(slices.Clone(*target), ids...) + } else { + *target = slices.DeleteFunc(slices.Clone(*target), func(id string) bool { return slices.Contains(ids, id) }) + } + parts.changed = true + slog.Debug("mujica: transfer moved", "count", len(ids), "right", right) + } + height := opts.Height + if height == 0 { + height = 240 + } + srcTitle, dstTitle := opts.SourceTitle, opts.TargetTitle + if srcTitle == "" { + srcTitle = msg(c, "transfer.source") + } + if dstTitle == "" { + dstTitle = msg(c, "transfer.target") + } + row.Children(func() { + transferPanel(c, st, 0, srcTitle, source, height) + ui.Column(c).Gap(d.Unit() * 2).Children(func() { + right := transferCount(st, source) > 0 + left := transferCount(st, chosen) > 0 + if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-right"), Label: fmt.Sprintf(msg(c, "transfer.to"), dstTitle), Disabled: !right}).Clicked() { + move(source, true) + } + if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-left"), Label: fmt.Sprintf(msg(c, "transfer.to"), srcTitle), Disabled: !left}).Clicked() { + move(chosen, false) + } + }) + transferPanel(c, st, 1, dstTitle, chosen, height) + }) + return parts +} + +// transferCount counts the checked items that can move. +func transferCount(st *transferState, items []TransferItem) int { + n := 0 + for _, it := range items { + if st.checked[it.ID] && !it.Disabled { + n++ + } + } + return n +} + +// transferPanel is one side: header with select-all, search, and the list. +func transferPanel(c *ui.Context, st *transferState, side int, title string, items []TransferItem, height float32) { + k := Tokens(c) + d := Density(c) + q := strings.ToLower(strings.TrimSpace(st.query[side])) + var shown []TransferItem + enabled := 0 + for _, it := range items { + if q == "" || strings.Contains(strings.ToLower(it.Label), q) { + shown = append(shown, it) + if !it.Disabled { + enabled++ + } + } + } + checked := transferCount(st, shown) + ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() { + head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3). + BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) + head.Children(func() { + all := checked > 0 && checked == enabled + box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0) + transferMark(c, box, all, checked > 0 && !all) + if box.Changed() { + for _, it := range shown { + if !it.Disabled { + st.checked[it.ID] = all + } + } + } + ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1) + ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum") + }) + ui.Box(c).Padding(d.Unit() * 2).Children(func() { + InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: msg(c, "transfer.search"), Label: fmt.Sprintf(msg(c, "transfer.search_in"), title)}) + }) + list := ui.Scroll(c).Height(height).Padding(0, d.Unit(), d.Unit(), d.Unit()).FocusGroup(ui.Vertical).Role(ui.RoleList).Label(title) + list.Children(func() { + if len(shown) == 0 { + empty := msg(c, "transfer.empty") + if q != "" { + empty = msg(c, "transfer.no_match") + } + ui.Text(c, empty).TextColor(k.TextMuted).Padding(d.Unit() * 3).TextAlign(ui.Center).FillWidth() + return + } + for _, it := range shown { + on := st.checked[it.ID] + ui.Box(c).Key(it.ID).Children(func() { + cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(d.ControlHeight()-d.Unit()).Padding(0, d.Unit()*2). + Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled) + if cb.Changed() { + st.checked[it.ID] = on + } + if on { + cb.Background(k.Selection) + } else if cb.Hovered() { + cb.Background(k.SurfaceHover) + } + transferMark(c, cb, on, false) + cb.Children(func() { ui.Text(c, it.Label).SingleLine().Ellipsis("…").MinWidth(0) }) + }) + } + }) + }) +} + +// transferMark draws a check box's square inside box, which takes the focus ring. +func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) { + k := Tokens(c) + internal.Ring(box, k.Focus, theme.ControlRadius) + box.Children(func() { + sq := ui.Box(c).Size(16, 16).Shrink(0).Center().Radius(theme.ControlRadius).Role(ui.RoleNone) + if on || mixed { + sq.Background(k.Accent).TextColor(k.OnAccent) + } else { + sq.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) + } + sq.Children(func() { + switch { + case on: + ui.Icon(c, icons.Must("check")).Size(12, 12) + case mixed: + ui.Icon(c, icons.Must("minus")).Size(12, 12) + } + }) + }) +} diff --git a/transfer_test.go b/transfer_test.go new file mode 100644 index 0000000..fb366b0 --- /dev/null +++ b/transfer_test.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +var transferCast = []TransferItem{ + {ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "umiri", Label: "Umiri"}, + {ID: "nyamu", Label: "Nyamu", Disabled: true}, {ID: "sakiko", Label: "Sakiko"}, +} + +func transferTester(target *[]string, items *[]TransferItem, opts TransferOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Transfer(c, target, *items, opts).Changed() { + *changes++ + } + }, 640, 420) +} + +func TestTransferMoveSelected(t *testing.T) { + target, items, n := []string{"sakiko"}, slices.Clone(transferCast), 0 + tt := transferTester(&target, &items, TransferOptions{}, &n) + _ = tt.Click("Mutsumi") + _ = tt.Click("Uika") + if err := tt.Click("Move to Chosen"); err != nil { + t.Fatal(err) + } + if !slices.Equal(target, []string{"sakiko", "uika", "mutsumi"}) || n != 1 { + t.Fatalf("target %q changes %d", target, n) + } + _ = tt.Click("Sakiko") + _ = tt.Click("Move to Available") + if !slices.Equal(target, []string{"uika", "mutsumi"}) || n != 2 { + t.Fatalf("back: %q", target) + } +} + +func TestTransferSelectAllSkipsDisabledAndFollowsSearch(t *testing.T) { + target, items, n := []string{}, slices.Clone(transferCast), 0 + tt := transferTester(&target, &items, TransferOptions{}, &n) + _ = tt.Click("Search Available") + tt.Type("u") + if tt.HasText("Sakiko") { + t.Fatal("search did not filter") + } + _ = tt.Click("Select all in Available") + _ = tt.Click("Move to Chosen") + if !slices.Equal(target, []string{"uika", "mutsumi", "umiri"}) { + t.Fatalf("select all moved %q", target) + } + _ = tt.Click("Search Available") + tt.Type("zzz") + if !tt.HasText("No matches") { + t.Fatal("no empty search state") + } +} + +func TestTransferKeyboardAndStableIDs(t *testing.T) { + target, items, n := []string{}, slices.Clone(transferCast), 0 + tt := transferTester(&target, &items, TransferOptions{}, &n) + _ = tt.Click("Umiri") + // Items reorder and relabel; the check stays on the same ID. + items = []TransferItem{{ID: "umiri", Label: "Yahata Umiri"}, {ID: "uika", Label: "Uika"}} + tt.Frame() + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeySpace) + _ = tt.Click("Move to Chosen") + if !slices.Equal(target, []string{"umiri", "uika"}) { + t.Fatalf("target %q", target) + } +} + +func TestTransferDisabledAndBadIDs(t *testing.T) { + target, items, n := []string{}, slices.Clone(transferCast), 0 + tt := transferTester(&target, &items, TransferOptions{Disabled: true}, &n) + _ = tt.Click("Uika") + _ = tt.Click("Move to Chosen") + if len(target) != 0 { + t.Fatal("disabled transfer moved") + } + defer func() { + if recover() == nil { + t.Fatal("unknown target ID accepted") + } + }() + bad := []string{"ghost"} + transferTester(&bad, &items, TransferOptions{}, &n) +} + +func TestTransferSnapshot(t *testing.T) { + target := []string{"sakiko", "mutsumi"} + snapshot(t, "transfer", 620, 380, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + p := Transfer(c, &target, transferCast, TransferOptions{Height: 200}) + st := ui.Local(p.Element, "transfer", func() transferState { return transferState{} }) + if !st.checked["uika"] { + st.checked["uika"] = true + c.Invalidate() + } + }) + }) +} diff --git a/year_picker.go b/year_picker.go new file mode 100644 index 0000000..b607152 --- /dev/null +++ b/year_picker.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" +) + +// YearPickerOptions configure a YearPicker. +type YearPickerOptions struct { + // Min and Max bound the years chosen; zero leaves a side open. + Min, Max int + DisableYear func(int) bool + Clearable bool + // Today marks the current year; zero uses the system clock. + Today Date + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// YearPicker chooses a year into *value, zero meaning none, from a decade page. +// Arrows move by year and row, PageUp and PageDown by decade. +func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResult { + if *value < 0 || opts.Min < 0 || opts.Max < 0 || opts.Min != 0 && opts.Max != 0 && opts.Min > opts.Max { + panic(fmt.Sprintf("mujica: YearPicker value %d or range %d..%d is not valid", *value, opts.Min, opts.Max)) + } + now := datetimeToday(c, opts.Today).Year + home := *value + if home == 0 { + home = now + } + decade := func(o int) int { return o / 10 * 10 } + text := "" + if *value != 0 { + text = strconv.Itoa(*value) + } + label := opts.Label + if label == "" { + label = msg(c, "year_picker.placeholder") + } + col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{ + text: text, placeholder: msg(c, "year_picker.placeholder"), label: label, + clearable: opts.Clearable, hasValue: *value != 0, disabled: opts.Disabled, home: home, + }, datetimeGridConfig{ + pageOf: func(o int) (int, int) { return decade(o) - 1, 12 }, + cols: 3, + page: 10, + title: func(start int) string { return fmt.Sprintf("%d – %d", start+1, start+10) }, + prev: msg(c, "year_picker.prev"), + next: msg(c, "year_picker.next"), + text: strconv.Itoa, + label: strconv.Itoa, + chosen: func(o int) bool { return o == *value }, + current: func(o int) bool { return o == now }, + enabled: func(o int) bool { + return o > 0 && (opts.Min == 0 || o >= opts.Min) && (opts.Max == 0 || o <= opts.Max) && + (opts.DisableYear == nil || !opts.DisableYear(o)) + }, + muted: func(o, start int) bool { return o == start || o == start+11 }, + }) + to := *value + switch { + case cleared: + to = 0 + case ok: + to = picked + } + changed := to != *value + if changed { + slog.Debug("mujica: year_picker change", "from", *value, "to", to) + *value = to + } + return DateTimeResult{Element: col, changed: changed} +} diff --git a/year_picker_test.go b/year_picker_test.go new file mode 100644 index 0000000..da6b59b --- /dev/null +++ b/year_picker_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func yearPickerTester(value *int, opts YearPickerOptions, changes *int) *ui.Tester { + opts.Today = calendarToday + opts.Label = "Vintage" + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(16).Children(func() { + if YearPicker(c, value, opts).Changed() { + *changes++ + } + }) + }, 420, 420) +} + +func TestYearPickerClickAndDecade(t *testing.T) { + v, n := 0, 0 + tt := yearPickerTester(&v, YearPickerOptions{}, &n) + _ = tt.Click("Vintage") + if !tt.HasText("2020 – 2029") || !tt.HasText("2019") || !tt.HasText("2030") { + t.Fatalf("decade page: %v", tt.Texts()) + } + _ = tt.Click("Previous decade") + if err := tt.Click("2014"); err != nil { + t.Fatal(err) + } + if v != 2014 || n != 1 || tt.HasText("2015") { + t.Fatalf("picked %d", v) + } +} + +func TestYearPickerKeyboard(t *testing.T) { + v, n := 2026, 0 + tt := yearPickerTester(&v, YearPickerOptions{}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if !tt.Focused("2026") { + t.Fatal("popup should focus the chosen year") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if !tt.HasText("2030 – 2039") || !tt.Focused("2030") { + t.Fatalf("crossing the decade should turn the page: %v", tt.Texts()) + } + tt.Key(0, ui.KeyPageUp) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeySpace) + if v != 2017 || n != 1 { + t.Fatalf("Space chose %d", v) + } +} + +func TestYearPickerLimitsAndClear(t *testing.T) { + v, n := 2024, 0 + opts := YearPickerOptions{Min: 2022, Max: 2027, Clearable: true, DisableYear: func(y int) bool { return y == 2025 }} + tt := yearPickerTester(&v, opts, &n) + _ = tt.Click("Vintage") + for _, y := range []string{"2021", "2025", "2028"} { + _ = tt.Click(y) + } + if v != 2024 || n != 0 { + t.Fatalf("disabled year chosen: %d", v) + } + _ = tt.Click("2026") + _ = tt.Click("Clear") + if v != 0 || n != 2 { + t.Fatalf("clear left %d (%d)", v, n) + } +} + +func TestYearPickerSnapshot(t *testing.T) { + a, b := 2026, 0 + snapshot(t, "year_picker", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + YearPicker(c, &b, YearPickerOptions{Today: calendarToday}) + YearPicker(c, &a, YearPickerOptions{Today: calendarToday, Clearable: true}) + }) + }) +}