Merge branch 'main' into worktree-agent-ac0c9e7144c57cf07
This commit is contained in:
444 files changed
+30007
-38
No files matched your search
@@ -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
|
||||
@@ -32,6 +32,7 @@ testdata/snapshots/ <os>/<name>-<light|dark>.png
|
||||
|
||||
- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) *ui.Element`. Composites may return a named struct.
|
||||
- Zero `Options` gives the default look. No `map[string]any` config.
|
||||
- Explicit font sizes: `FontSize(c, theme.CaptionSize)`; it applies the desktop text scale.
|
||||
- Colors: `k := mujica.Tokens(c)`. Metrics: `theme.*` consts and `Density(c).Unit()` / `.ControlHeight()`.
|
||||
- Build on MyGo Bases (`ui.ButtonBase`, `ui.CheckboxBase`, `ui.SelectBase`, ...) or MyGo widgets restyled through the theme `Use` sets. Never reach into MyGo internals.
|
||||
- Focus: `internal.Ring(e, k.Focus, radius)` for the 2 DIP ring with 2 DIP gap.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Severity is the meaning of a message. The zero value is SeverityInfo.
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
SeverityInfo Severity = iota
|
||||
SeveritySuccess
|
||||
SeverityWarning
|
||||
SeverityError
|
||||
)
|
||||
|
||||
func (s Severity) String() string {
|
||||
switch s {
|
||||
case SeverityInfo:
|
||||
return "info"
|
||||
case SeveritySuccess:
|
||||
return "success"
|
||||
case SeverityWarning:
|
||||
return "warning"
|
||||
case SeverityError:
|
||||
return "error"
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown Severity %d", int(s)))
|
||||
}
|
||||
|
||||
// severityLook returns the color and icon that, with text, mark s.
|
||||
func severityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
|
||||
switch s {
|
||||
case SeveritySuccess:
|
||||
return k.Success, icons.Must("circle-check")
|
||||
case SeverityWarning:
|
||||
return k.Warning, icons.Must("triangle-alert")
|
||||
case SeverityError:
|
||||
return k.Danger, icons.Must("circle-x")
|
||||
}
|
||||
return k.Info, icons.Must("info")
|
||||
}
|
||||
|
||||
// AlertOptions configure an Alert.
|
||||
type AlertOptions struct {
|
||||
Severity Severity
|
||||
// Title goes above the message in bold; empty shows none.
|
||||
Title string
|
||||
// Action labels a button at the end; empty shows none.
|
||||
Action string
|
||||
}
|
||||
|
||||
// AlertResult is what an Alert built and reported this frame.
|
||||
type AlertResult struct {
|
||||
// Element is nil while the alert is closed.
|
||||
Element *ui.Element
|
||||
// Action reports a click on the action button.
|
||||
Action bool
|
||||
// Closed reports the user dismissing the alert.
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Alert shows an inline message marked by color, icon and accessible name.
|
||||
// A non-nil open adds a dismiss button that sets *open to false; nothing
|
||||
// shows while *open is false.
|
||||
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
|
||||
if message == "" {
|
||||
panic("mujica: Alert needs a message")
|
||||
}
|
||||
if open != nil && !*open {
|
||||
return AlertResult{}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
tone, icon := severityLook(k, opts.Severity)
|
||||
name := msg(c, "alert."+opts.Severity.String())
|
||||
var res AlertResult
|
||||
box := ui.Row(c).FillWidth().AlignItems(ui.Start).Gap(u*3).Padding(u*3, u*3, u*3, u*4).
|
||||
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Role(ui.RoleGroup).Label(name).Clip()
|
||||
// A bar in the severity color along the leading edge.
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0)
|
||||
})
|
||||
// Warnings and errors are read out when they appear or change.
|
||||
said := ui.Local(box, "alert.said", func() string { return "" })
|
||||
if news := name + ": " + message; opts.Severity >= SeverityWarning && *said != news {
|
||||
*said = news
|
||||
c.Announce(news)
|
||||
}
|
||||
box.Children(func() {
|
||||
ui.Icon(c, icon).Size(20, 20).TextColor(tone).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u).Children(func() {
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).Bold().TextColor(k.Text)
|
||||
}
|
||||
ui.Text(c, message).TextColor(k.Text).LineHeight(theme.LineHeight)
|
||||
if opts.Action != "" {
|
||||
ui.Row(c).Margin(u, 0, 0, 0).Children(func() {
|
||||
if Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() {
|
||||
slog.Debug("mujica: alert action", "severity", opts.Severity, "action", opts.Action)
|
||||
res.Action = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if open != nil {
|
||||
dismiss := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Label: msg(c, "alert.close")})
|
||||
dismiss.TextColor(k.TextMuted).Padding(0).MinHeight(theme.MinHitSize).Margin(-u, -u, -u, 0)
|
||||
if dismiss.Clicked() {
|
||||
slog.Debug("mujica: alert closed", "severity", opts.Severity)
|
||||
*open, res.Closed = false, true
|
||||
}
|
||||
}
|
||||
})
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
+110
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type alertApp struct {
|
||||
open bool
|
||||
opts AlertDialogOptions
|
||||
confirmed, cancelled int
|
||||
}
|
||||
|
||||
func (a *alertApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Remove", ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
r := AlertDialog(c, &a.open, a.opts)
|
||||
if r.Confirmed {
|
||||
a.confirmed++
|
||||
}
|
||||
if r.Cancelled {
|
||||
a.cancelled++
|
||||
}
|
||||
}
|
||||
|
||||
func newAlertApp(opts AlertDialogOptions) (*alertApp, *ui.Tester) {
|
||||
opts.Title = "Delete the archive?"
|
||||
a := &alertApp{opts: opts}
|
||||
tt := ui.NewTester(a.view, 600, 400)
|
||||
_ = tt.Click("Remove")
|
||||
return a, tt
|
||||
}
|
||||
|
||||
func TestAlertDialogFocusesCancelAndEnterCancels(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{Description: "This cannot be undone."})
|
||||
if !a.open || !tt.Focused("Cancel") {
|
||||
t.Fatalf("open %v, Cancel focused %v", a.open, tt.Focused("Cancel"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 0 || a.cancelled != 1 || a.open {
|
||||
t.Fatalf("Enter: confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
|
||||
}
|
||||
if !tt.Focused("Remove") {
|
||||
t.Fatal("focus not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogIgnoresBackdropEscapeCancels(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{})
|
||||
tt.ClickAt(8, 392)
|
||||
if !a.open || a.cancelled != 0 {
|
||||
t.Fatalf("backdrop click: open %v cancelled %d", a.open, a.cancelled)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open || a.cancelled != 1 || a.confirmed != 0 {
|
||||
t.Fatalf("Escape: open %v cancelled %d confirmed %d", a.open, a.cancelled, a.confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogConfirmOnce(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{ConfirmLabel: "Erase"})
|
||||
if err := tt.Click("Erase"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.confirmed != 1 || a.cancelled != 0 || a.open {
|
||||
t.Fatalf("confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogConfirmTextGatesEnter(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{ConfirmText: "archive"})
|
||||
_ = tt.Click("Delete") // locked
|
||||
if a.confirmed != 0 || !a.open {
|
||||
t.Fatal("locked Delete confirmed")
|
||||
}
|
||||
if err := tt.Click("Type “archive” to confirm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type("archiv")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 0 || !a.open {
|
||||
t.Fatalf("before the target matched: confirmed %d open %v", a.confirmed, a.open)
|
||||
}
|
||||
tt.Type("e")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 1 || a.open {
|
||||
t.Fatalf("typed target: confirmed %d open %v", a.confirmed, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogSnapshot(t *testing.T) {
|
||||
snapshot(t, "alert_dialog", 560, 320, func(c *ui.Context) {
|
||||
open := true
|
||||
AlertDialog(c, &open, AlertDialogOptions{
|
||||
Title: "Burn the old letters?", Description: "Twelve letters will be destroyed. This cannot be undone.",
|
||||
ConfirmText: "letters",
|
||||
})
|
||||
})
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestAlertActionAndClose(t *testing.T) {
|
||||
open := true
|
||||
actions, closes := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := Alert(c, &open, "Disk almost full", AlertOptions{Severity: SeverityWarning, Title: "Storage", Action: "Free space"})
|
||||
if r.Action {
|
||||
actions++
|
||||
}
|
||||
if r.Closed {
|
||||
closes++
|
||||
}
|
||||
}, 500, 300)
|
||||
if err := tt.Click("Free space"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actions != 1 || !open {
|
||||
t.Fatalf("actions %d open %v", actions, open)
|
||||
}
|
||||
if err := tt.Click("Dismiss"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if open || closes != 1 {
|
||||
t.Fatalf("after dismiss open %v closes %d", open, closes)
|
||||
}
|
||||
if tt.HasText("Disk almost full") {
|
||||
t.Fatal("closed alert still shows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertKeyboard(t *testing.T) {
|
||||
open := true
|
||||
actions := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Alert(c, &open, "Sync failed", AlertOptions{Severity: SeverityError, Action: "Retry"}).Action {
|
||||
actions++
|
||||
}
|
||||
}, 500, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if actions != 1 {
|
||||
t.Fatalf("Enter on action: %d", actions)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if open {
|
||||
t.Fatal("Space on dismiss left it open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertWithoutOpenHasNoDismiss(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Alert(c, nil, "Read only", AlertOptions{})
|
||||
}, 500, 300)
|
||||
if _, ok := tt.Find("Dismiss"); ok {
|
||||
t.Fatal("dismiss button without open")
|
||||
}
|
||||
if _, ok := tt.Find("Information"); !ok {
|
||||
t.Fatal("alert lacks its severity name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAnnouncesWarningsOnce(t *testing.T) {
|
||||
sev := SeverityInfo
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
Alert(c, nil, "磁盘已满", AlertOptions{Severity: sev})
|
||||
}, 500, 300)
|
||||
if a := tt.Announcements(); len(a) != 0 {
|
||||
t.Fatalf("info announced %q", a)
|
||||
}
|
||||
sev = SeverityError
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if a := tt.Announcements(); !slices.Equal(a, []string{"错误: 磁盘已满"}) {
|
||||
t.Fatalf("error announcements %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertPanicsWithoutMessage(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Alert(c, nil, "", AlertOptions{})
|
||||
}, 300, 200)
|
||||
}
|
||||
|
||||
func TestAlertSnapshot(t *testing.T) {
|
||||
open := true
|
||||
snapshot(t, "alert", 560, 400, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(12).Children(func() {
|
||||
Alert(c, nil, "A new version is ready to install.", AlertOptions{})
|
||||
Alert(c, &open, "Your changes were saved.", AlertOptions{Severity: SeveritySuccess})
|
||||
Alert(c, nil, "The certificate expires in 3 days.", AlertOptions{Severity: SeverityWarning, Title: "Certificate"})
|
||||
Alert(c, &open, "The archive could not be read.", AlertOptions{Severity: SeverityError, Title: "Import failed", Action: "Retry"})
|
||||
})
|
||||
})
|
||||
}
|
||||
+120
@@ -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
|
||||
}
|
||||
@@ -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"}}}) },
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AvatarOptions configure an Avatar.
|
||||
type AvatarOptions struct {
|
||||
// Image is the picture; nil falls back to initials, then a person icon.
|
||||
Image *ui.Bitmap
|
||||
// Size is the diameter in DIPs; zero means 32.
|
||||
Size float32
|
||||
}
|
||||
|
||||
// Avatar shows a person as a round picture, their initials or an icon.
|
||||
// Assistive technology reads it as an image named name.
|
||||
func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element {
|
||||
size := opts.Size
|
||||
if size < 0 {
|
||||
panic("mujica: Avatar Size must not be negative")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 32
|
||||
}
|
||||
k := Tokens(c)
|
||||
label := name
|
||||
if label == "" {
|
||||
label = msg(c, "avatar.unnamed")
|
||||
}
|
||||
e := ui.Box(c).Size(size, size).Radius(size / 2).Clip().Shrink(0).Center().Role(ui.RoleImage).Label(label)
|
||||
initials := avatarInitials(name)
|
||||
e.Children(func() {
|
||||
switch {
|
||||
case opts.Image != nil:
|
||||
ui.Image(c, opts.Image).Fit(ui.Cover).Size(size, size)
|
||||
case initials != "":
|
||||
e.Background(k.Selection).Border(theme.BorderWidth, k.Border)
|
||||
ui.Text(c, initials).Font(theme.SerifFont).FontSize(size * avatarInitialsScale(initials)).FontWeight(600).
|
||||
TextColor(k.AccentText).SingleLine()
|
||||
default:
|
||||
e.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
|
||||
Icon(c, "user", IconOptions{Size: size * 0.5, Tone: IconMuted})
|
||||
}
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// avatarInitials returns the first letters of the first and last words.
|
||||
func avatarInitials(name string) string {
|
||||
var first, last rune
|
||||
inWord := false
|
||||
for _, r := range name {
|
||||
letter := unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
if letter && !inWord {
|
||||
if first == 0 {
|
||||
first = unicode.ToUpper(r)
|
||||
} else {
|
||||
last = unicode.ToUpper(r)
|
||||
}
|
||||
}
|
||||
inWord = letter
|
||||
}
|
||||
switch {
|
||||
case first == 0:
|
||||
return ""
|
||||
case last == 0:
|
||||
return string(first)
|
||||
}
|
||||
return string([]rune{first, last})
|
||||
}
|
||||
|
||||
// avatarInitialsScale fits two letters inside the circle.
|
||||
func avatarInitialsScale(initials string) float32 {
|
||||
if len([]rune(initials)) > 1 {
|
||||
return 0.34
|
||||
}
|
||||
return 0.42
|
||||
}
|
||||
+128
@@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// BadgeTone is a badge's emphasis. The zero value is BadgeAccent.
|
||||
type BadgeTone int
|
||||
|
||||
const (
|
||||
BadgeAccent BadgeTone = iota
|
||||
BadgeNeutral
|
||||
BadgeSuccess
|
||||
BadgeWarning
|
||||
BadgeDanger
|
||||
BadgeInfo
|
||||
)
|
||||
|
||||
// BadgePosition places a badge. The zero value lays it out inline.
|
||||
type BadgePosition int
|
||||
|
||||
const (
|
||||
BadgeInline BadgePosition = iota
|
||||
BadgeTopRight
|
||||
BadgeTopLeft
|
||||
BadgeBottomRight
|
||||
BadgeBottomLeft
|
||||
)
|
||||
|
||||
// BadgeOptions configure a Badge.
|
||||
type BadgeOptions struct {
|
||||
// Text shows as is; when empty, Count shows instead.
|
||||
Text string
|
||||
// Count must not be negative; zero with no Text hides the badge.
|
||||
Count int
|
||||
// Max caps the shown count as "Max+"; zero means 99.
|
||||
Max int
|
||||
Tone BadgeTone
|
||||
Position BadgePosition
|
||||
// Label says what the badge counts; it is read before the full value.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Badge shows a short text or count. Inline badges take no content; corner
|
||||
// positions pin the badge on content's corner.
|
||||
func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element {
|
||||
if opts.Count < 0 || opts.Max < 0 {
|
||||
panic("mujica: Badge Count and Max must not be negative")
|
||||
}
|
||||
if (opts.Position == BadgeInline) != (content == nil) {
|
||||
panic("mujica: Badge needs content exactly when Position is not BadgeInline")
|
||||
}
|
||||
shown, full := badgeText(opts)
|
||||
if opts.Position == BadgeInline {
|
||||
if shown == "" {
|
||||
return ui.Box(c).Size(0, 0)
|
||||
}
|
||||
return badgePill(c, opts, shown, full)
|
||||
}
|
||||
at := badgeAnchor(opts.Position)
|
||||
wrap := ui.Box(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Start)
|
||||
wrap.Children(func() {
|
||||
content()
|
||||
if shown != "" {
|
||||
badgePill(c, opts, shown, full).Attach(at, ui.AnchorCenter)
|
||||
}
|
||||
})
|
||||
return wrap
|
||||
}
|
||||
|
||||
// badgeText returns what the badge shows and what it reads.
|
||||
func badgeText(opts BadgeOptions) (shown, full string) {
|
||||
if opts.Text != "" {
|
||||
return opts.Text, opts.Text
|
||||
}
|
||||
if opts.Count == 0 {
|
||||
return "", ""
|
||||
}
|
||||
limit := opts.Max
|
||||
if limit == 0 {
|
||||
limit = 99
|
||||
}
|
||||
full = strconv.Itoa(opts.Count)
|
||||
if opts.Count > limit {
|
||||
return strconv.Itoa(limit) + "+", full
|
||||
}
|
||||
return full, full
|
||||
}
|
||||
|
||||
func badgePill(c *ui.Context, opts BadgeOptions, shown, full string) *ui.Element {
|
||||
k := Tokens(c)
|
||||
bg, fg, border := badgeColors(k, opts.Tone)
|
||||
label := full
|
||||
if opts.Label != "" {
|
||||
label = opts.Label + " " + full
|
||||
}
|
||||
p := ui.Box(c).Row().Center().MinWidth(18).Height(18).Padding(0, 5).Radius(theme.CardRadius).
|
||||
Background(bg).Shrink(0).Role(ui.RoleStatus).Label(label)
|
||||
if border.A > 0 {
|
||||
p.Border(theme.BorderWidth, border)
|
||||
}
|
||||
p.Children(func() {
|
||||
ui.Text(c, shown).FontSize(theme.CaptionSize - 1).FontWeight(600).TextColor(fg).
|
||||
FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
func badgeColors(k theme.Tokens, t BadgeTone) (bg, fg, border ui.Color) {
|
||||
switch t {
|
||||
case BadgeAccent:
|
||||
return k.Accent, k.OnAccent, ui.Color{}
|
||||
case BadgeNeutral:
|
||||
return k.SurfaceHover, k.Text, k.Border
|
||||
case BadgeSuccess:
|
||||
return k.Surface, k.Success, k.Success
|
||||
case BadgeWarning:
|
||||
return k.Surface, k.Warning, k.Warning
|
||||
case BadgeDanger:
|
||||
return k.DangerFill, k.OnDanger, ui.Color{}
|
||||
case BadgeInfo:
|
||||
return k.Surface, k.Info, k.Info
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown BadgeTone %d", t))
|
||||
}
|
||||
|
||||
func badgeAnchor(p BadgePosition) ui.Anchor {
|
||||
switch p {
|
||||
case BadgeTopRight:
|
||||
return ui.AnchorTopRight
|
||||
case BadgeTopLeft:
|
||||
return ui.AnchorTopLeft
|
||||
case BadgeBottomRight:
|
||||
return ui.AnchorBottomRight
|
||||
case BadgeBottomLeft:
|
||||
return ui.AnchorBottomLeft
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown BadgePosition %d", p))
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestBadgeText(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
opts BadgeOptions
|
||||
shown, full string
|
||||
}{
|
||||
{BadgeOptions{Count: 7}, "7", "7"},
|
||||
{BadgeOptions{Count: 99}, "99", "99"},
|
||||
{BadgeOptions{Count: 100}, "99+", "100"},
|
||||
{BadgeOptions{Count: 12, Max: 9}, "9+", "12"},
|
||||
{BadgeOptions{Count: 0}, "", ""},
|
||||
{BadgeOptions{Text: "New", Count: 5}, "New", "New"},
|
||||
} {
|
||||
shown, full := badgeText(tc.opts)
|
||||
if shown != tc.shown || full != tc.full {
|
||||
t.Errorf("%+v: %q %q", tc.opts, shown, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeReadsFullValue(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Badge(c, BadgeOptions{Count: 1204, Label: "Unread"}, nil)
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
if !tt.HasText("99+") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("Unread 1204"); !ok {
|
||||
t.Fatal("full value not read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeCornerPosition(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
pos BadgePosition
|
||||
right, down bool
|
||||
}{{BadgeTopRight, true, false}, {BadgeTopLeft, false, false}, {BadgeBottomRight, true, true}, {BadgeBottomLeft, false, true}} {
|
||||
var host ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(40).Children(func() {
|
||||
Badge(c, BadgeOptions{Count: 3, Position: tc.pos}, func() {
|
||||
host = ui.Box(c).Size(60, 60).Bounds()
|
||||
})
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("3")
|
||||
if !ok {
|
||||
t.Fatalf("%v: badge missing", tc.pos)
|
||||
}
|
||||
cx, cy := r.X+r.W/2, r.Y+r.H/2
|
||||
wantX, wantY := host.X, host.Y
|
||||
if tc.right {
|
||||
wantX += host.W
|
||||
}
|
||||
if tc.down {
|
||||
wantY += host.H
|
||||
}
|
||||
if d := cx - wantX; d < -2 || d > 2 {
|
||||
t.Errorf("%v: x %v, want %v", tc.pos, cx, wantX)
|
||||
}
|
||||
if d := cy - wantY; d < -2 || d > 2 {
|
||||
t.Errorf("%v: y %v, want %v", tc.pos, cy, wantY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeBadOptionsPanic(t *testing.T) {
|
||||
for i, build := range []func(c *ui.Context){
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: -1}, nil) },
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1, Position: BadgeTopRight}, nil) },
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1}, func() {}) },
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1, Tone: BadgeTone(42)}, nil) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("case %d: no panic", i)
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
build(c)
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeSnapshot(t *testing.T) {
|
||||
snapshot(t, "badge", 460, 100, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(14).AlignItems(ui.Center).Children(func() {
|
||||
Badge(c, BadgeOptions{Count: 7}, nil)
|
||||
Badge(c, BadgeOptions{Count: 250}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Draft", Tone: BadgeNeutral}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Live", Tone: BadgeSuccess}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Late", Tone: BadgeWarning}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Info", Tone: BadgeInfo}, nil)
|
||||
Badge(c, BadgeOptions{Count: 3, Tone: BadgeDanger, Position: BadgeTopRight}, func() {
|
||||
Avatar(c, "Sakiko Togawa", AvatarOptions{})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// BreadcrumbItem is one level of a Breadcrumb path.
|
||||
type BreadcrumbItem struct {
|
||||
Label string
|
||||
Icon *ui.SVG
|
||||
}
|
||||
|
||||
// BreadcrumbOptions configure a Breadcrumb.
|
||||
type BreadcrumbOptions struct {
|
||||
// MaxItems shown before middle items fold into a menu; zero means 4, minimum 3.
|
||||
MaxItems int
|
||||
// MaxItemWidth cuts longer labels with an ellipsis; zero means 160 DIPs.
|
||||
MaxItemWidth float32
|
||||
// Label names the path for assistive technology; empty uses the built-in name.
|
||||
Label string
|
||||
}
|
||||
|
||||
// BreadcrumbResult is the built Breadcrumb.
|
||||
type BreadcrumbResult struct {
|
||||
*ui.Element
|
||||
chosen int
|
||||
}
|
||||
|
||||
// Chosen returns the index of the item the user activated in this frame.
|
||||
func (r BreadcrumbResult) Chosen() (int, bool) { return r.chosen, r.chosen >= 0 }
|
||||
|
||||
// Breadcrumb shows a path whose last item is the current one; earlier items are links.
|
||||
func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) BreadcrumbResult {
|
||||
if len(items) == 0 {
|
||||
panic("mujica: Breadcrumb needs at least one item")
|
||||
}
|
||||
limit := opts.MaxItems
|
||||
if limit == 0 {
|
||||
limit = 4
|
||||
}
|
||||
if limit < 3 {
|
||||
panic("mujica: Breadcrumb MaxItems must be at least 3")
|
||||
}
|
||||
width := opts.MaxItemWidth
|
||||
if width == 0 {
|
||||
width = 160
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "breadcrumb.label")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u).MinWidth(0).Role(ui.RoleGroup).Label(label)
|
||||
res := BreadcrumbResult{Element: row, chosen: -1}
|
||||
choose := func(i int) {
|
||||
res.chosen = i
|
||||
slog.Debug("mujica: breadcrumb choose", "index", i, "label", items[i].Label)
|
||||
}
|
||||
// Folded items are those between the first and the last limit-2.
|
||||
fold := len(items) > limit
|
||||
hideFrom, hideTo := 1, len(items)-(limit-2)
|
||||
row.Children(func() {
|
||||
for i, it := range items {
|
||||
if it.Label == "" {
|
||||
panic("mujica: BreadcrumbItem needs a Label")
|
||||
}
|
||||
if fold && i >= hideFrom && i < hideTo {
|
||||
if i == hideFrom {
|
||||
breadcrumbSeparator(c)
|
||||
breadcrumbMore(c, items[hideFrom:hideTo], func(j int) { choose(hideFrom + j) })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
breadcrumbSeparator(c)
|
||||
}
|
||||
if i == len(items)-1 {
|
||||
cur := ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(u, u*1.5).MinWidth(0).
|
||||
FontWeight(600).TextColor(k.Text).Tooltip(it.Label).Label(it.Label + ", " + msg(c, "breadcrumb.current"))
|
||||
cur.Children(func() { breadcrumbContent(c, it, width) })
|
||||
continue
|
||||
}
|
||||
link := ui.ButtonBase(c).Role(ui.RoleLink).Gap(u).Padding(u, u*1.5).Radius(theme.ControlRadius).
|
||||
Shrink(1).MinWidth(0).Cursor(ui.CursorPointer).Tooltip(it.Label).Label(it.Label)
|
||||
internal.Ring(link, k.Focus, theme.ControlRadius)
|
||||
link.TextColor(k.TextMuted)
|
||||
if link.Hovered() {
|
||||
link.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
link.Children(func() { breadcrumbContent(c, it, width) })
|
||||
if link.Clicked() {
|
||||
choose(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
func breadcrumbContent(c *ui.Context, it BreadcrumbItem, width float32) {
|
||||
if it.Icon != nil {
|
||||
ui.Icon(c, it.Icon).Size(14, 14).Shrink(0)
|
||||
}
|
||||
ui.Text(c, it.Label).SingleLine().MaxWidth(width).MinWidth(0)
|
||||
}
|
||||
|
||||
func breadcrumbSeparator(c *ui.Context) {
|
||||
ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).TextColor(Tokens(c).Ornament).Role(ui.RoleNone)
|
||||
}
|
||||
|
||||
// breadcrumbMore is the ellipsis button listing the folded items.
|
||||
func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize).
|
||||
Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).TextColor(k.TextMuted).Cursor(ui.CursorPointer)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
if b.Hovered() || *open {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
if b.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) })
|
||||
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
|
||||
panel.Margin(u, 0, 0, 0).Padding(u).Gap(u*0.5).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(160).MaxWidth(320)
|
||||
for j, it := range hidden {
|
||||
item := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).Gap(u*2).Padding(u*1.5, u*2).
|
||||
Radius(theme.ControlRadius).MinHeight(Density(c).ControlHeight() - u*2).Label(it.Label).TextColor(k.Text)
|
||||
if j == 0 {
|
||||
item.AutoFocus()
|
||||
}
|
||||
internal.Ring(item, k.Focus, theme.ControlRadius)
|
||||
if item.Hovered() {
|
||||
item.Background(k.SurfaceHover)
|
||||
}
|
||||
item.Children(func() {
|
||||
if it.Icon != nil {
|
||||
ui.Icon(c, it.Icon).Size(14, 14).Shrink(0)
|
||||
}
|
||||
ui.Text(c, it.Label).SingleLine().MinWidth(0)
|
||||
})
|
||||
if item.Clicked() {
|
||||
*open = false
|
||||
choose(j)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func breadcrumbPath(names ...string) []BreadcrumbItem {
|
||||
var items []BreadcrumbItem
|
||||
for _, n := range names {
|
||||
items = append(items, BreadcrumbItem{Label: n})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func TestBreadcrumbChoose(t *testing.T) {
|
||||
got := -1
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if i, ok := Breadcrumb(c, breadcrumbPath("Home", "Library", "Poems"), BreadcrumbOptions{}).Chosen(); ok {
|
||||
got = i
|
||||
}
|
||||
}, 500, 100)
|
||||
if err := tt.Click("Library"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Fatalf("chose %d, want 1", got)
|
||||
}
|
||||
got = -1
|
||||
_ = tt.Click("Poems")
|
||||
if got != -1 {
|
||||
t.Fatalf("current item acted as a link: %d", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if got != 0 {
|
||||
t.Fatalf("Enter on first link chose %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreadcrumbFolds(t *testing.T) {
|
||||
got := -1
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if i, ok := Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{}).Chosen(); ok {
|
||||
got = i
|
||||
}
|
||||
}, 600, 300)
|
||||
for _, hidden := range []string{"Alpha", "Beta", "Gamma"} {
|
||||
if tt.HasText(hidden) {
|
||||
t.Fatalf("%s should be folded: %q", hidden, tt.Texts())
|
||||
}
|
||||
}
|
||||
if !tt.HasText("Omega") || !tt.HasText("Root") {
|
||||
t.Fatalf("ends missing: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Show hidden path"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Beta") || !tt.Focused("Alpha") {
|
||||
t.Fatalf("menu did not open with focus on first: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Beta") {
|
||||
t.Fatal("Escape did not close the menu")
|
||||
}
|
||||
_ = tt.Click("Show hidden path")
|
||||
if err := tt.Click("Gamma"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 3 || tt.HasText("Beta") {
|
||||
t.Fatalf("chose %d, want 3, menu closed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreadcrumbInvalid(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
items []BreadcrumbItem
|
||||
opts BreadcrumbOptions
|
||||
}{
|
||||
"empty": {nil, BreadcrumbOptions{}},
|
||||
"max": {breadcrumbPath("a", "b"), BreadcrumbOptions{MaxItems: 2}},
|
||||
"label": {[]BreadcrumbItem{{Icon: icons.Must("house")}}, BreadcrumbOptions{}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Breadcrumb(c, tc.items, tc.opts)
|
||||
}, 300, 100, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreadcrumbSnapshot(t *testing.T) {
|
||||
snapshot(t, "breadcrumb", 560, 120, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
Breadcrumb(c, []BreadcrumbItem{{Label: "Home", Icon: icons.Must("house")}, {Label: "Library"}, {Label: "Collected poems"}}, BreadcrumbOptions{})
|
||||
Breadcrumb(c, breadcrumbPath("Archive", "2026", "October", "Correspondence of the court", "Letter to the Duchess of Mujica"), BreadcrumbOptions{MaxItemWidth: 140})
|
||||
})
|
||||
})
|
||||
}
|
||||
+124
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
var buttonGroupTestItems = []ButtonGroupItem{{Label: "Left"}, {Label: "Center", Disabled: true}, {Label: "Right"}}
|
||||
|
||||
func TestButtonGroupClickAndArrows(t *testing.T) {
|
||||
var got []int
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if i := ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Label: "Align"}).ClickedItem(); i >= 0 {
|
||||
got = append(got, i)
|
||||
}
|
||||
}, 400, 200)
|
||||
_ = tt.Click("Center")
|
||||
_ = tt.Click("Left")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.Focused("Right") {
|
||||
t.Fatal("Right arrow did not skip the disabled item to Right")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.Focused("Left") {
|
||||
t.Fatal("arrows do not wrap round")
|
||||
}
|
||||
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
|
||||
t.Fatalf("clicked %v, want [0 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestButtonGroupOneTabStop(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Vertical: true})
|
||||
Button(c, "After", ButtonOptions{})
|
||||
}, 400, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Left") {
|
||||
t.Fatal("Tab did not enter the group")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.Focused("Right") {
|
||||
t.Fatal("Down did not move in a vertical group")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("After") {
|
||||
t.Fatal("Tab did not leave the group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestButtonGroupDisabledGroup(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Disabled: true}).ClickedItem() >= 0 {
|
||||
n++
|
||||
}
|
||||
}, 400, 200)
|
||||
_ = tt.Click("Left")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 0 {
|
||||
t.Fatalf("disabled group clicked %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestButtonGroupSnapshot(t *testing.T) {
|
||||
snapshot(t, "button_group", 420, 200, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(24).AlignItems(ui.Start).Children(func() {
|
||||
ButtonGroup(c, []ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month", Disabled: true}}, ButtonGroupOptions{})
|
||||
ButtonGroup(c, []ButtonGroupItem{
|
||||
{Icon: icons.Must("bold"), Name: "Bold"},
|
||||
{Icon: icons.Must("italic"), Name: "Italic"},
|
||||
{Icon: icons.Must("underline"), Name: "Underline"},
|
||||
}, ButtonGroupOptions{Vertical: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
+47
-10
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
@@ -80,16 +82,51 @@ func TestButtonMinHeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "button", 640, 140, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(12).Wrap().Children(func() {
|
||||
Button(c, "Primary", ButtonOptions{})
|
||||
Button(c, "Secondary", ButtonOptions{Variant: Secondary})
|
||||
Button(c, "Outline", ButtonOptions{Variant: Outline})
|
||||
Button(c, "Ghost", ButtonOptions{Variant: Ghost})
|
||||
Button(c, "Delete", ButtonOptions{Variant: Danger})
|
||||
Button(c, "Saving", ButtonOptions{Loading: true})
|
||||
Button(c, "Disabled", ButtonOptions{Disabled: true})
|
||||
func TestButtonIconAndDanger(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "", ButtonOptions{Variant: Danger, Icon: icons.Must("trash-2"), Label: "Delete"}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 300, 200)
|
||||
if err := tt.Click("Delete"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("icon button clicked %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
// buttonDensitySnapshots snapshots view at Compact and Comfortable as name-<density>.
|
||||
func buttonDensitySnapshots(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
for _, d := range []theme.Density{theme.Compact, theme.Comfortable} {
|
||||
snapshot(t, name+"-"+strings.ToLower(d.String()), w, h, func(c *ui.Context) {
|
||||
m := Light
|
||||
if Tokens(c).Dark {
|
||||
m = Dark
|
||||
}
|
||||
Use(c, Settings{Mode: m, Density: d})
|
||||
view(c)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "button", 640, 140, buttonSnapshotView)
|
||||
buttonDensitySnapshots(t, "button", 640, 160, buttonSnapshotView)
|
||||
}
|
||||
|
||||
func buttonSnapshotView(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(12).Wrap().Children(func() {
|
||||
Button(c, "Primary", ButtonOptions{})
|
||||
Button(c, "Secondary", ButtonOptions{Variant: Secondary})
|
||||
Button(c, "Outline", ButtonOptions{Variant: Outline})
|
||||
Button(c, "Ghost", ButtonOptions{Variant: Ghost})
|
||||
Button(c, "Delete", ButtonOptions{Variant: Danger})
|
||||
Button(c, "Saving", ButtonOptions{Loading: true})
|
||||
Button(c, "Disabled", ButtonOptions{Disabled: true})
|
||||
Button(c, "Add", ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")})
|
||||
})
|
||||
}
|
||||
+267
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var calendarToday = Date{2026, time.October, 5}
|
||||
|
||||
// datetimeTestLabel is the en-US spoken name of a day cell.
|
||||
func datetimeTestLabel(d Date) string {
|
||||
return fmt.Sprintf("%v, %v %d, %d", d.Weekday(), d.Month, d.Day, d.Year)
|
||||
}
|
||||
|
||||
func calendarTester(value *Date, opts CalendarOptions, changes *int) *ui.Tester {
|
||||
opts.Today = calendarToday
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
if Calendar(c, value, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 400, 400)
|
||||
}
|
||||
|
||||
func TestCalendarClickChoosesDay(t *testing.T) {
|
||||
v, n := Date{}, 0
|
||||
tt := calendarTester(&v, CalendarOptions{}, &n)
|
||||
if !tt.HasText("October 2026") {
|
||||
t.Fatalf("empty value should show today's month, got %v", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Monday, October 12, 2026"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v != (Date{2026, time.October, 12}) || n != 1 {
|
||||
t.Fatalf("value %v changes %d", v, n)
|
||||
}
|
||||
if err := tt.Click("Monday, October 12, 2026"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("same day reported change again: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarKeyboard(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 15}, 0
|
||||
tt := calendarTester(&v, CalendarOptions{}, &n)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Thursday, October 15, 2026") {
|
||||
t.Fatal("Tab should land on the chosen day")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.Focused("Friday, October 23, 2026") {
|
||||
t.Fatal("arrows should move the focus by day and week")
|
||||
}
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
if !tt.HasText("November 2026") || !tt.Focused("Monday, November 23, 2026") {
|
||||
t.Fatalf("PageDown should move a month: %v", tt.Texts()[:3])
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyPageUp)
|
||||
if !tt.HasText("November 2025") {
|
||||
t.Fatal("Shift+PageUp should move a year")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != (Date{2025, time.November, 30}) || n != 1 {
|
||||
t.Fatalf("Enter chose %v (%d changes)", v, n)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if v != (Date{2025, time.November, 1}) || n != 2 {
|
||||
t.Fatalf("Space chose %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarLimits(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 10}, 0
|
||||
opts := CalendarOptions{
|
||||
Min: Date{2026, time.October, 5},
|
||||
Max: Date{2026, time.October, 20},
|
||||
DisableDate: func(d Date) bool { return d.Weekday() == time.Sunday },
|
||||
}
|
||||
tt := calendarTester(&v, opts, &n)
|
||||
for _, label := range []string{"Thursday, October 1, 2026", "Sunday, October 11, 2026", "Wednesday, October 21, 2026"} {
|
||||
if err := tt.Click(label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if v != (Date{2026, time.October, 10}) || n != 0 {
|
||||
t.Fatalf("disabled days changed the value to %v", v)
|
||||
}
|
||||
if err := tt.Click("Saturday, October 10, 2026"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.Focused("Monday, October 12, 2026") {
|
||||
t.Fatal("arrows should skip the disabled Sunday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarWeekStart(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ws WeekStart
|
||||
loc Locale
|
||||
first string
|
||||
}{{WeekStartLocale, EnUS, "Sunday"}, {WeekStartLocale, ZhCN, "星期一"}, {WeekStartMonday, EnUS, "Monday"}, {WeekStartSaturday, EnUS, "Saturday"}} {
|
||||
v := Date{2026, time.October, 5}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: tc.loc})
|
||||
Calendar(c, &v, CalendarOptions{WeekStart: tc.ws, Today: calendarToday})
|
||||
}, 400, 400)
|
||||
texts := tt.Texts()
|
||||
var first string
|
||||
for i, s := range texts {
|
||||
if s == "2026年10月" || s == "October 2026" {
|
||||
first = texts[i+3]
|
||||
break
|
||||
}
|
||||
}
|
||||
if first != tc.first {
|
||||
t.Errorf("%v %v: first column %q, want %q", tc.ws, tc.loc, first, tc.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarDisabled(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 10}, 0
|
||||
tt := calendarTester(&v, CalendarOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Monday, October 12, 2026")
|
||||
_ = tt.Click("Next month")
|
||||
if v != (Date{2026, time.October, 10}) || n != 0 || !tt.HasText("October 2026") {
|
||||
t.Fatalf("disabled calendar changed: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarRejectsInvalidValue(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for February 30")
|
||||
}
|
||||
}()
|
||||
v := Date{2026, time.February, 30}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Calendar(c, &v, CalendarOptions{})
|
||||
}, 400, 400)
|
||||
}
|
||||
|
||||
func TestCalendarSnapshot(t *testing.T) {
|
||||
v := Date{2026, time.October, 14}
|
||||
snapshot(t, "calendar", 320, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Calendar(c, &v, CalendarOptions{Today: calendarToday, Max: Date{2026, time.October, 28},
|
||||
DisableDate: func(d Date) bool { return d.Day == 20 }})
|
||||
})
|
||||
})
|
||||
}
|
||||
+134
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestCheckboxLabelClickAndSpace(t *testing.T) {
|
||||
on, changes := false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Checkbox(c, &on, "Remember me", CheckboxOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 300, 200)
|
||||
_ = tt.Click("Remember me")
|
||||
if !on || changes != 1 {
|
||||
t.Fatalf("label click: %v changes %d", on, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if on || changes != 2 {
|
||||
t.Fatalf("Space: %v changes %d", on, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckboxIndeterminateChecks(t *testing.T) {
|
||||
for _, start := range []bool{false, true} {
|
||||
on := start
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Checkbox(c, &on, "Some", CheckboxOptions{Indeterminate: true})
|
||||
}, 300, 200)
|
||||
_ = tt.Click("Some")
|
||||
if !on {
|
||||
t.Fatalf("from mixed with %v, click left the box clear", start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckboxDisabled(t *testing.T) {
|
||||
on := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Checkbox(c, &on, "Off", CheckboxOptions{Disabled: true})
|
||||
}, 300, 200)
|
||||
_ = tt.Click("Off")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if on || tt.Focused("Off") {
|
||||
t.Fatalf("disabled box changed %v or took focus", on)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckboxGroupSelectAll(t *testing.T) {
|
||||
mail, cal, locked := true, false, false
|
||||
changes := 0
|
||||
items := []CheckboxItem{{Label: "Mail", Value: &mail}, {Label: "Calendar", Value: &cal}, {Label: "Admin", Value: &locked, Disabled: true}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if CheckboxGroup(c, "Notifications", items, CheckboxGroupOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 300, 300)
|
||||
_ = tt.Click("Notifications")
|
||||
if !mail || !cal || locked || changes != 1 {
|
||||
t.Fatalf("select all: mail %v cal %v admin %v changes %d", mail, cal, locked, changes)
|
||||
}
|
||||
_ = tt.Click("Notifications")
|
||||
if mail || cal || locked {
|
||||
t.Fatalf("clear all: mail %v cal %v admin %v", mail, cal, locked)
|
||||
}
|
||||
_ = tt.Click("Calendar")
|
||||
if !cal || changes != 3 {
|
||||
t.Fatalf("item click: cal %v changes %d", cal, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Notifications") {
|
||||
t.Fatal("Tab did not reach the select-all box")
|
||||
}
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !mail || !cal {
|
||||
t.Fatalf("Space on select-all: mail %v cal %v", mail, cal)
|
||||
}
|
||||
}
|
||||
|
||||
func checkboxSnapshotView(c *ui.Context) {
|
||||
a, b, m, x, y := false, true, false, true, false
|
||||
ui.Row(c).Padding(24).Gap(32).AlignItems(ui.Start).Children(func() {
|
||||
ui.Column(c).Children(func() {
|
||||
Checkbox(c, &a, "Unchecked", CheckboxOptions{})
|
||||
Checkbox(c, &b, "Checked", CheckboxOptions{})
|
||||
Checkbox(c, &m, "Indeterminate", CheckboxOptions{Indeterminate: true})
|
||||
Checkbox(c, &b, "Disabled", CheckboxOptions{Disabled: true})
|
||||
})
|
||||
CheckboxGroup(c, "Notifications", []CheckboxItem{{Label: "Mail", Value: &x}, {Label: "Calendar", Value: &y}}, CheckboxGroupOptions{})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckboxSnapshot(t *testing.T) {
|
||||
snapshot(t, "checkbox", 420, 220, checkboxSnapshotView)
|
||||
buttonDensitySnapshots(t, "checkbox", 420, 260, checkboxSnapshotView)
|
||||
}
|
||||
+396
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Command is an action of a CommandPalette.
|
||||
type Command struct {
|
||||
ID string
|
||||
Label string
|
||||
// Group heads the command in the list; commands keep their order within groups.
|
||||
Group string
|
||||
// Keywords also match the search.
|
||||
Keywords []string
|
||||
Icon *ui.SVG
|
||||
}
|
||||
|
||||
// CommandPaletteOptions configure a CommandPalette.
|
||||
type CommandPaletteOptions struct {
|
||||
// OpenMods and OpenKey open the palette; zero key means Cmd+K (Ctrl+K off macOS).
|
||||
OpenMods ui.Modifiers
|
||||
OpenKey ui.Key
|
||||
}
|
||||
|
||||
// CommandPaletteResult is the built CommandPalette; Element is nil while closed.
|
||||
type CommandPaletteResult struct {
|
||||
*ui.Element
|
||||
chosen string
|
||||
}
|
||||
|
||||
// Chosen returns the ID of the command run in this frame; running closes the palette.
|
||||
func (r CommandPaletteResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" }
|
||||
|
||||
type commandPaletteKey struct{ open *bool }
|
||||
|
||||
type commandPaletteState struct {
|
||||
query string
|
||||
hl int
|
||||
ptr [2]float32
|
||||
wasOpen bool
|
||||
reveal bool
|
||||
}
|
||||
|
||||
// CommandPalette is a searchable command dialog opened by a shortcut or by setting *open.
|
||||
func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandPaletteOptions) CommandPaletteResult {
|
||||
seen := map[string]bool{}
|
||||
for _, cmd := range commands {
|
||||
if cmd.ID == "" || cmd.Label == "" {
|
||||
panic("mujica: Command needs an ID and a Label")
|
||||
}
|
||||
if seen[cmd.ID] {
|
||||
panic("mujica: duplicate Command ID " + cmd.ID)
|
||||
}
|
||||
seen[cmd.ID] = true
|
||||
}
|
||||
mods, key := opts.OpenMods, opts.OpenKey
|
||||
if key == 0 {
|
||||
mods, key = ui.Cmd, ui.KeyK
|
||||
}
|
||||
st := ui.Local(c.Root(), commandPaletteKey{open}, func() commandPaletteState { return commandPaletteState{} })
|
||||
if c.Shortcut(mods, key) && !*open {
|
||||
*open = true
|
||||
}
|
||||
if *open && !st.wasOpen {
|
||||
st.query, st.hl = "", 0
|
||||
slog.Debug("mujica: command palette open")
|
||||
}
|
||||
st.wasOpen = *open
|
||||
res := CommandPaletteResult{}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
|
||||
ww, wh := c.Size()
|
||||
back.Background(ui.RGBA(0, 0, 0, 0.4)).Justify(ui.Start)
|
||||
panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww-32).MaxHeight(wh*0.76).
|
||||
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Shadow(0, 12, 32, 0, ui.RGBA(0, 0, 0, 0.3)).AlignItems(ui.Stretch).Label(msg(c, "command_palette.label"))
|
||||
found := commandPaletteFilter(commands, st.query)
|
||||
var in *ui.Element
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*3, u*4).Children(func() {
|
||||
ui.Icon(c, icons.Must("search")).Size(16, 16).TextColor(k.TextMuted)
|
||||
in = ui.TextInputBase(c, &st.query).FillWidth().AutoFocus().Label(msg(c, "command_palette.search"))
|
||||
})
|
||||
ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
||||
if in.Changed() {
|
||||
st.hl = 0
|
||||
}
|
||||
n := len(found)
|
||||
switch {
|
||||
case n == 0:
|
||||
case in.Shortcut(0, ui.KeyDown):
|
||||
st.hl, st.reveal = (st.hl+1)%n, true
|
||||
case in.Shortcut(0, ui.KeyUp):
|
||||
st.hl, st.reveal = (st.hl-1+n)%n, true
|
||||
}
|
||||
st.hl = max(0, min(st.hl, n-1))
|
||||
run := -1
|
||||
if in.Submitted() && n > 0 {
|
||||
run = st.hl
|
||||
}
|
||||
list := ui.Scroll(c).Padding(u * 2).Gap(2).MaxHeight(360).Role(ui.RoleList)
|
||||
list.Children(func() {
|
||||
if n == 0 {
|
||||
ui.Text(c, msg(c, "command_palette.empty")).TextColor(k.TextMuted).Padding(u*6, u*2).TextAlign(ui.Center).FillWidth()
|
||||
return
|
||||
}
|
||||
gutter := slices.ContainsFunc(found, func(cmd Command) bool { return cmd.Icon != nil })
|
||||
group := "\x00"
|
||||
for i, cmd := range found {
|
||||
if cmd.Group != group {
|
||||
group = cmd.Group
|
||||
if group != "" {
|
||||
ui.Text(c, group).Font(theme.SerifFont).FontSize(theme.CaptionSize).
|
||||
TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u*2, u*2, u, u*2).SingleLine()
|
||||
}
|
||||
}
|
||||
if commandPaletteRow(c, cmd, i, st, gutter) {
|
||||
run = i
|
||||
}
|
||||
}
|
||||
})
|
||||
ui.Row(c).Gap(u*4).Padding(u*2, u*4).Background(k.Background).Children(func() {
|
||||
for _, hint := range []string{"command_palette.hint_move", "command_palette.hint_run", "command_palette.hint_close"} {
|
||||
ui.Text(c, msg(c, hint)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
})
|
||||
if run >= 0 {
|
||||
res.chosen = found[run].ID
|
||||
*open = false
|
||||
slog.Debug("mujica: command palette run", "id", res.chosen)
|
||||
}
|
||||
})
|
||||
if !*open {
|
||||
st.wasOpen = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// commandPaletteRow builds row i and reports a click on it.
|
||||
func commandPaletteRow(c *ui.Context, cmd Command, i int, st *commandPaletteState, gutter bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u*3).MinHeight(d.ControlHeight()).Padding(0, u*3).
|
||||
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(cmd.Label).TextColor(k.Text).Cursor(ui.CursorPointer)
|
||||
on := i == st.hl
|
||||
if on {
|
||||
row.Background(k.Selection).FontWeight(600)
|
||||
row.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5)
|
||||
})
|
||||
if st.reveal {
|
||||
row.ScrollIntoView()
|
||||
st.reveal = false
|
||||
}
|
||||
}
|
||||
px, py, over := row.PointerPosition()
|
||||
b := row.Bounds()
|
||||
if at := [2]float32{b.X + px, b.Y + py}; over && at != st.ptr {
|
||||
st.ptr = at
|
||||
if !on {
|
||||
st.hl = i
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
row.Children(func() {
|
||||
if cmd.Icon != nil {
|
||||
ui.Icon(c, cmd.Icon).Size(16, 16).TextColor(k.TextMuted)
|
||||
} else if gutter {
|
||||
ui.Box(c).Size(16, 16).Role(ui.RoleNone)
|
||||
}
|
||||
ui.Text(c, cmd.Label).SingleLine()
|
||||
})
|
||||
return row.Clicked()
|
||||
}
|
||||
|
||||
// commandPaletteFilter keeps commands matching every word of query, grouped by first appearance.
|
||||
func commandPaletteFilter(commands []Command, query string) []Command {
|
||||
words := strings.Fields(strings.ToLower(query))
|
||||
var groups []string
|
||||
byGroup := map[string][]Command{}
|
||||
for _, cmd := range commands {
|
||||
hay := strings.ToLower(cmd.Label + " " + cmd.Group + " " + strings.Join(cmd.Keywords, " "))
|
||||
ok := true
|
||||
for _, w := range words {
|
||||
if !strings.Contains(hay, w) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, seen := byGroup[cmd.Group]; !seen {
|
||||
groups = append(groups, cmd.Group)
|
||||
}
|
||||
byGroup[cmd.Group] = append(byGroup[cmd.Group], cmd)
|
||||
}
|
||||
var out []Command
|
||||
for _, g := range groups {
|
||||
out = append(out, byGroup[g]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func commandPaletteSample() []Command {
|
||||
return []Command{
|
||||
{ID: "new", Label: "New document", Group: "File", Icon: icons.Must("file")},
|
||||
{ID: "open", Label: "Open recent", Group: "File", Icon: icons.Must("folder")},
|
||||
{ID: "theme", Label: "Toggle dark mode", Group: "View", Keywords: []string{"theme", "night"}, Icon: icons.Must("moon")},
|
||||
{ID: "zoom", Label: "Zoom in", Group: "View", Icon: icons.Must("zoom-in")},
|
||||
{ID: "save", Label: "Save all", Group: "File"},
|
||||
}
|
||||
}
|
||||
|
||||
type paletteApp struct {
|
||||
open bool
|
||||
ran []string
|
||||
}
|
||||
|
||||
func (a *paletteApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Fill().Children(func() {
|
||||
ui.Text(c, "Editor")
|
||||
if id, ok := CommandPalette(c, &a.open, commandPaletteSample(), CommandPaletteOptions{}).Chosen(); ok {
|
||||
a.ran = append(a.ran, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommandPaletteFilter(t *testing.T) {
|
||||
ids := func(cs []Command) (out []string) {
|
||||
for _, c := range cs {
|
||||
out = append(out, c.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := ids(commandPaletteFilter(commandPaletteSample(), ""))
|
||||
// Groups keep first-appearance order: File's Save joins its group.
|
||||
want := []string{"new", "open", "save", "theme", "zoom"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
if got := ids(commandPaletteFilter(commandPaletteSample(), "NIGHT")); len(got) != 1 || got[0] != "theme" {
|
||||
t.Fatalf("keyword search %v", got)
|
||||
}
|
||||
if got := ids(commandPaletteFilter(commandPaletteSample(), "file save")); len(got) != 1 || got[0] != "save" {
|
||||
t.Fatalf("multi-word search %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteShortcutSearchRun(t *testing.T) {
|
||||
a := &paletteApp{}
|
||||
tt := ui.NewTester(a.view, 800, 600)
|
||||
tt.Key(ui.Cmd, ui.KeyK)
|
||||
if !a.open || !tt.HasText("Zoom in") {
|
||||
t.Fatalf("Cmd+K did not open: %q", tt.Texts())
|
||||
}
|
||||
tt.Type("zo")
|
||||
if tt.HasText("New document") || !tt.HasText("Zoom in") {
|
||||
t.Fatalf("search did not filter: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.open || len(a.ran) != 1 || a.ran[0] != "zoom" {
|
||||
t.Fatalf("Enter: open %v ran %q", a.open, a.ran)
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyK)
|
||||
if !tt.HasText("New document") {
|
||||
t.Fatal("query not reset on reopen")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyUp) // wraps to the last
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(a.ran) != 2 || a.ran[1] != "zoom" {
|
||||
t.Fatalf("arrows ran %q", a.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteEmptyClickEscape(t *testing.T) {
|
||||
a := &paletteApp{open: true}
|
||||
tt := ui.NewTester(a.view, 800, 600)
|
||||
tt.Type("qqq")
|
||||
if !tt.HasText("No matching commands") {
|
||||
t.Fatalf("no empty state: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !a.open || len(a.ran) != 0 {
|
||||
t.Fatal("Enter with no results ran something")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
a.open = true
|
||||
tt.Frame()
|
||||
if err := tt.Click("Open recent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.open || len(a.ran) != 1 || a.ran[0] != "open" {
|
||||
t.Fatalf("click: open %v ran %q", a.open, a.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteInvalid(t *testing.T) {
|
||||
for name, cmds := range map[string][]Command{
|
||||
"id": {{Label: "A"}},
|
||||
"dup": {{ID: "a", Label: "A"}, {ID: "a", Label: "B"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
open := true
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CommandPalette(c, &open, cmds, CommandPaletteOptions{})
|
||||
}, 300, 100, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteSnapshot(t *testing.T) {
|
||||
open := true
|
||||
snapshot(t, "command_palette", 640, 460, func(c *ui.Context) {
|
||||
CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func contextMenuTester(a *menuApp, disabled bool) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(20).Gap(8).Children(func() {
|
||||
row := ui.Row(c).Height(80).FillWidth().Padding(12).Background(Tokens(c).Surface)
|
||||
row.Children(func() {
|
||||
Button(c, "Letter row", ButtonOptions{Variant: Ghost})
|
||||
})
|
||||
ContextMenu(c, row, ContextMenuOptions{Disabled: disabled}, a.build)
|
||||
})
|
||||
}, 500, 320)
|
||||
}
|
||||
|
||||
func TestContextMenuOpensAtPointer(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, false)
|
||||
tt.RightClickAt(200, 60)
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("Rename")
|
||||
// The label sits past the menu padding and the check column.
|
||||
if !ok || r.X < 230 || r.X > 250 || r.Y < 60 || r.Y > 110 {
|
||||
t.Fatalf("menu item %+v not at the pointer (200, 60)", r)
|
||||
}
|
||||
if tt.Menu() != nil {
|
||||
t.Fatal("the system menu opened")
|
||||
}
|
||||
_ = tt.Click("Rename")
|
||||
if len(a.chosen) != 1 || tt.HasText("Rename") {
|
||||
t.Fatalf("chosen %q", a.chosen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuOnInnerButtonAndEdge(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, false)
|
||||
b, _ := tt.Find("Letter row")
|
||||
tt.RightClickAt(b.X+4, b.Y+4)
|
||||
if !tt.HasText("Rename") {
|
||||
t.Fatal("right click on an inner button did not open the menu")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.RightClickAt(480, 95)
|
||||
tt.Frame()
|
||||
last, _ := tt.Find("Burn")
|
||||
first, _ := tt.Find("Rename")
|
||||
if last.Y+last.H > 320 || first.Y > 95 || last.X+last.W > 500 {
|
||||
t.Fatalf("menu not kept in the window: first %+v last %+v", first, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuKeyboard(t *testing.T) {
|
||||
for _, key := range []struct {
|
||||
mods ui.Modifiers
|
||||
key ui.Key
|
||||
}{{0, ui.KeyContextMenu}, {ui.Shift, ui.KeyF10}} {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, false)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(key.mods, key.key)
|
||||
tt.Frame()
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatalf("%v: menu did not open with focus", key)
|
||||
}
|
||||
item, _ := tt.Find("Rename")
|
||||
if item.Y < 100 {
|
||||
t.Fatalf("%v: keyboard menu %+v not below the focused target", key, item)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Rename") || !tt.Focused("Letter row") {
|
||||
t.Fatalf("%v: Escape did not close and restore", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuDisabled(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, true)
|
||||
tt.RightClickAt(200, 60)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(ui.Shift, ui.KeyF10)
|
||||
if tt.HasText("Rename") {
|
||||
t.Fatal("disabled context menu opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuSnapshot(t *testing.T) {
|
||||
snapshot(t, "context_menu", 460, 320, func(c *ui.Context) {
|
||||
a := &menuApp{}
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
row := ui.Row(c).Height(36).FillWidth().Padding(0, 12).AlignItems(ui.Center).Background(Tokens(c).Selection)
|
||||
row.Children(func() { ui.Text(c, "Letter to the Countess") })
|
||||
anchor := ui.Box(c).Absolute().Left(140).Top(48).Size(1, 1)
|
||||
open := true
|
||||
popupMenuOpen(c, anchor, &open, a.build)
|
||||
})
|
||||
})
|
||||
}
|
||||
+155
@@ -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 ""
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func dateTimeTester(t *testing.T, value *time.Time, opts DateTimePickerOptions, changes, cancels *int) *ui.Tester {
|
||||
t.Helper()
|
||||
opts.Today = calendarToday
|
||||
opts.Label = "Meeting"
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
r := DateTimePicker(c, value, opts)
|
||||
if r.Changed() {
|
||||
*changes++
|
||||
}
|
||||
if r.Cancelled() {
|
||||
*cancels++
|
||||
}
|
||||
})
|
||||
}, 480, 640)
|
||||
}
|
||||
|
||||
func newYork(t *testing.T) *time.Location {
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func TestDateTimeResolve(t *testing.T) {
|
||||
ny := newYork(t)
|
||||
if _, n := dateTimeResolve(Date{2026, time.March, 8}, TimeOfDay{2, 30, 0}, ny); n != 0 {
|
||||
t.Fatalf("spring-forward gap gave %d instants", n)
|
||||
}
|
||||
if _, n := dateTimeResolve(Date{2026, time.November, 1}, TimeOfDay{1, 30, 0}, ny); n != 2 {
|
||||
t.Fatalf("fall-back overlap gave %d instants", n)
|
||||
}
|
||||
at, n := dateTimeResolve(Date{2026, time.July, 4}, TimeOfDay{12, 0, 0}, ny)
|
||||
if n != 1 || !at.Equal(time.Date(2026, time.July, 4, 16, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("plain time %v (%d)", at, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimePickerConfirmInLocation(t *testing.T) {
|
||||
tokyo, err := time.LoadLocation("Asia/Tokyo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := time.Date(2026, time.October, 5, 23, 30, 0, 0, time.UTC) // 08:30 on Oct 6 in Tokyo
|
||||
n, x := 0, 0
|
||||
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: tokyo}, &n, &x)
|
||||
if !tt.HasText("2026-10-06 08:30 JST") {
|
||||
t.Fatalf("value should show in the caller's zone: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Meeting")
|
||||
_ = tt.Click(datetimeTestLabel(oct(9)))
|
||||
_ = tt.Click("Time: Hours")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if n != 0 {
|
||||
t.Fatal("the draft should wait for Confirm")
|
||||
}
|
||||
_ = tt.Click("Confirm")
|
||||
want := time.Date(2026, time.October, 9, 9, 30, 0, 0, tokyo)
|
||||
if !v.Equal(want) || n != 1 || x != 0 || v.Location() != tokyo {
|
||||
t.Fatalf("confirmed %v, want %v", v, want)
|
||||
}
|
||||
_ = tt.Click("Meeting")
|
||||
_ = tt.Click(datetimeTestLabel(oct(20)))
|
||||
_ = tt.Click("Cancel")
|
||||
if !v.Equal(want) || x != 1 {
|
||||
t.Fatalf("cancel changed %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimePickerReportsDSTGap(t *testing.T) {
|
||||
ny := newYork(t)
|
||||
v := time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)
|
||||
n, x := 0, 0
|
||||
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny, Today: Date{2026, time.March, 1}}, &n, &x)
|
||||
_ = tt.Click("Meeting")
|
||||
_ = tt.Click("Sunday, March 8, 2026")
|
||||
_ = tt.Click("Confirm")
|
||||
if n != 0 || !v.Equal(time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)) {
|
||||
t.Fatalf("gap time was shifted into %v", v)
|
||||
}
|
||||
if !tt.HasText("2026-03-08 02:30 does not exist in America/New_York: the clocks skip it.") {
|
||||
t.Fatalf("missing gap error: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Time: Hours")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
_ = tt.Click("Confirm")
|
||||
if n != 1 || !v.Equal(time.Date(2026, time.March, 8, 3, 30, 0, 0, ny)) {
|
||||
t.Fatalf("after fixing: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimePickerReportsAmbiguous(t *testing.T) {
|
||||
ny := newYork(t)
|
||||
v := time.Date(2026, time.October, 31, 1, 30, 0, 0, ny)
|
||||
n, x := 0, 0
|
||||
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny}, &n, &x)
|
||||
_ = tt.Click("Meeting")
|
||||
_ = tt.Click("Sunday, November 1, 2026")
|
||||
_ = tt.Click("Confirm")
|
||||
if n != 0 || !tt.HasText("2026-11-01 01:30 happens twice in America/New_York: the clocks repeat it.") {
|
||||
t.Fatalf("ambiguous time accepted: %v %v", v, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimePickerRange(t *testing.T) {
|
||||
v := time.Time{}
|
||||
n, x := 0, 0
|
||||
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
|
||||
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT}, &n, &x)
|
||||
_ = tt.Click("Meeting")
|
||||
_ = tt.Click(datetimeTestLabel(oct(11)))
|
||||
_ = tt.Click(datetimeTestLabel(oct(10)))
|
||||
_ = tt.Click("Time: Hours")
|
||||
tt.Key(0, ui.Key1)
|
||||
tt.Key(0, ui.Key3)
|
||||
_ = tt.Click("Confirm")
|
||||
if n != 0 || !v.IsZero() || !tt.HasText("Choose 2026-10-10 12:00 UTC or earlier.") {
|
||||
t.Fatalf("past Max accepted: %v %v", v, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimePickerNeedsLocation(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without Location")
|
||||
}
|
||||
}()
|
||||
v := time.Time{}
|
||||
dateTimeTester(t, &v, DateTimePickerOptions{}, new(int), new(int))
|
||||
}
|
||||
|
||||
func TestDateTimePickerSnapshot(t *testing.T) {
|
||||
a, b := time.Date(2026, time.October, 14, 18, 45, 0, 0, time.UTC), time.Time{}
|
||||
snapshot(t, "date_time_picker", 720, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
DateTimePicker(c, &b, DateTimePickerOptions{Location: time.UTC, Today: calendarToday})
|
||||
DateTimePicker(c, &a, DateTimePickerOptions{Location: time.UTC, Today: calendarToday, Hour12: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
+201
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DialogOptions configure a Dialog. Title is required.
|
||||
type DialogOptions struct {
|
||||
Title string
|
||||
Description string
|
||||
// Width is 440 DIPs when zero; the window always bounds it.
|
||||
Width float32
|
||||
// Decorated draws the double border, corner ornaments and title rule.
|
||||
Decorated bool
|
||||
// Actions builds the footer, usually buttons, aligned to the end.
|
||||
Actions func()
|
||||
// KeepOnBackdrop ignores clicks on the backdrop.
|
||||
KeepOnBackdrop bool
|
||||
// KeepOnEscape ignores Escape.
|
||||
KeepOnEscape bool
|
||||
// HideClose removes the close button from the header.
|
||||
HideClose bool
|
||||
}
|
||||
|
||||
// Dialog shows a modal dialog while *open is true. It traps the focus,
|
||||
// makes the window inert and gives the focus back to the opener on close.
|
||||
func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: Dialog needs a Title")
|
||||
}
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
shown, backClicked, closed := true, false, false
|
||||
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
backClicked = back.Clicked()
|
||||
p := overlayEnter(c, panel)
|
||||
back.Background(overlayScrim(k).Alpha(p)).Padding(16)
|
||||
dialogPanel(c, panel, opts.Width, opts.Decorated, p)
|
||||
panel.Label(opts.Title)
|
||||
if opts.Description != "" {
|
||||
panel.Description(opts.Description)
|
||||
}
|
||||
closed = dialogHeader(c, opts.Title, opts.Description, opts.Decorated, !opts.HideClose, "dialog.close")
|
||||
if content != nil {
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
}
|
||||
if opts.Actions != nil {
|
||||
dialogActions(c, opts.Actions)
|
||||
}
|
||||
})
|
||||
switch {
|
||||
case closed:
|
||||
slog.Debug("mujica: dialog close", "by", "button")
|
||||
*open = false
|
||||
case !shown && backClicked && !opts.KeepOnBackdrop:
|
||||
slog.Debug("mujica: dialog close", "by", "backdrop")
|
||||
*open = false
|
||||
case !shown && !backClicked && !opts.KeepOnEscape:
|
||||
slog.Debug("mujica: dialog close", "by", "escape")
|
||||
*open = false
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
// dialogPanel styles a centered dialog panel at entrance progress p.
|
||||
func dialogPanel(c *ui.Context, panel *ui.Element, width float32, decorated bool, p float32) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
if width == 0 {
|
||||
width = 440
|
||||
}
|
||||
w, h := c.Size()
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Width(width).MaxWidth(w-32).MaxHeight(h-32).Padding(u*6).Gap(u*3).Margin(8*(1-p), 0, 0, 0)
|
||||
if decorated {
|
||||
panel.Border(theme.BorderWidth, k.Ornament).Padding(u*6 + 6).DrawOver(func(pt *ui.Painter, r ui.Rect) {
|
||||
dialogOrnaments(pt, r, k.Ornament, k.Surface)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dialogOrnaments draws the inner frame and the four corner diamonds.
|
||||
func dialogOrnaments(p *ui.Painter, r ui.Rect, ink, ground ui.Color) {
|
||||
const in = 5
|
||||
f := ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}
|
||||
p.Stroke(f, ink, 1, 1)
|
||||
for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} {
|
||||
x, y := internal.Snap(p, pt[0]), internal.Snap(p, pt[1])
|
||||
p.FillPath(internal.Diamond(x, y, 11), ground)
|
||||
p.FillPath(internal.Diamond(x, y, 7), ink)
|
||||
}
|
||||
}
|
||||
|
||||
// dialogHeader builds the title, description and close button; it reports a close click.
|
||||
func dialogHeader(c *ui.Context, title, description string, decorated, closable bool, closeKey string) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
clicked := false
|
||||
ui.Column(c).Gap(u).Shrink(0).Children(func() {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).Grow(1)
|
||||
if closable {
|
||||
clicked = dialogCloseButton(c, msg(c, closeKey)).Clicked()
|
||||
}
|
||||
})
|
||||
if decorated {
|
||||
dialogRule(c, k.Ornament)
|
||||
}
|
||||
if description != "" {
|
||||
ui.Text(c, description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
return clicked
|
||||
}
|
||||
|
||||
// dialogRule is the ornament line under a decorated title.
|
||||
func dialogRule(c *ui.Context, ink ui.Color) {
|
||||
ui.Box(c).FillWidth().Height(9).Margin(2, 0, 2, 0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
y, cx := r.Y+r.H/2, r.X+r.W/2
|
||||
p.Line(r.X, y, cx-10, y, 1, ink)
|
||||
p.Line(cx+10, y, r.X+r.W, y, 1, ink)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, y), 7), ink)
|
||||
})
|
||||
}
|
||||
|
||||
// dialogCloseButton is the 28 DIP icon button closing a panel.
|
||||
func dialogCloseButton(c *ui.Context, label string) *ui.Element {
|
||||
k := Tokens(c)
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).Margin(-4, -4, 0, 0)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
b.TextColor(k.TextMuted)
|
||||
if b.Pressed() {
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must("x")).Size(16, 16) })
|
||||
return b
|
||||
}
|
||||
|
||||
// dialogActions lays out a dialog footer.
|
||||
func dialogActions(c *ui.Context, actions func()) {
|
||||
u := Density(c).Unit()
|
||||
ui.Row(c).Gap(u*2).Justify(ui.End).Wrap().Shrink(0).Margin(u*2, 0, 0, 0).Children(actions)
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type dialogApp struct {
|
||||
open bool
|
||||
opts DialogOptions
|
||||
saved int
|
||||
name string
|
||||
densities theme.Density
|
||||
}
|
||||
|
||||
func (a *dialogApp) view(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light, Density: a.densities})
|
||||
if Button(c, "Rename", ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
a.opts.Actions = func() {
|
||||
if Button(c, "Cancel", ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
a.open = false
|
||||
}
|
||||
if Button(c, "Save", ButtonOptions{}).Clicked() {
|
||||
a.saved++
|
||||
a.open = false
|
||||
}
|
||||
}
|
||||
Dialog(c, &a.open, a.opts, func() {
|
||||
ui.TextInput(c, &a.name)
|
||||
})
|
||||
}
|
||||
|
||||
func newDialogApp(opts DialogOptions) (*dialogApp, *ui.Tester) {
|
||||
opts.Title = "Rename"
|
||||
a := &dialogApp{opts: opts}
|
||||
tt := ui.NewTester(a.view, 600, 400)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
return a, tt
|
||||
}
|
||||
|
||||
func TestDialogOpenFocusTrapRestore(t *testing.T) {
|
||||
a, tt := newDialogApp(DialogOptions{Description: "Pick a new name."})
|
||||
if err := tt.Click("Rename"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !a.open || !tt.HasText("Pick a new name.") {
|
||||
t.Fatalf("open %v, texts %q", a.open, tt.Texts())
|
||||
}
|
||||
if !tt.Focused("Close") {
|
||||
t.Fatalf("focus did not move into the dialog")
|
||||
}
|
||||
// Tab cycles Close, input, Cancel, Save and back to Close.
|
||||
for range 3 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if !tt.Focused("Save") {
|
||||
t.Fatalf("tab order left the dialog")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Close") {
|
||||
t.Fatalf("tab did not wrap inside the dialog")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatal("focus not restored to the opener")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogActionsAndClose(t *testing.T) {
|
||||
a, tt := newDialogApp(DialogOptions{})
|
||||
_ = tt.Click("Rename")
|
||||
if err := tt.Click("Save"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.saved != 1 || a.open {
|
||||
t.Fatalf("saved %d open %v", a.saved, a.open)
|
||||
}
|
||||
_ = tt.Click("Rename")
|
||||
if err := tt.Click("Close"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.open || a.saved != 1 {
|
||||
t.Fatalf("close button: open %v saved %d", a.open, a.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogCloseRules(t *testing.T) {
|
||||
a, tt := newDialogApp(DialogOptions{})
|
||||
_ = tt.Click("Rename")
|
||||
tt.ClickAt(10, 390)
|
||||
if a.open {
|
||||
t.Fatal("backdrop click did not close")
|
||||
}
|
||||
a, tt = newDialogApp(DialogOptions{KeepOnBackdrop: true, KeepOnEscape: true, HideClose: true})
|
||||
_ = tt.Click("Rename")
|
||||
tt.ClickAt(10, 390)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if !a.open {
|
||||
t.Fatal("kept dialog closed")
|
||||
}
|
||||
if tt.HasText("Close") {
|
||||
t.Fatal("close button shown with HideClose")
|
||||
}
|
||||
if _, ok := tt.Find("Close"); ok {
|
||||
t.Fatal("close button present with HideClose")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogEscapeGoesToNestedPopoverFirst(t *testing.T) {
|
||||
open, inner := true, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Dialog(c, &open, DialogOptions{Title: "Outer"}, func() {
|
||||
b := Button(c, "More", ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
inner = true
|
||||
}
|
||||
Popover(c, b, &inner, PopoverOptions{}, func() { ui.Text(c, "Inner") })
|
||||
})
|
||||
}, 600, 400)
|
||||
_ = tt.Click("More")
|
||||
if !inner {
|
||||
t.Fatal("popover did not open")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if inner || !open {
|
||||
t.Fatalf("after one Escape inner %v outer %v", inner, open)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if open {
|
||||
t.Fatal("second Escape did not close the dialog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogNeedsTitle(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Dialog(c, &open, DialogOptions{}, nil)
|
||||
}, 300, 200)
|
||||
}
|
||||
|
||||
func dialogSnapshotView(d theme.Density, decorated bool) func(c *ui.Context) {
|
||||
return func(c *ui.Context) {
|
||||
mode := Light
|
||||
if Tokens(c).Dark {
|
||||
mode = Dark
|
||||
}
|
||||
Use(c, Settings{Mode: mode, Density: d})
|
||||
open := true
|
||||
name := "Ophelia"
|
||||
ui.Column(c).Padding(24).Children(func() { ui.Text(c, "Behind the dialog") })
|
||||
Dialog(c, &open, DialogOptions{
|
||||
Title: "Rename the chamber", Description: "The new name shows in every hall.", Decorated: decorated,
|
||||
Actions: func() {
|
||||
Button(c, "Cancel", ButtonOptions{Variant: Secondary})
|
||||
Button(c, "Rename", ButtonOptions{})
|
||||
},
|
||||
}, func() { ui.TextInput(c, &name) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshot(t *testing.T) {
|
||||
snapshot(t, "dialog", 560, 360, dialogSnapshotView(theme.Default, true))
|
||||
snapshot(t, "dialog-compact", 560, 360, dialogSnapshotView(theme.Compact, false))
|
||||
snapshot(t, "dialog-comfortable", 560, 400, dialogSnapshotView(theme.Comfortable, true))
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// dividerDiamond is the ornament's size in DIPs.
|
||||
const dividerDiamond float32 = 7
|
||||
|
||||
// DividerOptions configure a Divider.
|
||||
type DividerOptions struct {
|
||||
// Vertical draws a line down a row instead of across a column.
|
||||
Vertical bool
|
||||
// Label sits in the middle of a horizontal divider.
|
||||
Label string
|
||||
// Ornament draws the line in the ornament color with a single diamond in the middle; a Label replaces the diamond.
|
||||
Ornament bool
|
||||
}
|
||||
|
||||
// Divider is a thin separating line. It never takes focus and its ornament is not read out.
|
||||
func Divider(c *ui.Context, opts DividerOptions) *ui.Element {
|
||||
if opts.Vertical && opts.Label != "" {
|
||||
panic("mujica: a vertical Divider cannot have a Label")
|
||||
}
|
||||
k := Tokens(c)
|
||||
line := k.Border
|
||||
if opts.Ornament {
|
||||
line = k.Ornament
|
||||
}
|
||||
if opts.Label == "" && !opts.Ornament {
|
||||
e := ui.Box(c).Background(line).Shrink(0).AlignSelf(ui.Stretch)
|
||||
if opts.Vertical {
|
||||
return e.Width(theme.BorderWidth)
|
||||
}
|
||||
return e.Height(theme.BorderWidth)
|
||||
}
|
||||
e := ui.Row(c).Gap(Density(c).Unit() * 2).Shrink(0).AlignSelf(ui.Stretch)
|
||||
if opts.Vertical {
|
||||
e.Column().AlignItems(ui.Center)
|
||||
}
|
||||
segment := func() {
|
||||
s := ui.Box(c).Grow(1).Background(line)
|
||||
if opts.Vertical {
|
||||
s.Width(theme.BorderWidth)
|
||||
} else {
|
||||
s.Height(theme.BorderWidth)
|
||||
}
|
||||
}
|
||||
return e.Children(func() {
|
||||
segment()
|
||||
if opts.Label != "" {
|
||||
ui.Text(c, opts.Label).Font(theme.SerifFont).FontSize(theme.CaptionSize).
|
||||
TextColor(k.TextMuted).SingleLine().Shrink(0)
|
||||
} else {
|
||||
ui.Box(c).Size(dividerDiamond, dividerDiamond).Shrink(0).Role(ui.RoleNone).
|
||||
Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.Ornament)
|
||||
})
|
||||
}
|
||||
segment()
|
||||
})
|
||||
}
|
||||
+107
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,不可配置。
|
||||
@@ -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()` 为零,组件只是一行普通标题。
|
||||
@@ -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` 中说明。
|
||||
@@ -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`。
|
||||
@@ -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 顺序即子项构建顺序。
|
||||
|
||||
## 限制
|
||||
|
||||
- 隐藏项不会被构建,其内部界面状态(如未提交的输入)随之丢弃;需要保留时把状态放在应用中。
|
||||
- 只有横向支持换行。
|
||||
@@ -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 按其规则扩展。
|
||||
@@ -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 会构建全部子项。
|
||||
@@ -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 没有公开的无障碍数值范围接口,当前位置通过描述文字提供。
|
||||
- 键盘或拖动产生的尺寸在下一帧生效(组件会立即请求下一帧)。
|
||||
@@ -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` 只能由宽推高,不能同时受宽高约束)。
|
||||
@@ -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`。
|
||||
- 纵向不支持文字。
|
||||
@@ -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 无法检测文本是否真的被截断,所以截断时不会自动显示完整内容的提示。
|
||||
@@ -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 组件。
|
||||
@@ -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`。
|
||||
- 回退标记带悬停提示,但图标本身不获取焦点,键盘用户看不到这个提示,只能依赖无障碍名称。
|
||||
@@ -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`,不会显示占位。
|
||||
@@ -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`)。
|
||||
@@ -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` 卡片上时描边颜色会与卡片背景不同。
|
||||
- 名单中的条目只用于展示,不可选择。
|
||||
@@ -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`,部分屏幕阅读器可能在数值变化时主动播报。
|
||||
@@ -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 类组件。
|
||||
@@ -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`。
|
||||
@@ -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。
|
||||
@@ -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` 混合黑色得到,未单独定义变量。
|
||||
@@ -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,提示随之不出现;名称仍作为无障碍描述保留。
|
||||
@@ -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)。
|
||||
@@ -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 清空。
|
||||
@@ -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。
|
||||
@@ -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`),所以仅步进模式要求有界。
|
||||
@@ -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`),因此遮蔽与显示两种模式各用一个编辑器,切换时撤销历史和光标位置不保留,焦点留在切换按钮上。
|
||||
- 显示模式下内容是普通文本,可复制,辅助技术可朗读——这是用户主动选择的结果。
|
||||
@@ -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。
|
||||
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
|
||||
@@ -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` 未导出)。半选时组件在无障碍描述中加入“部分选中”,但角色状态仍报告为未选中。
|
||||
@@ -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`)。
|
||||
@@ -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 标签不支持富文本,无法单独着色。
|
||||
@@ -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)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
|
||||
@@ -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。
|
||||
@@ -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 位”的说明。
|
||||
@@ -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 单行输入框会处理粘贴文本中的换行,换行能否作为分隔符取决于平台粘贴行为;逗号、分号总是有效。
|
||||
@@ -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 未公开为自定义元素设置数值范围的接口。
|
||||
@@ -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 决定,空间不足时显示在按钮上方。
|
||||
@@ -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` 启动,无窗口测试中无法打开真实对话框。
|
||||
- 不读取文件内容。
|
||||
@@ -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 的无窗口测试器不能模拟系统拖放,自动测试通过“浏览…”路径覆盖同一检查流程。
|
||||
@@ -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 到达全选框、搜索框与移动按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 条目逐项创建,适合数百条以内;大数据量请使用虚拟化列表组合。
|
||||
- 全选只作用于当前搜索结果中的可用条目。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 059 Tabs 标签页
|
||||
|
||||
## 用途
|
||||
|
||||
在同一区域内切换若干面板。标签列表与当前标签的面板一起构建;支持横向与纵向布局、禁用项和溢出滚动。Tabs 是视觉回归基准组件,快照覆盖三种密度。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Tabs(c, &tab, []mujica.Tab{
|
||||
{Label: "General", Panel: func() { ui.Text(c, "General settings") }},
|
||||
{Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }},
|
||||
{Label: "Archive", Disabled: true},
|
||||
}, mujica.TabsOptions{Label: "Settings"})
|
||||
```
|
||||
|
||||
签名:`func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) TabsResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`selected`:当前标签下标(从 0 开始)。越界或指向禁用标签时 panic。`tabs` 为空时 panic。
|
||||
|
||||
`Tab`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 标签文字,也是面板的无障碍名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 标签文字前的 16 DIP 图标。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不可点击、不进入方向键序列。 |
|
||||
| `Panel` | `func()` | nil | 选中时构建的面板内容;nil 显示空面板。 |
|
||||
|
||||
`TabsOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Vertical` | `bool` | false | 标签竖排在左侧,面板在右侧。 |
|
||||
| `Label` | `string` | "" | 标签列表的无障碍名称。 |
|
||||
|
||||
返回 `TabsResult`,内嵌包含标签与面板的 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 选中:Selection 背景、2 DIP AccentText 边线(横向在底边,纵向在右边)、600 字重,三者同时标记。
|
||||
- 悬停:SurfaceHover 背景;按下:Selection 背景。
|
||||
- 焦点:2 DIP Focus 焦点环,外留 2 DIP。
|
||||
- 禁用:MyGo 统一降低不透明度,不响应指针与键盘。
|
||||
- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧用户选择了新标签(点击或方向键)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入标签列表,落在当前标签;再按 Tab 离开。
|
||||
- ←/→、↑/↓:选择上一个/下一个可用标签,跳过禁用项,首尾循环;焦点随选择移动。
|
||||
- Home / End:第一个 / 最后一个可用标签。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo `TabsBase` 的方向键不知道禁用项(`ui/base.go:192` 直接选择 i±1)。本组件只把可用标签交给 `TabsBase`,禁用标签以普通 `RoleTab` 行显示,因此禁用标签不在 MyGo 的标签计数内。
|
||||
- MyGo 没有 tabpanel 角色与 aria-controls 式关联;面板以 `RoleGroup` 呈现,名称取自所选标签。
|
||||
- 不提供标签关闭、拖动排序。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 060 SegmentedControl 分段控件
|
||||
|
||||
## 用途
|
||||
|
||||
在少量互斥选项之间切换视图或范围,如“日 / 周 / 月”或“列表 / 网格”。基于 MyGo `SegmentedBase`,辅助技术看到单选组。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.SegmentedControl(c, &view, []mujica.Segment{
|
||||
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
|
||||
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
|
||||
}, mujica.SegmentedControlOptions{Label: "View"})
|
||||
```
|
||||
|
||||
签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
`selected`:当前分段下标;越界时 panic。`segments` 为空时 panic。
|
||||
|
||||
`Segment`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 分段文字与无障碍名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 文字前的图标。 |
|
||||
| `HideLabel` | `bool` | false | 只显示图标,`Label` 作为名称与提示文字;没有 `Icon` 时 panic。 |
|
||||
|
||||
`SegmentedControlOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Equal` | `bool` | false | 所有分段等宽(取最宽者);否则按内容自适应。 |
|
||||
| `Label` | `string` | "" | 单选组的无障碍名称。 |
|
||||
| `Disabled` | `bool` | false | 整组禁用。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 选中:Accent 填充、OnAccent 文字、600 字重。
|
||||
- 悬停 SurfaceHover,按下 Selection;焦点环 2 DIP。
|
||||
- 禁用:整组降低不透明度,点击与方向键不改变值。
|
||||
|
||||
## 事件
|
||||
|
||||
- 返回元素的 `Changed()`:本帧用户选择了新分段。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入控件,落在当前分段(一个 Tab 停靠点)。
|
||||
- 方向键:选择上一个/下一个分段并循环;Home / End 到两端。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不支持单个分段禁用:MyGo `SegmentedBase` 的焦点组在方向键移动时直接点击目标分段。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 061 Breadcrumb 面包屑
|
||||
|
||||
## 用途
|
||||
|
||||
显示当前位置的层级路径。最后一项为当前页(非链接),前面各项可点击返回;层级过多时中间项折叠到“…”菜单;过长名称以省略号截断,悬停显示完整名称。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok {
|
||||
path = path[:i+1]
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) BreadcrumbResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`items` 为空时 panic。
|
||||
|
||||
`BreadcrumbItem`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前的 14 DIP 图标。 |
|
||||
|
||||
`BreadcrumbOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MaxItems` | `int` | 0(即 4) | 最多显示的项数;超出时保留第一项和最后 `MaxItems-2` 项,其余折叠。小于 3 时 panic。 |
|
||||
| `MaxItemWidth` | `float32` | 0(即 160 DIP) | 单项文字最大宽度,超出以省略号截断。 |
|
||||
| `Label` | `string` | 内置“路径导航” | 整组的无障碍名称。 |
|
||||
|
||||
返回 `BreadcrumbResult`,内嵌 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 链接:TextMuted 文字,悬停 SurfaceHover 背景与 Text 文字,焦点环 2 DIP。
|
||||
- 当前项:Text 文字、600 字重,无障碍名称附“当前页”。
|
||||
- 分隔:旧金(Ornament)小箭头,不进入无障碍树。
|
||||
- 折叠按钮“…”:打开弹层列出被折叠的项,弹层打开时焦点在第一项。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Chosen() (int, bool)`:本帧被激活的项下标(含折叠菜单中的项)。组件不修改路径,由调用方处理。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab / Shift+Tab:在各链接与折叠按钮之间移动;Enter 或 Space 激活。
|
||||
- 折叠菜单:Tab 在项之间移动,Enter 选择,Escape 关闭并把焦点还给“…”按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 完整名称提示对每一项都提供,MyGo 不报告文字是否被截断。
|
||||
- 未复用 MyGo `ui.Breadcrumbs`:它不支持折叠与提示,本组件用 `ButtonBase`、`PopoverBase` 组合实现。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 062 Pagination 分页
|
||||
|
||||
## 用途
|
||||
|
||||
在分页数据之间移动:页码、上一页、下一页、页容量选择与跳页。总数变化后,超出范围的页码自动修正到最后一页并报告变化。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageSizes: []int{10, 20, 50}, Jump: true}).Changed() {
|
||||
load(page, size)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`page`:当前页,从 1 开始;每帧被限制在 `[1, 总页数]`。`pageSize`:每页条数,必须为正,否则 panic。
|
||||
|
||||
`PaginationOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Total` | `int` | 0 | 记录总数;为负 panic。为 0 时仍显示 1 页。 |
|
||||
| `PageSizes` | `[]int` | nil | 可选的页容量;nil 隐藏选择器。非 nil 时须包含 `*pageSize`,否则 panic。 |
|
||||
| `Jump` | `bool` | false | 显示跳页输入框。 |
|
||||
| `Siblings` | `int` | 0(即 1) | 当前页两侧显示的页码数;为负 panic。 |
|
||||
| `Disabled` | `bool` | false | 整体禁用。 |
|
||||
|
||||
返回 `PaginationResult`,内嵌 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 当前页:Accent 填充、OnAccent 文字、600 字重,名称附“当前”。
|
||||
- 页码按钮:Border 细边框,悬停 SurfaceHover 与 ControlBorder 边框;首页时“上一页”禁用,末页时“下一页”禁用。
|
||||
- 页码多时首尾保留,中间以“…”省略,按钮数固定为 `2×Siblings+5`,位置稳定。
|
||||
- 跳页错误:输入非数字或超出范围时边框变为 Danger,并在旁显示有效范围;输入保留,可直接修改。
|
||||
- 修改页容量时,保持原页第一条记录可见。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧页码或页容量改变(用户操作或总数变化导致的修正)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 页码按钮组是一个 Tab 停靠点:←/→ 移动,Home / End 到两端,Enter 或 Space 跳到该页。
|
||||
- 页容量:↑/↓ 打开并移动,Enter 选择,Escape 关闭。
|
||||
- 跳页框:输入页码后按 Enter。
|
||||
|
||||
## 限制
|
||||
|
||||
- 页容量选择基于 MyGo `SelectBase`,弹层由 MujicaUI 绘制。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 063 Steps 步骤条
|
||||
|
||||
## 用途
|
||||
|
||||
显示多步流程的进度:已完成、当前、等待和出错四种状态;横向或纵向排列;每一步是否可点击由调用方决定。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Steps(c, ¤t, []mujica.Step{
|
||||
{Title: "Account", Clickable: true},
|
||||
{Title: "Profile", Description: "Name and seal"},
|
||||
{Title: "Confirm"},
|
||||
}, mujica.StepsOptions{Label: "Sign-up"})
|
||||
```
|
||||
|
||||
签名:`func Steps(c *ui.Context, current *int, steps []Step, opts StepsOptions) StepsResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`current`:进行中的步骤下标;之前的步骤视为已完成。等于 `len(steps)` 表示全部完成;超出范围 panic。`steps` 为空时 panic。
|
||||
|
||||
`Step`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 步骤标题;为空时 panic。 |
|
||||
| `Description` | `string` | "" | 标题下的说明文字。 |
|
||||
| `Error` | `bool` | false | 标为出错,优先于位置推导的状态。 |
|
||||
| `Clickable` | `bool` | false | 允许用户点击跳到该步。 |
|
||||
|
||||
`StepsOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Vertical` | `bool` | false | 纵向排列,连接线在标记下方。 |
|
||||
| `Label` | `string` | "" | 整组的无障碍名称。 |
|
||||
|
||||
返回 `StepsResult`,内嵌 `*ui.Element`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 已完成:Accent 圆形标记内 OnAccent 勾号;之后的连接线为 AccentText。
|
||||
- 当前:AccentText 圆环与序号,标题 600 字重。
|
||||
- 等待:ControlBorder 圆环与 TextMuted 序号、标题。
|
||||
- 出错:Danger 圆环与叉号,标题 Danger 色。
|
||||
- 每步的无障碍名称为“第 n 步,共 m 步:标题,状态”,状态不只靠颜色表达。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧用户点击了可点击的步骤,`*current` 已更新。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 可点击的步骤组成一个 Tab 停靠点,方向键在其间移动,Enter 或 Space 跳到该步。不可点击的步骤不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 没有 step 或 aria-current 角色;步骤以列表项(可点击时为按钮)呈现,状态写入名称。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 064 Sidebar 侧边栏
|
||||
|
||||
## 用途
|
||||
|
||||
应用的源列表导航:分组、嵌套、图标、计数和选中项,分组与嵌套组可展开折叠;紧凑模式只显示图标。基于 MyGo `ui.Sidebar`,保留其键盘、首字母跳转与树形无障碍结构。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Sidebar(c, &selected, []mujica.SidebarSection{
|
||||
{Title: "Mail", Items: []mujica.SidebarItem{
|
||||
{ID: "inbox", Label: "Inbox", Icon: icons.Must("inbox"), Count: 3},
|
||||
{Label: "Folders", Items: []mujica.SidebarItem{{ID: "work", Label: "Work", Icon: icons.Must("folder")}}},
|
||||
}},
|
||||
}, mujica.SidebarOptions{})
|
||||
```
|
||||
|
||||
签名:`func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts SidebarOptions) SidebarResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`selected`:选中项的 ID。
|
||||
|
||||
`SidebarSection`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 分组标题,点击展开折叠;为空时 panic(紧凑模式不显示)。 |
|
||||
| `Items` | `[]SidebarItem` | nil | 分组内的项。 |
|
||||
|
||||
`SidebarItem`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `string` | 叶子项必填 | 选择时写入 `*selected`;叶子项为空时 panic。 |
|
||||
| `Label` | `string` | 必填 | 名称;为空时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前图标;紧凑模式下必填。 |
|
||||
| `Count` | `int` | 0 | 大于 0 时在右侧显示计数,超过 999 显示 “999+”。 |
|
||||
| `Items` | `[]SidebarItem` | nil | 非 nil 时该项成为可展开折叠的嵌套组,子项缩进。 |
|
||||
|
||||
`SidebarOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Compact` | `bool` | false | 图标栏:隐藏文字与组标题,组之间以旧金细线分隔,名称作为悬停提示。 |
|
||||
| `Width` | `float32` | 0(240,紧凑时 56) | 宽度,DIP。高度填满父容器。 |
|
||||
|
||||
返回 `SidebarResult`,内嵌 `*ui.Element`。展开折叠状态由组件保存。
|
||||
|
||||
## 状态
|
||||
|
||||
- 选中:Selection 背景、左侧 3 DIP AccentText 边线、600 字重,三者同时标记。
|
||||
- 悬停:SurfaceHover 背景。
|
||||
- 焦点:侧边栏获得焦点时,在选中项上画焦点环。
|
||||
- 计数:等宽数字,名称附计数(如 “Inbox (12)”)。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:本帧用户选择了新项。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入侧边栏(一个停靠点)。
|
||||
- ↑/↓:上一项/下一项;Home / End:首项/末项;输入名称开头字母跳到匹配项。
|
||||
- 分组与嵌套组标题用指针点击展开折叠。
|
||||
|
||||
## 限制
|
||||
|
||||
- 复用 MyGo `ui.SidebarItem`,它用主题 Accent 绘制图标(`ui/sidebar.go:182`);深色主题 Accent 对比度不足,本组件在构建侧边栏时临时把 MyGo 主题的 Accent 换成 AccentText。
|
||||
- MyGo `SidebarItem` 总是追加名称文字(`ui/sidebar.go:212`);紧凑模式传入空名称,名称改由 `Label` 与提示提供,因此紧凑模式下首字母跳转不可用。
|
||||
- 嵌套组用 MyGo `SidebarSection` 实现,组标题本身不能被选中,且只能用指针展开折叠(MyGo 不给标题注册按键)。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 065 NavigationMenu 导航菜单
|
||||
|
||||
## 用途
|
||||
|
||||
站点式的顶层导航:直接链接与下拉分组(分组内可再分小节),用应用已有的 `*ui.Router` 标记当前路径并跳转;可选显示 MyGo 的后退、前进按钮。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.NavigationMenu(c, router, []mujica.NavigationLink{
|
||||
{Label: "Home", Path: "/"},
|
||||
{Label: "Library", Items: []mujica.NavigationLink{
|
||||
{Label: "Poems", Path: "/library/poems", Description: "Verse of the court"},
|
||||
}},
|
||||
}, mujica.NavigationMenuOptions{History: true})
|
||||
router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) })
|
||||
```
|
||||
|
||||
签名:`func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
`router` 为 nil 或 `links` 为空时 panic。路由状态由应用持有,组件只读 `Path()` 并调用 `Push`。
|
||||
|
||||
`NavigationLink`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 必填 | 名称;为空时 panic。 |
|
||||
| `Path` | `string` | 叶子必填 | 点击后 `router.Push(Path)`;既无 `Path` 也无 `Items` 时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | nil | 名称前图标。 |
|
||||
| `Description` | `string` | "" | 下拉面板中名称下方的说明。 |
|
||||
| `Items` | `[]NavigationLink` | nil | 非 nil 时为分组:顶层显示为下拉按钮,面板内显示为小节标题。 |
|
||||
|
||||
`NavigationMenuOptions`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `History` | `bool` | false | 在前面显示后退、前进按钮(MyGo `BackButton` / `ForwardButton`)。 |
|
||||
| `Label` | `string` | 内置“导航菜单” | 链接组的无障碍名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 当前路径:路径等于链接,或位于其下(`/library` 覆盖 `/library/poems`;`/` 只匹配自身)。顶层项以 600 字重与底部 AccentText 线标记;面板中的当前项为 Selection 背景、左侧边线与 600 字重。
|
||||
- 悬停或展开:SurfaceHover 背景。焦点环 2 DIP。
|
||||
- 历史按钮在历史两端禁用;右键列出可前往的页面(MyGo 行为)。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无返回事件:跳转直接作用于 `router`,调用方读取 `router.Path()`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入顶层链接组;←/→ 在顶层项之间移动。
|
||||
- 在分组上按 ↓、Enter 或 Space 打开面板,焦点在第一个链接;↑/↓ 在面板内移动,Enter 跳转并关闭,Escape 关闭并把焦点还给分组按钮。
|
||||
- 历史:Cmd+[ / Cmd+](macOS),Alt+←/→(其他平台),以及鼠标侧键,由 MyGo 路由处理。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 历史按钮的提示文字固定为英文 “Back” / “Forward”(`ui/router.go:713`);本组件覆盖了无障碍名称,但提示仍为英文。
|
||||
- 历史按钮的外观来自 MyGo 主题按钮样式(经 `mujica.Use` 映射为 MujicaUI 颜色)。
|
||||
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user