Merge branch 'main' into worktree-agent-ad7403b5194d75449
This commit is contained in:
227 files changed
+13252
-30
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
|
||||
@@ -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
|
||||
}
|
||||
+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{})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+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")})
|
||||
})
|
||||
}
|
||||
+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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
+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,51 @@
|
||||
# 091 Alert 提示条
|
||||
|
||||
## 用途
|
||||
|
||||
在页面内显示一条信息、成功、警告或错误提示,可带标题、一个操作按钮和关闭按钮。严重程度同时由左侧色条、图标和无障碍名称(如“Error”)表达,不只靠颜色。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{
|
||||
Severity: mujica.SeverityError,
|
||||
Title: "Import failed",
|
||||
Action: "Retry",
|
||||
})
|
||||
if r.Action {
|
||||
// retry the import
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `open` | `*bool` | `nil` | 非 nil 时显示关闭按钮,关闭后置为 `false`;为 `false` 时不构建任何元素。`nil` 表示不可关闭。 |
|
||||
| `message` | `string` | 必填 | 正文;为空时 panic。 |
|
||||
| `Severity` | `Severity` | `SeverityInfo` | `SeverityInfo`、`SeveritySuccess`、`SeverityWarning`、`SeverityError`。 |
|
||||
| `Title` | `string` | `""` | 正文上方的粗体标题。 |
|
||||
| `Action` | `string` | `""` | 操作按钮文字;为空不显示。 |
|
||||
|
||||
返回 `AlertResult{Element, Action, Closed}`;关闭状态下 `Element` 为 nil。
|
||||
|
||||
## 状态
|
||||
|
||||
四种严重程度;操作与关闭按钮有悬停、按下和 2 DIP 焦点环。警告与错误在出现或文字变化时通过 `Context.Announce` 播报一次。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Action`:操作按钮被点击或经键盘触发。
|
||||
- `Closed`:用户关闭;同时 `*open` 变为 `false`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 依次聚焦操作按钮与关闭按钮;Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 信息与成功提示不主动播报,避免页面加载时打扰读屏用户。
|
||||
- 关闭不带退出动画;需要动画时由调用方在外层加 `Transition`。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 092 Toast 轻提示
|
||||
|
||||
## 用途
|
||||
|
||||
在窗口右下角短暂显示操作结果,可带一个操作(如撤销)与关闭按钮。每个窗口一条队列:按 ID 去重,同时最多显示 3 条,其余排队。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
|
||||
}
|
||||
// Last in the view:
|
||||
if r := mujica.Toaster(c); r.Action == "note-1" {
|
||||
// restore the note
|
||||
}
|
||||
```
|
||||
|
||||
接口:
|
||||
|
||||
- `func ShowToast(c *ui.Context, m ToastMessage)`:加入队列,须在界面线程调用(后台任务经 `Window.Update` 回到界面线程)。
|
||||
- `func DismissToast(c *ui.Context, id string)`:关闭显示中或排队中的消息。
|
||||
- `func Toaster(c *ui.Context) ToasterResult`:每帧在视图末尾调用一次,绘制消息并报告事件。
|
||||
|
||||
## 参数
|
||||
|
||||
`ToastMessage`:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `string` | `Message` | 去重键。相同 ID 再次显示时替换内容并重新计时,不新增一条。 |
|
||||
| `Severity` | `Severity` | `SeverityInfo` | 图标、色条与颜色。 |
|
||||
| `Message` | `string` | 必填 | 文字;为空时 panic。 |
|
||||
| `Action` | `string` | `""` | 操作按钮文字。 |
|
||||
| `Duration` | `time.Duration` | `ToastDuration`(5 秒) | 显示时长;负数 panic。错误消息在为 0 时一直保留到用户关闭。 |
|
||||
|
||||
常量:`ToastDuration = 5 * time.Second`,`ToastVisible = 3`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 排队消息在显示时才开始计时。
|
||||
- 指针悬停或焦点位于消息内时暂停计时,离开后从剩余时间继续。
|
||||
- 出现时向读屏播报文字;进出场 160 ms 位移与淡入,减少动画时直接切换。
|
||||
|
||||
## 事件
|
||||
|
||||
`ToasterResult{Action, Closed}`:本帧被点击操作或被关闭的消息 ID;两者都会移除该消息。到时自动消失不报告。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
消息从不抢夺焦点,正在输入的文本框保持焦点。Tab 在页面之后到达消息中的按钮;焦点在消息内时 Escape 关闭该消息。
|
||||
|
||||
## 复用与限制
|
||||
|
||||
MyGo 自带 `c.Toast` / `c.ToastAction`(docs/ui/toast.md),但它按文字去重、没有 ID、不限制条数、无关闭按钮、错误不能常驻、外观不可替换,计时只在悬停时暂停而不在聚焦时暂停。这些都是 PRD §5.5 的要求,因此 MujicaUI 自建队列,只复用 MyGo 的 `Overlay`、`Transition`、`Announce` 与 `After` 定时唤醒。
|
||||
|
||||
- 所有消息(不只是含操作的消息)都在悬停或聚焦时暂停,这是 PRD 要求的超集,便于阅读。
|
||||
- 位置固定在右下角;切换到未调用 `Toaster` 的视图时消息仍在队列中,回来后继续显示。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 093 Progress 进度
|
||||
|
||||
## 用途
|
||||
|
||||
显示任务进度(确定或不确定,线形或环形),以及计量值(如磁盘占用)及其阈值。归并 MyGo 的 Progress bar 与 Meter:`Progress` 为进度模式,`Meter` 为计量模式。两者分别基于 `ui.Progress` 与 `ui.Meter` 元素重绘,保留其无障碍角色与数值。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true})
|
||||
mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"})
|
||||
mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
Max: 100, Label: "Disk", ShowValue: true,
|
||||
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
|
||||
})
|
||||
```
|
||||
|
||||
签名:
|
||||
|
||||
- `func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element`
|
||||
- `func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
`ProgressOptions`:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `float64` | 必填 | 0 到 1;超出或 NaN 时 panic(不确定模式忽略)。 |
|
||||
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`。 |
|
||||
| `Indeterminate` | `bool` | `false` | 未知长度的进度。 |
|
||||
| `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 |
|
||||
| `ShowValue` | `bool` | `false` | 显示百分比。 |
|
||||
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数 panic。 |
|
||||
|
||||
`MeterOptions`:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `float64` | 必填 | 须在范围内,否则 panic。 |
|
||||
| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;`Max <= Min` panic。 |
|
||||
| `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 |
|
||||
| `Label` | `string` | `""` | 名称。 |
|
||||
| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 |
|
||||
| `Width` | `float32` | 填满 | 宽度。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。
|
||||
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示静态填充和“In progress”文字。
|
||||
- 计量条按级别使用 Success / Warning / Danger 颜色,同时显示“Normal / High / Critical / Low”文字与阈值刻度,并把级别写入无障碍名称。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不可聚焦;读屏读取名称与数值。
|
||||
|
||||
## 限制
|
||||
|
||||
- 环形直径小于 44 DIP 时百分比显示在旁边,而不是环内。
|
||||
- MyGo 不提供自定义无障碍数值的公开接口,因此必须基于 `ui.Progress` / `ui.Meter` 元素绘制。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 094 Spinner 加载标记
|
||||
|
||||
## 用途
|
||||
|
||||
表示未知长度的加载:四个菱形绕中心旋转。可调尺寸,可显示说明文字。Button 的加载状态也使用它。标记基于 MyGo `ui.Spinner` 元素重绘,读屏视为不确定进度。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})
|
||||
```
|
||||
|
||||
签名:`func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Size` | `float32` | 16 | 标记边长(DIP)。 |
|
||||
| `Label` | `string` | 内置“Loading / 加载中” | 无障碍名称;`ShowLabel` 时也显示在旁边。 |
|
||||
| `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 |
|
||||
| `Color` | `ui.Color` | `AccentText` | 标记颜色。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 只在被绘制时请求重绘:不再构建(隐藏或停止加载)或滚出视口后动画立即停止,不留定时器。
|
||||
- 减少动画模式下静止显示,保留名称与说明。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不可聚焦。
|
||||
|
||||
## 限制
|
||||
|
||||
- 每帧重绘,而非 MyGo 自带 spinner 的分步重绘;只在显示时产生开销。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 095 Skeleton 骨架屏
|
||||
|
||||
## 用途
|
||||
|
||||
内容加载前占位,形状为文字行、圆形或矩形。尺寸固定,加载完成后替换为真实内容时不跳动。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
ui.Row(c).Gap(12).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
签名:`func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Shape` | `SkeletonShape` | `SkeletonText` | `SkeletonText`、`SkeletonCircle`、`SkeletonRect`。 |
|
||||
| `Width` | `float32` | 填满父级;圆形 40 | 宽度;圆形为直径。 |
|
||||
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
|
||||
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
|
||||
|
||||
负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
明暗缓慢脉动(1.6 秒周期),只重绘不重建视图;减少动画模式使用静态填充。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不可聚焦,不进入无障碍树。
|
||||
|
||||
## 限制
|
||||
|
||||
- 骨架本身不向读屏报告加载;请在容器上配合 Spinner 或 LoadingOverlay 的说明文字。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 096 EmptyState 空状态
|
||||
|
||||
## 用途
|
||||
|
||||
说明视图为何为空并给出下一步。区分“无数据”(集合本身为空)与“无搜索结果”(筛选没有匹配),两者使用不同的默认图标与文案。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.EmptyState(c, mujica.EmptyStateOptions{
|
||||
Kind: mujica.EmptyNoResults,
|
||||
Query: query,
|
||||
Action: "Clear search",
|
||||
})
|
||||
if r.Action {
|
||||
query = ""
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Kind` | `EmptyKind` | `EmptyNoData` | `EmptyNoData` 或 `EmptyNoResults`。 |
|
||||
| `Title` | `string` | 按类型的内置文案 | 衬线标题。 |
|
||||
| `Description` | `string` | 按类型的内置文案 | 说明。 |
|
||||
| `Query` | `string` | `""` | 仅 `EmptyNoResults`:默认说明写出该关键词。用于 `EmptyNoData` 时 panic。 |
|
||||
| `Icon` | `*ui.SVG` | inbox / search | 替换图标。 |
|
||||
| `NoIcon` | `bool` | `false` | 不显示图标;与 `Icon` 同时设置时 panic。 |
|
||||
| `Action` | `string` | `""` | 次按钮文字。 |
|
||||
|
||||
## 状态
|
||||
|
||||
静态展示;操作按钮有悬停、按下与焦点状态。
|
||||
|
||||
## 事件
|
||||
|
||||
`EmptyStateResult.Action`:操作按钮被触发。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦操作按钮;Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只有一个操作;需要多个下一步时使用 Result。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 097 Result 结果页
|
||||
|
||||
## 用途
|
||||
|
||||
任务完成后的整页结果:成功、失败或无权限,附说明与下一步操作。组件只报告选择了哪个操作,不执行跳转。标题与操作之间用旧金色细线与菱形分隔。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.Result(c, mujica.ResultOptions{
|
||||
Kind: mujica.ResultForbidden,
|
||||
Description: "You need the Editor role to change billing.",
|
||||
Actions: []string{"Request access", "Go back"},
|
||||
})
|
||||
switch r.Action {
|
||||
case 0: // send the request
|
||||
case 1: // navigate back
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Result(c *ui.Context, opts ResultOptions) ResultResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Kind` | `ResultKind` | `ResultSuccess` | `ResultSuccess`、`ResultFailure`、`ResultForbidden`;未知值 panic。 |
|
||||
| `Title` | `string` | 按类型的内置文案 | 衬线标题。 |
|
||||
| `Description` | `string` | `""` | 说明。 |
|
||||
| `Actions` | `[]string` | 无 | 下一步操作;第一个为主按钮,其余为次按钮。 |
|
||||
|
||||
## 状态
|
||||
|
||||
图标(对勾、叉、盾牌)与标题文字共同表达结果,不只靠颜色。按钮有悬停、按下与焦点状态。
|
||||
|
||||
## 事件
|
||||
|
||||
`ResultResult.Action`:被触发的操作序号,未触发为 -1。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 按顺序聚焦操作按钮;Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不含路由;导航由调用方完成。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 098 StatusIndicator 状态标记
|
||||
|
||||
## 用途
|
||||
|
||||
以图形加文字标出状态:成功、警告、错误、离线、处理中。每种状态有独立形状(对勾圆、三角、叉圆、断开的 Wi-Fi、旋转菱形),并总是显示文字,不只靠颜色。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"})
|
||||
mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})
|
||||
```
|
||||
|
||||
签名:`func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `status` | `Status` | `StatusSuccess` | `StatusSuccess`、`StatusWarning`、`StatusError`、`StatusOffline`、`StatusBusy`;未知值 panic。 |
|
||||
| `Label` | `string` | 内置词(Healthy、Degraded、Failing、Offline、Working) | 替换显示的文字。 |
|
||||
|
||||
## 状态
|
||||
|
||||
处理中使用 Spinner,减少动画时静止。整体为 `RoleStatus`,名称为文字。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不可聚焦。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无 Badge 式的纯圆点模式;PRD 要求不能只靠颜色。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 099 NotificationCenter 通知中心
|
||||
|
||||
## 用途
|
||||
|
||||
应用内消息列表:未读标记、已读/未读切换、单条清除、全部已读、全部清除和每条消息的操作。消息列表与持久化由调用方持有;组件就地修改调用方的切片,并报告发生了什么,供调用方保存。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{})
|
||||
if r.Action == "a" {
|
||||
// open the build log
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult`
|
||||
|
||||
## 参数
|
||||
|
||||
`Notification`:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `string` | 必填 | 列表内唯一;为空或重复时 panic。 |
|
||||
| `Title` | `string` | `""` | 标题,未读时加粗。 |
|
||||
| `Body` | `string` | `""` | 正文。 |
|
||||
| `Time` | `string` | `""` | 按原样显示的时间,如“5 min ago”。 |
|
||||
| `Read` | `bool` | `false` | 是否已读。 |
|
||||
| `Action` | `string` | `""` | 操作按钮文字。 |
|
||||
|
||||
`NotificationCenterOptions.Title`:面板标题,默认“Notifications / 通知”。`items` 为 nil 时 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
- 未读:左侧酒红菱形、粗体标题、无障碍名称前缀“Unread”。
|
||||
- 行悬停加浅底;按钮有悬停、按下与焦点状态;无未读或无消息时头部按钮禁用。
|
||||
- 空列表显示 EmptyState。
|
||||
- 面板标题下为旧金细线。列表超出容器高度时滚动。
|
||||
|
||||
## 事件
|
||||
|
||||
`NotificationCenterResult`:`Action`、`Toggled`(已读状态切换)、`Opened`(点击正文后标为已读)、`Removed` 为对应 ID;`MarkedAllRead`、`ClearedAll` 为布尔值。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
列表是一个 Tab 停靠点;方向键在各条消息及其按钮之间移动,Home/End 到首尾。焦点在消息正文上时 Enter 或 Space 标为已读;Delete 或 Backspace 清除当前消息。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不分页,也不虚拟化;面向几十到几百条的应用内消息。
|
||||
- 时间文字由调用方格式化。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 100 LoadingOverlay 加载遮罩
|
||||
|
||||
## 用途
|
||||
|
||||
在一个容器范围内显示加载遮罩:被覆盖的内容不接收指针、按键和焦点,防止重复操作;窗口其余部分照常可用。显示说明文字与可选取消操作。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
|
||||
ui.Text(c, "Quarterly report")
|
||||
mujica.Button(c, "Export", mujica.ButtonOptions{})
|
||||
})
|
||||
if r.Canceled {
|
||||
loading = false
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `loading` | `bool` | 必填 | 是否覆盖。 |
|
||||
| `content` | `func()` | 必填 | 构建被覆盖的内容;nil 时 panic。 |
|
||||
| `Label` | `string` | 内置“Loading… / 正在加载…” | 说明文字。 |
|
||||
| `Cancel` | `string` | `""` | 取消按钮文字;为空时不显示。 |
|
||||
|
||||
返回 `LoadingOverlayResult{Element, Canceled}`。`Element` 即遮罩所覆盖的范围,可在其上设置宽高。
|
||||
|
||||
## 状态
|
||||
|
||||
- 加载时内容经 MyGo `Disabled` 阻止输入(MyGo 统一减淡),上方为半透明背景与居中面板。
|
||||
- 开始加载时播报说明;焦点原在内容内时移到取消按钮(无取消时移到可聚焦的说明面板),焦点不会离开该区域。
|
||||
- 遮罩淡入淡出,减少动画时直接切换。
|
||||
|
||||
## 事件
|
||||
|
||||
`Canceled`:点击取消,或焦点在面板内时按 Escape。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 到达取消按钮;Enter、Space 或 Escape 取消。被覆盖的控件不进入 Tab 序列。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只遮盖容器自身范围;整窗阻塞请使用模态对话框。
|
||||
@@ -0,0 +1,88 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// EmptyKind tells why there is nothing to show. The zero value is EmptyNoData.
|
||||
type EmptyKind int
|
||||
|
||||
const (
|
||||
// EmptyNoData: the collection itself is empty.
|
||||
EmptyNoData EmptyKind = iota
|
||||
// EmptyNoResults: a search or filter matched nothing.
|
||||
EmptyNoResults
|
||||
)
|
||||
|
||||
// EmptyStateOptions configure an EmptyState. Empty texts use the kind's built-in copy.
|
||||
type EmptyStateOptions struct {
|
||||
Kind EmptyKind
|
||||
Title string
|
||||
Description string
|
||||
// Query, for EmptyNoResults, is named in the default description.
|
||||
Query string
|
||||
// Icon replaces the kind's icon (inbox or search); NoIcon shows none.
|
||||
Icon *ui.SVG
|
||||
NoIcon bool
|
||||
// Action labels a button, such as "Clear search"; empty shows none.
|
||||
Action string
|
||||
}
|
||||
|
||||
// EmptyStateResult is what an EmptyState built and reported this frame.
|
||||
type EmptyStateResult struct {
|
||||
Element *ui.Element
|
||||
Action bool
|
||||
}
|
||||
|
||||
// EmptyState explains an empty view and offers a next step.
|
||||
func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult {
|
||||
if opts.NoIcon && opts.Icon != nil {
|
||||
panic("mujica: EmptyState with both Icon and NoIcon")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
title, desc, icon := opts.Title, opts.Description, opts.Icon
|
||||
switch opts.Kind {
|
||||
case EmptyNoData:
|
||||
if opts.Query != "" {
|
||||
panic("mujica: EmptyState Query needs EmptyNoResults")
|
||||
}
|
||||
title = cmp.Or(title, msg(c, "empty_state.no_data.title"))
|
||||
desc = cmp.Or(desc, msg(c, "empty_state.no_data.description"))
|
||||
if icon == nil {
|
||||
icon = icons.Must("inbox")
|
||||
}
|
||||
case EmptyNoResults:
|
||||
title = cmp.Or(title, msg(c, "empty_state.no_results.title"))
|
||||
if desc == "" && opts.Query != "" {
|
||||
desc = fmt.Sprintf(msg(c, "empty_state.no_results.query"), opts.Query)
|
||||
}
|
||||
desc = cmp.Or(desc, msg(c, "empty_state.no_results.description"))
|
||||
if icon == nil {
|
||||
icon = icons.Must("search")
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("mujica: unknown EmptyKind %d", int(opts.Kind)))
|
||||
}
|
||||
var res EmptyStateResult
|
||||
res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*2).Padding(u*8, u*4).Role(ui.RoleGroup).Label(title)
|
||||
res.Element.Children(func() {
|
||||
if !opts.NoIcon {
|
||||
ui.Icon(c, icon).Size(32, 32).TextColor(k.TextMuted).Margin(0, 0, u*2, 0)
|
||||
}
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text).TextAlign(ui.Center)
|
||||
ui.Text(c, desc).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(360).LineHeight(theme.LineHeight)
|
||||
if opts.Action != "" {
|
||||
ui.Box(c).Margin(u*2, 0, 0, 0).Children(func() {
|
||||
res.Action = Button(c, opts.Action, ButtonOptions{Variant: Secondary}).Clicked()
|
||||
})
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func TestEmptyStateKinds(t *testing.T) {
|
||||
opts := EmptyStateOptions{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
EmptyState(c, opts)
|
||||
}, 500, 400)
|
||||
if !tt.HasText("Nothing here yet") || !tt.HasText("Items you add will appear here.") {
|
||||
t.Fatalf("no data: %q", tt.Texts())
|
||||
}
|
||||
opts = EmptyStateOptions{Kind: EmptyNoResults, Query: "invoice"}
|
||||
tt.Frame()
|
||||
if !tt.HasText("No results") || !tt.HasText("Nothing matches “invoice”.") {
|
||||
t.Fatalf("no results: %q", tt.Texts())
|
||||
}
|
||||
opts = EmptyStateOptions{Kind: EmptyNoResults}
|
||||
tt.Frame()
|
||||
if !tt.HasText("Try other words or clear the filters.") {
|
||||
t.Fatalf("no results without query: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateActionKeyboard(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
if EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: "发票", Action: "清除搜索"}).Action {
|
||||
n++
|
||||
}
|
||||
}, 500, 400)
|
||||
if !tt.HasText("没有与“发票”匹配的内容。") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if err := tt.Click("清除搜索"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("actions %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStatePanics(t *testing.T) {
|
||||
for name, o := range map[string]EmptyStateOptions{
|
||||
"icon and no icon": {Icon: icons.Must("inbox"), NoIcon: true},
|
||||
"query without kind": {Query: "x"},
|
||||
"unknown kind": {Kind: 9},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); EmptyState(c, o) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateSnapshot(t *testing.T) {
|
||||
snapshot(t, "empty_state", 640, 280, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Gap(12).Children(func() {
|
||||
ui.Column(c).Grow(1).Basis(0).Children(func() {
|
||||
EmptyState(c, EmptyStateOptions{Action: "New project"})
|
||||
})
|
||||
ui.Column(c).Grow(1).Basis(0).Children(func() {
|
||||
EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: "quarterly", Action: "Clear search"})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleText() {
|
||||
name := "Coronation.md"
|
||||
saveErr := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
|
||||
mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2})
|
||||
if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() {
|
||||
saveErr = "" // save name here; set saveErr on failure
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHeading() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleIcon() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Icon(c, "star", mujica.IconOptions{})
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleImage() {
|
||||
var photo *ui.Bitmap // decode once with ui.DecodeBitmap, outside the view
|
||||
loading, loadErr := false, errors.New("timeout")
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
img := mujica.Image(c, photo, mujica.ImageOptions{
|
||||
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
|
||||
Loading: loading, Err: loadErr,
|
||||
})
|
||||
if img.Retry {
|
||||
loading, loadErr = true, nil // start loading again here
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAvatar() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAvatarGroup() {
|
||||
people := []mujica.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
|
||||
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBadge() {
|
||||
unread := 120
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
|
||||
mujica.Badge(c, mujica.BadgeOptions{Count: unread, Label: "Unread", Position: mujica.BadgeTopRight}, func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTag() {
|
||||
tags := []string{"Baroque", "Gothic"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
for i, t := range tags {
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true})
|
||||
v.Element.Key(t)
|
||||
if v.Closed {
|
||||
tags = append(tags[:i:i], tags[i+1:]...)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLink() {
|
||||
details := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
|
||||
if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() {
|
||||
details = !details
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleKbd() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(6).Children(func() {
|
||||
mujica.Text(c, "Command palette", mujica.TextOptions{})
|
||||
mujica.Kbd(c, "Mod", "Shift", "P")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleAlert() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{
|
||||
Severity: mujica.SeverityError,
|
||||
Title: "Import failed",
|
||||
Action: "Retry",
|
||||
})
|
||||
if r.Action {
|
||||
// retry the import
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToaster() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
|
||||
}
|
||||
if r := mujica.Toaster(c); r.Action == "note-1" {
|
||||
// restore the note
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleProgress() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Column(c).Padding(16).Gap(16).Children(func() {
|
||||
mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true})
|
||||
mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"})
|
||||
mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
Max: 100, Label: "Disk", ShowValue: true,
|
||||
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSpinner() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSkeleton() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Padding(16).Gap(12).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2})
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleEmptyState() {
|
||||
query := "quarterly"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.EmptyState(c, mujica.EmptyStateOptions{
|
||||
Kind: mujica.EmptyNoResults,
|
||||
Query: query,
|
||||
Action: "Clear search",
|
||||
})
|
||||
if r.Action {
|
||||
query = ""
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleResult() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.Result(c, mujica.ResultOptions{
|
||||
Kind: mujica.ResultForbidden,
|
||||
Description: "You need the Editor role to change billing.",
|
||||
Actions: []string{"Request access", "Go back"},
|
||||
})
|
||||
switch r.Action {
|
||||
case 0: // send the request
|
||||
case 1: // navigate back
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStatusIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(16).Children(func() {
|
||||
mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"})
|
||||
mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleNotificationCenter() {
|
||||
items := []mujica.Notification{
|
||||
{ID: "a", Title: "Build failed", Body: "3 tests failing", Time: "2 min ago", Action: "View log"},
|
||||
{ID: "b", Title: "Backup complete", Time: "Yesterday", Read: true},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{})
|
||||
if r.Action == "a" {
|
||||
// open the build log
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLoadingOverlay() {
|
||||
loading := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
|
||||
ui.Text(c, "Quarterly report")
|
||||
mujica.Button(c, "Export", mujica.ButtonOptions{})
|
||||
})
|
||||
if r.Canceled {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func ExampleButton() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() {
|
||||
fmt.Println("saved")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleIconButton() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
fmt.Println("open settings")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleButtonGroup() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}
|
||||
if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}).ClickedItem(); i >= 0 {
|
||||
fmt.Println("chose", items[i].Label)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTextInput() {
|
||||
var name string
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true})
|
||||
if r.Submitted() {
|
||||
fmt.Println("hello", name)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTextArea() {
|
||||
var notes string
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200})
|
||||
if r.Submitted() {
|
||||
fmt.Println("send", notes)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleNumberInput() {
|
||||
copies := 1.0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() {
|
||||
fmt.Println("copies", copies)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePasswordInput() {
|
||||
var password string
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() {
|
||||
fmt.Println("signing in")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSearchInput() {
|
||||
var query string
|
||||
current := 0
|
||||
text := "rose lys rose crown rose"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
matches := 0
|
||||
if query != "" {
|
||||
matches = strings.Count(text, query)
|
||||
}
|
||||
r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t})
|
||||
if r.Moved() {
|
||||
fmt.Println("show match", current)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCheckbox() {
|
||||
var mail, calendar bool
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{
|
||||
{Label: "Mail", Value: &mail},
|
||||
{Label: "Calendar", Value: &calendar},
|
||||
}, mujica.CheckboxGroupOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleRadioGroup() {
|
||||
size := "m"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}}
|
||||
if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).Changed() {
|
||||
fmt.Println("size", size)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func ExampleAppShell() {
|
||||
var collapsed bool
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AppShell(c, &collapsed, mujica.AppShellOptions{
|
||||
TitleBar: func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) },
|
||||
Sidebar: func() { ui.Text(c, "Chronicles") },
|
||||
Content: func() { ui.Text(c, "The Grand Hall") },
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTitleBar() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{
|
||||
Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) },
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStatusBar() {
|
||||
opened := 0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
bar := mujica.StatusBar(c, mujica.StatusBarOptions{
|
||||
Left: []mujica.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
|
||||
Right: []mujica.StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
|
||||
})
|
||||
if bar.Clicked == "bell" {
|
||||
opened++
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleContainer() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() {
|
||||
ui.Text(c, "A centered column of prose.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStack() {
|
||||
showDraft := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Stack(c, mujica.StackOptions{Horizontal: true, Gap: mujica.SpaceM}, func() {
|
||||
mujica.Button(c, "Save", mujica.ButtonOptions{})
|
||||
mujica.StackItem(!showDraft, func() { mujica.Button(c, "Draft", mujica.ButtonOptions{Variant: mujica.Secondary}) })
|
||||
mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleGrid() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
|
||||
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { ui.Text(c, "Header") })
|
||||
ui.Text(c, "Name")
|
||||
ui.Text(c, "Ada")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScrollArea() {
|
||||
var offset ui.ScrollState
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() {
|
||||
for range 50 {
|
||||
ui.Text(c, "A line of the log")
|
||||
}
|
||||
}).Height(200)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSplitPane() {
|
||||
split := mujica.SplitPaneState{Size: 160}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst},
|
||||
func() { ui.Text(c, "Files") },
|
||||
func() { ui.Text(c, "Editor") },
|
||||
).Fill()
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAspectRatio() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Box(c).Size(320, 240).Children(func() {
|
||||
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
|
||||
ui.Text(c, "16 : 9")
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDivider() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
mujica.Divider(c, mujica.DividerOptions{Ornament: true})
|
||||
mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,495 @@
|
||||
package main
|
||||
|
||||
var displayPages = []page{}
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
var displayPages = []page{
|
||||
{ID: "011", Name: "Text", ZhName: "文本", Category: "Display", Demo: textDemo, Code: textCode,
|
||||
Keys: "Selectable 文本:点击后 Cmd/Ctrl+A 全选、Cmd/Ctrl+C 复制。可编辑:Tab 聚焦,Enter 或双击开始编辑,Enter 确认,Escape 取消,移走焦点也确认。"},
|
||||
{ID: "012", Name: "Heading", ZhName: "标题", Category: "Display", Demo: headingDemo, Code: headingCode,
|
||||
Keys: "纯展示,不获取焦点。"},
|
||||
{ID: "013", Name: "Icon", ZhName: "图标", Category: "Display", Demo: iconDemo, Code: iconCode,
|
||||
Keys: "纯展示,不获取焦点。"},
|
||||
{ID: "014", Name: "Image", ZhName: "图片", Category: "Display", Demo: imageDemo, Code: imageCode,
|
||||
Keys: "错误状态的「重试」按钮可 Tab 聚焦,Enter 或 Space 触发。"},
|
||||
{ID: "015", Name: "Avatar", ZhName: "头像", Category: "Display", Demo: avatarDemo, Code: avatarCode,
|
||||
Keys: "纯展示,不获取焦点。"},
|
||||
{ID: "016", Name: "AvatarGroup", ZhName: "头像组", Category: "Display", Demo: avatarGroupDemo, Code: avatarGroupCode,
|
||||
Keys: "Tab 聚焦「+N」按钮,Enter 或 Space 展开完整名单,Escape 或点击外部关闭。"},
|
||||
{ID: "017", Name: "Badge", ZhName: "徽标", Category: "Display", Demo: badgeDemo, Code: badgeCode,
|
||||
Keys: "纯展示,不获取焦点;辅助技术朗读完整数值。"},
|
||||
{ID: "018", Name: "Tag", ZhName: "标签", Category: "Display", Demo: tagDemo, Code: tagCode,
|
||||
Keys: "可点击标签:Tab 聚焦,Enter 或 Space 点击,Delete 或 Backspace 关闭。关闭按钮是独立的 Tab 停靠点。"},
|
||||
{ID: "019", Name: "Link", ZhName: "链接", Category: "Display", Demo: linkDemo, Code: linkCode,
|
||||
Keys: "Tab 聚焦链接,Enter 激活。"},
|
||||
{ID: "020", Name: "Kbd", ZhName: "快捷键", Category: "Display", Demo: kbdDemo, Code: kbdCode,
|
||||
Keys: "纯展示,不注册快捷键,不获取焦点。"},
|
||||
}
|
||||
|
||||
type textState struct {
|
||||
maxLines float64
|
||||
selectable bool
|
||||
name string
|
||||
saving bool
|
||||
failNext bool
|
||||
saveErr string
|
||||
}
|
||||
|
||||
func textDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "011", func() textState { return textState{maxLines: 0, selectable: true, name: "Coronation.md"} })
|
||||
mujica.Heading(c, "Text", mujica.HeadingOptions{Subtitle: "换行、省略、选择复制与可编辑模式", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("MaxLines %d", int(s.maxLines)))
|
||||
ui.StepSlider(c, &s.maxLines, 0, 4, 1).Width(160)
|
||||
ui.Checkbox(c, &s.selectable, "Selectable")
|
||||
ui.Checkbox(c, &s.saving, "Saving")
|
||||
ui.Checkbox(c, &s.failNext, "Next save fails")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Display", func() {
|
||||
ui.Box(c).Width(360).Children(func() {
|
||||
mujica.Text(c, "Velvet curtains fall over the long gallery. Every candle leans toward the open door, and the quartet plays on while the guests take their places beneath the chandeliers.",
|
||||
mujica.TextOptions{MaxLines: int(s.maxLines), Selectable: s.selectable})
|
||||
})
|
||||
})
|
||||
displaySection(c, "Editable", func() {
|
||||
e := mujica.EditableText(c, &s.name, mujica.EditableTextOptions{Saving: s.saving, Error: s.saveErr, Label: "File name"})
|
||||
if e.Changed() {
|
||||
if s.failNext {
|
||||
s.saveErr = "Save failed: the name is already taken."
|
||||
log("save failed: " + s.name)
|
||||
} else {
|
||||
s.saveErr = ""
|
||||
log("saved: " + s.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
displayCode(c, textCode, log)
|
||||
}
|
||||
|
||||
const textCode = `mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
|
||||
mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2})
|
||||
if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() {
|
||||
saveErr = "" // save name here; set saveErr on failure
|
||||
}`
|
||||
|
||||
type headingState struct {
|
||||
level float64
|
||||
subtitle, divide bool
|
||||
}
|
||||
|
||||
func headingDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "012", func() headingState { return headingState{level: 1, subtitle: true, divide: true} })
|
||||
mujica.Heading(c, "Heading", mujica.HeadingOptions{Subtitle: "三级衬线标题、副标题与装饰分隔线", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Level %d", int(s.level)))
|
||||
if ui.StepSlider(c, &s.level, 1, 3, 1).Width(120).Changed() {
|
||||
log(fmt.Sprintf("level %d", int(s.level)))
|
||||
}
|
||||
ui.Checkbox(c, &s.subtitle, "Subtitle")
|
||||
ui.Checkbox(c, &s.divide, "Divider")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
sub := ""
|
||||
if s.subtitle {
|
||||
sub = "Ceremonies of the winter season"
|
||||
}
|
||||
mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Level: int(s.level), Subtitle: sub, Divider: s.divide})
|
||||
})
|
||||
displaySection(c, "Levels", func() {
|
||||
for l := 1; l <= 3; l++ {
|
||||
mujica.Heading(c, fmt.Sprintf("Level %d · 宫廷", l), mujica.HeadingOptions{Level: l})
|
||||
}
|
||||
})
|
||||
displayCode(c, headingCode, log)
|
||||
}
|
||||
|
||||
const headingCode = `mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})`
|
||||
|
||||
type iconState struct {
|
||||
name string
|
||||
size string
|
||||
tone string
|
||||
label bool
|
||||
}
|
||||
|
||||
var iconTones = map[string]mujica.IconTone{"Inherit": mujica.IconInherit, "Muted": mujica.IconMuted, "Accent": mujica.IconAccent,
|
||||
"Success": mujica.IconSuccess, "Warning": mujica.IconWarning, "Danger": mujica.IconDanger, "Info": mujica.IconInfo}
|
||||
|
||||
func iconDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "013", func() iconState { return iconState{name: "star", size: "24", tone: "Accent"} })
|
||||
mujica.Heading(c, "Icon", mujica.HeadingOptions{Subtitle: "按名称取图标;尺寸与语义颜色;缺失时可见回退", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.Select(c, &s.name, []string{"star", "bell", "settings", "moon", "sun", "no-such-icon"}).Changed() {
|
||||
log("name " + s.name)
|
||||
}
|
||||
if ui.Select(c, &s.size, []string{"16", "20", "24"}).Changed() {
|
||||
log("size " + s.size)
|
||||
}
|
||||
if ui.Select(c, &s.tone, []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"}).Changed() {
|
||||
log("tone " + s.tone)
|
||||
}
|
||||
ui.Checkbox(c, &s.label, "Label")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var size float32
|
||||
fmt.Sscan(s.size, &size)
|
||||
label := ""
|
||||
if s.label {
|
||||
label = "Preview icon"
|
||||
}
|
||||
mujica.Icon(c, s.name, mujica.IconOptions{Size: size, Tone: iconTones[s.tone], Label: label})
|
||||
})
|
||||
displaySection(c, "Tones and fallback", func() {
|
||||
ui.Row(c).Gap(14).Children(func() {
|
||||
for _, t := range []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"} {
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: iconTones[t]}).Key(t)
|
||||
}
|
||||
mujica.Icon(c, "missing-name", mujica.IconOptions{Size: 20})
|
||||
})
|
||||
})
|
||||
displayCode(c, iconCode, log)
|
||||
}
|
||||
|
||||
const iconCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Icon(c, "star", mujica.IconOptions{})
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
|
||||
})`
|
||||
|
||||
type imageState struct {
|
||||
crop, loading, fail, empty bool
|
||||
err error
|
||||
}
|
||||
|
||||
var displayPicture = func() *ui.SVG {
|
||||
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
|
||||
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
|
||||
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
|
||||
}()
|
||||
|
||||
func imageDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
|
||||
mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Checkbox(c, &s.crop, "Crop")
|
||||
ui.Checkbox(c, &s.empty, "No source")
|
||||
ui.Checkbox(c, &s.loading, "Loading")
|
||||
if ui.Checkbox(c, &s.fail, "Error").Changed() {
|
||||
s.err = nil
|
||||
if s.fail {
|
||||
s.err = errors.New("connection reset")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var src ui.ImageSource = displayPicture
|
||||
if s.empty {
|
||||
src = nil
|
||||
}
|
||||
v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
|
||||
if v.Retry {
|
||||
s.fail, s.err = false, nil
|
||||
log("retry")
|
||||
}
|
||||
})
|
||||
displaySection(c, "States", func() {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
|
||||
log("retry (static example)")
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, imageCode, log)
|
||||
}
|
||||
|
||||
const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
|
||||
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
|
||||
Loading: loading, Err: loadErr,
|
||||
})
|
||||
if img.Retry {
|
||||
loading, loadErr = true, nil // start loading again here
|
||||
}`
|
||||
|
||||
type avatarState struct {
|
||||
name string
|
||||
size float64
|
||||
img bool
|
||||
}
|
||||
|
||||
var displayBitmap = func() *ui.Bitmap {
|
||||
return ui.NewBitmap(ui.Render(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
|
||||
ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
|
||||
}, 64, 64, 1))
|
||||
}()
|
||||
|
||||
func avatarDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
|
||||
mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.TextInput(c, &s.name).Width(200).Changed() {
|
||||
log("name " + s.name)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
|
||||
ui.StepSlider(c, &s.size, 24, 80, 4).Width(160)
|
||||
ui.Checkbox(c, &s.img, "Image")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var img *ui.Bitmap
|
||||
if s.img {
|
||||
img = displayBitmap
|
||||
}
|
||||
mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
|
||||
})
|
||||
displaySection(c, "Fallbacks", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
|
||||
mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
|
||||
})
|
||||
})
|
||||
displayCode(c, avatarCode, log)
|
||||
}
|
||||
|
||||
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})`
|
||||
|
||||
var displayPeople = []mujica.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
|
||||
{Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"},
|
||||
}
|
||||
|
||||
type avatarGroupState struct{ max, count float64 }
|
||||
|
||||
func avatarGroupDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "016", func() avatarGroupState { return avatarGroupState{max: 3, count: 6} })
|
||||
mujica.Heading(c, "AvatarGroup", mujica.HeadingOptions{Subtitle: "最大显示数量、剩余数量与完整名单", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Max %d", int(s.max)))
|
||||
if ui.StepSlider(c, &s.max, 1, 8, 1).Width(160).Changed() {
|
||||
log(fmt.Sprintf("max %d", int(s.max)))
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("People %d", int(s.count)))
|
||||
if ui.StepSlider(c, &s.count, 1, 8, 1).Width(160).Changed() {
|
||||
log(fmt.Sprintf("people %d", int(s.count)))
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
for i, g := range []mujica.AvatarGroupView{
|
||||
mujica.AvatarGroup(c, displayPeople[:int(s.count)], mujica.AvatarGroupOptions{Max: int(s.max)}),
|
||||
mujica.AvatarGroup(c, displayPeople, mujica.AvatarGroupOptions{Max: 4, Size: 24}),
|
||||
} {
|
||||
if g.Toggled {
|
||||
log(fmt.Sprintf("group %d open=%v", i+1, g.Open))
|
||||
}
|
||||
}
|
||||
})
|
||||
displayCode(c, avatarGroupCode, log)
|
||||
}
|
||||
|
||||
const avatarGroupCode = `mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})`
|
||||
|
||||
type badgeState struct {
|
||||
count, max float64
|
||||
text string
|
||||
tone, pos string
|
||||
}
|
||||
|
||||
var badgeTones = map[string]mujica.BadgeTone{"Accent": mujica.BadgeAccent, "Neutral": mujica.BadgeNeutral, "Success": mujica.BadgeSuccess,
|
||||
"Warning": mujica.BadgeWarning, "Danger": mujica.BadgeDanger, "Info": mujica.BadgeInfo}
|
||||
var badgePositions = map[string]mujica.BadgePosition{"TopRight": mujica.BadgeTopRight, "TopLeft": mujica.BadgeTopLeft,
|
||||
"BottomRight": mujica.BadgeBottomRight, "BottomLeft": mujica.BadgeBottomLeft}
|
||||
|
||||
func badgeDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "017", func() badgeState { return badgeState{count: 120, max: 99, tone: "Accent", pos: "TopRight"} })
|
||||
mujica.Heading(c, "Badge", mujica.HeadingOptions{Subtitle: "文字或计数、上限、位置与完整朗读", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Count %d", int(s.count)))
|
||||
ui.StepSlider(c, &s.count, 0, 200, 1).Width(160)
|
||||
ui.Text(c, fmt.Sprintf("Max %d", int(s.max)))
|
||||
ui.StepSlider(c, &s.max, 9, 999, 10).Width(120)
|
||||
ui.TextInput(c, &s.text).Placeholder("Text overrides count").Width(180)
|
||||
if ui.Select(c, &s.tone, []string{"Accent", "Neutral", "Success", "Warning", "Danger", "Info"}).Changed() {
|
||||
log("tone " + s.tone)
|
||||
}
|
||||
if ui.Select(c, &s.pos, []string{"TopRight", "TopLeft", "BottomRight", "BottomLeft"}).Changed() {
|
||||
log("position " + s.pos)
|
||||
}
|
||||
})
|
||||
})
|
||||
opts := mujica.BadgeOptions{Text: s.text, Count: int(s.count), Max: int(s.max), Tone: badgeTones[s.tone], Label: "Unread"}
|
||||
displaySection(c, "Inline", func() {
|
||||
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, "Inbox")
|
||||
mujica.Badge(c, opts, nil)
|
||||
})
|
||||
})
|
||||
displaySection(c, "Corner", func() {
|
||||
opts.Position = badgePositions[s.pos]
|
||||
ui.Box(c).Padding(10).Children(func() {
|
||||
mujica.Badge(c, opts, func() { mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40}) })
|
||||
})
|
||||
})
|
||||
displayCode(c, badgeCode, log)
|
||||
}
|
||||
|
||||
const badgeCode = `mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
|
||||
mujica.Badge(c, mujica.BadgeOptions{Count: unread, Label: "Unread", Position: mujica.BadgeTopRight}, func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{})
|
||||
})`
|
||||
|
||||
type tagState struct {
|
||||
tags []string
|
||||
icon, clickable, closable, dis bool
|
||||
}
|
||||
|
||||
func tagDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "018", func() tagState {
|
||||
return tagState{tags: []string{"Baroque", "Gothic", "Rococo", "Court"}, icon: true, clickable: true, closable: true}
|
||||
})
|
||||
mujica.Heading(c, "Tag", mujica.HeadingOptions{Subtitle: "标签文字、图标;点击与关闭分离", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Checkbox(c, &s.icon, "Icon")
|
||||
ui.Checkbox(c, &s.clickable, "Clickable")
|
||||
ui.Checkbox(c, &s.closable, "Closable")
|
||||
ui.Checkbox(c, &s.dis, "Disabled")
|
||||
if mujica.Button(c, "Reset tags", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.tags = []string{"Baroque", "Gothic", "Rococo", "Court"}
|
||||
log("reset")
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
icon := ""
|
||||
if s.icon {
|
||||
icon = "star"
|
||||
}
|
||||
ui.Row(c).Gap(8).Wrap().Children(func() {
|
||||
for i := 0; i < len(s.tags); i++ {
|
||||
t := s.tags[i]
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: icon, Clickable: s.clickable, Closable: s.closable, Disabled: s.dis})
|
||||
v.Element.Key(t)
|
||||
if v.Clicked {
|
||||
log("clicked " + t)
|
||||
}
|
||||
if v.Closed {
|
||||
log("closed " + t)
|
||||
s.tags = append(s.tags[:i:i], s.tags[i+1:]...)
|
||||
i--
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, tagCode, log)
|
||||
}
|
||||
|
||||
const tagCode = `for i, t := range tags {
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true})
|
||||
v.Element.Key(t)
|
||||
if v.Closed {
|
||||
tags = append(tags[:i:i], tags[i+1:]...)
|
||||
}
|
||||
}`
|
||||
|
||||
type linkState struct {
|
||||
url string
|
||||
external string
|
||||
}
|
||||
|
||||
func linkDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "019", func() linkState { return linkState{url: "https://example.com/charter"} })
|
||||
mujica.Heading(c, "Link", mujica.HeadingOptions{Subtitle: "外部地址或应用内动作;键盘激活;打开失败反馈", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.TextInput(c, &s.url).Placeholder("URL (empty = in-app action)").Width(320)
|
||||
if mujica.Button(c, "Simulate open failure", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.external = "No application can open this link."
|
||||
log("open failure set")
|
||||
}
|
||||
if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.external = ""
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
if mujica.Link(c, "Read the court charter", mujica.LinkOptions{URL: s.url, Error: s.external}).Clicked() {
|
||||
log("activated " + s.url)
|
||||
}
|
||||
if mujica.Link(c, "Show the guest list", mujica.LinkOptions{}).Clicked() {
|
||||
log("in-app action")
|
||||
}
|
||||
})
|
||||
displayCode(c, linkCode, log)
|
||||
}
|
||||
|
||||
const linkCode = `mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
|
||||
if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() {
|
||||
details = !details
|
||||
}`
|
||||
|
||||
type kbdState struct{ combo string }
|
||||
|
||||
func kbdDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "020", func() kbdState { return kbdState{combo: "Mod+Shift+P"} })
|
||||
mujica.Heading(c, "Kbd", mujica.HeadingOptions{Subtitle: "单键与组合键;按平台显示 Command 或 Ctrl", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.TextInput(c, &s.combo).Placeholder("Keys joined by +").Width(220).Changed() {
|
||||
log("keys " + s.combo)
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var keys []string
|
||||
for _, k := range strings.Split(s.combo, "+") {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
mujica.Kbd(c, keys...)
|
||||
}
|
||||
})
|
||||
displaySection(c, "Common", func() {
|
||||
for _, row := range [][]string{{"Mod", "K"}, {"Mod", "Shift", "P"}, {"Alt", "Up"}, {"Esc"}, {"Ctrl", "Tab"}} {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Kbd(c, row...)
|
||||
ui.Text(c, strings.Join(row, " + ")).TextColor(mujica.Tokens(c).TextMuted)
|
||||
})
|
||||
}
|
||||
})
|
||||
displayCode(c, kbdCode, log)
|
||||
}
|
||||
|
||||
const kbdCode = `ui.Row(c).Gap(6).Children(func() {
|
||||
mujica.Text(c, "Command palette", mujica.TextOptions{})
|
||||
mujica.Kbd(c, "Mod", "Shift", "P")
|
||||
})`
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// displaySection shows a titled block of a demo.
|
||||
func displaySection(c *ui.Context, title string, fn func()) {
|
||||
ui.Column(c).Gap(10).Margin(0, 0, 20, 0).Children(func() {
|
||||
mujica.Heading(c, title, mujica.HeadingOptions{Level: 3})
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
// displayControls lays out a row of parameter controls.
|
||||
func displayControls(c *ui.Context, fn func()) {
|
||||
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn)
|
||||
}
|
||||
|
||||
// displayCode shows a page's snippet with a copy button.
|
||||
func displayCode(c *ui.Context, code string, log func(string)) {
|
||||
displaySection(c, "Code", func() {
|
||||
if mujica.Button(c, "Copy code", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() {
|
||||
c.WriteClipboard(code)
|
||||
log("code copied")
|
||||
}
|
||||
mujica.Text(c, code, mujica.TextOptions{Selectable: true}).Font(theme.MonoFont).FontSize(12)
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,113 @@
|
||||
package main
|
||||
|
||||
var feedbackPages = []page{}
|
||||
var feedbackPages = []page{
|
||||
{
|
||||
ID: "091", Name: "Alert", ZhName: "提示条 警告 通知横幅", Category: "Feedback",
|
||||
Demo: alertDemo,
|
||||
Code: `open := true
|
||||
r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{
|
||||
Severity: mujica.SeverityError,
|
||||
Title: "Import failed",
|
||||
Action: "Retry",
|
||||
})
|
||||
if r.Action {
|
||||
// retry the import
|
||||
}`,
|
||||
Keys: "Tab moves to the action and the dismiss button; Enter or Space presses them.",
|
||||
},
|
||||
{
|
||||
ID: "092", Name: "Toast", ZhName: "轻提示 消息队列 吐司", Category: "Feedback",
|
||||
Demo: toastDemo,
|
||||
Code: `if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
|
||||
}
|
||||
// Last in the view:
|
||||
if r := mujica.Toaster(c); r.Action == "note-1" {
|
||||
// restore the note
|
||||
}`,
|
||||
Keys: "Toasts never take the focus. Tab reaches their buttons after the page; Escape closes the focused toast. Hover or focus pauses its timer.",
|
||||
},
|
||||
{
|
||||
ID: "093", Name: "Progress", ZhName: "进度条 环形进度 计量条", Category: "Feedback",
|
||||
Demo: progressDemo,
|
||||
Code: `mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true})
|
||||
mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"})
|
||||
mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
Max: 100, Label: "Disk", ShowValue: true,
|
||||
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
|
||||
})`,
|
||||
Keys: "Not focusable; assistive technology reads its name and value.",
|
||||
},
|
||||
{
|
||||
ID: "094", Name: "Spinner", ZhName: "加载标记 加载中 转圈", Category: "Feedback",
|
||||
Demo: spinnerDemo,
|
||||
Code: `mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})`,
|
||||
Keys: "Not focusable; read as an indeterminate progress indicator named by Label.",
|
||||
},
|
||||
{
|
||||
ID: "095", Name: "Skeleton", ZhName: "骨架屏 占位", Category: "Feedback",
|
||||
Demo: skeletonDemo,
|
||||
Code: `ui.Row(c).Gap(12).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2})
|
||||
})
|
||||
})`,
|
||||
Keys: "Decorative; not focusable and hidden from assistive technology.",
|
||||
},
|
||||
{
|
||||
ID: "096", Name: "EmptyState", ZhName: "空状态 无数据 无搜索结果", Category: "Feedback",
|
||||
Demo: emptyStateDemo,
|
||||
Code: `r := mujica.EmptyState(c, mujica.EmptyStateOptions{
|
||||
Kind: mujica.EmptyNoResults,
|
||||
Query: query,
|
||||
Action: "Clear search",
|
||||
})
|
||||
if r.Action {
|
||||
query = ""
|
||||
}`,
|
||||
Keys: "Tab reaches the action button; Enter or Space presses it.",
|
||||
},
|
||||
{
|
||||
ID: "097", Name: "Result", ZhName: "结果页 成功 失败 无权限", Category: "Feedback",
|
||||
Demo: resultDemo,
|
||||
Code: `r := mujica.Result(c, mujica.ResultOptions{
|
||||
Kind: mujica.ResultForbidden,
|
||||
Description: "You need the Editor role to change billing.",
|
||||
Actions: []string{"Request access", "Go back"},
|
||||
})
|
||||
switch r.Action {
|
||||
case 0: // send the request
|
||||
case 1: // navigate back
|
||||
}`,
|
||||
Keys: "Tab moves through the actions, primary first; Enter or Space presses one.",
|
||||
},
|
||||
{
|
||||
ID: "098", Name: "StatusIndicator", ZhName: "状态标记 在线 离线", Category: "Feedback",
|
||||
Demo: statusDemo,
|
||||
Code: `mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"})
|
||||
mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})`,
|
||||
Keys: "Not focusable; read as a status with its word.",
|
||||
},
|
||||
{
|
||||
ID: "099", Name: "NotificationCenter", ZhName: "通知中心 消息列表", Category: "Feedback",
|
||||
Demo: notificationDemo,
|
||||
Code: `r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{})
|
||||
if r.Action == "a" {
|
||||
// open the build log
|
||||
}`,
|
||||
Keys: "The list is one Tab stop. Arrows move among notifications and their buttons; Enter marks the focused one read; Delete or Backspace removes it.",
|
||||
},
|
||||
{
|
||||
ID: "100", Name: "LoadingOverlay", ZhName: "加载遮罩 区域加载", Category: "Feedback",
|
||||
Demo: loadingOverlayDemo,
|
||||
Code: `r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
|
||||
ui.Text(c, "Quarterly report")
|
||||
mujica.Button(c, "Export", mujica.ButtonOptions{})
|
||||
})
|
||||
if r.Canceled {
|
||||
loading = false
|
||||
}`,
|
||||
Keys: "While loading, the covered region takes no input; focus inside moves to Cancel, and Escape there cancels. The rest of the window stays usable.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// feedbackSection titles a part of a demo.
|
||||
func feedbackSection(c *ui.Context, title string, fn func()) {
|
||||
ui.Column(c).Gap(8).FillWidth().Children(func() {
|
||||
ui.Text(c, title).FontSize(theme.CaptionSize).TextColor(mujica.Tokens(c).TextMuted)
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
var feedbackSeverities = []mujica.Severity{mujica.SeverityInfo, mujica.SeveritySuccess, mujica.SeverityWarning, mujica.SeverityError}
|
||||
|
||||
func alertDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
sev int
|
||||
title, action bool
|
||||
closable bool
|
||||
open bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "091", func() state { return state{sev: 3, title: true, action: true, closable: true, open: true} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error")
|
||||
ui.Row(c).Gap(16).Children(func() {
|
||||
ui.Checkbox(c, &s.title, "Title")
|
||||
ui.Checkbox(c, &s.action, "Action")
|
||||
ui.Checkbox(c, &s.closable, "Closable")
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Live", func() {
|
||||
opts := mujica.AlertOptions{Severity: feedbackSeverities[s.sev]}
|
||||
if s.title {
|
||||
opts.Title = "Import failed"
|
||||
}
|
||||
if s.action {
|
||||
opts.Action = "Retry"
|
||||
}
|
||||
var open *bool
|
||||
if s.closable {
|
||||
open = &s.open
|
||||
}
|
||||
r := mujica.Alert(c, open, "The archive could not be read.", opts)
|
||||
if r.Action {
|
||||
log("alert: action Retry")
|
||||
}
|
||||
if r.Closed {
|
||||
log("alert: closed")
|
||||
}
|
||||
if s.closable && !s.open && mujica.Button(c, "Show again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.open = true
|
||||
log("alert: reopened")
|
||||
}
|
||||
})
|
||||
feedbackSection(c, "Severities", func() {
|
||||
for _, sev := range feedbackSeverities {
|
||||
mujica.Alert(c, nil, "Severity "+sev.String()+" uses its own icon, bar and name.", mujica.AlertOptions{Severity: sev}).Element.Key(sev)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func toastDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
sev int
|
||||
action bool
|
||||
seconds float64
|
||||
n int
|
||||
note string
|
||||
}
|
||||
s := ui.Local(c.Root(), "092", func() state { return state{seconds: 5} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error")
|
||||
ui.Checkbox(c, &s.action, "Undo action")
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("Duration %.0f s (errors stay until closed)", s.seconds))
|
||||
ui.Slider(c, &s.seconds, 1, 10).Width(200)
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Actions", func() {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
if mujica.Button(c, "Show toast", mujica.ButtonOptions{}).Clicked() {
|
||||
s.n++
|
||||
m := mujica.ToastMessage{ID: fmt.Sprint(s.n), Severity: feedbackSeverities[s.sev], Message: fmt.Sprintf("Message %d", s.n)}
|
||||
if s.action {
|
||||
m.Action = "Undo"
|
||||
}
|
||||
if m.Severity != mujica.SeverityError {
|
||||
m.Duration = time.Duration(s.seconds * float64(time.Second))
|
||||
}
|
||||
mujica.ShowToast(c, m)
|
||||
log("toast: show " + m.ID)
|
||||
}
|
||||
if mujica.Button(c, "Show five at once", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
for range 5 {
|
||||
s.n++
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: fmt.Sprint(s.n), Message: fmt.Sprintf("Queued %d", s.n)})
|
||||
}
|
||||
log("toast: five queued, three visible")
|
||||
}
|
||||
if mujica.Button(c, "Repeat same ID", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.n++
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "sync", Message: fmt.Sprintf("Synced %d times", s.n)})
|
||||
log("toast: dedupe id sync")
|
||||
}
|
||||
})
|
||||
ui.Text(c, "Type here while toasts appear; the focus stays:")
|
||||
ui.TextInput(c, &s.note).Width(320)
|
||||
})
|
||||
})
|
||||
if r := mujica.Toaster(c); r.Action != "" {
|
||||
log("toast: action " + r.Action)
|
||||
} else if r.Closed != "" {
|
||||
log("toast: closed " + r.Closed)
|
||||
}
|
||||
}
|
||||
|
||||
func progressDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
value, meter float64
|
||||
indet, showValue bool
|
||||
ring int
|
||||
}
|
||||
s := ui.Local(c.Root(), "093", func() state { return state{value: 0.62, meter: 72, showValue: true} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Segmented(c, &s.ring, "Linear", "Ring")
|
||||
ui.Row(c).Gap(16).Children(func() {
|
||||
if ui.Checkbox(c, &s.indet, "Indeterminate").Changed() {
|
||||
log(fmt.Sprintf("progress: indeterminate %v", s.indet))
|
||||
}
|
||||
ui.Checkbox(c, &s.showValue, "Show value")
|
||||
})
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, "Value")
|
||||
if ui.Slider(c, &s.value, 0, 1).Width(240).Changed() {
|
||||
log(fmt.Sprintf("progress: value %.2f", s.value))
|
||||
}
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Progress", func() {
|
||||
mujica.Progress(c, s.value, mujica.ProgressOptions{
|
||||
Shape: mujica.ProgressShape(s.ring), Indeterminate: s.indet, Label: "Uploading", ShowValue: s.showValue,
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Meter with thresholds 70 and 90", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, "Level")
|
||||
if ui.Slider(c, &s.meter, 0, 100).Width(240).Changed() {
|
||||
log(fmt.Sprintf("meter: value %.0f", s.meter))
|
||||
}
|
||||
})
|
||||
v := float64(int(s.meter))
|
||||
mujica.Meter(c, v, mujica.MeterOptions{Max: 100, Label: "Disk", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}})
|
||||
mujica.Meter(c, 100-v, mujica.MeterOptions{Max: 100, Label: "Battery (low is bad)", ShowValue: true, Levels: &ui.MeterLevels{Warning: 30, Critical: 10}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func spinnerDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
size float64
|
||||
showLabel, active bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "094", func() state { return state{size: 20, showLabel: true, active: true} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("Size %.0f", s.size))
|
||||
ui.Slider(c, &s.size, 12, 48).Width(200)
|
||||
})
|
||||
ui.Row(c).Gap(16).Children(func() {
|
||||
ui.Checkbox(c, &s.showLabel, "Show label")
|
||||
if ui.Checkbox(c, &s.active, "Loading (unchecked stops the animation)").Changed() {
|
||||
log(fmt.Sprintf("spinner: loading %v", s.active))
|
||||
}
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Live", func() {
|
||||
if s.active {
|
||||
mujica.Spinner(c, mujica.SpinnerOptions{Size: float32(s.size), Label: "Syncing", ShowLabel: s.showLabel})
|
||||
} else {
|
||||
ui.Text(c, "Done")
|
||||
}
|
||||
})
|
||||
feedbackSection(c, "In a button", func() {
|
||||
mujica.Button(c, "Saving", mujica.ButtonOptions{Loading: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func skeletonDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
lines float64
|
||||
loading bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "095", func() state { return state{lines: 3, loading: true} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("Lines %.0f", s.lines))
|
||||
ui.Slider(c, &s.lines, 1, 6).Width(200)
|
||||
})
|
||||
if ui.Checkbox(c, &s.loading, "Loading").Changed() {
|
||||
log(fmt.Sprintf("skeleton: loading %v", s.loading))
|
||||
}
|
||||
})
|
||||
feedbackSection(c, "Card (same size loading and loaded)", func() {
|
||||
ui.Row(c).Gap(12).Padding(16).Width(420).Background(mujica.Tokens(c).Surface).Radius(theme.CardRadius).Children(func() {
|
||||
if s.loading {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: int(s.lines)})
|
||||
})
|
||||
return
|
||||
}
|
||||
ui.Box(c).Size(40, 40).Radius(20).Background(mujica.Tokens(c).Selection)
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
for i := range int(s.lines) {
|
||||
ui.Text(c, fmt.Sprintf("Loaded line %d", i+1)).LineHeight(theme.LineHeight)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Rect", func() {
|
||||
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonRect, Width: 420, Height: 96})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func emptyStateDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
kind int
|
||||
query string
|
||||
icon, action bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "096", func() state { return state{kind: 1, query: "quarterly", icon: true, action: true} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Segmented(c, &s.kind, "No data", "No results")
|
||||
ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() {
|
||||
ui.Checkbox(c, &s.icon, "Icon")
|
||||
ui.Checkbox(c, &s.action, "Action")
|
||||
if s.kind == 1 {
|
||||
ui.TextInput(c, &s.query).Width(200)
|
||||
}
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Live", func() {
|
||||
opts := mujica.EmptyStateOptions{Kind: mujica.EmptyKind(s.kind), NoIcon: !s.icon}
|
||||
if s.kind == 1 {
|
||||
opts.Query = s.query
|
||||
}
|
||||
if s.action {
|
||||
opts.Action = []string{"New project", "Clear search"}[s.kind]
|
||||
}
|
||||
if mujica.EmptyState(c, opts).Action {
|
||||
log("empty state: action " + opts.Action)
|
||||
s.query = ""
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func resultDemo(c *ui.Context, log func(string)) {
|
||||
type state struct{ kind int }
|
||||
s := ui.Local(c.Root(), "097", func() state { return state{kind: 2} })
|
||||
descs := []string{"Your order has been placed.", "The payment was declined by the bank.", "You need the Editor role to change billing."}
|
||||
actions := [][]string{{"View order", "Keep shopping"}, {"Try again", "Back to cart"}, {"Request access", "Go back"}}
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Segmented(c, &s.kind, "Success", "Failure", "Forbidden")
|
||||
})
|
||||
r := mujica.Result(c, mujica.ResultOptions{Kind: mujica.ResultKind(s.kind), Description: descs[s.kind], Actions: actions[s.kind]})
|
||||
if r.Action >= 0 {
|
||||
log("result: action " + actions[s.kind][r.Action])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func statusDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status int
|
||||
custom bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "098", func() state { return state{} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
if ui.Segmented(c, &s.status, "Success", "Warning", "Error", "Offline", "Busy").Changed() {
|
||||
log("status: " + mujica.Status(s.status).String())
|
||||
}
|
||||
ui.Checkbox(c, &s.custom, "Custom label")
|
||||
})
|
||||
feedbackSection(c, "Live", func() {
|
||||
opts := mujica.StatusIndicatorOptions{}
|
||||
if s.custom {
|
||||
opts.Label = "Database replica"
|
||||
}
|
||||
mujica.StatusIndicator(c, mujica.Status(s.status), opts)
|
||||
})
|
||||
feedbackSection(c, "All states (shape and word, not color alone)", func() {
|
||||
ui.Row(c).Gap(20).Wrap().Children(func() {
|
||||
for st := mujica.StatusSuccess; st <= mujica.StatusBusy; st++ {
|
||||
mujica.StatusIndicator(c, st, mujica.StatusIndicatorOptions{}).Key(st)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func notificationDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
items []mujica.Notification
|
||||
n int
|
||||
}
|
||||
s := ui.Local(c.Root(), "099", func() state {
|
||||
return state{items: []mujica.Notification{
|
||||
{ID: "a", Title: "Build failed", Body: "main · 3 tests failing", Time: "2 min ago", Action: "View log"},
|
||||
{ID: "b", Title: "Invite accepted", Body: "Mira joined the workspace", Time: "1 h ago"},
|
||||
{ID: "c", Title: "Backup complete", Time: "Yesterday", Read: true},
|
||||
}}
|
||||
})
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Caller-owned list", func() {
|
||||
if mujica.Button(c, "Add notification", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.n++
|
||||
id := fmt.Sprintf("new-%d", s.n)
|
||||
s.items = append([]mujica.Notification{{ID: id, Title: fmt.Sprintf("Comment %d on the design doc", s.n), Time: "now"}}, s.items...)
|
||||
log("notifications: added " + id)
|
||||
}
|
||||
})
|
||||
ui.Column(c).Width(520).Height(420).Children(func() {
|
||||
r := mujica.NotificationCenter(c, &s.items, mujica.NotificationCenterOptions{})
|
||||
for _, ev := range [][2]string{{"action", r.Action}, {"toggled", r.Toggled}, {"opened", r.Opened}, {"removed", r.Removed}} {
|
||||
if ev[1] != "" {
|
||||
log("notifications: " + ev[0] + " " + ev[1])
|
||||
}
|
||||
}
|
||||
if r.MarkedAllRead {
|
||||
log("notifications: marked all read")
|
||||
}
|
||||
if r.ClearedAll {
|
||||
log("notifications: cleared all")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func loadingOverlayDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
loading, cancel bool
|
||||
saves int
|
||||
}
|
||||
s := ui.Local(c.Root(), "100", func() state { return state{loading: true, cancel: true} })
|
||||
ui.Column(c).Gap(16).FillWidth().Children(func() {
|
||||
feedbackSection(c, "Parameters", func() {
|
||||
ui.Row(c).Gap(16).Children(func() {
|
||||
if ui.Checkbox(c, &s.loading, "Loading").Changed() {
|
||||
log(fmt.Sprintf("overlay: loading %v", s.loading))
|
||||
}
|
||||
ui.Checkbox(c, &s.cancel, "Cancel action")
|
||||
})
|
||||
})
|
||||
feedbackSection(c, "Covered region", func() {
|
||||
opts := mujica.LoadingOverlayOptions{Label: "Generating report…"}
|
||||
if s.cancel {
|
||||
opts.Cancel = "Cancel"
|
||||
}
|
||||
r := mujica.LoadingOverlay(c, s.loading, opts, func() {
|
||||
ui.Column(c).Gap(8).Padding(16).FillWidth().AlignItems(ui.Start).Background(mujica.Tokens(c).Surface).Radius(theme.CardRadius).Children(func() {
|
||||
ui.Text(c, "Quarterly report").Font(theme.SerifFont).FontSize(theme.H3Size)
|
||||
ui.Text(c, fmt.Sprintf("Exported %d times.", s.saves))
|
||||
if mujica.Button(c, "Export", mujica.ButtonOptions{}).Clicked() {
|
||||
s.saves++
|
||||
log("overlay: export clicked")
|
||||
}
|
||||
})
|
||||
})
|
||||
r.Element.Width(420)
|
||||
if r.Canceled {
|
||||
s.loading = false
|
||||
log("overlay: canceled")
|
||||
}
|
||||
})
|
||||
feedbackSection(c, "Outside the region (always usable)", func() {
|
||||
if mujica.Button(c, "Outside button", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() {
|
||||
log("overlay: outside clicked")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,358 @@
|
||||
package main
|
||||
|
||||
var inputPages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
const inputCategory = "Inputs"
|
||||
|
||||
// inputVariants lists the button variants for variant pickers.
|
||||
var inputVariants = []mujica.RadioItem[mujica.ButtonVariant]{
|
||||
{Value: mujica.Primary, Label: "Primary"}, {Value: mujica.Secondary, Label: "Secondary"},
|
||||
{Value: mujica.Outline, Label: "Outline"}, {Value: mujica.Ghost, Label: "Ghost"}, {Value: mujica.Danger, Label: "Danger"},
|
||||
}
|
||||
|
||||
// inputSection titles one part of a demo.
|
||||
func inputSection(c *ui.Context, title string, fn func()) {
|
||||
ui.Column(c).Gap(8).Children(func() {
|
||||
ui.Text(c, title).FontWeight(600).TextColor(mujica.Tokens(c).TextMuted)
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
// inputFlags builds one checkbox per named flag, logging each toggle.
|
||||
func inputFlags(c *ui.Context, log func(string), flags map[string]*bool, order ...string) {
|
||||
ui.Row(c).Gap(16).Wrap().Children(func() {
|
||||
for _, name := range order {
|
||||
if mujica.Checkbox(c, flags[name], name, mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("option %s = %v", name, *flags[name]))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type inputButtonDemo struct {
|
||||
variant mujica.ButtonVariant
|
||||
icon, loading, disabled bool
|
||||
}
|
||||
|
||||
type inputTextDemo struct {
|
||||
value, err string
|
||||
clearable, readOnly, disabled, sl, fail bool
|
||||
}
|
||||
|
||||
type inputAreaDemo struct {
|
||||
value string
|
||||
max float64
|
||||
readOnly, disabled bool
|
||||
}
|
||||
|
||||
type inputNumberDemo struct {
|
||||
value, step float64
|
||||
empty, stepper, disabled, fail bool
|
||||
}
|
||||
|
||||
type inputSearchDemo struct {
|
||||
query, find string
|
||||
current int
|
||||
disabled bool
|
||||
}
|
||||
|
||||
type inputCheckDemo struct {
|
||||
single, mixed, disabled bool
|
||||
items [4]bool
|
||||
}
|
||||
|
||||
type inputGroupDemo struct {
|
||||
vertical, disabled bool
|
||||
}
|
||||
|
||||
type inputRadioDemo struct {
|
||||
value string
|
||||
horizontal, disabledAll bool
|
||||
}
|
||||
|
||||
var inputPages = []page{
|
||||
{ID: "021", Name: "Button", ZhName: "按钮", Category: inputCategory, Keys: "Tab 聚焦;Enter 或 Space 触发。",
|
||||
Code: `if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() {
|
||||
fmt.Println("saved")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "021", func() inputButtonDemo { return inputButtonDemo{} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
mujica.RadioGroup(c, &s.variant, inputVariants, mujica.RadioGroupOptions{Horizontal: true, Label: "Variant"})
|
||||
inputFlags(c, log, map[string]*bool{"Icon": &s.icon, "Loading": &s.loading, "Disabled": &s.disabled}, "Icon", "Loading", "Disabled")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.ButtonOptions{Variant: s.variant, Loading: s.loading, Disabled: s.disabled}
|
||||
if s.icon {
|
||||
opts.Icon = icons.Must("check")
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
if mujica.Button(c, "Save changes", opts).Clicked() {
|
||||
log("Button clicked")
|
||||
}
|
||||
})
|
||||
})
|
||||
inputSection(c, "Variants", func() {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
for _, v := range inputVariants {
|
||||
if mujica.Button(c, v.Label, mujica.ButtonOptions{Variant: v.Value}).Clicked() {
|
||||
log(v.Label + " clicked")
|
||||
}
|
||||
}
|
||||
mujica.Button(c, "Loading", mujica.ButtonOptions{Loading: true})
|
||||
mujica.Button(c, "Disabled", mujica.ButtonOptions{Disabled: true})
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "022", Name: "IconButton", ZhName: "图标按钮", Category: inputCategory, Keys: "Tab 聚焦;Enter 或 Space 触发;悬停显示提示。",
|
||||
Code: `if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
fmt.Println("open settings")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "022", func() inputButtonDemo { return inputButtonDemo{variant: mujica.Ghost} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
mujica.RadioGroup(c, &s.variant, inputVariants, mujica.RadioGroupOptions{Horizontal: true, Label: "Variant"})
|
||||
inputFlags(c, log, map[string]*bool{"Loading": &s.loading, "Disabled": &s.disabled}, "Loading", "Disabled")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
for _, n := range []string{"bold", "italic", "underline", "copy", "trash-2"} {
|
||||
opts := mujica.IconButtonOptions{Variant: s.variant, Loading: s.loading, Disabled: s.disabled}
|
||||
if mujica.IconButton(c, icons.Must(n), n, opts).Clicked() {
|
||||
log("IconButton " + n + " clicked")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "023", Name: "ButtonGroup", ZhName: "按钮组", Category: inputCategory, Keys: "Tab 进入组;方向键在组内移动并跳过禁用项;Enter 或 Space 触发。",
|
||||
Code: `items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}
|
||||
if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}).ClickedItem(); i >= 0 {
|
||||
fmt.Println("chose", items[i].Label)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "023", func() inputGroupDemo { return inputGroupDemo{} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Vertical": &s.vertical, "Disable group": &s.disabled}, "Vertical", "Disable group")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week", Disabled: true}, {Label: "Month"}, {Icon: icons.Must("calendar"), Name: "Pick a date"}}
|
||||
r := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Vertical: s.vertical, Disabled: s.disabled, Label: "Range"})
|
||||
if i := r.ClickedItem(); i >= 0 {
|
||||
log(fmt.Sprintf("ButtonGroup item %d clicked", i))
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "024", Name: "TextInput", ZhName: "文本输入框", Category: inputCategory, Keys: "Tab 聚焦;Enter 提交;Escape 清空(可清空时);Cmd/Ctrl+Z 撤销、+C/X/V 剪贴板;支持输入法组合。",
|
||||
Code: `r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true})
|
||||
if r.Submitted() {
|
||||
fmt.Println("hello", name)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "024", func() inputTextDemo { return inputTextDemo{clearable: true, sl: true} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Clearable": &s.clearable, "Slots": &s.sl, "ReadOnly": &s.readOnly, "Disabled": &s.disabled}, "Clearable", "Slots", "ReadOnly", "Disabled")
|
||||
mujica.TextInput(c, &s.err, mujica.TextInputOptions{Label: "Error message", Placeholder: "Error message (empty for none)"})
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: s.clearable, ReadOnly: s.readOnly, Disabled: s.disabled, Error: s.err}
|
||||
if s.sl {
|
||||
opts.Leading = func() { ui.Icon(c, icons.Must("user")).Size(16, 16).TextColor(mujica.Tokens(c).TextMuted) }
|
||||
opts.Trailing = func() { ui.Text(c, fmt.Sprintf("%d", len([]rune(s.value)))).TextColor(mujica.Tokens(c).TextMuted) }
|
||||
}
|
||||
r := mujica.TextInput(c, &s.value, opts)
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("TextInput changed: %q", s.value))
|
||||
}
|
||||
if r.Submitted() {
|
||||
log(fmt.Sprintf("TextInput submitted: %q", s.value))
|
||||
}
|
||||
})
|
||||
inputSection(c, "States", func() {
|
||||
ui.Row(c).Gap(12).Children(func() {
|
||||
e, ro, off := "1789-13-01", "Read only", "Disabled"
|
||||
mujica.TextInput(c, &e, mujica.TextInputOptions{Label: "Error state", Error: "No such date"}).Grow(1)
|
||||
mujica.TextInput(c, &ro, mujica.TextInputOptions{Label: "Read-only state", ReadOnly: true}).Grow(1)
|
||||
mujica.TextInput(c, &off, mujica.TextInputOptions{Label: "Disabled state", Disabled: true}).Grow(1)
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "025", Name: "TextArea", ZhName: "多行文本框", Category: inputCategory, Keys: "Enter 换行;Cmd/Ctrl+Enter 提交(可配置);滚轮与方向键滚动;撤销、剪贴板与输入法同 TextInput。",
|
||||
Code: `r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200})
|
||||
if r.Submitted() {
|
||||
fmt.Println("send", notes)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "025", func() inputAreaDemo { return inputAreaDemo{max: 120} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"ReadOnly": &s.readOnly, "Disabled": &s.disabled}, "ReadOnly", "Disabled")
|
||||
if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 0, Max: 1000, Step: 10, Label: "MaxLength"}).Changed() {
|
||||
log(fmt.Sprintf("option MaxLength = %v", s.max))
|
||||
}
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
r := mujica.TextArea(c, &s.value, mujica.TextAreaOptions{Label: "Notes", Placeholder: "Write a dedication…", MinHeight: 80, MaxHeight: 160,
|
||||
MaxLength: int(s.max), ReadOnly: s.readOnly, Disabled: s.disabled})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("TextArea changed: %d characters", len([]rune(s.value))))
|
||||
}
|
||||
if r.Submitted() {
|
||||
log("TextArea submitted")
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "026", Name: "NumberInput", ZhName: "数字输入框", Category: inputCategory, Keys: "Up/Down 按步长增减;步进模式下 Up/Down/Left/Right、Home、End。",
|
||||
Code: `if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() {
|
||||
fmt.Println("copies", copies)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "026", func() inputNumberDemo { return inputNumberDemo{value: 3, step: 1} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"AllowEmpty": &s.empty, "StepperOnly": &s.stepper, "Disabled": &s.disabled, "Error": &s.fail}, "AllowEmpty", "StepperOnly", "Disabled", "Error")
|
||||
steps := []mujica.RadioItem[float64]{{Value: 1, Label: "Step 1"}, {Value: 0.5, Label: "Step 0.5"}, {Value: 0.01, Label: "Step 0.01"}}
|
||||
if mujica.RadioGroup(c, &s.step, steps, mujica.RadioGroupOptions{Horizontal: true, Label: "Step"}).Changed() {
|
||||
log(fmt.Sprintf("option Step = %v", s.step))
|
||||
}
|
||||
})
|
||||
if s.stepper {
|
||||
s.empty = false
|
||||
}
|
||||
if !s.empty && math.IsNaN(s.value) {
|
||||
s.value = 0
|
||||
}
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.NumberInputOptions{Min: 0, Max: 10, Step: s.step, AllowEmpty: s.empty, StepperOnly: s.stepper, Disabled: s.disabled, Label: "Quantity", Placeholder: "Empty"}
|
||||
if s.fail {
|
||||
opts.Error = "Not in stock"
|
||||
}
|
||||
ui.Box(c).Width(240).Children(func() {
|
||||
if mujica.NumberInput(c, &s.value, opts).Changed() {
|
||||
log(fmt.Sprintf("NumberInput changed: %v", s.value))
|
||||
}
|
||||
})
|
||||
})
|
||||
inputSection(c, "States", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
a, b, e := 4.0, 2.0, 7.5
|
||||
mujica.NumberInput(c, &a, mujica.NumberInputOptions{Min: 1, Max: 8, StepperOnly: true, Label: "Stepper state"})
|
||||
mujica.NumberInput(c, &b, mujica.NumberInputOptions{Label: "Disabled state", Disabled: true}).Width(160)
|
||||
mujica.NumberInput(c, &e, mujica.NumberInputOptions{Step: 0.5, Label: "Error state", Error: "Must be whole"}).Width(160)
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "027", Name: "PasswordInput", ZhName: "密码输入框", Category: inputCategory, Keys: "Tab 依次聚焦输入框与显隐按钮;Space 或 Enter 切换显隐;Enter 在输入框中提交;粘贴可用,遮蔽时不可复制。",
|
||||
Code: `if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() {
|
||||
fmt.Println("signing in")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "027", func() inputTextDemo { return inputTextDemo{} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled, "Error": &s.fail}, "Disabled", "Error")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.PasswordInputOptions{Label: "Password", Placeholder: "Password", Disabled: s.disabled}
|
||||
if s.fail {
|
||||
opts.Error = "At least 8 characters"
|
||||
}
|
||||
r := mujica.PasswordInput(c, &s.value, opts)
|
||||
if r.Changed() {
|
||||
// The contents never reach the log.
|
||||
log(fmt.Sprintf("PasswordInput changed: %d characters", len([]rune(s.value))))
|
||||
}
|
||||
if r.Submitted() {
|
||||
log("PasswordInput submitted")
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "028", Name: "SearchInput", ZhName: "搜索框", Category: inputCategory, Keys: "Enter 提交(搜索模式)或下一项(查找栏);Shift+Enter 上一项;Cmd+G / F3 下一项,Shift 加上为上一项;Escape 清空。",
|
||||
Code: `matches := 0
|
||||
if query != "" {
|
||||
matches = strings.Count(text, query)
|
||||
}
|
||||
r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t})
|
||||
if r.Moved() {
|
||||
fmt.Println("show match", current)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "028", func() inputSearchDemo { return inputSearchDemo{} })
|
||||
const text = "The rose of the court, the rose of the chapel, and the rose of the garden all bloom in May."
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled")
|
||||
})
|
||||
inputSection(c, "Search mode", func() {
|
||||
r := mujica.SearchInput(c, &s.query, mujica.SearchInputOptions{Disabled: s.disabled})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("SearchInput changed: %q", s.query))
|
||||
}
|
||||
if r.Submitted() {
|
||||
log(fmt.Sprintf("SearchInput submitted: %q", s.query))
|
||||
}
|
||||
})
|
||||
inputSection(c, "Find bar mode", func() {
|
||||
n := 0
|
||||
if s.find != "" {
|
||||
n = strings.Count(text, s.find)
|
||||
}
|
||||
r := mujica.SearchInput(c, &s.find, mujica.SearchInputOptions{Find: true, Matches: n, Current: &s.current, Disabled: s.disabled})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("find query: %q, %d matches", s.find, n))
|
||||
}
|
||||
if r.Moved() {
|
||||
log(fmt.Sprintf("find moved to match %d", s.current+1))
|
||||
}
|
||||
ui.Text(c, text).TextColor(mujica.Tokens(c).TextMuted)
|
||||
})
|
||||
}},
|
||||
{ID: "029", Name: "Checkbox", ZhName: "复选框", Category: inputCategory, Keys: "Tab 聚焦;Space 切换;点击标签同样切换;全选框在组内勾选全部或清空。",
|
||||
Code: `mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{
|
||||
{Label: "Mail", Value: &mail},
|
||||
{Label: "Calendar", Value: &calendar},
|
||||
}, mujica.CheckboxGroupOptions{})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "029", func() inputCheckDemo { return inputCheckDemo{items: [4]bool{true, false, false, true}} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Indeterminate": &s.mixed, "Disabled": &s.disabled}, "Indeterminate", "Disabled")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
if mujica.Checkbox(c, &s.single, "Accept the terms", mujica.CheckboxOptions{Indeterminate: s.mixed, Disabled: s.disabled}).Changed() {
|
||||
s.mixed = false
|
||||
log(fmt.Sprintf("Checkbox changed: %v", s.single))
|
||||
}
|
||||
})
|
||||
inputSection(c, "Group with select all", func() {
|
||||
items := []mujica.CheckboxItem{
|
||||
{Label: "Mail", Value: &s.items[0]}, {Label: "Calendar", Value: &s.items[1]},
|
||||
{Label: "Messages", Value: &s.items[2]}, {Label: "Security alerts (required)", Value: &s.items[3], Disabled: true},
|
||||
}
|
||||
if mujica.CheckboxGroup(c, "Notifications", items, mujica.CheckboxGroupOptions{Disabled: s.disabled}).Changed() {
|
||||
log(fmt.Sprintf("CheckboxGroup changed: %v", s.items))
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "030", Name: "RadioGroup", ZhName: "单选组", Category: inputCategory, Keys: "Tab 进入组并落在已选项;方向键选择并跳过禁用项;Home、End 选择首末项。",
|
||||
Code: `items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}}
|
||||
if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).Changed() {
|
||||
fmt.Println("size", size)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "030", func() inputRadioDemo { return inputRadioDemo{value: "m"} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Horizontal": &s.horizontal, "Disabled": &s.disabledAll}, "Horizontal", "Disabled")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}, {Value: "xl", Label: "Sold out", Disabled: true}}
|
||||
if mujica.RadioGroup(c, &s.value, items, mujica.RadioGroupOptions{Horizontal: s.horizontal, Disabled: s.disabledAll, Label: "Size"}).Changed() {
|
||||
log("RadioGroup chose " + s.value)
|
||||
}
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -1,3 +1,499 @@
|
||||
package main
|
||||
|
||||
var layoutPages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
var layoutPages = []page{
|
||||
{ID: "001", Name: "AppShell", ZhName: "应用外壳", Category: "Layout", Demo: layoutAppShellDemo, Code: layoutAppShellCode,
|
||||
Keys: "Cmd/Ctrl+B or the toggle button folds the sidebar. Tab moves between the toggle, the sidebar and the content; arrows, Page Up/Down, Home and End scroll the focused region."},
|
||||
{ID: "002", Name: "TitleBar", ZhName: "标题栏", Category: "Layout", Demo: layoutTitleBarDemo, Code: layoutTitleBarCode,
|
||||
Keys: "Tools in the slots take Tab and Enter/Space as usual. Dragging the empty bar moves the window; double-click zooms it."},
|
||||
{ID: "003", Name: "StatusBar", ZhName: "状态栏", Category: "Layout", Demo: layoutStatusBarDemo, Code: layoutStatusBarCode,
|
||||
Keys: "Tab reaches action items; Enter or Space activates them. Plain status text is not a tab stop."},
|
||||
{ID: "004", Name: "Container", ZhName: "容器", Category: "Layout", Demo: layoutContainerDemo, Code: layoutContainerCode,
|
||||
Keys: "None: Container never takes the focus."},
|
||||
{ID: "005", Name: "Stack", ZhName: "堆叠", Category: "Layout", Demo: layoutStackDemo, Code: layoutStackCode,
|
||||
Keys: "None: Stack never takes the focus; Tab follows the children's order."},
|
||||
{ID: "006", Name: "Grid", ZhName: "网格", Category: "Layout", Demo: layoutGridDemo, Code: layoutGridCode,
|
||||
Keys: "None: Grid never takes the focus; Tab follows the build order."},
|
||||
{ID: "007", Name: "ScrollArea", ZhName: "滚动区域", Category: "Layout", Demo: layoutScrollAreaDemo, Code: layoutScrollAreaCode,
|
||||
Keys: "When the content overflows, Tab focuses the area; arrows scroll by a line, Page Up/Down and Space by a page, Home and End to the ends."},
|
||||
{ID: "008", Name: "SplitPane", ZhName: "分栏", Category: "Layout", Demo: layoutSplitPaneDemo, Code: layoutSplitPaneCode,
|
||||
Keys: "Tab focuses the divider. Left/Right (Up/Down when vertical) move it by 4 units, Home and End to the limits, Enter folds the collapsible pane; an arrow past its minimum folds it, the opposite arrow opens it."},
|
||||
{ID: "009", Name: "AspectRatio", ZhName: "宽高比", Category: "Layout", Demo: layoutAspectRatioDemo, Code: layoutAspectRatioCode,
|
||||
Keys: "None: AspectRatio never takes the focus."},
|
||||
{ID: "010", Name: "Divider", ZhName: "分隔线", Category: "Layout", Demo: layoutDividerDemo, Code: layoutDividerCode,
|
||||
Keys: "None: Divider and its ornament never take the focus."},
|
||||
}
|
||||
|
||||
// layoutCheck is a check box that logs its changes.
|
||||
func layoutCheck(c *ui.Context, v *bool, label string, log func(string)) {
|
||||
was := *v
|
||||
ui.Checkbox(c, v, label)
|
||||
if *v != was {
|
||||
log(fmt.Sprintf("%s: %v", label, *v))
|
||||
}
|
||||
}
|
||||
|
||||
// layoutChoice is a labeled drop-down that logs its changes.
|
||||
func layoutChoice(c *ui.Context, v *string, label string, options []string, log func(string)) {
|
||||
was := *v
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
ui.Text(c, label)
|
||||
ui.Select(c, v, options)
|
||||
})
|
||||
if *v != was {
|
||||
log(fmt.Sprintf("%s: %s", label, *v))
|
||||
}
|
||||
}
|
||||
|
||||
// layoutSlider is a labeled slider that logs whole-number changes.
|
||||
func layoutSlider(c *ui.Context, v *float64, label string, lo, hi float64, log func(string)) {
|
||||
was := math.Round(*v)
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("%s %.0f", label, *v))
|
||||
ui.Slider(c, v, lo, hi).Width(140)
|
||||
})
|
||||
if math.Round(*v) != was {
|
||||
log(fmt.Sprintf("%s: %.0f", label, *v))
|
||||
}
|
||||
}
|
||||
|
||||
func layoutControls(c *ui.Context, fn func()) { ui.Row(c).Wrap().Gap(12).Children(fn) }
|
||||
|
||||
func layoutFrame(c *ui.Context, h float32, fn func()) *ui.Element {
|
||||
k := mujica.Tokens(c)
|
||||
return ui.Column(c).Height(h).Border(1, k.Border).Radius(4).Clip().Children(fn)
|
||||
}
|
||||
|
||||
var layoutSpaces = []string{"Default", "None", "XS", "S", "M", "L", "XL", "XXL"}
|
||||
|
||||
func layoutSpace(s string) mujica.Space { return mujica.Space(slices.Index(layoutSpaces, s)) }
|
||||
|
||||
const layoutAppShellCode = `var collapsed bool
|
||||
mujica.AppShell(c, &collapsed, mujica.AppShellOptions{
|
||||
TitleBar: func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) },
|
||||
Sidebar: func() { ui.Text(c, "Chronicles") },
|
||||
Content: func() { ui.Text(c, "The Grand Hall") },
|
||||
})`
|
||||
|
||||
func layoutAppShellDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
collapsed, title, status bool
|
||||
width float64
|
||||
scroll ui.ScrollState
|
||||
}
|
||||
s := ui.Local(c.Root(), "001", func() state { return state{title: true, status: true, width: 220} })
|
||||
layoutControls(c, func() {
|
||||
layoutCheck(c, &s.title, "Title bar", log)
|
||||
layoutCheck(c, &s.status, "Status bar", log)
|
||||
layoutCheck(c, &s.collapsed, "Collapsed", log)
|
||||
layoutSlider(c, &s.width, "Sidebar width", 160, 320, log)
|
||||
})
|
||||
opts := mujica.AppShellOptions{
|
||||
SidebarWidth: float32(s.width),
|
||||
ContentScroll: &s.scroll,
|
||||
Sidebar: func() {
|
||||
for _, n := range []string{"Chronicles", "Letters", "Decrees", "Portraits", "Ledgers"} {
|
||||
ui.Text(c, n).Padding(6, 12)
|
||||
}
|
||||
},
|
||||
Content: func() {
|
||||
mujica.Container(c, mujica.ContainerOptions{}, func() {
|
||||
for i := range 30 {
|
||||
ui.Text(c, fmt.Sprintf("Entry %d of the court record", i+1)).Padding(4, 0)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
if s.title {
|
||||
opts.TitleBar = func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) }
|
||||
}
|
||||
if s.status {
|
||||
opts.StatusBar = func() {
|
||||
mujica.StatusBar(c, mujica.StatusBarOptions{Left: []mujica.StatusItem{{ID: "pos", Text: fmt.Sprintf("Scrolled %.0f", s.scroll.Y)}}})
|
||||
}
|
||||
}
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Children(func() {
|
||||
layoutFrame(c, 360, func() {
|
||||
if mujica.AppShell(c, &s.collapsed, opts).Toggled {
|
||||
log(fmt.Sprintf("sidebar collapsed: %v", s.collapsed))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layoutTitleBarCode = `mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{
|
||||
Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) },
|
||||
})`
|
||||
|
||||
func layoutTitleBarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
leading, trailing bool
|
||||
title string
|
||||
}
|
||||
s := ui.Local(c.Root(), "002", func() state { return state{trailing: true, title: "Court Records"} })
|
||||
layoutControls(c, func() {
|
||||
layoutCheck(c, &s.leading, "Leading tool", log)
|
||||
layoutCheck(c, &s.trailing, "Trailing tool", log)
|
||||
layoutChoice(c, &s.title, "Title", []string{"Court Records", "宫廷档案", "A very long title of the royal chronicle of the court"}, log)
|
||||
})
|
||||
var opts mujica.TitleBarOptions
|
||||
if s.leading {
|
||||
opts.Leading = func() {
|
||||
if mujica.Button(c, "", mujica.ButtonOptions{Variant: mujica.Ghost, Icon: icons.Must("panel-left"), Label: "Sidebar"}).Clicked() {
|
||||
log("leading tool clicked")
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.trailing {
|
||||
opts.Trailing = func() {
|
||||
if mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
log("Share clicked")
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(520).Children(func() {
|
||||
layoutFrame(c, 90, func() { mujica.TitleBar(c, s.title, opts) })
|
||||
})
|
||||
bar := c.TitleBar()
|
||||
ui.Text(c, fmt.Sprintf("Window controls reserve: left %.0f, right %.0f, height %.0f DIP", bar.Left, bar.Right, bar.Height)).
|
||||
TextColor(mujica.Tokens(c).TextMuted).Margin(8, 0, 0, 0)
|
||||
}
|
||||
|
||||
const layoutStatusBarCode = `bar := mujica.StatusBar(c, mujica.StatusBarOptions{
|
||||
Left: []mujica.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
|
||||
Right: []mujica.StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
|
||||
})
|
||||
if bar.Clicked == "bell" {
|
||||
opened++
|
||||
}`
|
||||
|
||||
func layoutStatusBarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
width, progress float64
|
||||
showProgress bool
|
||||
tone string
|
||||
}
|
||||
s := ui.Local(c.Root(), "003", func() state { return state{width: 640, progress: 40, showProgress: true, tone: "Warning"} })
|
||||
layoutControls(c, func() {
|
||||
layoutSlider(c, &s.width, "Width", 240, 720, log)
|
||||
layoutCheck(c, &s.showProgress, "Progress", log)
|
||||
layoutSlider(c, &s.progress, "Percent", 0, 100, log)
|
||||
layoutChoice(c, &s.tone, "Tone", []string{"Neutral", "Success", "Warning", "Danger", "Info"}, log)
|
||||
})
|
||||
tones := map[string]mujica.StatusItemTone{"Neutral": mujica.StatusItemNeutral, "Success": mujica.StatusItemSuccess,
|
||||
"Warning": mujica.StatusItemWarning, "Danger": mujica.StatusItemDanger, "Info": mujica.StatusItemInfo}
|
||||
opts := mujica.StatusBarOptions{
|
||||
Left: []mujica.StatusItem{
|
||||
{ID: "branch", Text: "main", Icon: icons.Must("folder")},
|
||||
{ID: "sync", Text: "Syncing", ShowProgress: s.showProgress, Progress: float32(s.progress / 100)},
|
||||
{ID: "lint", Text: "2 warnings", Tone: tones[s.tone], Secondary: true, Action: true},
|
||||
},
|
||||
Right: []mujica.StatusItem{
|
||||
{ID: "enc", Text: "UTF-8", Secondary: true},
|
||||
{ID: "pos", Text: "Ln 12, Col 4", Secondary: true},
|
||||
{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true},
|
||||
},
|
||||
}
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(float32(s.width)).Children(func() {
|
||||
if id := mujica.StatusBar(c, opts).Clicked; id != "" {
|
||||
log("clicked " + id)
|
||||
}
|
||||
})
|
||||
ui.Text(c, "Narrow the width: secondary items fold into a count, last first.").
|
||||
TextColor(mujica.Tokens(c).TextMuted).Margin(8, 0, 0, 0)
|
||||
}
|
||||
|
||||
const layoutContainerCode = `mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() {
|
||||
ui.Text(c, "A centered column of prose.")
|
||||
})`
|
||||
|
||||
func layoutContainerDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
maxWidth float64
|
||||
padding string
|
||||
center, fill, clip, wide bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "004", func() state { return state{maxWidth: 360, padding: "Default", center: true} })
|
||||
layoutControls(c, func() {
|
||||
layoutSlider(c, &s.maxWidth, "Max width", 0, 600, log)
|
||||
layoutChoice(c, &s.padding, "Padding", layoutSpaces, log)
|
||||
layoutCheck(c, &s.center, "Center", log)
|
||||
layoutCheck(c, &s.fill, "Fill", log)
|
||||
layoutCheck(c, &s.clip, "Clip", log)
|
||||
layoutCheck(c, &s.wide, "Overflowing child", log)
|
||||
})
|
||||
k := mujica.Tokens(c)
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Children(func() {
|
||||
layoutFrame(c, 200, func() {
|
||||
mujica.Container(c, mujica.ContainerOptions{MaxWidth: float32(s.maxWidth), Padding: layoutSpace(s.padding),
|
||||
Center: s.center, Fill: s.fill, Clip: s.clip}, func() {
|
||||
ui.Text(c, "Of the Court").FontSize(18)
|
||||
ui.Text(c, "Prose keeps a readable measure inside a container.").TextColor(k.TextMuted)
|
||||
if s.wide {
|
||||
ui.Box(c).Size(900, 24).Background(k.Selection).Shrink(0)
|
||||
}
|
||||
}).Background(k.Surface).Border(1, k.Border)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layoutStackCode = `mujica.Stack(c, mujica.StackOptions{Horizontal: true, Gap: mujica.SpaceM}, func() {
|
||||
mujica.Button(c, "Save", mujica.ButtonOptions{})
|
||||
mujica.StackItem(!showDraft, func() { mujica.Button(c, "Draft", mujica.ButtonOptions{Variant: mujica.Secondary}) })
|
||||
mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
})`
|
||||
|
||||
func layoutStackDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
horizontal, wrap, hide bool
|
||||
gap, align, justify string
|
||||
}
|
||||
s := ui.Local(c.Root(), "005", func() state {
|
||||
return state{horizontal: true, gap: "Default", align: "Auto", justify: "Start"}
|
||||
})
|
||||
aligns := []string{"Auto", "Start", "Center", "End", "Stretch"}
|
||||
justifies := map[string]ui.Align{"Start": ui.Start, "Center": ui.Center, "End": ui.End, "SpaceBetween": ui.SpaceBetween}
|
||||
layoutControls(c, func() {
|
||||
layoutCheck(c, &s.horizontal, "Horizontal", log)
|
||||
s.wrap = s.wrap && s.horizontal
|
||||
layoutCheck(c, &s.wrap, "Wrap", log)
|
||||
layoutCheck(c, &s.hide, "Hide Draft", log)
|
||||
layoutChoice(c, &s.gap, "Gap", layoutSpaces, log)
|
||||
layoutChoice(c, &s.align, "Align", aligns, log)
|
||||
layoutChoice(c, &s.justify, "Justify", []string{"Start", "Center", "End", "SpaceBetween"}, log)
|
||||
})
|
||||
align := mujica.StackAlign(slices.Index(aligns, s.align))
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(420).Children(func() {
|
||||
layoutFrame(c, 200, func() {
|
||||
mujica.Stack(c, mujica.StackOptions{Horizontal: s.horizontal, Wrap: s.wrap, Gap: layoutSpace(s.gap),
|
||||
Align: align, Justify: justifies[s.justify]}, func() {
|
||||
for _, n := range []string{"Save", "Draft", "Publish", "Archive", "Cancel"} {
|
||||
mujica.StackItem(s.hide && n == "Draft", func() {
|
||||
if mujica.Button(c, n, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
log(n + " clicked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}).Grow(1).Padding(12)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layoutGridCode = `mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
|
||||
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { ui.Text(c, "Header") })
|
||||
ui.Text(c, "Name")
|
||||
ui.Text(c, "Ada")
|
||||
})`
|
||||
|
||||
func layoutGridDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
first, gap, rowGap string
|
||||
header, tall bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "006", func() state {
|
||||
return state{first: "Fixed 120", gap: "Default", rowGap: "Default", header: true, tall: true}
|
||||
})
|
||||
tracks := map[string]ui.Track{"Fixed 120": ui.Fixed(120), "Fit content": ui.FitContent(), "Fr 1": ui.Fr(1), "Fr 2": ui.Fr(2)}
|
||||
layoutControls(c, func() {
|
||||
layoutChoice(c, &s.first, "First column", []string{"Fixed 120", "Fit content", "Fr 1", "Fr 2"}, log)
|
||||
layoutChoice(c, &s.gap, "Gap", layoutSpaces, log)
|
||||
layoutChoice(c, &s.rowGap, "Row gap", layoutSpaces, log)
|
||||
layoutCheck(c, &s.header, "Header spans all", log)
|
||||
layoutCheck(c, &s.tall, "First cell spans 2 rows", log)
|
||||
})
|
||||
k := mujica.Tokens(c)
|
||||
cell := func(text string) {
|
||||
ui.Box(c).Center().Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).
|
||||
Children(func() { ui.Text(c, text).TextColor(k.TextMuted) })
|
||||
}
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(520).Children(func() {
|
||||
mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{tracks[s.first], ui.Fr(1), ui.Fr(1)},
|
||||
Gap: layoutSpace(s.gap), RowGap: layoutSpace(s.rowGap)}, func() {
|
||||
if s.header {
|
||||
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { cell("Header, ColumnSpan -1") })
|
||||
}
|
||||
span := 1
|
||||
if s.tall {
|
||||
span = 2
|
||||
}
|
||||
mujica.GridCell(c, mujica.GridCellOptions{RowSpan: span}, func() { cell(s.first) })
|
||||
for i := range 4 {
|
||||
cell(fmt.Sprintf("Cell %d", i+1))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layoutScrollAreaCode = `var offset ui.ScrollState
|
||||
mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() {
|
||||
for range 50 {
|
||||
ui.Text(c, "A line of the log")
|
||||
}
|
||||
}).Height(200)`
|
||||
|
||||
func layoutScrollAreaDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
direction string
|
||||
offset ui.ScrollState
|
||||
lastX, lastY float32
|
||||
}
|
||||
s := ui.Local(c.Root(), "007", func() state { return state{direction: "Vertical"} })
|
||||
dirs := map[string]mujica.ScrollDirection{"Vertical": mujica.ScrollVertical, "Horizontal": mujica.ScrollHorizontal, "Both": mujica.ScrollBoth}
|
||||
layoutControls(c, func() {
|
||||
layoutChoice(c, &s.direction, "Direction", []string{"Vertical", "Horizontal", "Both"}, log)
|
||||
if mujica.Button(c, "Top", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.offset.X, s.offset.Y = 0, 0
|
||||
log("scroll to start")
|
||||
}
|
||||
if mujica.Button(c, "Middle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.offset.X, s.offset.Y = s.offset.MaxX/2, s.offset.MaxY/2
|
||||
log("scroll to middle")
|
||||
}
|
||||
if mujica.Button(c, "End", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.offset.X, s.offset.Y = math.MaxFloat32, math.MaxFloat32
|
||||
log("scroll to end")
|
||||
}
|
||||
})
|
||||
k := mujica.Tokens(c)
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(420).Children(func() {
|
||||
mujica.ScrollArea(c, &s.offset, mujica.ScrollAreaOptions{Direction: dirs[s.direction], Label: "Ledger"}, func() {
|
||||
for i := range 40 {
|
||||
ui.Text(c, fmt.Sprintf("%02d Ledger line of the treasury, folio %d, carried over from the last quarter", i+1, i+7)).
|
||||
SingleLine().Padding(4, 12)
|
||||
}
|
||||
}).Height(220).Background(k.Surface).Border(1, k.Border).Radius(4)
|
||||
})
|
||||
if s.offset.Y != s.lastY || s.offset.X != s.lastX {
|
||||
s.lastX, s.lastY = s.offset.X, s.offset.Y
|
||||
log(fmt.Sprintf("offset %.0f, %.0f", s.offset.X, s.offset.Y))
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("Offset %.0f, %.0f of %.0f, %.0f", s.offset.X, s.offset.Y, s.offset.MaxX, s.offset.MaxY)).
|
||||
TextColor(k.TextMuted).Margin(8, 0, 0, 0)
|
||||
}
|
||||
|
||||
const layoutSplitPaneCode = `split := mujica.SplitPaneState{Size: 160}
|
||||
mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst},
|
||||
func() { ui.Text(c, "Files") },
|
||||
func() { ui.Text(c, "Editor") },
|
||||
).Fill()`
|
||||
|
||||
func layoutSplitPaneDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
split mujica.SplitPaneState
|
||||
vertical bool
|
||||
collapsible string
|
||||
minFirst float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "008", func() state {
|
||||
return state{split: mujica.SplitPaneState{Size: 180}, collapsible: "First", minFirst: 120}
|
||||
})
|
||||
sides := map[string]mujica.SplitPaneSide{"Neither": mujica.SplitPaneNeither, "First": mujica.SplitPaneFirst, "Second": mujica.SplitPaneSecond}
|
||||
layoutControls(c, func() {
|
||||
layoutCheck(c, &s.vertical, "Vertical", log)
|
||||
layoutChoice(c, &s.collapsible, "Collapsible", []string{"Neither", "First", "Second"}, log)
|
||||
layoutSlider(c, &s.minFirst, "Min first", 40, 200, log)
|
||||
})
|
||||
s.split.Collapsed = s.split.Collapsed && s.collapsible != "Neither"
|
||||
k := mujica.Tokens(c)
|
||||
pane := func(title, body string, bg ui.Color) func() {
|
||||
return func() {
|
||||
ui.Column(c).Fill().Padding(12).Gap(6).Background(bg).Children(func() {
|
||||
ui.Text(c, title).FontSize(16)
|
||||
ui.Text(c, body).TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
}
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(560).Children(func() {
|
||||
layoutFrame(c, 280, func() {
|
||||
r := mujica.SplitPane(c, &s.split, mujica.SplitPaneOptions{Vertical: s.vertical, MinFirst: float32(s.minFirst),
|
||||
Collapsible: sides[s.collapsible]},
|
||||
pane("Files", "Drag the divider, or focus it and use the arrows.", k.Surface),
|
||||
pane("Editor", "Enter on the divider folds the collapsible pane.", k.Background))
|
||||
r.Fill()
|
||||
if r.Resized {
|
||||
log(fmt.Sprintf("resized to %.0f", s.split.Size))
|
||||
}
|
||||
if r.Toggled {
|
||||
log(fmt.Sprintf("collapsed: %v", s.split.Collapsed))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layoutAspectRatioCode = `ui.Box(c).Size(320, 240).Children(func() {
|
||||
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
|
||||
ui.Text(c, "16 : 9")
|
||||
})
|
||||
})`
|
||||
|
||||
func layoutAspectRatioDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
ratio string
|
||||
width, height float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "009", func() state { return state{ratio: "16 : 9", width: 360, height: 220} })
|
||||
ratios := map[string]float32{"1 : 1": 1, "4 : 3": 4.0 / 3, "16 : 9": 16.0 / 9, "3 : 4": 3.0 / 4, "21 : 9": 21.0 / 9}
|
||||
layoutControls(c, func() {
|
||||
layoutChoice(c, &s.ratio, "Ratio", []string{"1 : 1", "4 : 3", "16 : 9", "3 : 4", "21 : 9"}, log)
|
||||
layoutSlider(c, &s.width, "Parent width", 120, 560, log)
|
||||
layoutSlider(c, &s.height, "Parent height", 80, 320, log)
|
||||
})
|
||||
k := mujica.Tokens(c)
|
||||
ui.Box(c).Margin(16, 0, 0, 0).Size(float32(s.width), float32(s.height)).Border(1, k.Border).Children(func() {
|
||||
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: ratios[s.ratio]}, func() {
|
||||
ui.Box(c).Fill().Center().Background(k.Surface).Border(1, k.Ornament).Children(func() {
|
||||
ui.Text(c, s.ratio).TextColor(k.TextMuted)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layoutDividerCode = `ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
mujica.Divider(c, mujica.DividerOptions{Ornament: true})
|
||||
mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"})
|
||||
})`
|
||||
|
||||
func layoutDividerDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
vertical, ornament bool
|
||||
label string
|
||||
}
|
||||
s := ui.Local(c.Root(), "010", func() state { return state{ornament: true, label: "None"} })
|
||||
layoutControls(c, func() {
|
||||
layoutCheck(c, &s.vertical, "Vertical", log)
|
||||
layoutCheck(c, &s.ornament, "Ornament", log)
|
||||
if !s.vertical {
|
||||
layoutChoice(c, &s.label, "Label", []string{"None", "Chapter II", "第二章"}, log)
|
||||
}
|
||||
})
|
||||
label := s.label
|
||||
if label == "None" || s.vertical {
|
||||
label = ""
|
||||
}
|
||||
opts := mujica.DividerOptions{Vertical: s.vertical, Ornament: s.ornament, Label: label}
|
||||
ui.Column(c).Margin(16, 0, 0, 0).Width(480).Children(func() {
|
||||
if s.vertical {
|
||||
ui.Row(c).Height(80).Gap(16).Children(func() {
|
||||
ui.Text(c, "Overture")
|
||||
mujica.Divider(c, opts)
|
||||
ui.Text(c, "Aria")
|
||||
})
|
||||
return
|
||||
}
|
||||
ui.Column(c).Gap(16).Children(func() {
|
||||
ui.Text(c, "Overture")
|
||||
mujica.Divider(c, opts)
|
||||
ui.Text(c, "Aria")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package mujica
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// GridOptions configure a Grid.
|
||||
type GridOptions struct {
|
||||
// Columns size each column with ui.Fixed, ui.Fr or ui.FitContent; nil is one Fr(1) column.
|
||||
Columns []ui.Track
|
||||
// Rows size the first rows; rows added after fit their content.
|
||||
Rows []ui.Track
|
||||
// Gap defaults to SpaceM.
|
||||
Gap Space
|
||||
// RowGap defaults to Gap.
|
||||
RowGap Space
|
||||
}
|
||||
|
||||
// Grid lays children out in columns and rows; GridCell places and spans them.
|
||||
func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element {
|
||||
if children == nil {
|
||||
panic("mujica: Grid needs children")
|
||||
}
|
||||
d := Density(c)
|
||||
gap := containerSpace(d, opts.Gap, SpaceM)
|
||||
rowGap := gap
|
||||
if opts.RowGap != SpaceDefault {
|
||||
rowGap = containerSpace(d, opts.RowGap, SpaceM)
|
||||
}
|
||||
cols := opts.Columns
|
||||
if len(cols) == 0 {
|
||||
cols = []ui.Track{ui.Fr(1)}
|
||||
}
|
||||
e := ui.Grid(c).ColumnTracks(cols...).GapX(gap).GapY(rowGap)
|
||||
if len(opts.Rows) > 0 {
|
||||
e.RowTracks(opts.Rows...)
|
||||
}
|
||||
return e.Children(children)
|
||||
}
|
||||
|
||||
// GridCellOptions place one cell. Lines count from 1; zero places the cell automatically.
|
||||
type GridCellOptions struct {
|
||||
Column, Row int
|
||||
// ColumnSpan and RowSpan default to 1; -1 spans to the last track.
|
||||
ColumnSpan, RowSpan int
|
||||
}
|
||||
|
||||
// GridCell is one child of a Grid, placed and spanned by opts; build makes one element, stretched over the cell.
|
||||
func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element {
|
||||
if build == nil {
|
||||
panic("mujica: GridCell needs build")
|
||||
}
|
||||
if opts.Column < 0 || opts.Row < 0 {
|
||||
panic("mujica: GridCell Column and Row count from 1")
|
||||
}
|
||||
if opts.ColumnSpan < -1 || opts.RowSpan < -1 {
|
||||
panic("mujica: GridCell spans must be positive, or -1 for the rest")
|
||||
}
|
||||
// A one-track grid stretches its child over the whole cell, as the grid does its own children.
|
||||
e := ui.Grid(c).Columns(1).Rows(1)
|
||||
if opts.Column > 0 {
|
||||
e.ColumnStart(opts.Column)
|
||||
}
|
||||
if opts.Row > 0 {
|
||||
e.RowStart(opts.Row)
|
||||
}
|
||||
if opts.ColumnSpan != 0 {
|
||||
e.ColumnSpan(opts.ColumnSpan)
|
||||
}
|
||||
if opts.RowSpan != 0 {
|
||||
e.RowSpan(opts.RowSpan)
|
||||
}
|
||||
return e.Children(build)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestGridFixedAndFrTracks(t *testing.T) {
|
||||
var a, b ui.Rect
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Grid(c, GridOptions{Columns: []ui.Track{ui.Fixed(100), ui.Fr(1)}}, func() {
|
||||
a = ui.Box(c).Height(20).Bounds()
|
||||
b = ui.Box(c).Height(20).Bounds()
|
||||
}).Width(400)
|
||||
}, 400, 300).Frame()
|
||||
if a.W != 100 || b.X != 112 || b.W != 288 {
|
||||
t.Fatalf("tracks %v and %v at %v, want 100, 288 at 112", a.W, b.W, b.X)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridSpans(t *testing.T) {
|
||||
var head, tall, side ui.Rect
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Grid(c, GridOptions{Columns: []ui.Track{ui.Fr(1), ui.Fr(1), ui.Fr(1)}, Gap: SpaceS, RowGap: SpaceL}, func() {
|
||||
head = GridCell(c, GridCellOptions{ColumnSpan: -1}, func() { ui.Box(c).Height(20) }).Bounds()
|
||||
tall = GridCell(c, GridCellOptions{RowSpan: 2}, func() { ui.Box(c).Height(10) }).Bounds()
|
||||
side = GridCell(c, GridCellOptions{Column: 3, Row: 3}, func() { ui.Box(c).Height(30) }).Bounds()
|
||||
GridCell(c, GridCellOptions{Column: 2, Row: 2}, func() { ui.Box(c).Height(30) })
|
||||
}).Width(316)
|
||||
}, 400, 300).Frame()
|
||||
if head.W != 316 {
|
||||
t.Fatalf("header width %v, want 316", head.W)
|
||||
}
|
||||
// Rows of 30 with a 16 gap: the tall cell spans 30+16+30.
|
||||
if tall.H != 76 {
|
||||
t.Fatalf("row-spanning cell height %v, want 76", tall.H)
|
||||
}
|
||||
if side.X != 216 || side.Y != tall.Y+46 {
|
||||
t.Fatalf("placed cell at %v,%v, want 216,%v", side.X, side.Y, tall.Y+46)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridRowTracksShareHeight(t *testing.T) {
|
||||
var a, b ui.Rect
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Grid(c, GridOptions{Rows: []ui.Track{ui.Fixed(40), ui.Fr(1)}, Gap: SpaceNone}, func() {
|
||||
a = ui.Box(c).Bounds()
|
||||
b = ui.Box(c).Bounds()
|
||||
}).Height(200)
|
||||
}, 300, 300).Frame()
|
||||
if a.H != 40 || b.H != 160 {
|
||||
t.Fatalf("rows %v and %v, want 40 and 160", a.H, b.H)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridCellRejectsBadPlacement(t *testing.T) {
|
||||
for _, o := range []GridCellOptions{{Column: -1}, {RowSpan: -2}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", o)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Grid(c, GridOptions{}, func() { GridCell(c, o, func() {}) })
|
||||
}, 100, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridSnapshot(t *testing.T) {
|
||||
snapshot(t, "grid", 420, 200, func(c *ui.Context) {
|
||||
k := Tokens(c)
|
||||
cell := func(s string) {
|
||||
ui.Box(c).Center().Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).
|
||||
Children(func() { ui.Text(c, s).TextColor(k.TextMuted) })
|
||||
}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Grid(c, GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1), ui.Fr(1)}}, func() {
|
||||
GridCell(c, GridCellOptions{RowSpan: 2}, func() { cell("Fixed 96") })
|
||||
GridCell(c, GridCellOptions{ColumnSpan: 2}, func() { cell("Span 2") })
|
||||
cell("Fr 1")
|
||||
cell("Fr 1")
|
||||
GridCell(c, GridCellOptions{ColumnSpan: -1}, func() { cell("Across") })
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// HeadingOptions configure a Heading. The zero value is a level 1 heading.
|
||||
type HeadingOptions struct {
|
||||
// Level is 1, 2 or 3; zero means 1.
|
||||
Level int
|
||||
// Subtitle shows below the title in muted text.
|
||||
Subtitle string
|
||||
// Divider draws an ornament rule with a center diamond below.
|
||||
Divider bool
|
||||
}
|
||||
|
||||
// Heading shows a serif title that follows the desktop's text scale.
|
||||
func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element {
|
||||
k := Tokens(c)
|
||||
size := headingSize(opts.Level)
|
||||
scale := c.Preferences().TextScale
|
||||
if scale <= 0 {
|
||||
scale = 1
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
col := ui.Column(c).Gap(u).Shrink(0)
|
||||
col.Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(size * scale).FontWeight(600).
|
||||
LineHeight(1.25).TextColor(k.Text)
|
||||
if opts.Subtitle != "" {
|
||||
ui.Text(c, opts.Subtitle).FontSize(theme.BodySize * scale).LineHeight(theme.LineHeight).TextColor(k.TextMuted)
|
||||
}
|
||||
if opts.Divider {
|
||||
ui.Box(c).FillWidth().Height(9).Margin(u, 0, 0, 0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
headingOrnament(p, r, k.Ornament)
|
||||
})
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
|
||||
func headingSize(level int) float32 {
|
||||
switch level {
|
||||
case 0, 1:
|
||||
return theme.H1Size
|
||||
case 2:
|
||||
return theme.H2Size
|
||||
case 3:
|
||||
return theme.H3Size
|
||||
}
|
||||
panic("mujica: Heading Level must be 1, 2 or 3")
|
||||
}
|
||||
|
||||
// headingOrnament draws symmetric rules around a center diamond.
|
||||
func headingOrnament(p *ui.Painter, r ui.Rect, c ui.Color) {
|
||||
cx, cy := r.X+r.W/2, r.Y+r.H/2
|
||||
const d, gap = 7, 6
|
||||
internal.Rule(p, r.X, cx-gap, cy, c)
|
||||
internal.Rule(p, cx+gap, r.X+r.W, cy, c)
|
||||
p.FillPath(internal.Diamond(cx, cy, d), c)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func headingHeights(t *testing.T, p ui.Preferences) (h1, h2, h3 float32) {
|
||||
t.Helper()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
h1 = Heading(c, "Court", HeadingOptions{}).Bounds().H
|
||||
h2 = Heading(c, "Court", HeadingOptions{Level: 2}).Bounds().H
|
||||
h3 = Heading(c, "Court", HeadingOptions{Level: 3}).Bounds().H
|
||||
})
|
||||
}, 400, 400)
|
||||
tt.SetPreferences(p)
|
||||
tt.Frame()
|
||||
return
|
||||
}
|
||||
|
||||
func TestHeadingLevels(t *testing.T) {
|
||||
h1, h2, h3 := headingHeights(t, ui.Preferences{TextScale: 1})
|
||||
if !(h1 > h2 && h2 > h3 && h3 > 0) {
|
||||
t.Fatalf("heights %v %v %v not decreasing", h1, h2, h3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingTextScale(t *testing.T) {
|
||||
h1, _, _ := headingHeights(t, ui.Preferences{TextScale: 1})
|
||||
big, _, _ := headingHeights(t, ui.Preferences{TextScale: 2})
|
||||
if big < h1*1.8 {
|
||||
t.Fatalf("scaled height %v, base %v", big, h1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingSubtitleAndDivider(t *testing.T) {
|
||||
var plain, full float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
plain = Heading(c, "Archive", HeadingOptions{Level: 2}).Bounds().H
|
||||
full = Heading(c, "Ledger", HeadingOptions{Level: 2, Subtitle: "Entries since 1802", Divider: true}).Bounds().H
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Entries since 1802") {
|
||||
t.Fatal("subtitle missing")
|
||||
}
|
||||
if full < plain+20 {
|
||||
t.Fatalf("subtitle and divider add %v DIPs", full-plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingBadLevelPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Heading(c, "x", HeadingOptions{Level: 4})
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestHeadingSnapshot(t *testing.T) {
|
||||
snapshot(t, "heading", 420, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(16).Width(380).Children(func() {
|
||||
Heading(c, "The Grand Hall", HeadingOptions{Subtitle: "Ceremonies of the winter season", Divider: true})
|
||||
Heading(c, "Guest Ledger", HeadingOptions{Level: 2})
|
||||
Heading(c, "宫廷礼仪", HeadingOptions{Level: 3, Subtitle: "第三章"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// IconTone is an icon's semantic color. The zero value takes the text color.
|
||||
type IconTone int
|
||||
|
||||
const (
|
||||
IconInherit IconTone = iota
|
||||
IconMuted
|
||||
IconAccent
|
||||
IconSuccess
|
||||
IconWarning
|
||||
IconDanger
|
||||
IconInfo
|
||||
)
|
||||
|
||||
// IconOptions configure an Icon.
|
||||
type IconOptions struct {
|
||||
// Size is the icon's width and height in DIPs; zero means 16.
|
||||
Size float32
|
||||
Tone IconTone
|
||||
// Label names a meaningful icon for assistive technology; empty hides it.
|
||||
Label string
|
||||
}
|
||||
|
||||
type iconMissingKey struct{}
|
||||
|
||||
// Icon shows the named icon of the icons package. A missing name shows a
|
||||
// dashed fallback box in the Danger color and logs a warning.
|
||||
func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element {
|
||||
if name == "" {
|
||||
panic("mujica: Icon needs a name")
|
||||
}
|
||||
size := opts.Size
|
||||
if size < 0 {
|
||||
panic("mujica: Icon Size must not be negative")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
k := Tokens(c)
|
||||
svg, ok := icons.Get(name)
|
||||
if !ok {
|
||||
return iconMissing(c, name, size, k)
|
||||
}
|
||||
e := ui.Icon(c, svg).Size(size, size).Shrink(0)
|
||||
if col, set := iconColor(k, opts.Tone); set {
|
||||
e.TextColor(col)
|
||||
}
|
||||
if opts.Label != "" {
|
||||
e.Label(opts.Label)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func iconMissing(c *ui.Context, name string, size float32, k theme.Tokens) *ui.Element {
|
||||
text := fmt.Sprintf(msg(c, "icon.missing"), name)
|
||||
e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(text).Tooltip(text)
|
||||
logged := ui.Local(e, iconMissingKey{}, func() string { return "" })
|
||||
if *logged != name {
|
||||
*logged = name
|
||||
slog.Warn("mujica: icon missing", "name", name)
|
||||
}
|
||||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
in := ui.Rect{X: r.X + 1, Y: r.Y + 1, W: r.W - 2, H: r.H - 2}
|
||||
p.StrokeDashed(in, k.Danger, theme.ControlRadius, 1)
|
||||
p.Line(in.X+3, in.Y+3, in.X+in.W-3, in.Y+in.H-3, 1, k.Danger)
|
||||
})
|
||||
}
|
||||
|
||||
func iconColor(k theme.Tokens, t IconTone) (ui.Color, bool) {
|
||||
switch t {
|
||||
case IconInherit:
|
||||
return ui.Color{}, false
|
||||
case IconMuted:
|
||||
return k.TextMuted, true
|
||||
case IconAccent:
|
||||
return k.AccentText, true
|
||||
case IconSuccess:
|
||||
return k.Success, true
|
||||
case IconWarning:
|
||||
return k.Warning, true
|
||||
case IconDanger:
|
||||
return k.Danger, true
|
||||
case IconInfo:
|
||||
return k.Info, true
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown IconTone %d", t))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// IconButtonOptions configure an IconButton.
|
||||
type IconButtonOptions struct {
|
||||
// Variant is the emphasis; the zero value is Primary, as for Button.
|
||||
Variant ButtonVariant
|
||||
// Tooltip shows on hover; empty uses the button's name.
|
||||
Tooltip string
|
||||
Loading bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IconButton is a square button showing only icon; label names its action.
|
||||
func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element {
|
||||
if icon == nil {
|
||||
panic("mujica: IconButton needs an icon")
|
||||
}
|
||||
if label == "" {
|
||||
panic("mujica: IconButton needs an action name")
|
||||
}
|
||||
h := Density(c).ControlHeight()
|
||||
b := Button(c, "", ButtonOptions{Variant: opts.Variant, Icon: icon, Loading: opts.Loading, Disabled: opts.Disabled, Label: label})
|
||||
b.Padding(0).Size(h, h)
|
||||
tip := opts.Tooltip
|
||||
if tip == "" {
|
||||
tip = label
|
||||
}
|
||||
return b.Tooltip(tip)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func TestIconButtonClickByNameAndKeyboard(t *testing.T) {
|
||||
n := 0
|
||||
var w, h float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Children(func() {
|
||||
b := IconButton(c, icons.Must("settings"), "Settings", IconButtonOptions{Variant: Ghost})
|
||||
w, h = b.Bounds().W, b.Bounds().H
|
||||
if b.Clicked() {
|
||||
n++
|
||||
}
|
||||
})
|
||||
}, 300, 200)
|
||||
if err := tt.Click("Settings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 2 {
|
||||
t.Fatalf("clicks %d", n)
|
||||
}
|
||||
if w != 36 || h != 36 {
|
||||
t.Fatalf("size %vx%v, want 36 square", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconButtonBlockedStates(t *testing.T) {
|
||||
for _, opts := range []IconButtonOptions{{Disabled: true}, {Loading: true}} {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if IconButton(c, icons.Must("trash-2"), "Delete", opts).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 300, 200)
|
||||
_ = tt.Click("Delete")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if n != 0 {
|
||||
t.Fatalf("%+v: clicked %d", opts, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconButtonNeedsName(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without an action name")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IconButton(c, icons.Must("x"), "", IconButtonOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestIconButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "icon_button", 360, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(12).Children(func() {
|
||||
IconButton(c, icons.Must("plus"), "Add", IconButtonOptions{})
|
||||
IconButton(c, icons.Must("settings"), "Settings", IconButtonOptions{Variant: Secondary})
|
||||
IconButton(c, icons.Must("star"), "Favorite", IconButtonOptions{Variant: Outline})
|
||||
IconButton(c, icons.Must("copy"), "Copy", IconButtonOptions{Variant: Ghost})
|
||||
IconButton(c, icons.Must("trash-2"), "Delete", IconButtonOptions{Variant: Danger})
|
||||
IconButton(c, icons.Must("refresh-cw"), "Refresh", IconButtonOptions{Loading: true})
|
||||
IconButton(c, icons.Must("lock"), "Lock", IconButtonOptions{Disabled: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestIconSizeAndLabel(t *testing.T) {
|
||||
var def, big ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
def = Icon(c, "star", IconOptions{}).Bounds()
|
||||
big = Icon(c, "bell", IconOptions{Size: 24, Tone: IconAccent, Label: "Alerts"}).Bounds()
|
||||
})
|
||||
}, 200, 100)
|
||||
tt.Frame()
|
||||
if def.W != 16 || def.H != 16 || big.W != 24 || big.H != 24 {
|
||||
t.Fatalf("sizes %v %v", def, big)
|
||||
}
|
||||
if _, ok := tt.Find("Alerts"); !ok {
|
||||
t.Fatal("label not exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconMissingFallbackIsVisibleAndLogged(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
old := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
defer slog.SetDefault(old)
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Icon(c, "no-such-icon", IconOptions{Size: 20}).Bounds()
|
||||
})
|
||||
}, 200, 100)
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if r.W != 20 || r.H != 20 {
|
||||
t.Fatalf("fallback size %v", r)
|
||||
}
|
||||
if _, ok := tt.Find("Missing icon: no-such-icon"); !ok {
|
||||
t.Fatal("fallback not labeled")
|
||||
}
|
||||
if n := strings.Count(buf.String(), "mujica: icon missing"); n != 1 {
|
||||
t.Fatalf("logged %d times: %s", n, buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "name=no-such-icon") {
|
||||
t.Fatalf("log lacks name: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconBadOptionsPanic(t *testing.T) {
|
||||
for name, opts := range map[string]IconOptions{"": {}, "star": {Size: -1}, "x": {Tone: IconTone(99)}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("%q %+v: no panic", name, opts)
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Icon(c, name, opts)
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconSnapshot(t *testing.T) {
|
||||
snapshot(t, "icon", 360, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(14).AlignItems(ui.Center).Children(func() {
|
||||
Icon(c, "star", IconOptions{})
|
||||
Icon(c, "star", IconOptions{Size: 20, Tone: IconMuted})
|
||||
Icon(c, "star", IconOptions{Size: 24, Tone: IconAccent})
|
||||
Icon(c, "circle-check", IconOptions{Size: 20, Tone: IconSuccess})
|
||||
Icon(c, "triangle-alert", IconOptions{Size: 20, Tone: IconWarning})
|
||||
Icon(c, "circle-x", IconOptions{Size: 20, Tone: IconDanger})
|
||||
Icon(c, "info", IconOptions{Size: 20, Tone: IconInfo})
|
||||
Icon(c, "no-such-icon", IconOptions{Size: 20})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ImageOptions configure an Image.
|
||||
type ImageOptions struct {
|
||||
// Width and Height size the box in DIPs; zero keeps the picture's size,
|
||||
// or 160×120 for the placeholder, loading and error states.
|
||||
Width, Height float32
|
||||
// Crop covers the box and crops the rest; false fits the picture inside.
|
||||
Crop bool
|
||||
// Alt describes the picture for assistive technology.
|
||||
Alt string
|
||||
// Loading shows the caller's load in progress.
|
||||
Loading bool
|
||||
// Err shows the caller's load failure with a retry button.
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImageView is a shown Image and what the user did to it this frame.
|
||||
type ImageView struct {
|
||||
Element *ui.Element
|
||||
// Retry reports a click on the retry button of the error state.
|
||||
Retry bool
|
||||
}
|
||||
|
||||
// Image shows a *ui.Bitmap or *ui.SVG. A nil src shows a placeholder.
|
||||
func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
|
||||
if opts.Width < 0 || opts.Height < 0 {
|
||||
panic("mujica: Image Width and Height must not be negative")
|
||||
}
|
||||
if opts.Err == nil && !opts.Loading && src != nil {
|
||||
e := ui.Image(c, src).Radius(theme.ControlRadius)
|
||||
if opts.Crop {
|
||||
e.Fit(ui.Cover)
|
||||
}
|
||||
if opts.Width > 0 {
|
||||
e.Width(opts.Width)
|
||||
}
|
||||
if opts.Height > 0 {
|
||||
e.Height(opts.Height)
|
||||
}
|
||||
if opts.Alt != "" {
|
||||
e.Label(opts.Alt)
|
||||
}
|
||||
return ImageView{Element: e}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
w, h := opts.Width, opts.Height
|
||||
if w == 0 {
|
||||
w = 160
|
||||
}
|
||||
if h == 0 {
|
||||
h = 120
|
||||
}
|
||||
var v ImageView
|
||||
frame := ui.Column(c).Size(w, h).Shrink(0).Center().Gap(u*2).Padding(u*2).Clip().
|
||||
Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
|
||||
v.Element = frame
|
||||
frame.Children(func() {
|
||||
switch {
|
||||
case opts.Err != nil:
|
||||
frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
|
||||
Icon(c, "circle-alert", IconOptions{Size: 20, Tone: IconDanger})
|
||||
ui.Text(c, msg(c, "image.error")).FontSize(theme.CaptionSize).TextColor(k.Danger).TextAlign(ui.Center)
|
||||
if Button(c, msg(c, "image.retry"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
v.Retry = true
|
||||
slog.Debug("mujica: image retry", "alt", opts.Alt)
|
||||
}
|
||||
case opts.Loading:
|
||||
frame.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt)
|
||||
Spinner(c, SpinnerOptions{Size: 20, Label: msg(c, "image.loading")})
|
||||
default:
|
||||
frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
|
||||
Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted})
|
||||
}
|
||||
})
|
||||
return v
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func imageTestBitmap() *ui.Bitmap {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 80, 40))
|
||||
for y := range 40 {
|
||||
for x := range 80 {
|
||||
img.Set(x, y, color.RGBA{uint8(100 + x), 32, 52, 255})
|
||||
}
|
||||
}
|
||||
return ui.NewBitmap(img)
|
||||
}
|
||||
|
||||
func TestImageLoadedSizes(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
var natural, sized ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
natural = Image(c, bmp, ImageOptions{Alt: "Gallery"}).Element.Bounds()
|
||||
sized = Image(c, bmp, ImageOptions{Width: 50, Height: 50, Crop: true}).Element.Bounds()
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
if natural.W != 80 || natural.H != 40 {
|
||||
t.Fatalf("natural %v", natural)
|
||||
}
|
||||
if sized.W != 50 || sized.H != 50 {
|
||||
t.Fatalf("sized %v", sized)
|
||||
}
|
||||
if _, ok := tt.Find("Gallery"); !ok {
|
||||
t.Fatal("alt text not exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStates(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
src ui.ImageSource
|
||||
opts ImageOptions
|
||||
label string
|
||||
}{
|
||||
{"placeholder", nil, ImageOptions{}, "No image"},
|
||||
{"loading", imageTestBitmap(), ImageOptions{Loading: true}, "Loading image"},
|
||||
{"error", imageTestBitmap(), ImageOptions{Loading: true, Err: errors.New("404")}, "Image failed to load"},
|
||||
} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Image(c, tc.src, tc.opts).Element.Bounds()
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
if r.W != 160 || r.H != 120 {
|
||||
t.Errorf("%s: size %v", tc.name, r)
|
||||
}
|
||||
if _, ok := tt.Find(tc.label); !ok {
|
||||
t.Errorf("%s: no %q", tc.name, tc.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRetry(t *testing.T) {
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Image(c, nil, ImageOptions{Width: 200, Height: 140, Err: errors.New("timeout")}).Retry {
|
||||
retries++
|
||||
}
|
||||
}, 300, 300)
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if retries != 2 {
|
||||
t.Fatalf("retries %d, want 2", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageNegativeSizePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Image(c, nil, ImageOptions{Width: -1})
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestImageSnapshot(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
snapshot(t, "image", 600, 180, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
Image(c, bmp, ImageOptions{Width: 120, Height: 120, Crop: true, Alt: "Cropped"})
|
||||
Image(c, nil, ImageOptions{Width: 120, Height: 120})
|
||||
Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
Image(c, nil, ImageOptions{Width: 160, Height: 140, Err: errors.New("404")})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Fade animates e's background and border colors over d; zero skips it.
|
||||
func Fade(e *ui.Element, d time.Duration) *ui.Element {
|
||||
if d > 0 {
|
||||
e.Transition(ui.ElementTransition{Colors: true, Duration: d})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Rule draws a 1 DIP horizontal line from x0 to x1 at y, on device pixels.
|
||||
func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
|
||||
s := float64(p.Scale())
|
||||
px := math.Floor(float64(y) * s)
|
||||
if int(math.Round(s))%2 == 1 {
|
||||
px += 0.5
|
||||
}
|
||||
yy := float32(px / s)
|
||||
p.Line(x0, yy, x1, yy, 1, c)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Kbd shows a key or key combination as key caps. It registers nothing.
|
||||
// "Mod" is Command on macOS and Ctrl elsewhere; Ctrl, Alt, Shift, Enter,
|
||||
// Esc, Tab, Backspace, Delete, Up, Down, Left and Right follow the platform.
|
||||
func Kbd(c *ui.Context, keys ...string) *ui.Element {
|
||||
if len(keys) == 0 {
|
||||
panic("mujica: Kbd needs at least one key")
|
||||
}
|
||||
k := Tokens(c)
|
||||
mac := runtime.GOOS == "darwin"
|
||||
spoken := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
if key == "" {
|
||||
panic("mujica: Kbd key must not be empty")
|
||||
}
|
||||
_, spoken[i] = kbdKey(key, mac)
|
||||
}
|
||||
row := ui.Row(c).Gap(2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleText).Label(strings.Join(spoken, "+"))
|
||||
row.Children(func() {
|
||||
for i, key := range keys {
|
||||
if i > 0 && !mac {
|
||||
ui.Text(c, "+").FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
}
|
||||
face, _ := kbdKey(key, mac)
|
||||
ui.Box(c).Key(i).Row().Center().MinWidth(20).Height(20).Padding(0, 5).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).BorderColor(k.ControlBorder).
|
||||
BorderWidth(1, 1, 2, 1).Children(func() {
|
||||
ui.Text(c, face).Font(theme.MonoFont).FontSize(theme.CaptionSize).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// kbdNames maps a key to its macOS cap, macOS word and other platforms' cap.
|
||||
var kbdNames = map[string]struct{ macCap, macWord, other string }{
|
||||
"mod": {"⌘", "Command", "Ctrl"},
|
||||
"ctrl": {"⌃", "Control", "Ctrl"},
|
||||
"alt": {"⌥", "Option", "Alt"},
|
||||
"shift": {"⇧", "Shift", "Shift"},
|
||||
"enter": {"↩", "Return", "Enter"},
|
||||
"esc": {"esc", "Escape", "Esc"},
|
||||
"tab": {"⇥", "Tab", "Tab"},
|
||||
"backspace": {"⌫", "Delete", "Backspace"},
|
||||
"delete": {"⌦", "Forward Delete", "Delete"},
|
||||
"up": {"↑", "Up", "↑"},
|
||||
"down": {"↓", "Down", "↓"},
|
||||
"left": {"←", "Left", "←"},
|
||||
"right": {"→", "Right", "→"},
|
||||
}
|
||||
|
||||
// kbdKey returns how key shows and how it is read on the platform.
|
||||
func kbdKey(key string, mac bool) (shown, spoken string) {
|
||||
n, ok := kbdNames[strings.ToLower(key)]
|
||||
if !ok {
|
||||
if len([]rune(key)) == 1 {
|
||||
key = strings.ToUpper(key)
|
||||
}
|
||||
return key, key
|
||||
}
|
||||
if mac {
|
||||
return n.macCap, n.macWord
|
||||
}
|
||||
word := n.other
|
||||
switch strings.ToLower(key) {
|
||||
case "up", "down", "left", "right":
|
||||
word = n.macWord
|
||||
}
|
||||
return n.other, word
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestKbdKeyPlatforms(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
key string
|
||||
mac bool
|
||||
shown, spoken string
|
||||
}{
|
||||
{"Mod", true, "⌘", "Command"},
|
||||
{"Mod", false, "Ctrl", "Ctrl"},
|
||||
{"shift", true, "⇧", "Shift"},
|
||||
{"Alt", false, "Alt", "Alt"},
|
||||
{"Alt", true, "⌥", "Option"},
|
||||
{"Up", false, "↑", "Up"},
|
||||
{"k", false, "K", "K"},
|
||||
{"F5", true, "F5", "F5"},
|
||||
} {
|
||||
shown, spoken := kbdKey(tc.key, tc.mac)
|
||||
if shown != tc.shown || spoken != tc.spoken {
|
||||
t.Errorf("%q mac=%v: %q %q", tc.key, tc.mac, shown, spoken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKbdShowsPlatformCombo(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Kbd(c, "Mod", "Shift", "p")
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
spoken, mod := "Ctrl+Shift+P", "Ctrl"
|
||||
if runtime.GOOS == "darwin" {
|
||||
spoken, mod = "Command+Shift+P", "⌘"
|
||||
}
|
||||
if _, ok := tt.Find(spoken); !ok {
|
||||
t.Fatalf("not read as %q", spoken)
|
||||
}
|
||||
if !tt.HasText(mod) || !tt.HasText("P") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused(spoken) {
|
||||
t.Fatal("Kbd took focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKbdEmptyPanics(t *testing.T) {
|
||||
for _, keys := range [][]string{nil, {"Mod", ""}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("%q: no panic", keys)
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Kbd(c, keys...)
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestKbdSnapshot(t *testing.T) {
|
||||
snapshot(t, "kbd", 360, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(16).AlignItems(ui.Center).Children(func() {
|
||||
Kbd(c, "Mod", "K")
|
||||
Kbd(c, "Mod", "Shift", "P")
|
||||
Kbd(c, "Esc")
|
||||
Kbd(c, "Alt", "Up")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// LinkOptions configure a Link. An empty URL makes an in-app action link.
|
||||
type LinkOptions struct {
|
||||
// URL opens in the browser, or goes there in a MyGo router for a path.
|
||||
URL string
|
||||
// Error is the caller's open failure, shown below the link.
|
||||
Error string
|
||||
}
|
||||
|
||||
// Link is underlined text activated by a click or Enter. Clicked reports
|
||||
// every activation. A malformed URL is not opened and shows an error.
|
||||
func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element {
|
||||
if label == "" {
|
||||
panic("mujica: Link needs a label")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
external := false
|
||||
problem := opts.Error
|
||||
target := opts.URL
|
||||
if opts.URL != "" {
|
||||
ok := false
|
||||
external, ok = linkCheck(opts.URL)
|
||||
if !ok {
|
||||
external, target = false, ""
|
||||
if problem == "" {
|
||||
problem = fmt.Sprintf(msg(c, "link.invalid"), opts.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
var l *ui.Element
|
||||
ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
l = ui.Link(c, label, target).Underline().DecorationThickness(1)
|
||||
internal.Ring(l, k.Focus, theme.ControlRadius)
|
||||
color := k.AccentText
|
||||
if l.Pressed() || l.Hovered() {
|
||||
color = k.Text
|
||||
}
|
||||
l.TextColor(color).DecorationColor(color)
|
||||
if external {
|
||||
Icon(c, "external-link", IconOptions{Size: 12, Tone: IconAccent})
|
||||
}
|
||||
})
|
||||
if problem != "" {
|
||||
l.Error(problem)
|
||||
ui.Text(c, problem).TextColor(k.Danger).FontSize(theme.CaptionSize)
|
||||
}
|
||||
})
|
||||
if l.Clicked() {
|
||||
slog.Debug("mujica: link activate", "url", opts.URL, "opened", target != "")
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// linkCheck reports whether raw has a scheme, and whether it can be opened.
|
||||
func linkCheck(raw string) (external, ok bool) {
|
||||
p, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
switch p.Scheme {
|
||||
case "":
|
||||
return false, p.Path != "" || p.RawQuery != ""
|
||||
case "http", "https":
|
||||
return true, p.Host != ""
|
||||
}
|
||||
return true, p.Opaque != "" || p.Host != "" || p.Path != ""
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestLinkOpensURL(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Link(c, "Charter", LinkOptions{URL: "https://example.com/charter"})
|
||||
}, 300, 100)
|
||||
if err := tt.Click("Charter"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
want := []string{"https://example.com/charter", "https://example.com/charter"}
|
||||
if got := tt.OpenedURLs(); !slices.Equal(got, want) {
|
||||
t.Fatalf("opened %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkAction(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Link(c, "Show details", LinkOptions{}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 300, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 1 || len(tt.OpenedURLs()) != 0 {
|
||||
t.Fatalf("clicks %d, opened %v", n, tt.OpenedURLs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkInvalidURLIsNotOpened(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Link(c, "Broken", LinkOptions{URL: "https://"}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 400, 100)
|
||||
if err := tt.Click("Broken"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 || len(tt.OpenedURLs()) != 0 {
|
||||
t.Fatalf("clicks %d, opened %v", n, tt.OpenedURLs())
|
||||
}
|
||||
if !tt.HasText("This link cannot be opened: https://") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkCallerError(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
Link(c, "手册", LinkOptions{URL: "https://example.com", Error: "没有可以打开此链接的应用"})
|
||||
}, 400, 100)
|
||||
tt.Frame()
|
||||
if !tt.HasText("没有可以打开此链接的应用") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkCheck(t *testing.T) {
|
||||
for raw, want := range map[string][2]bool{
|
||||
"https://example.com": {true, true},
|
||||
"https://": {true, false},
|
||||
"mailto:a@b.c": {true, true},
|
||||
"/notes/42": {false, true},
|
||||
"?tab=info": {false, true},
|
||||
"http://[::1": {false, false},
|
||||
} {
|
||||
ext, ok := linkCheck(raw)
|
||||
if ext != want[0] || ok != want[1] {
|
||||
t.Errorf("%q: external %v ok %v", raw, ext, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkSnapshot(t *testing.T) {
|
||||
snapshot(t, "link", 420, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
Link(c, "Read the court charter", LinkOptions{URL: "https://example.com"})
|
||||
Link(c, "Show the guest list", LinkOptions{})
|
||||
Link(c, "Broken link", LinkOptions{URL: "https://"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// LoadingOverlayOptions configure a LoadingOverlay.
|
||||
type LoadingOverlayOptions struct {
|
||||
// Label explains the wait; empty uses "Loading…".
|
||||
Label string
|
||||
// Cancel labels a button that reports Canceled; empty shows none.
|
||||
Cancel string
|
||||
}
|
||||
|
||||
// LoadingOverlayResult is what a LoadingOverlay built and reported this frame.
|
||||
type LoadingOverlayResult struct {
|
||||
Element *ui.Element
|
||||
// Canceled reports the cancel button or Escape while loading.
|
||||
Canceled bool
|
||||
}
|
||||
|
||||
// LoadingOverlay builds content and, while loading, covers it with a
|
||||
// spinner and label. Covered content takes no pointer, keys or focus;
|
||||
// the rest of the window stays usable. Focus inside moves to the overlay.
|
||||
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
|
||||
if content == nil {
|
||||
panic("mujica: LoadingOverlay needs content")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := cmp.Or(opts.Label, msg(c, "loading_overlay.label"))
|
||||
var res LoadingOverlayResult
|
||||
box := ui.Box(c)
|
||||
was := ui.Local(box, "loading_overlay.loading", func() bool { return false })
|
||||
if loading != *was {
|
||||
slog.Debug("mujica: loading_overlay changed", "loading", loading)
|
||||
if loading {
|
||||
c.Announce(label)
|
||||
}
|
||||
*was = loading
|
||||
}
|
||||
box.Children(func() {
|
||||
inner := ui.Column(c).FillWidth().Disabled(loading)
|
||||
inner.Children(content)
|
||||
if !loading {
|
||||
return
|
||||
}
|
||||
held := inner.FocusWithin()
|
||||
scrim := ui.Column(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Padding(u * 4).
|
||||
Background(k.Background.Alpha(0.5)).Radius(theme.CardRadius)
|
||||
scrim.Transition(ui.ElementTransition{Duration: theme.StateDuration, Enter: &ui.Motion{}, Exit: &ui.Motion{}})
|
||||
scrim.Children(func() {
|
||||
panel := ui.Column(c).AlignItems(ui.Center).Gap(u*3).Padding(u*4, u*6).Radius(theme.PopupRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleStatus).Label(label)
|
||||
if opts.Cancel == "" {
|
||||
// Without a button the panel keeps the focus in the region.
|
||||
internal.Ring(panel.Focusable(), k.Focus, theme.PopupRadius)
|
||||
if held {
|
||||
panel.Focus()
|
||||
}
|
||||
}
|
||||
panel.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
Spinner(c, SpinnerOptions{Size: 20, Label: label})
|
||||
ui.Text(c, label).TextColor(k.Text)
|
||||
})
|
||||
if opts.Cancel == "" {
|
||||
return
|
||||
}
|
||||
b := Button(c, opts.Cancel, ButtonOptions{Variant: Secondary})
|
||||
if held {
|
||||
b.Focus()
|
||||
}
|
||||
if b.Clicked() {
|
||||
res.Canceled = true
|
||||
}
|
||||
})
|
||||
if opts.Cancel != "" && panel.Shortcut(0, ui.KeyEscape) {
|
||||
res.Canceled = true
|
||||
}
|
||||
})
|
||||
})
|
||||
if res.Canceled {
|
||||
slog.Debug("mujica: loading_overlay canceled")
|
||||
}
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type overlayProbe struct {
|
||||
loading bool
|
||||
saves, outside int
|
||||
cancels int
|
||||
cancel, label string
|
||||
}
|
||||
|
||||
func overlayTester(p *overlayProbe) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Gap(12).Children(func() {
|
||||
r := LoadingOverlay(c, p.loading, LoadingOverlayOptions{Cancel: p.cancel, Label: p.label}, func() {
|
||||
ui.Column(c).Padding(24).Children(func() {
|
||||
if Button(c, "Save", ButtonOptions{}).Clicked() {
|
||||
p.saves++
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.Canceled {
|
||||
p.cancels++
|
||||
}
|
||||
if Button(c, "Outside", ButtonOptions{}).Clicked() {
|
||||
p.outside++
|
||||
}
|
||||
})
|
||||
}, 500, 400)
|
||||
}
|
||||
|
||||
func TestLoadingOverlayBlocksOnlyItsRegion(t *testing.T) {
|
||||
p := &overlayProbe{}
|
||||
tt := overlayTester(p)
|
||||
_ = tt.Click("Save")
|
||||
p.loading = true
|
||||
tt.Frame()
|
||||
r, _ := tt.Find("Save")
|
||||
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if err := tt.Click("Outside"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.saves != 1 || p.outside != 1 {
|
||||
t.Fatalf("saves %d outside %d", p.saves, p.outside)
|
||||
}
|
||||
if !tt.HasText("Loading…") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
if a := tt.Announcements(); !slices.Contains(a, "Loading…") {
|
||||
t.Fatalf("announcements %q", a)
|
||||
}
|
||||
p.loading = false
|
||||
tt.Frame()
|
||||
_ = tt.Click("Save")
|
||||
if p.saves != 2 || tt.HasText("Loading…") {
|
||||
t.Fatalf("after loading saves %d", p.saves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadingOverlayCancelAndFocus(t *testing.T) {
|
||||
p := &overlayProbe{cancel: "Stop", label: "Importing"}
|
||||
tt := overlayTester(p)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Save") {
|
||||
t.Fatal("Save not focused")
|
||||
}
|
||||
p.loading = true
|
||||
tt.Frame()
|
||||
if !tt.Focused("Stop") {
|
||||
t.Fatal("focus left the region instead of moving to cancel")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if p.cancels != 1 {
|
||||
t.Fatalf("Escape cancels %d", p.cancels)
|
||||
}
|
||||
if err := tt.Click("Stop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.cancels != 2 || p.saves != 0 {
|
||||
t.Fatalf("cancels %d saves %d", p.cancels, p.saves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadingOverlayFocusWithoutCancel(t *testing.T) {
|
||||
p := &overlayProbe{}
|
||||
tt := overlayTester(p)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
p.loading = true
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.Focused("Loading…") {
|
||||
t.Fatal("focus not kept in the region")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if p.cancels != 0 {
|
||||
t.Fatal("Escape canceled without a cancel action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadingOverlaySnapshot(t *testing.T) {
|
||||
snapshot(t, "loading_overlay", 420, 220, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
LoadingOverlay(c, true, LoadingOverlayOptions{Cancel: "Cancel"}, func() {
|
||||
ui.Column(c).Padding(16).Gap(8).AlignItems(ui.Start).Background(Tokens(c).Surface).Radius(4).Children(func() {
|
||||
ui.Text(c, "Quarterly report")
|
||||
ui.Text(c, "Revenue, churn and pipeline for Q3.")
|
||||
Button(c, "Export", ButtonOptions{})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"editable_text.saving": {"Saving", "正在保存"},
|
||||
"icon.missing": {"Missing icon: %s", "缺少图标:%s"},
|
||||
"image.placeholder": {"No image", "暂无图片"},
|
||||
"image.loading": {"Loading image", "正在加载图片"},
|
||||
"image.error": {"Image failed to load", "图片加载失败"},
|
||||
"image.retry": {"Retry", "重试"},
|
||||
"avatar.unnamed": {"Unnamed", "未命名"},
|
||||
"avatar_group.more": {"Show all %d people", "显示全部 %d 人"},
|
||||
"tag.remove": {"Remove %s", "移除%s"},
|
||||
"link.invalid": {"This link cannot be opened: %s", "无法打开此链接:%s"},
|
||||
})
|
||||
}
|
||||
@@ -3,5 +3,49 @@ package mujica
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"loading": {"Loading", "加载中"},
|
||||
|
||||
"alert.close": {"Dismiss", "关闭"},
|
||||
"alert.info": {"Information", "信息"},
|
||||
"alert.success": {"Success", "成功"},
|
||||
"alert.warning": {"Warning", "警告"},
|
||||
"alert.error": {"Error", "错误"},
|
||||
|
||||
"toast.close": {"Dismiss notification", "关闭通知"},
|
||||
"toast.region": {"Notifications", "通知"},
|
||||
|
||||
"progress.indeterminate": {"In progress", "进行中"},
|
||||
"meter.normal": {"Normal", "正常"},
|
||||
"meter.warning": {"High", "偏高"},
|
||||
"meter.critical": {"Critical", "严重"},
|
||||
"meter.low_warning": {"Low", "偏低"},
|
||||
|
||||
"empty_state.no_data.title": {"Nothing here yet", "暂无内容"},
|
||||
"empty_state.no_data.description": {"Items you add will appear here.", "添加的内容会显示在这里。"},
|
||||
"empty_state.no_results.title": {"No results", "没有结果"},
|
||||
"empty_state.no_results.description": {"Try other words or clear the filters.", "请换个关键词,或清除筛选条件。"},
|
||||
"empty_state.no_results.query": {"Nothing matches “%s”.", "没有与“%s”匹配的内容。"},
|
||||
|
||||
"result.success.title": {"Done", "操作成功"},
|
||||
"result.failure.title": {"Something went wrong", "操作失败"},
|
||||
"result.forbidden.title": {"No access", "无访问权限"},
|
||||
|
||||
"status_indicator.success": {"Healthy", "正常"},
|
||||
"status_indicator.warning": {"Degraded", "警告"},
|
||||
"status_indicator.error": {"Failing", "错误"},
|
||||
"status_indicator.offline": {"Offline", "离线"},
|
||||
"status_indicator.busy": {"Working", "处理中"},
|
||||
|
||||
"notification_center.title": {"Notifications", "通知"},
|
||||
"notification_center.unread": {"%d unread", "%d 条未读"},
|
||||
"notification_center.unread_mark": {"Unread", "未读"},
|
||||
"notification_center.mark_read": {"Mark read", "标为已读"},
|
||||
"notification_center.mark_unread": {"Mark unread", "标为未读"},
|
||||
"notification_center.mark_all_read": {"Mark all read", "全部已读"},
|
||||
"notification_center.clear": {"Clear", "清除"},
|
||||
"notification_center.clear_all": {"Clear all", "全部清除"},
|
||||
"notification_center.empty": {"You're all caught up", "没有新通知"},
|
||||
"notification_center.empty_hint": {"New notifications will appear here.", "新的通知会显示在这里。"},
|
||||
|
||||
"loading_overlay.label": {"Loading…", "正在加载…"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"text_input.clear": {"Clear", "清空"},
|
||||
"number_input.increase": {"Increase", "增加"},
|
||||
"number_input.decrease": {"Decrease", "减少"},
|
||||
"number_input.invalid": {"Enter a number", "请输入数字"},
|
||||
"number_input.range": {"Enter a number from %s to %s", "请输入 %s 到 %s 之间的数字"},
|
||||
"password_input.show": {"Show password", "显示密码"},
|
||||
"password_input.hide": {"Hide password", "隐藏密码"},
|
||||
"search_input.search": {"Search", "搜索"},
|
||||
"search_input.find": {"Find", "查找"},
|
||||
"search_input.previous": {"Previous match", "上一项"},
|
||||
"search_input.next": {"Next match", "下一项"},
|
||||
"search_input.count": {"%d of %d", "第 %d 项,共 %d 项"},
|
||||
"search_input.none": {"No matches", "无匹配"},
|
||||
"checkbox.mixed": {"Partly selected", "部分选中"},
|
||||
"text_area.count": {"%d of %d characters", "%d / %d 字"},
|
||||
"text_area.over": {"%d characters over the limit", "超出 %d 字"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"app_shell.collapse": {"Collapse sidebar", "收起侧栏"},
|
||||
"app_shell.expand": {"Expand sidebar", "展开侧栏"},
|
||||
"status_bar.label": {"Status bar", "状态栏"},
|
||||
"status_bar.hidden": {"%d more items", "另有 %d 项"},
|
||||
"split_pane.divider": {"Divider", "分隔条"},
|
||||
"split_pane.collapsed": {"Collapsed", "已折叠"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Notification is one in-app message. The caller owns and persists the list.
|
||||
type Notification struct {
|
||||
// ID is required and unique within the list.
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
// Time shows as given, as "5 min ago".
|
||||
Time string
|
||||
Read bool
|
||||
// Action labels a button; empty shows none.
|
||||
Action string
|
||||
}
|
||||
|
||||
// NotificationCenterOptions configure a NotificationCenter.
|
||||
type NotificationCenterOptions struct {
|
||||
// Title uses the built-in "Notifications" when empty.
|
||||
Title string
|
||||
}
|
||||
|
||||
// NotificationCenterResult reports the frame's events by notification ID.
|
||||
type NotificationCenterResult struct {
|
||||
Element *ui.Element
|
||||
Action string
|
||||
// Toggled changed its Read state; Opened was clicked and is now read.
|
||||
Toggled string
|
||||
Opened string
|
||||
Removed string
|
||||
// MarkedAllRead and ClearedAll report the header buttons.
|
||||
MarkedAllRead bool
|
||||
ClearedAll bool
|
||||
}
|
||||
|
||||
// NotificationCenter lists *items and edits them in place: read, unread,
|
||||
// remove, clear. The list is one Tab stop; arrows move among its buttons
|
||||
// and Delete removes the focused notification.
|
||||
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
|
||||
if items == nil {
|
||||
panic("mujica: NotificationCenter needs items")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
unread := 0
|
||||
for _, n := range *items {
|
||||
if n.ID == "" || seen[n.ID] {
|
||||
panic(fmt.Sprintf("mujica: notification ID %q empty or repeated", n.ID))
|
||||
}
|
||||
seen[n.ID] = true
|
||||
if !n.Read {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
title := cmp.Or(opts.Title, msg(c, "notification_center.title"))
|
||||
var res NotificationCenterResult
|
||||
panel := ui.Column(c).FillWidth().Shrink(1).MinHeight(0).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Role(ui.RoleGroup).Label(title).Clip()
|
||||
panel.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
|
||||
for i := range *items {
|
||||
(*items)[i].Read = true
|
||||
}
|
||||
res.MarkedAllRead = true
|
||||
slog.Debug("mujica: notification_center mark all read", "count", unread)
|
||||
}
|
||||
if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() {
|
||||
slog.Debug("mujica: notification_center clear all", "count", len(*items))
|
||||
*items = (*items)[:0]
|
||||
res.ClearedAll = true
|
||||
}
|
||||
})
|
||||
// Ornament hairline under the panel title.
|
||||
ui.Box(c).FillWidth().Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
||||
if len(*items) == 0 && !res.ClearedAll {
|
||||
EmptyState(c, EmptyStateOptions{
|
||||
Title: msg(c, "notification_center.empty"),
|
||||
Description: msg(c, "notification_center.empty_hint"),
|
||||
Icon: icons.Must("bell"),
|
||||
})
|
||||
return
|
||||
}
|
||||
list := ui.Scroll(c).Shrink(1).FillWidth().Role(ui.RoleList).FocusGroup(ui.Vertical | ui.Horizontal)
|
||||
list.Dividers(theme.BorderWidth, k.Border)
|
||||
list.Children(func() {
|
||||
for i := range *items {
|
||||
notificationRow(c, k, u, &(*items)[i], &res)
|
||||
}
|
||||
})
|
||||
})
|
||||
if res.Removed != "" {
|
||||
for i, n := range *items {
|
||||
if n.ID == res.Removed {
|
||||
*items = append((*items)[:i], (*items)[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
res.Element = panel
|
||||
return res
|
||||
}
|
||||
|
||||
func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, res *NotificationCenterResult) {
|
||||
name := n.Title
|
||||
if !n.Read {
|
||||
name = msg(c, "notification_center.unread_mark") + ": " + n.Title
|
||||
}
|
||||
row := ui.Row(c).Key(n.ID).FillWidth().AlignItems(ui.Start).Gap(u*2).Padding(u*3, u*3, u*3, u*2).
|
||||
Role(ui.RoleListItem)
|
||||
if row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
row.Children(func() {
|
||||
// Unread mark: a diamond and bold title, not color alone.
|
||||
ui.Box(c).Size(12, 20).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if !n.Read {
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText)
|
||||
}
|
||||
})
|
||||
open := ui.ButtonBase(c).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
|
||||
Radius(theme.ControlRadius).Label(name)
|
||||
internal.Ring(open, k.Focus, theme.ControlRadius)
|
||||
open.Children(func() {
|
||||
ui.Row(c).FillWidth().Gap(u * 2).Children(func() {
|
||||
t := ui.Text(c, n.Title).TextColor(k.Text).Grow(1).Shrink(1)
|
||||
if !n.Read {
|
||||
t.Bold()
|
||||
}
|
||||
if n.Time != "" {
|
||||
ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
})
|
||||
if n.Body != "" {
|
||||
ui.Text(c, n.Body).TextColor(k.TextMuted).LineHeight(theme.LineHeight)
|
||||
}
|
||||
})
|
||||
if open.Clicked() && !n.Read {
|
||||
n.Read, res.Opened = true, n.ID
|
||||
slog.Debug("mujica: notification_center opened", "id", n.ID)
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
if n.Action != "" && Button(c, n.Action, ButtonOptions{Variant: Outline}).Clicked() {
|
||||
res.Action = n.ID
|
||||
slog.Debug("mujica: notification_center action", "id", n.ID)
|
||||
}
|
||||
toggle, icon := "notification_center.mark_read", icons.Must("check")
|
||||
if n.Read {
|
||||
toggle, icon = "notification_center.mark_unread", icons.Must("circle")
|
||||
}
|
||||
if notificationIconButton(c, k, u, icon, msg(c, toggle)).Clicked() {
|
||||
n.Read, res.Toggled = !n.Read, n.ID
|
||||
slog.Debug("mujica: notification_center toggled", "id", n.ID, "read", n.Read)
|
||||
}
|
||||
if notificationIconButton(c, k, u, icons.Must("x"), msg(c, "notification_center.clear")).Clicked() {
|
||||
res.Removed = n.ID
|
||||
}
|
||||
})
|
||||
})
|
||||
if row.Shortcut(0, ui.KeyDelete) || row.Shortcut(0, ui.KeyBackspace) {
|
||||
res.Removed = n.ID
|
||||
}
|
||||
if res.Removed == n.ID {
|
||||
slog.Debug("mujica: notification_center removed", "id", n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func notificationIconButton(c *ui.Context, k theme.Tokens, u float32, icon *ui.SVG, label string) *ui.Element {
|
||||
b := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icon, Label: label})
|
||||
b.TextColor(k.TextMuted).Padding(0, u).Tooltip(label)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func sampleNotifications() []Notification {
|
||||
return []Notification{
|
||||
{ID: "a", Title: "Build failed", Body: "main · 3 tests failing", Time: "2 min ago", Action: "View log"},
|
||||
{ID: "b", Title: "Invite accepted", Body: "Mira joined the workspace", Time: "1 h ago"},
|
||||
{ID: "c", Title: "Backup complete", Time: "Yesterday", Read: true},
|
||||
}
|
||||
}
|
||||
|
||||
func notificationTester(items *[]Notification, last *NotificationCenterResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Height(560).Children(func() {
|
||||
r := NotificationCenter(c, items, NotificationCenterOptions{})
|
||||
r.Element = nil
|
||||
if r != (NotificationCenterResult{}) {
|
||||
*last = r
|
||||
}
|
||||
})
|
||||
}, 640, 600)
|
||||
}
|
||||
|
||||
func TestNotificationCenterReadAndAction(t *testing.T) {
|
||||
items := sampleNotifications()
|
||||
var last NotificationCenterResult
|
||||
tt := notificationTester(&items, &last)
|
||||
if !tt.HasText("2 unread") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Unread: Invite accepted"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !items[1].Read || last.Opened != "b" || !tt.HasText("1 unread") {
|
||||
t.Fatalf("open: %+v %+v", items[1], last)
|
||||
}
|
||||
if err := tt.Click("View log"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if last.Action != "a" || items[0].Read {
|
||||
t.Fatalf("action: %+v %+v", last, items[0])
|
||||
}
|
||||
if err := tt.Click("Mark unread"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items[1].Read || last.Toggled != "b" {
|
||||
t.Fatalf("toggle: %+v %+v", items[1], last)
|
||||
}
|
||||
if err := tt.Click("Mark all read"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range items {
|
||||
if !n.Read {
|
||||
t.Fatalf("mark all: %+v", n)
|
||||
}
|
||||
}
|
||||
if !last.MarkedAllRead || tt.HasText("1 unread") || tt.HasText("2 unread") {
|
||||
t.Fatalf("mark all: %+v %q", last, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationCenterRemoveAndClear(t *testing.T) {
|
||||
items := sampleNotifications()
|
||||
var last NotificationCenterResult
|
||||
tt := notificationTester(&items, &last)
|
||||
if err := tt.Click("Clear"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].ID != "b" || last.Removed != "a" {
|
||||
t.Fatalf("remove: %+v %+v", items, last)
|
||||
}
|
||||
if err := tt.Click("Clear all"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 || !last.ClearedAll || !tt.HasText("You're all caught up") {
|
||||
t.Fatalf("clear all: %+v %q", items, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationCenterKeyboard(t *testing.T) {
|
||||
items := sampleNotifications()
|
||||
var last NotificationCenterResult
|
||||
tt := notificationTester(&items, &last)
|
||||
tt.Key(0, ui.KeyTab) // Mark all read
|
||||
tt.Key(0, ui.KeyTab) // Clear all
|
||||
tt.Key(0, ui.KeyTab) // first notification
|
||||
if !tt.Focused("Unread: Build failed") {
|
||||
t.Fatal("Tab did not enter the list")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !items[0].Read || last.Opened != "a" {
|
||||
t.Fatalf("Enter: %+v", items[0])
|
||||
}
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if len(items) != 2 || items[0].ID != "b" {
|
||||
t.Fatalf("Delete: %+v", items)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused("Unread: Invite accepted") {
|
||||
t.Fatal("the list takes more than one Tab stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationCenterPanicsOnRepeatedID(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
items := []Notification{{ID: "x"}, {ID: "x"}}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
}, 300, 200)
|
||||
}
|
||||
|
||||
func TestNotificationCenterSnapshot(t *testing.T) {
|
||||
items := sampleNotifications()
|
||||
snapshot(t, "notification_center", 520, 400, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Height(400).Children(func() {
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// NumberInputOptions configure a NumberInput.
|
||||
type NumberInputOptions struct {
|
||||
// Min and Max bound the value; equal bounds (the zero value) mean no bounds.
|
||||
Min, Max float64
|
||||
// Step is what Up, Down and the buttons add; zero means 1.
|
||||
Step float64
|
||||
// Precision is the decimals kept and shown; zero takes the decimals of Step.
|
||||
Precision int
|
||||
// AllowEmpty lets the field be empty, which sets the value to NaN.
|
||||
AllowEmpty bool
|
||||
// StepperOnly shows the value with step buttons and no typing; it needs bounds.
|
||||
StepperOnly bool
|
||||
Placeholder string
|
||||
// Label names the field for assistive technology.
|
||||
Label string
|
||||
Disabled bool
|
||||
// Error marks the value invalid and shows the message below.
|
||||
Error string
|
||||
}
|
||||
|
||||
// numberInputSpec is NumberInputOptions resolved.
|
||||
type numberInputSpec struct {
|
||||
lo, hi, step float64
|
||||
decimals int
|
||||
}
|
||||
|
||||
func (s numberInputSpec) format(v float64) string {
|
||||
if math.IsNaN(v) {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', s.decimals, 64)
|
||||
}
|
||||
|
||||
// clamp bounds v and rounds it to the precision.
|
||||
func (s numberInputSpec) clamp(v float64) float64 {
|
||||
p := math.Pow(10, float64(s.decimals))
|
||||
return math.Round(math.Max(s.lo, math.Min(s.hi, v))*p) / p
|
||||
}
|
||||
|
||||
func numberInputResolve(opts NumberInputOptions) numberInputSpec {
|
||||
s := numberInputSpec{lo: opts.Min, hi: opts.Max, step: opts.Step, decimals: opts.Precision}
|
||||
if s.lo > s.hi {
|
||||
panic("mujica: NumberInput Min is above Max")
|
||||
}
|
||||
if s.step < 0 || opts.Precision < 0 {
|
||||
panic("mujica: NumberInput Step and Precision cannot be negative")
|
||||
}
|
||||
if s.lo == s.hi {
|
||||
if opts.StepperOnly {
|
||||
panic("mujica: NumberInput StepperOnly needs Min < Max")
|
||||
}
|
||||
s.lo, s.hi = math.Inf(-1), math.Inf(1)
|
||||
}
|
||||
if s.step == 0 {
|
||||
s.step = 1
|
||||
}
|
||||
if s.decimals == 0 {
|
||||
if t := strconv.FormatFloat(s.step, 'f', -1, 64); strings.Contains(t, ".") {
|
||||
s.decimals = len(t) - strings.IndexByte(t, '.') - 1
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NumberInput edits *value as a number; Up and Down step it.
|
||||
func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputResult {
|
||||
if value == nil {
|
||||
panic("mujica: NumberInput needs a value")
|
||||
}
|
||||
if opts.StepperOnly && opts.AllowEmpty {
|
||||
panic("mujica: NumberInput StepperOnly cannot be empty")
|
||||
}
|
||||
if math.IsNaN(*value) && !opts.AllowEmpty {
|
||||
panic("mujica: NumberInput value is NaN without AllowEmpty")
|
||||
}
|
||||
s := numberInputResolve(opts)
|
||||
if opts.StepperOnly {
|
||||
return numberInputStepper(c, value, s, opts)
|
||||
}
|
||||
var r InputResult
|
||||
set := func(v float64) {
|
||||
if !math.IsNaN(v) {
|
||||
v = s.clamp(v)
|
||||
}
|
||||
if v != *value && !(math.IsNaN(v) && math.IsNaN(*value)) {
|
||||
*value = v
|
||||
r.changed = true
|
||||
}
|
||||
}
|
||||
stepBy := func(n float64) {
|
||||
base := *value
|
||||
if math.IsNaN(base) {
|
||||
base = math.Max(s.lo, math.Min(s.hi, 0))
|
||||
}
|
||||
set(base + n*s.step)
|
||||
}
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
text := ui.Local(frame, "text", func() string { return s.format(*value) })
|
||||
invalid := ui.Local(frame, "invalid", func() string { return "" })
|
||||
in := ui.TextInputBase(c, text).Grow(1).MinWidth(Density(c).Unit() * 12).
|
||||
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||
if in.Changed() {
|
||||
*invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set)
|
||||
}
|
||||
switch {
|
||||
case in.Shortcut(0, ui.KeyUp):
|
||||
stepBy(1)
|
||||
*text, *invalid = s.format(*value), ""
|
||||
case in.Shortcut(0, ui.KeyDown):
|
||||
stepBy(-1)
|
||||
*text, *invalid = s.format(*value), ""
|
||||
}
|
||||
if !in.Focused() {
|
||||
*text, *invalid = s.format(*value), ""
|
||||
}
|
||||
for _, b := range []struct {
|
||||
name string
|
||||
icon string
|
||||
delta float64
|
||||
can bool
|
||||
}{
|
||||
{msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo},
|
||||
{msg(c, "number_input.increase"), "plus", 1, math.IsNaN(*value) || *value < s.hi},
|
||||
} {
|
||||
if numberInputButton(c, b.icon, b.name, b.can) {
|
||||
stepBy(b.delta)
|
||||
*text, *invalid = s.format(*value), ""
|
||||
in.Focus()
|
||||
}
|
||||
}
|
||||
if look.err == "" {
|
||||
look.err = *invalid
|
||||
}
|
||||
return in
|
||||
}, nil)
|
||||
if r.changed {
|
||||
slog.Debug("mujica: number_input change", "value", *value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// numberInputParse applies typed text and returns why it is invalid, if it is.
|
||||
func numberInputParse(c *ui.Context, s numberInputSpec, text string, allowEmpty bool, set func(float64)) string {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" && allowEmpty {
|
||||
set(math.NaN())
|
||||
return ""
|
||||
}
|
||||
v, err := strconv.ParseFloat(t, 64)
|
||||
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return msg(c, "number_input.invalid")
|
||||
}
|
||||
if v < s.lo || v > s.hi {
|
||||
return fmt.Sprintf(msg(c, "number_input.range"), s.format(s.lo), s.format(s.hi))
|
||||
}
|
||||
set(v)
|
||||
return ""
|
||||
}
|
||||
|
||||
// numberInputButton builds a pointer-only step button; Up and Down are its keys.
|
||||
func numberInputButton(c *ui.Context, icon, name string, can bool) bool {
|
||||
k := Tokens(c)
|
||||
b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius).
|
||||
Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer).Disabled(!can)
|
||||
fg := k.TextMuted
|
||||
if b.Pressed() {
|
||||
b.Background(k.Selection)
|
||||
fg = k.Text
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(fg) })
|
||||
return b.Clicked()
|
||||
}
|
||||
|
||||
// numberInputStepper shows the value beside MyGo's stepper, restyled.
|
||||
func numberInputStepper(c *ui.Context, value *float64, s numberInputSpec, opts NumberInputOptions) InputResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var r InputResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled)
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).Children(func() {
|
||||
ui.Text(c, s.format(*value)).FontFeatures("tnum").MinWidth(d.Unit() * 8).TextAlign(ui.End)
|
||||
// MyGo's stepper arrows ignore Disabled: give them a copy.
|
||||
target := value
|
||||
if opts.Disabled {
|
||||
held := *value
|
||||
target = &held
|
||||
}
|
||||
st := ui.Stepper(c, target, s.lo, s.hi, s.step).Label(opts.Label).Width(d.Unit()*6).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
|
||||
if opts.Error != "" {
|
||||
st.Error(opts.Error).Border(theme.BorderWidth, k.Danger)
|
||||
}
|
||||
internal.Ring(st, k.Focus, theme.ControlRadius)
|
||||
r.Input = st
|
||||
r.changed = st.Changed() && !opts.Disabled
|
||||
})
|
||||
if opts.Error != "" {
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize)
|
||||
}
|
||||
})
|
||||
if r.changed {
|
||||
*value = s.clamp(*value)
|
||||
slog.Debug("mujica: number_input step", "value", *value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func numberInputTester(v *float64, changes *int, opts NumberInputOptions) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if NumberInput(c, v, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
Button(c, "Elsewhere", ButtonOptions{})
|
||||
}, 400, 300)
|
||||
}
|
||||
|
||||
func TestNumberInputStepsAndBounds(t *testing.T) {
|
||||
v, n := 9.0, 0
|
||||
tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0, Max: 10, Label: "Copies"})
|
||||
_ = tt.Click("Copies")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 10 || n != 1 {
|
||||
t.Fatalf("value %v changes %d, want 10 and 1", v, n)
|
||||
}
|
||||
_ = tt.Click("Decrease")
|
||||
_ = tt.Click("Decrease")
|
||||
if v != 8 || n != 3 {
|
||||
t.Fatalf("after buttons %v changes %d", v, n)
|
||||
}
|
||||
_ = tt.Click("Increase")
|
||||
_ = tt.Click("Increase")
|
||||
_ = tt.Click("Increase")
|
||||
if v != 10 {
|
||||
t.Fatalf("increase passed Max: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberInputTypingPrecisionAndErrors(t *testing.T) {
|
||||
v, n := 1.0, 0
|
||||
tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0, Max: 2, Step: 0.25, Label: "Ratio"})
|
||||
_ = tt.Click("Ratio")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("1.5")
|
||||
if v != 1.5 {
|
||||
t.Fatalf("typed value %v", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if v != 1.25 {
|
||||
t.Fatalf("Down from 1.5 by 0.25 gave %v", v)
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("abc")
|
||||
if v != 1.25 || !tt.HasText("Enter a number") {
|
||||
t.Fatalf("invalid text changed value to %v or showed no error: %q", v, tt.Texts())
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("7")
|
||||
if v != 1.25 || !tt.HasText("Enter a number from 0.00 to 2.00") {
|
||||
t.Fatalf("out of range: value %v texts %q", v, tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Elsewhere")
|
||||
if tt.HasText("Enter a number") {
|
||||
t.Fatal("error stays after leaving the field, which shows the value again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberInputEmpty(t *testing.T) {
|
||||
v, n := 3.0, 0
|
||||
tt := numberInputTester(&v, &n, NumberInputOptions{AllowEmpty: true, Label: "Limit"})
|
||||
_ = tt.Click("Limit")
|
||||
tt.Command("selectAll")
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
if !math.IsNaN(v) {
|
||||
t.Fatalf("emptied value %v, want NaN", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 1 {
|
||||
t.Fatalf("Up from empty gave %v, want 1", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberInputStepperOnly(t *testing.T) {
|
||||
v, n := 2.0, 0
|
||||
tt := numberInputTester(&v, &n, NumberInputOptions{Min: 1, Max: 3, StepperOnly: true, Label: "Guests"})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 3 || n != 1 || !tt.HasText("3") {
|
||||
t.Fatalf("value %v changes %d texts %q", v, n, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if v != 1 {
|
||||
t.Fatalf("Home gave %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberInputDisabled(t *testing.T) {
|
||||
for _, stepper := range []bool{false, true} {
|
||||
v, n := 2.0, 0
|
||||
tt := numberInputTester(&v, &n, NumberInputOptions{Min: 1, Max: 3, StepperOnly: stepper, Disabled: true, Label: "Off"})
|
||||
_ = tt.Click("Increase")
|
||||
_ = tt.Click("Off")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 2 || n != 0 {
|
||||
t.Fatalf("stepper %v: disabled changed to %v (%d changes)", stepper, v, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberInputBadOptionsPanic(t *testing.T) {
|
||||
for _, opts := range []NumberInputOptions{{Min: 3, Max: 1}, {StepperOnly: true}, {Step: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: no panic", opts)
|
||||
}
|
||||
}()
|
||||
v := 0.0
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
NumberInput(c, &v, opts)
|
||||
}, 200, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberInputSnapshot(t *testing.T) {
|
||||
snapshot(t, "number_input", 420, 260, func(c *ui.Context) {
|
||||
a, b, e, s := 12.0, math.NaN(), 7.5, 4.0
|
||||
ui.Column(c).Padding(24).Gap(12).Width(260).Children(func() {
|
||||
NumberInput(c, &a, NumberInputOptions{Min: 0, Max: 99, Label: "A"})
|
||||
NumberInput(c, &b, NumberInputOptions{AllowEmpty: true, Placeholder: "No limit", Label: "B"})
|
||||
NumberInput(c, &e, NumberInputOptions{Step: 0.5, Label: "E", Error: "Must be whole"})
|
||||
NumberInput(c, &s, NumberInputOptions{Min: 1, Max: 8, StepperOnly: true, Label: "S"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PasswordInputOptions configure a PasswordInput.
|
||||
type PasswordInputOptions struct {
|
||||
Placeholder string
|
||||
// Label names the field for assistive technology.
|
||||
Label string
|
||||
Disabled bool
|
||||
// Error marks the value invalid and shows the message below.
|
||||
Error string
|
||||
}
|
||||
|
||||
// PasswordInput is a masked field with a show and hide toggle.
|
||||
func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) InputResult {
|
||||
if value == nil {
|
||||
panic("mujica: PasswordInput needs a value")
|
||||
}
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
shown := ui.Local(frame, "shown", func() bool { return false })
|
||||
var in *ui.Element
|
||||
// MyGo's mask cannot be lifted, so each mode keeps its own editor.
|
||||
mode := "masked"
|
||||
if *shown {
|
||||
mode = "shown"
|
||||
}
|
||||
ui.Row(c).Key(mode).Grow(1).Children(func() {
|
||||
in = ui.TextInputBase(c, value).Grow(1).MinWidth(Density(c).Unit() * 10).
|
||||
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||
if !*shown {
|
||||
in.Password()
|
||||
}
|
||||
})
|
||||
r.changed = in.Changed()
|
||||
r.submitted = in.Submitted()
|
||||
if passwordInputToggle(c, *shown) {
|
||||
*shown = !*shown
|
||||
slog.Debug("mujica: password_input reveal", "shown", *shown)
|
||||
}
|
||||
return in
|
||||
}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
// passwordInputToggle builds the show or hide button and reports a click.
|
||||
func passwordInputToggle(c *ui.Context, shown bool) bool {
|
||||
k := Tokens(c)
|
||||
name, icon := msg(c, "password_input.show"), "eye"
|
||||
if shown {
|
||||
name, icon = msg(c, "password_input.hide"), "eye-off"
|
||||
}
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(name).Tooltip(name)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
fg := k.TextMuted
|
||||
if b.Pressed() {
|
||||
b.Background(k.Selection)
|
||||
fg = k.Text
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(fg) })
|
||||
return b.Clicked()
|
||||
}
|
||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user