Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// BannerOptions configure a Banner.
type BannerOptions struct {
Severity core.Severity
// Action labels a button after the message; empty shows none.
Action string
}
// Banner is a full-width notice for the top of a page; a non-nil open adds a close button.
func Banner(c *ui.Context, open *bool, message string, opts BannerOptions) core.AlertResult {
if message == "" {
panic("mujica: Banner needs a message")
}
k := core.Tokens(c)
tone, icon := core.SeverityLook(k, opts.Severity)
if open != nil && !*open {
return core.AlertResult{}
}
u := core.Density(c).Unit()
name := core.Msg(c, "alert."+opts.Severity.String())
var res core.AlertResult
bar := ui.Row(c).FillWidth().MinHeight(core.Density(c).ControlHeight()+2*u).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3, u*2, u*4).
Background(k.Surface.Mix(tone, 0.08)).Role(ui.RoleGroup).Label(name)
bar.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, tone, 0)
})
said := ui.Local(bar, "banner.said", func() string { return "" })
if news := name + ": " + message; opts.Severity >= core.SeverityWarning && *said != news {
*said = news
c.Announce(news)
}
bar.Children(func() {
ui.Icon(c, icon).Size(20, 20).TextColor(tone)
ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight)
if opts.Action != "" && Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() {
slog.Debug("mujica: banner action", "severity", opts.Severity)
res.Action = true
}
if open != nil && core.IconAction(c, icons.Must("x"), core.Msg(c, "alert.close")).Clicked() {
slog.Debug("mujica: banner closed", "severity", opts.Severity)
*open, res.Closed = false, true
}
})
res.Element = bar
return res
}
+77
View File
@@ -0,0 +1,77 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestBannerActionCloseAndAnnounce(t *testing.T) {
open := true
var last core.AlertResult
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := Banner(c, &open, "Maintenance tonight at 22:00", BannerOptions{Severity: core.SeverityWarning, Action: "Details"}); r.Action || r.Closed {
last = r
}
}, 700, 200)
if a := tt.Announcements(); !slices.Equal(a, []string{"Warning: Maintenance tonight at 22:00"}) {
t.Fatalf("announcements %q", a)
}
if err := tt.Click("Details"); err != nil {
t.Fatal(err)
}
if !last.Action || !open {
t.Fatalf("action: %+v open %v", last, open)
}
tt.Key(0, ui.KeyTab) // from Details to Dismiss
tt.Key(0, ui.KeyEnter)
if open || !last.Closed || tt.HasText("Maintenance") {
t.Fatalf("keyboard close: open %v %+v", open, last)
}
}
func TestBannerDisabledAndFixed(t *testing.T) {
open := true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(true).Children(func() {
Banner(c, &open, "Read only", BannerOptions{Severity: core.SeverityError})
})
Banner(c, nil, "Always here", BannerOptions{})
}, 700, 200)
_ = tt.Click("Dismiss")
if !open {
t.Fatal("disabled banner closed")
}
if !tt.HasText("Always here") {
t.Fatal("banner without open missing")
}
for _, f := range []func(c *ui.Context){
func(c *ui.Context) { Banner(c, nil, "", BannerOptions{}) },
func(c *ui.Context) { Banner(c, nil, "x", BannerOptions{Severity: 7}) },
} {
func() {
defer func() {
if recover() == nil {
t.Error("no panic")
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); f(c) }, 300, 100)
}()
}
}
func TestBannerSnapshot(t *testing.T) {
open := true
snapshot(t, "banner", 640, 200, func(c *ui.Context) {
ui.Column(c).Gap(8).Children(func() {
Banner(c, &open, "A new version is available.", BannerOptions{Action: "Update"})
Banner(c, &open, "Your trial ends in 3 days.", BannerOptions{Severity: core.SeverityWarning})
Banner(c, nil, "Sync is failing; changes stay on this device.", BannerOptions{Severity: core.SeverityError, Action: "Retry"})
})
})
}
+57
View File
@@ -0,0 +1,57 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
)
// baselineAt returns the center of the element whose text is s.
func baselineAt(t *testing.T, tt *ui.Tester, s string) (float32, float32) {
t.Helper()
r, ok := tt.Find(s)
if !ok {
t.Fatalf("%q not found in %q", s, tt.Texts())
}
return r.X + r.W/2, r.Y + r.H/2
}
func TestBaselineButtonStates(t *testing.T) {
view := func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(12).Children(func() {
Button(c, "Save", ButtonOptions{})
Button(c, "Cancel", ButtonOptions{Variant: Secondary})
})
}
snapshotAfter(t, "button-hover", 300, 90, view, func(tt *ui.Tester) { tt.Move(baselineAt(t, tt, "Save")) })
snapshotAfter(t, "button-pressed", 300, 90, view, func(tt *ui.Tester) { tt.Press(baselineAt(t, tt, "Save")) })
snapshotAfter(t, "button-focus", 300, 90, view, func(tt *ui.Tester) { tt.Key(0, ui.KeyTab) })
}
func TestBaselineTextInputStates(t *testing.T) {
name, date := "Uika", "1789-13-01"
view := func(c *ui.Context) {
ui.Column(c).Padding(24).Gap(12).Width(360).Children(func() {
TextInput(c, &name, TextInputOptions{Label: "Name"})
TextInput(c, &date, TextInputOptions{Label: "Date", Error: "No such date"})
})
}
snapshotAfter(t, "text_input-focus", 400, 170, view, func(tt *ui.Tester) { tt.Key(0, ui.KeyTab) })
snapshotAfter(t, "text_input-error-focus", 400, 170, view, func(tt *ui.Tester) {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
})
}
func TestBaselineCheckboxStates(t *testing.T) {
on, off := true, false
view := func(c *ui.Context) {
ui.Column(c).Padding(24).Gap(12).Children(func() {
Checkbox(c, &on, "Sealed", CheckboxOptions{})
Checkbox(c, &off, "Open", CheckboxOptions{})
})
}
snapshotAfter(t, "checkbox-hover", 240, 110, view, func(tt *ui.Tester) { tt.Move(baselineAt(t, tt, "Open")) })
snapshotAfter(t, "checkbox-focus", 240, 110, view, func(tt *ui.Tester) { tt.Key(0, ui.KeyTab) })
snapshotAfter(t, "checkbox-pressed", 240, 110, view, func(tt *ui.Tester) { tt.Press(baselineAt(t, tt, "Sealed")) })
}
+54
View File
@@ -0,0 +1,54 @@
package input
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// BulkActionBarOptions configure a BulkActionBar.
type BulkActionBarOptions struct {
Disabled bool
}
// BulkActionBar shows while selected > 0 with the count, actions and a clear button; Changed reports a clear.
func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actions func()) core.ChoiceResult {
if selected < 0 {
panic(fmt.Sprintf("mujica: BulkActionBar selected %d is negative", selected))
}
if selected == 0 {
return core.ChoiceResult{}
}
k := core.Tokens(c)
u := core.Density(c).Unit()
count := fmt.Sprintf(core.Msg(c, "bulk_action_bar.selected"), selected)
bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(core.Density(c).ControlHeight() + 4*u).
Role(ui.RoleToolbar).Label(count).Disabled(opts.Disabled)
core.OverlayStyle(c, bar, core.OverlayEnter(c, bar))
said := ui.Local(bar, "bulk_action_bar.said", func() int { return 0 })
if *said != selected {
*said = selected
c.Announce(count)
}
var clear bool
bar.Children(func() {
ui.Text(c, count).Bold().TextColor(k.Text).SingleLine()
ui.Box(c).Size(theme.BorderWidth, u*5).Background(k.Border).Role(ui.RoleNone)
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Grow(1).Children(func() {
if actions != nil {
actions()
}
})
b := Button(c, core.Msg(c, "bulk_action_bar.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("x")})
clear = b.Clicked() || bar.Shortcut(0, ui.KeyEscape) && !bar.IsDisabled()
})
if clear {
slog.Debug("mujica: bulk_action_bar clear", "selected", selected)
}
return core.NewChoiceResult(bar, clear)
}
+77
View File
@@ -0,0 +1,77 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestBulkActionBarCountActionsClear(t *testing.T) {
selected, deletes := 0, 0
dis := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Button(c, "Select", ButtonOptions{}).Clicked() {
selected += 3
}
r := BulkActionBar(c, selected, BulkActionBarOptions{Disabled: dis}, func() {
if Button(c, "Delete", ButtonOptions{Variant: Danger}).Clicked() {
deletes++
}
})
if r.Changed() {
selected = 0
}
}, 600, 300)
if tt.HasText("selected") {
t.Fatal("bar shown with nothing selected")
}
_ = tt.Click("Select")
if !tt.HasText("3 selected") || !slices.Contains(tt.Announcements(), "3 selected") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Delete")
if deletes != 1 {
t.Fatal("action slot did not run")
}
dis = true
tt.Frame()
_ = tt.Click("Clear selection")
if selected != 3 {
t.Fatal("disabled bar cleared")
}
dis = false
tt.Frame()
_ = tt.Click("Delete") // focus inside the bar
tt.Key(0, ui.KeyEscape)
if selected != 0 || tt.HasText("selected") {
t.Fatalf("Escape left %d selected", selected)
}
_ = tt.Click("Select")
if err := tt.Click("Clear selection"); err != nil || selected != 0 {
t.Fatalf("clear button: %v %d", err, selected)
}
}
func TestBulkActionBarRejectsNegative(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); BulkActionBar(c, -1, BulkActionBarOptions{}, nil) }, 200, 100)
}
func TestBulkActionBarSnapshot(t *testing.T) {
snapshot(t, "bulk_action_bar", 600, 90, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
BulkActionBar(c, 12, BulkActionBarOptions{}, func() {
Button(c, "Archive", ButtonOptions{Variant: Secondary})
Button(c, "Delete", ButtonOptions{Variant: Danger})
})
})
})
}
+104
View File
@@ -0,0 +1,104 @@
package input
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ButtonVariant is a button's emphasis. The zero value is Primary.
type ButtonVariant int
const (
Primary ButtonVariant = iota
Secondary
Outline
Ghost
Danger
)
// ButtonOptions configure a Button.
type ButtonOptions struct {
Variant ButtonVariant
// Icon goes before the label.
Icon *ui.SVG
Loading bool
Disabled bool
// Label names an icon-only button for assistive technology; required without a label.
Label string
}
// buttonSpinnerSize is the leading icon slot, which the spinner takes while loading.
const buttonSpinnerSize float32 = 16
// Button is a push button. Loading keeps its size and blocks clicks.
func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
if label == "" && opts.Label == "" {
panic("mujica: Button without a label needs Label to name it")
}
k := core.Tokens(c)
d := core.Density(c)
pad, gap := d.Unit()*4, d.Unit()*2
if opts.Loading && opts.Icon == nil && label != "" {
// The spinner slot comes out of the padding, so the width holds.
pad -= (buttonSpinnerSize + gap) / 2
}
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize).
Padding(0, pad).Gap(gap).Radius(theme.ControlRadius).
Disabled(opts.Disabled || opts.Loading)
internal.Ring(b, k.Focus, theme.ControlRadius)
if opts.Label != "" {
b.Label(opts.Label)
}
bg, fg, border := ButtonColors(k, opts.Variant, b.Hovered() && !opts.Disabled, b.Pressed())
b.Background(bg).TextColor(fg)
if border.A > 0 {
b.Border(theme.BorderWidth, border)
}
b.Children(func() {
if opts.Loading {
core.Spinner(c, core.SpinnerOptions{Size: buttonSpinnerSize, Color: fg})
} else if opts.Icon != nil {
ui.Icon(c, opts.Icon).Size(buttonSpinnerSize, buttonSpinnerSize)
}
if label != "" {
ui.Text(c, label).SingleLine()
}
})
return b
}
func ButtonColors(k theme.Tokens, v ButtonVariant, hover, pressed bool) (bg, fg, border ui.Color) {
switch v {
case Secondary:
bg, fg, border = k.Surface, k.Text, k.ControlBorder
case Outline:
bg, fg, border = ui.Transparent, k.AccentText, k.AccentText
case Ghost:
bg, fg = ui.Transparent, k.AccentText
case Danger:
bg, fg = k.DangerFill, k.OnDanger
if pressed {
bg = k.DangerFill.Mix(ui.RGB(0, 0, 0), 0.25)
} else if hover {
bg = k.DangerFill.Mix(ui.RGB(0, 0, 0), 0.12)
}
return
default:
bg, fg = k.Accent, k.OnAccent
if pressed {
bg = k.AccentPressed
} else if hover {
bg = k.AccentHover
}
return
}
if pressed {
bg = k.Selection
} else if hover {
bg = k.SurfaceHover
}
return
}
+128
View File
@@ -0,0 +1,128 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ButtonGroupItem is one button of a ButtonGroup. Give Label, Icon or both; icon-only needs Name.
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 {
Element *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 := core.Tokens(c)
d := core.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")
}
if it.Label == "" && it.Name == "" {
panic("mujica: icon-only ButtonGroupItem needs a Name")
}
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(core.ColorFade)
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}
}
}
+85
View File
@@ -0,0 +1,85 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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) {
core.Use(c, core.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) {
core.Use(c, core.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) {
core.Use(c, core.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})
})
})
}
+133
View File
@@ -0,0 +1,133 @@
package input
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestButtonClickOnce(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
if Button(c, "Save", ButtonOptions{}).Clicked() {
n++
}
}, 300, 200)
if err := tt.Click("Save"); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("clicked %d times", n)
}
}
func TestButtonKeyboard(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Button(c, "Save", ButtonOptions{}).Clicked() {
n++
}
}, 300, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if n != 2 {
t.Fatalf("keyboard clicks %d, want 2", n)
}
}
func TestButtonBlockedStates(t *testing.T) {
for _, opts := range []ButtonOptions{{Disabled: true}, {Loading: true}} {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Button(c, "Pay", opts).Clicked() {
n++
}
}, 300, 200)
_ = tt.Click("Pay")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if n != 0 {
t.Fatalf("%+v: clicked %d times", opts, n)
}
if !tt.HasText("Pay") {
t.Fatalf("%+v: label lost", opts)
}
}
}
func TestButtonMinHeight(t *testing.T) {
for _, tc := range []struct {
density theme.Density
min float32
}{{theme.Compact, 28}, {theme.Default, 36}, {theme.Comfortable, 44}} {
var h float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: tc.density})
ui.Column(c).AlignItems(ui.Start).Children(func() {
h = Button(c, "Go", ButtonOptions{}).Bounds().H
})
}, 300, 200)
tt.Frame()
if h < tc.min {
t.Errorf("%v: height %v < %v", tc.density, h, tc.min)
}
}
}
func TestButtonIconAndDanger(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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 := core.Light
if core.Tokens(c).Dark {
m = core.Dark
}
core.Use(c, core.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")})
})
}
+238
View File
@@ -0,0 +1,238 @@
package input
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CascaderNode is one choice at a level of a Cascader.
type CascaderNode struct {
Value, Label string
// Leaf marks a node without children.
Leaf bool
Disabled bool
}
// CascaderStatus is how far the caller got loading a level. The zero value is loaded.
type CascaderStatus int
const (
CascaderLoaded CascaderStatus = iota
// CascaderIdle asks the Cascader to call Load for the level.
CascaderIdle
CascaderLoading
CascaderFailed
)
// CascaderLevel is what the caller knows of the children under a path.
type CascaderLevel struct {
Nodes []CascaderNode
Status CascaderStatus
// Err explains a failed load; it shows beside the retry button.
Err error
}
// CascaderOptions configure a Cascader. Level is required.
type CascaderOptions struct {
// Level returns the children under path; the empty path is the roots.
Level func(path []string) CascaderLevel
// Load fetches the children under path while Idle and on retry; it marks the level Loading.
Load func(path []string)
// AnyLevel lets branches be chosen too; by default only leaves are.
AnyLevel bool
// Placeholder shows while nothing is chosen; empty uses "Select…".
Placeholder string
Disabled bool
// Label names the field for assistive technology.
Label string
}
// cascaderState is the popup's open path and the row the keys move to.
type cascaderState struct {
open bool
active []string
requested map[string]bool
focus []string
focusSet bool
}
type cascaderKey struct{}
// Cascader chooses a path through levels the caller supplies, loading children on demand.
func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceResult {
if opts.Level == nil {
panic("mujica: Cascader needs Level")
}
before := slices.Clone(*path)
tr := ui.ButtonBase(c).Disabled(opts.Disabled).Role(ui.RolePopUpButton)
if opts.Label != "" {
tr.Label(opts.Label)
}
st := *ui.Local(tr, cascaderKey{}, func() *cascaderState { return &cascaderState{requested: map[string]bool{}} })
if opts.Disabled {
st.open = false
}
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown) && !tr.IsDisabled()) {
st.open = !st.open
if st.open {
st.active = slices.Clone(*path)
st.focus, st.focusSet = slices.Clone(*path), true
if len(*path) == 0 {
st.focus = []string{""}
}
}
}
SelectField(c, tr, st.open, func() {
if len(*path) == 0 {
ui.Text(c, selectOr(opts.Placeholder, core.Msg(c, "select.placeholder"))).SingleLine().TextColor(core.Tokens(c).TextMuted).Grow(1)
return
}
ui.Text(c, cascaderLabels(opts, *path)).SingleLine().Grow(1).Shrink(1)
})
ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) {
SelectPanel(c, panel)
panel.Padding(0)
ui.Row(c).AlignItems(ui.Stretch).Dividers(theme.BorderWidth, core.Tokens(c).Border).Children(func() {
for depth := 0; depth <= len(st.active); depth++ {
if !cascaderColumn(c, st, path, opts, depth) {
break
}
}
})
})
changed := !slices.Equal(before, *path)
if changed {
slog.Debug("mujica: cascader changed", "label", opts.Label, "path", *path)
}
return core.NewChoiceResult(tr, changed)
}
// cascaderLabels writes the full path of labels, as "Europe / France / Paris".
func cascaderLabels(opts CascaderOptions, path []string) string {
labels := make([]string, len(path))
for i, v := range path {
labels[i] = v
for _, n := range opts.Level(path[:i]).Nodes {
if n.Value == v {
labels[i] = n.Label
}
}
}
return strings.Join(labels, " / ")
}
// cascaderColumn builds the column at depth and reports whether a deeper one may follow.
func cascaderColumn(c *ui.Context, st *cascaderState, path *[]string, opts CascaderOptions, depth int) bool {
k := core.Tokens(c)
prefix := slices.Clone(st.active[:depth])
lv := opts.Level(prefix)
id := strings.Join(prefix, "\x00")
if lv.Status == CascaderIdle && !st.requested[id] {
if opts.Load == nil {
panic("mujica: Cascader level is Idle but Load is nil")
}
st.requested[id] = true
slog.Debug("mujica: cascader load", "path", prefix)
opts.Load(prefix)
}
if lv.Status != CascaderIdle {
delete(st.requested, id)
}
deeper := false
col := ui.Column(c).Key(id).Width(180).FocusGroup(ui.Vertical)
col.Children(func() {
switch {
case lv.Status == CascaderIdle || lv.Status == CascaderLoading:
ui.Row(c).Gap(8).Padding(8).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
core.Spinner(c, core.SpinnerOptions{})
ui.Text(c, core.Msg(c, "cascader.loading")).TextColor(k.TextMuted)
})
case lv.Status == CascaderFailed:
ui.Column(c).Gap(8).Padding(8).AlignItems(ui.Start).Children(func() {
text := core.Msg(c, "cascader.failed")
if lv.Err != nil {
text += ": " + lv.Err.Error()
}
ui.Text(c, text).TextColor(k.Danger)
if Button(c, core.Msg(c, "cascader.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked() {
slog.Debug("mujica: cascader retry", "path", prefix)
opts.Load(prefix)
}
})
case len(lv.Nodes) == 0:
SelectNote(c, core.Msg(c, "cascader.empty"))
default:
ui.Scroll(c).MaxHeight(260).Padding(4).Children(func() {
entry := slices.IndexFunc(lv.Nodes, func(n CascaderNode) bool { return !n.Disabled })
for i, n := range lv.Nodes {
on := depth < len(st.active) && st.active[depth] == n.Value
deeper = deeper || on && !n.Leaf
cascaderRow(c, st, path, opts, prefix, n, on, i == entry)
}
})
}
})
return deeper
}
func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts CascaderOptions, prefix []string, n CascaderNode, on, first bool) {
k := core.Tokens(c)
d := core.Density(c)
full := append(slices.Clone(prefix), n.Value)
depth := len(prefix)
b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem)
internal.Ring(b, k.Focus, theme.ControlRadius)
if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) {
b.Focus()
st.focusSet = false
}
open := func(focusChild bool) {
st.active = full
if focusChild {
st.focus, st.focusSet = append(slices.Clone(full), ""), true
}
if opts.AnyLevel {
*path = slices.Clone(full)
}
}
clicked := b.Clicked()
switch {
case clicked && n.Leaf:
st.active, *path, st.open = full, full, false
case clicked:
open(false)
case b.Shortcut(0, ui.KeyRight) && !n.Leaf && !b.IsDisabled():
open(true)
case b.Shortcut(0, ui.KeyLeft) && depth > 0:
st.focus, st.focusSet = slices.Clone(prefix), true
}
chosen := len(*path) > depth && slices.Equal((*path)[:depth+1], full)
b.Selected(chosen).Background(ui.Transparent).TextColor(k.Text)
switch {
case on:
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
if chosen {
b.TextColor(k.AccentText)
}
b.Children(func() {
SelectCheck(c, chosen)
ui.Text(c, n.Label).SingleLine().Grow(1).Shrink(1)
if !n.Leaf {
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
internal.Mark(p, r, "chevron-right", k.TextMuted)
})
}
})
}
+169
View File
@@ -0,0 +1,169 @@
package input
import (
"errors"
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// cascaderFake is a caller's store: roots are known, children load on request.
type cascaderFake struct {
levels map[string]CascaderLevel
loads []string
}
func newCascaderFake() *cascaderFake {
return &cascaderFake{levels: map[string]CascaderLevel{
"": {Nodes: []CascaderNode{{Value: "eu", Label: "Europe"}, {Value: "as", Label: "Asia"}, {Value: "aq", Label: "Antarctica", Disabled: true}}},
}}
}
func (f *cascaderFake) level(path []string) CascaderLevel {
if lv, ok := f.levels[strings.Join(path, "/")]; ok {
return lv
}
return CascaderLevel{Status: CascaderIdle}
}
func (f *cascaderFake) load(path []string) {
key := strings.Join(path, "/")
f.loads = append(f.loads, key)
f.levels[key] = CascaderLevel{Status: CascaderLoading}
}
func (f *cascaderFake) finish(key string, lv CascaderLevel) { f.levels[key] = lv }
func cascaderTester(path *[]string, f *cascaderFake, anyLevel bool, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Children(func() {
if Cascader(c, path, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: anyLevel, Label: "Place"}).Changed() {
*changes++
}
})
}, 700, 500)
}
func TestCascaderLoadsAndChoosesLeaf(t *testing.T) {
var path []string
changes := 0
f := newCascaderFake()
tt := cascaderTester(&path, f, false, &changes)
_ = tt.Click("Select…")
_ = tt.Click("Europe")
if !slices.Equal(f.loads, []string{"eu"}) || !tt.HasText("Loading…") {
t.Fatalf("loads %v, texts %v", f.loads, tt.Texts())
}
if len(path) != 0 {
t.Fatalf("branch chosen without AnyLevel: %v", path)
}
f.finish("eu", CascaderLevel{Nodes: []CascaderNode{{Value: "fr", Label: "France"}}})
tt.Frame()
_ = tt.Click("France")
f.finish("eu/fr", CascaderLevel{Nodes: []CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}})
tt.Frame()
_ = tt.Click("Paris")
if !slices.Equal(path, []string{"eu", "fr", "paris"}) || changes != 1 {
t.Fatalf("path %v changes %d", path, changes)
}
if !tt.HasText("Europe / France / Paris") || tt.HasText("Asia") {
t.Fatalf("field shows %v", tt.Texts())
}
}
func TestCascaderRetryAfterFailure(t *testing.T) {
var path []string
changes := 0
f := newCascaderFake()
tt := cascaderTester(&path, f, false, &changes)
_ = tt.Click("Select…")
_ = tt.Click("Asia")
f.finish("as", CascaderLevel{Status: CascaderFailed, Err: errors.New("timeout")})
tt.Frame()
if !tt.HasText("Couldn't load: timeout") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
if !slices.Equal(f.loads, []string{"as", "as"}) || !tt.HasText("Loading…") {
t.Fatalf("loads %v", f.loads)
}
}
func TestCascaderAnyLevelAndDisabled(t *testing.T) {
var path []string
changes := 0
f := newCascaderFake()
tt := cascaderTester(&path, f, true, &changes)
_ = tt.Click("Select…")
_ = tt.Click("Antarctica")
if len(path) != 0 {
t.Fatalf("disabled node chosen: %v", path)
}
_ = tt.Click("Asia")
if !slices.Equal(path, []string{"as"}) {
t.Fatalf("AnyLevel path %v", path)
}
}
func TestCascaderKeyboard(t *testing.T) {
var path []string
changes := 0
f := newCascaderFake()
f.finish("eu", CascaderLevel{Nodes: []CascaderNode{{Value: "fr", Label: "France", Leaf: true}, {Value: "it", Label: "Italy", Leaf: true}}})
tt := cascaderTester(&path, f, false, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Europe") {
t.Fatal("first row not focused on open")
}
tt.Key(0, ui.KeyRight)
if !tt.Focused("France") {
t.Fatal("Right did not move into the children")
}
tt.Key(0, ui.KeyLeft)
if !tt.Focused("Europe") {
t.Fatal("Left did not go back")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !slices.Equal(path, []string{"eu", "it"}) {
t.Fatalf("path %v", path)
}
if !tt.Focused("Place") {
t.Fatal("focus did not return to the field")
}
}
func TestCascaderNeedsLevel(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
var path []string
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Cascader(c, &path, CascaderOptions{})
}, 100, 100, 1)
}
func TestCascaderSnapshot(t *testing.T) {
f := newCascaderFake()
f.finish("eu", CascaderLevel{Nodes: []CascaderNode{{Value: "fr", Label: "France"}}})
f.finish("eu/fr", CascaderLevel{Nodes: []CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}})
snapshot(t, "cascader", 460, 110, func(c *ui.Context) {
none, some := []string{}, []string{"eu", "fr", "paris"}
ui.Row(c).Padding(20).Gap(16).Children(func() {
Cascader(c, &none, CascaderOptions{Level: f.level, Load: f.load})
Cascader(c, &some, CascaderOptions{Level: f.level, Load: f.load})
})
})
}
+151
View File
@@ -0,0 +1,151 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
d := core.Density(c)
// Disabled on a wrapper blocks the base's click before it toggles.
var row *ui.Element
wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
wrap.Children(func() {
row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
})
if wrap.IsDisabled() && wrap.FocusWithin() {
wrap.FocusRing(false).Focus()
}
if row.Changed() && opts.Indeterminate && !*value {
// From mixed, a click always checks.
*value = true
}
if opts.Indeterminate {
row.Description(core.Msg(c, "checkbox.mixed"))
}
on, mixed := *value, opts.Indeterminate
hover, pressed := row.Hovered(), row.Pressed()
row.Children(func() {
bg, border := checkboxColors(k, on || mixed, hover, pressed)
box := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(theme.ControlRadius).
Background(bg).Border(theme.BorderWidth, border)
box.Transition(core.ColorFade)
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
}
// checkboxColors gives a check box or radio mark its fill and edge.
func checkboxColors(k theme.Tokens, on, hover, pressed bool) (bg, border ui.Color) {
switch {
case on && pressed:
return k.AccentPressed, k.Text
case on && hover:
return k.AccentHover, k.Text
case on:
return k.Accent, k.AccentText
case pressed:
return k.Selection, k.AccentText
case hover:
return k.SurfaceHover, k.AccentText
}
return k.Surface, k.ControlBorder
}
// 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) core.ChoiceResult {
if len(items) == 0 {
panic("mujica: CheckboxGroup needs items")
}
d := core.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 core.ChoiceResult
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})
if head.Changed() {
// Select-all leaves disabled items alone.
target := enabledOn < enabled
for _, it := range items {
if !it.Disabled {
*it.Value = target
}
}
r = core.NewChoiceResult(r.Element, 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 = core.NewChoiceResult(r.Element, true)
}
}
})
})
return r
}
+106
View File
@@ -0,0 +1,106 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestCheckboxLabelClickAndSpace(t *testing.T) {
on, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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) {
core.Use(c, core.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) {
core.Use(c, core.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) {
core.Use(c, core.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)
}
+107
View File
@@ -0,0 +1,107 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// choiceBetween builds control between two buttons, for Tab order checks.
func choiceBetween(control func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Gap(10).Width(300).Children(func() {
Button(c, "Before", ButtonOptions{})
control(c)
Button(c, "After", ButtonOptions{})
})
}, 400, 400)
}
// choiceTabSkips reports whether Tab from Before lands on After.
func choiceTabSkips(t *testing.T, tt *ui.Tester) bool {
t.Helper()
if err := tt.Click("Before"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
return tt.Focused("After")
}
func TestToggleDisabled(t *testing.T) {
for _, disabled := range []bool{false, true} {
on := false
tt := choiceBetween(func(c *ui.Context) {
Toggle(c, &on, "Italic", ToggleOptions{Disabled: disabled})
})
_ = tt.Click("Italic")
if on == disabled {
t.Fatalf("disabled=%v: click left on=%v", disabled, on)
}
if choiceTabSkips(t, tt) != disabled {
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
}
}
}
func TestSliderDisabled(t *testing.T) {
for _, disabled := range []bool{false, true} {
v := 30.0
tt := choiceBetween(func(c *ui.Context) {
Slider(c, &v, SliderOptions{Step: 1, Disabled: disabled, Label: "Volume"})
})
_ = tt.Click("Volume")
if (v != 30) == disabled {
t.Fatalf("disabled=%v: click left %v", disabled, v)
}
if choiceTabSkips(t, tt) != disabled {
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
}
}
}
func TestRangeSliderDisabled(t *testing.T) {
for _, disabled := range []bool{false, true} {
low, high := 20.0, 40.0
tt := choiceBetween(func(c *ui.Context) {
RangeSlider(c, &low, &high, RangeSliderOptions{Step: 1, Disabled: disabled, Label: "Price"})
})
_ = tt.Click("Price")
if (high != 40) == disabled || low != 20 {
t.Fatalf("disabled=%v: click left %v..%v", disabled, low, high)
}
if choiceTabSkips(t, tt) != disabled {
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
}
}
}
// Disabling a focused control in the frame a key arrives must block the key.
func TestChoiceDisabledWhileFocused(t *testing.T) {
disabled := false
on, pressed, group := false, false, []string{"left"}
items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}}
for name, build := range map[string]func(c *ui.Context){
"switch": func(c *ui.Context) { Switch(c, &on, "Sync", SwitchOptions{Disabled: disabled}) },
"toggle": func(c *ui.Context) { Toggle(c, &pressed, "Bold", ToggleOptions{Disabled: disabled}) },
"group": func(c *ui.Context) {
items[1].Disabled = disabled
ToggleGroup(c, &group, items, ToggleGroupOptions{})
},
} {
disabled, on, pressed, group = false, false, false, []string{"left"}
tt := choiceBetween(build)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if name == "group" {
tt.Key(0, ui.KeyRight)
}
disabled = true
tt.Key(0, ui.KeySpace)
if on || pressed || group[0] != "left" {
t.Errorf("%s: Space changed a disabled control: %v %v %v", name, on, pressed, group)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package input
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ChoiceChipsOptions configure ChoiceChips.
type ChoiceChipsOptions struct {
// Multiple lets several chips be on; otherwise one or none.
Multiple bool
Disabled bool
// Label names the chip set for assistive technology.
Label string
}
// ChoiceChips are wrapping filter chips toggling values in *value; arrows move between them.
func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ChoiceChipsOptions) core.ChoiceResult {
if value == nil {
panic("mujica: ChoiceChips needs a value")
}
if !opts.Multiple && len(*value) > 1 {
panic("mujica: single ChoiceChips holds more than one value")
}
k := core.Tokens(c)
d := core.Density(c)
g := ui.Row(c).Wrap().Gap(d.Unit() * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal)
if opts.Label != "" {
g.Label(opts.Label)
}
changed := false
g.Children(func() {
for _, it := range items {
on := slices.Contains(*value, it.Value)
pressed := on
b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled)
// The base took input before Disabled applied: drop a disabled chip's press.
if b.Changed() && !b.IsDisabled() && toggleGroupApply(value, it.Value, pressed, ToggleGroupOptions{Multiple: opts.Multiple, AllowEmpty: true}) {
changed, on = true, pressed
}
choiceChipLook(c, k, d, b, on, it)
}
})
if changed {
slog.Debug("mujica: choice_chips changed", "label", opts.Label, "value", *value)
}
return core.NewChoiceResult(g, changed)
}
func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) {
b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 1.5).
Radius(theme.PopupRadius)
internal.Ring(b, k.Focus, theme.PopupRadius)
bg, fg, line := k.Surface, k.Text, k.ControlBorder
switch {
case on:
bg, fg, line = k.Selection, k.AccentText, k.AccentText
case b.Pressed():
bg = k.Selection
case b.Hovered():
bg = k.SurfaceHover
}
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, line).Transition(core.ColorFade)
if it.Name != "" {
b.Label(it.Name)
}
b.Children(func() {
switch {
case on:
ui.Icon(c, icons.Must("check")).Size(14, 14)
case it.Icon != nil:
ui.Icon(c, it.Icon).Size(14, 14)
}
if it.Label != "" {
ui.Text(c, it.Label).SingleLine()
}
})
}
+87
View File
@@ -0,0 +1,87 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var chipItems = []ToggleItem[string]{{Value: "open", Label: "Open"}, {Value: "draft", Label: "Draft"}, {Value: "closed", Label: "Closed", Disabled: true}, {Value: "merged", Label: "Merged"}}
func chipTester(v *[]string, opts *ChoiceChipsOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if ChoiceChips(c, v, chipItems, *opts).Changed() {
*n++
}
}, 600, 200)
}
func TestChoiceChipsSingleAndMultiple(t *testing.T) {
var v []string
opts := ChoiceChipsOptions{}
n := 0
tt := chipTester(&v, &opts, &n)
_ = tt.Click("Open")
_ = tt.Click("Draft")
if !slices.Equal(v, []string{"draft"}) || n != 2 {
t.Fatalf("single: %v %d", v, n)
}
_ = tt.Click("Draft")
if len(v) != 0 {
t.Fatalf("single chip did not turn off: %v", v)
}
_ = tt.Click("Closed")
if len(v) != 0 || n != 3 {
t.Fatalf("disabled chip: %v %d", v, n)
}
opts.Multiple = true
_ = tt.Click("Open")
_ = tt.Click("Merged")
if !slices.Equal(v, []string{"open", "merged"}) {
t.Fatalf("multiple: %v", v)
}
}
func TestChoiceChipsKeyboard(t *testing.T) {
var v []string
opts := ChoiceChipsOptions{Multiple: true}
n := 0
tt := chipTester(&v, &opts, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyRight) // skips the disabled chip
tt.Key(0, ui.KeySpace)
if !slices.Equal(v, []string{"draft", "merged"}) {
t.Fatalf("keyboard: %v", v)
}
opts.Disabled = true
tt.Frame()
_ = tt.Click("Open")
if len(v) != 2 {
t.Fatalf("disabled set changed: %v", v)
}
}
func TestChoiceChipsRejectsTwoInSingle(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
v := []string{"open", "draft"}
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{}) }, 300, 100)
}
func TestChoiceChipsSnapshot(t *testing.T) {
v := []string{"open", "merged"}
snapshot(t, "choice_chips", 480, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Children(func() {
ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{Multiple: true})
})
})
}
+116
View File
@@ -0,0 +1,116 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// Keys delivered in the frame a control or option turns disabled must change nothing.
func TestMultiSelectBackspaceAsItDisables(t *testing.T) {
values, disabled := []int{1, 2}, false
tt := choiceBetween(func(c *ui.Context) {
MultiSelect(c, &values, multiSelectTestOptions, MultiSelectOptions{Disabled: disabled, Label: "Flowers"})
})
_ = tt.Click("Before")
tt.Key(0, ui.KeyTab)
if !tt.Focused("Flowers") {
t.Fatal("field not focused")
}
disabled = true
tt.Key(0, ui.KeyBackspace)
if !slices.Equal(values, []int{1, 2}) {
t.Fatalf("Backspace removed a value: %v", values)
}
}
func TestComboboxEnterAsItDisables(t *testing.T) {
value, disabled := "", false
tt := choiceBetween(func(c *ui.Context) {
Combobox(c, &value, comboboxTestFonts, ComboboxOptions{AllowCustom: true, Disabled: disabled, Label: "Font"})
})
_ = tt.Click("Before")
tt.Key(0, ui.KeyTab)
tt.Type("Caslon")
tt.Key(0, ui.KeyEscape)
disabled = true
tt.Key(0, ui.KeyEnter)
if value != "" {
t.Fatalf("Enter submitted %q", value)
}
}
func TestCascaderRightAsRowDisables(t *testing.T) {
var path []string
f := newCascaderFake()
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Cascader(c, &path, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: true, Label: "Place"})
}, 700, 500)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Europe") {
t.Fatal("Europe not focused")
}
roots := f.levels[""]
roots.Nodes = slices.Clone(roots.Nodes)
roots.Nodes[0].Disabled = true
f.levels[""] = roots
tt.Key(0, ui.KeyRight)
if len(path) != 0 || len(f.loads) != 0 {
t.Fatalf("Right on a disabled row chose %v, loaded %v", path, f.loads)
}
}
func TestSelectEnterOnOptionAsItDisables(t *testing.T) {
v := "apple"
options := slices.Clone(selectTestOptions)
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Select(c, &v, options, SelectOptions{Label: "Fruit"})
}, 400, 400)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
options[1].Disabled = true
tt.Key(0, ui.KeyEnter)
if v != "apple" {
t.Fatalf("Enter chose the disabled option: %q", v)
}
}
func TestEntryFocusSkipsDisabledFirstRow(t *testing.T) {
var path []string
f := &cascaderFake{levels: map[string]CascaderLevel{
"": {Nodes: []CascaderNode{{Value: "aq", Label: "Antarctica", Disabled: true}, {Value: "eu", Label: "Europe", Leaf: true}}},
}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Cascader(c, &path, CascaderOptions{Level: f.level, Load: f.load, Label: "Place"})
}, 700, 500)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Europe") {
t.Fatal("cascader entry did not reach the first enabled row")
}
nodes := []TreeSelectNode{{ID: "a", Label: "Archived", Disabled: true}, {ID: "b", Label: "Brass"}, {ID: "c", Label: "Copper"}}
var sel []string
tt = ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
TreeSelect(c, &sel, nodes, TreeSelectOptions{Label: "Metal"})
}, 500, 500)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Brass") {
t.Fatal("tree entry did not reach the first enabled row")
}
tt.Key(0, ui.KeyDown)
if !tt.Focused("Copper") {
t.Fatal("arrows stopped working after entry")
}
}
+212
View File
@@ -0,0 +1,212 @@
package input
import (
"image/color"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// A popup left open must stop taking choices once its control is disabled.
func TestChoicePopupsCloseWhenDisabled(t *testing.T) {
disabled := false
var multi []int
var paths, ids []string
f := newCascaderFake()
cases := []struct {
name, open, pick string
build func(c *ui.Context)
same func() bool
}{
{"multi", "Select…", "Lily", func(c *ui.Context) {
MultiSelect(c, &multi, multiSelectTestOptions, MultiSelectOptions{Disabled: disabled})
}, func() bool { return len(multi) == 0 }},
{"cascader", "Select…", "Europe", func(c *ui.Context) {
Cascader(c, &paths, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: true, Disabled: disabled})
}, func() bool { return len(paths) == 0 }},
{"tree", "Select…", "Guard", func(c *ui.Context) {
TreeSelect(c, &ids, treeSelectTestNodes, TreeSelectOptions{Disabled: disabled})
}, func() bool { return len(ids) == 0 }},
}
for _, tc := range cases {
disabled = false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Children(func() { tc.build(c) })
}, 700, 500)
if err := tt.Click(tc.open); err != nil {
t.Fatal(err)
}
if !tt.HasText(tc.pick) {
t.Fatalf("%s: popup did not open", tc.name)
}
disabled = true
tt.Frame()
_ = tt.Click(tc.pick)
if !tc.same() || tt.HasText(tc.pick) {
t.Errorf("%s: disabled control took a choice or kept its popup", tc.name)
}
}
}
func TestTreeSelectSingleMarksOwnNode(t *testing.T) {
single := TreeSelectOptions{}
court := &treeSelectTestNodes[0]
if got := treeSelectCheck(court, []string{"court"}, single); got != internal.Checked {
t.Fatalf("chosen parent shows %v", got)
}
if got := treeSelectCheck(court, []string{"king", "queen"}, single); got != internal.Unchecked {
t.Fatalf("parent of chosen leaves shows %v", got)
}
var sel []string
changes := 0
tt := treeSelectTester(&sel, single, &changes)
_ = tt.Click("Select…")
_ = tt.Click("Court")
if !slices.Equal(sel, []string{"court"}) {
t.Fatalf("sel %v", sel)
}
}
func TestTreeSelectRightSkipsHiddenChildren(t *testing.T) {
var sel []string
changes := 0
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true}, &changes)
_ = tt.Click("Select…")
tt.Type("queen")
tt.Key(0, ui.KeyDown)
if !tt.Focused("Court") {
t.Fatal("Down did not reach Court")
}
tt.Key(0, ui.KeyRight)
if !tt.Focused("Queen") {
t.Fatal("Right did not reach the only shown child")
}
}
// pixel reads the light theme color at (x, y) of the last frame.
func pixel(tt *ui.Tester, x, y float32) color.RGBA {
return tt.Image().RGBAAt(int(x), int(y))
}
func TestSelectChosenRowHasSelectionFill(t *testing.T) {
v, changes := "banana", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Move(0, 0)
tt.Key(0, ui.KeyDown)
blue, ok := tt.Find("Blueberry")
if !ok {
t.Fatal("popup closed")
}
h := theme.Default.ControlHeight()
got := pixel(tt, blue.X+blue.W+20, blue.Y+blue.H/2-h)
s := theme.Light().Selection
if got.R != s.R || got.G != s.G || got.B != s.B {
t.Fatalf("chosen row %v, want selection %v", got, s)
}
}
func TestChoiceHitAreas(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{Clearable: true}, &changes)
if r, _ := tt.Find("Clear"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
t.Errorf("clear button %v", r)
}
var sel []string
tt = treeSelectTester(&sel, TreeSelectOptions{}, &changes)
_ = tt.Click("Select…")
if r, _ := tt.Find("Expand Court"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
t.Errorf("tree arrow %v", r)
}
font := ""
tt = ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Combobox(c, &font, comboboxTestFonts, ComboboxOptions{})
}, 400, 300)
if r, _ := tt.Find("Show options"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
t.Errorf("combobox arrow %v", r)
}
}
func TestChoiceRowsFollowDensity(t *testing.T) {
for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
v, on := "apple", false
var sw *ui.Element
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: d})
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
sw = Switch(c, &on, "Wi-Fi", SwitchOptions{})
Select(c, &v, selectTestOptions, SelectOptions{Label: "Fruit"})
})
}, 400, 500)
if h := sw.Bounds().H; h < d.ControlHeight() {
t.Errorf("%v: switch %v high", d, h)
}
_ = tt.Click("Fruit")
a, _ := tt.Find("Cherry")
b, _ := tt.Find("Date")
if b.Y-a.Y < d.ControlHeight() {
t.Errorf("%v: option rows %v apart", d, b.Y-a.Y)
}
}
}
func TestMultiSelectCaptionFollowsTextScale(t *testing.T) {
height := func(scale float32) float32 {
values := []int{1}
tt := multiSelectTester(&values, MultiSelectOptions{Max: 2}, new(int))
tt.SetPreferences(ui.Preferences{TextScale: scale})
_ = tt.Click("Rose")
r, ok := tt.Find("1 of 2 chosen")
if !ok {
t.Fatal("limit note missing")
}
return r.H
}
if one, two := height(1), height(2); two < one*1.5 {
t.Fatalf("caption %v high at 2x, %v at 1x", two, one)
}
}
func TestComboboxCaretAfterChoice(t *testing.T) {
r := &comboboxRun{}
tt := comboboxTester(r, fixedFonts, ComboboxOptions{})
tt.Key(0, ui.KeyTab)
tt.Type("di")
tt.Key(0, ui.KeyEnter)
if r.value != "Didot" {
t.Fatalf("value %q", r.value)
}
tt.Type("x")
if r.query != "Didotx" {
t.Fatalf("typing after a choice gave %q", r.query)
}
}
// The first Tab enters a toggle group at its first enabled item; later at the last focused one.
func TestToggleGroupTabEntry(t *testing.T) {
value := []string{"right"}
tt := choiceBetween(func(c *ui.Context) {
ToggleGroup(c, &value, toggleGroupTestItems, ToggleGroupOptions{})
})
_ = tt.Click("Before")
tt.Key(0, ui.KeyTab)
if !tt.Focused("Left") {
t.Fatal("first Tab did not land on the first item")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyTab)
_ = tt.Click("Before")
tt.Key(0, ui.KeyTab)
if !tt.Focused("Center") {
t.Fatal("Tab did not return to the last focused item")
}
}
+110
View File
@@ -0,0 +1,110 @@
package input
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ColorSwatch is one preset of a ColorPalette; Name is read out.
type ColorSwatch struct {
Color ui.Color
Name string
}
// ColorPaletteOptions configure a ColorPalette.
type ColorPaletteOptions struct {
// Columns is the grid width in swatches; zero is 8.
Columns int
// Custom adds a ColorPicker after the swatches for any other color.
Custom bool
Disabled bool
// Label names the palette for assistive technology.
Label string
}
// ColorPalette picks *value from preset swatches in a grid; arrows move in two dimensions.
func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts ColorPaletteOptions) core.ChoiceResult {
if value == nil || len(swatches) == 0 {
panic("mujica: ColorPalette needs a value and swatches")
}
if opts.Columns < 0 {
panic(fmt.Sprintf("mujica: ColorPalette Columns %d is negative", opts.Columns))
}
cols := opts.Columns
if cols == 0 {
cols = 8
}
for _, s := range swatches {
if s.Name == "" {
panic("mujica: ColorPalette swatch needs a Name")
}
}
k := core.Tokens(c)
u := core.Density(c).Unit()
g := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleRadioGroup).Label(opts.Label)
disabled := g.IsDisabled()
changed := false
g.Children(func() {
grid := ui.Box(c).Grid().Columns(cols).Gap(u * 1.5).FocusGroup(ui.Horizontal)
var cells []*ui.Element
grid.Children(func() {
for i, s := range swatches {
b := colorPaletteSwatch(c, k, s, *value == s.Color).Key(i)
cells = append(cells, b)
if b.Clicked() && !disabled && *value != s.Color {
*value, changed = s.Color, true
}
}
})
// Up and Down move a whole row; FocusGroup moves by one.
for i, b := range cells {
switch {
case b.Shortcut(0, ui.KeyDown) && i+cols < len(cells):
cells[i+cols].Focus()
case b.Shortcut(0, ui.KeyUp) && i-cols >= 0:
cells[i-cols].Focus()
}
}
if opts.Custom {
ui.Row(c).Children(func() {
if ColorPicker(c, value, ColorPickerOptions{Label: core.Msg(c, "color_palette.custom"), Disabled: disabled}).Changed() {
changed = true
}
})
}
})
if changed {
slog.Debug("mujica: color_palette changed", "color", ColorPickerHex(*value))
}
return core.NewChoiceResult(g, changed)
}
// colorPaletteSwatch is one square swatch; the chosen one has an inset frame and a check.
func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) *ui.Element {
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio).
Label(s.Name).Tooltip(s.Name).Selected(on)
internal.Ring(b, k.Focus, theme.ControlRadius)
edge := k.ControlBorder
if b.Hovered() {
edge = k.Text
}
b.Background(s.Color).Border(theme.BorderWidth, edge)
mark := theme.Light().Text
if theme.Contrast(s.Color, mark) < theme.Contrast(s.Color, theme.Light().OnAccent) {
mark = theme.Light().OnAccent
}
b.DrawOver(func(p *ui.Painter, r ui.Rect) {
if !on {
return
}
p.Stroke(ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, mark, 1, 1.5)
internal.Mark(p, ui.Rect{X: r.X + 7, Y: r.Y + 7, W: r.W - 14, H: r.H - 14}, "check", mark)
})
return b
}
+80
View File
@@ -0,0 +1,80 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func paletteSwatches() []ColorSwatch {
var s []ColorSwatch
for i, c := range colorPickerPresets {
s = append(s, ColorSwatch{Color: c, Name: "Tone " + string(rune('A'+i))})
}
return s
}
func TestColorPaletteChooseAndKeyboard(t *testing.T) {
v := colorPickerPresets[0]
dis := false
n := 0
sw := paletteSwatches()
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if ColorPalette(c, &v, sw, ColorPaletteOptions{Columns: 4, Custom: true, Disabled: dis, Label: "Tag color"}).Changed() {
n++
}
}, 500, 400)
if err := tt.Click("Tone C"); err != nil || v != sw[2].Color || n != 1 {
t.Fatalf("click: %v %v %d", err, v, n)
}
tt.Key(0, ui.KeyDown) // focus stays on C: down a row is G
tt.Key(0, ui.KeyEnter)
if v != sw[6].Color {
t.Fatalf("Down then Enter chose %v, want %v", v, sw[6].Color)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeySpace)
if v != sw[3].Color {
t.Fatalf("Right, Up, Space chose %v", v)
}
if _, ok := tt.Find("Custom color"); !ok {
t.Fatal("no custom color entry")
}
dis = true
tt.Frame()
_ = tt.Click("Tone A")
if v != sw[3].Color || n != 3 {
t.Fatalf("disabled palette changed: %v %d", v, n)
}
}
func TestColorPaletteRejects(t *testing.T) {
v := ui.Hex("#000000")
for name, f := range map[string]func(c *ui.Context){
"no name": func(c *ui.Context) { ColorPalette(c, &v, []ColorSwatch{{Color: v}}, ColorPaletteOptions{}) },
"columns": func(c *ui.Context) { ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: -1}) },
"no items": func(c *ui.Context) { ColorPalette(c, &v, nil, ColorPaletteOptions{}) },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); f(c) }, 300, 200)
}()
}
}
func TestColorPaletteSnapshot(t *testing.T) {
v := colorPickerPresets[2]
snapshot(t, "color_palette", 300, 150, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: 6, Custom: true})
})
})
}
+400
View File
@@ -0,0 +1,400 @@
package input
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ColorPickerOptions configure a ColorPicker.
type ColorPickerOptions struct {
// Presets are offered as swatches; nil uses the built-in court palette.
Presets []ui.Color
// Label names the trigger; empty uses the built-in "Color".
Label string
Disabled bool
}
// ColorPickerParts are the parts of a ColorPicker.
type ColorPickerParts struct {
Trigger *ui.Element
changed bool
}
// Changed reports whether the user picked a new color this frame.
func (p ColorPickerParts) Changed() bool { return p.changed }
// colorPickerPresets are the default swatches: court tones and neutrals.
var colorPickerPresets = []ui.Color{
ui.Hex("#7D2034"), ui.Hex("#A12635"), ui.Hex("#AD9164"), ui.Hex("#805714"),
ui.Hex("#326247"), ui.Hex("#355E80"), ui.Hex("#4B3A5A"), ui.Hex("#211C1D"),
ui.Hex("#6A5B55"), ui.Hex("#D2C3AF"), ui.Hex("#F2EBDD"), ui.Hex("#FFFFFF"),
}
// colorPickerHSVA keeps hue for grays, which RGB loses.
type colorPickerHSVA struct{ h, s, v, a float64 }
type colorPickerState struct {
open, wasOpen bool
hsv colorPickerHSVA
last ui.Color
hex, r, g, b string
alpha string
bad string
}
// ColorPicker is a swatch button opening a panel that picks *color.
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
k := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.Msg(c, "color_picker.label")
}
presets := opts.Presets
if presets == nil {
presets = colorPickerPresets
}
parts := ColorPickerParts{}
trigger := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*1.5).Gap(d.Unit() * 2).
Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled).Label(label)
parts.Trigger = trigger
internal.Ring(trigger, k.Focus, theme.ControlRadius)
st := ui.Local(trigger, "picker", func() colorPickerState {
s := colorPickerState{last: *color, hsv: colorPickerToHSVA(*color)}
s.sync(*color)
return s
})
if *color != st.last {
st.hsv, st.last = colorPickerToHSVA(*color), *color
st.sync(*color)
}
set := func(x colorPickerHSVA, fields bool) {
x.s, x.v, x.a = colorPickerClamp(x.s), colorPickerClamp(x.v), colorPickerClamp(x.a)
st.hsv = x
next := x.color()
if next != *color {
slog.Debug("mujica: color picker changed", "color", ColorPickerHex(next))
*color = next
parts.changed = true
}
st.last = *color
if fields {
st.sync(*color)
}
}
if trigger.IsDisabled() {
st.open = false
} else if trigger.Clicked() {
st.open = !st.open
}
border := k.ControlBorder
if trigger.Hovered() {
border = k.ControlBorder.Mix(k.Text, 0.25)
trigger.Background(k.SurfaceHover)
}
trigger.Border(theme.BorderWidth, border).Description(ColorPickerHex(*color))
now := *color
trigger.Children(func() {
ui.Box(c).Size(d.Unit()*6, d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Clip().Shrink(0).
Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.Fill(r, now, 0)
})
ui.Text(c, strings.ToUpper(ColorPickerHex(now))).Font(theme.MonoFont).SingleLine()
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted)
})
opened := st.open && !st.wasOpen
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 3).Gap(d.Unit() * 3).Width(260)
colorPickerSquare(c, st, set, opened)
x := st.hsv
h := x.h
if colorPickerSlider(c, &h, 360, core.Msg(c, "color_picker.hue"), func(p *ui.Painter, r ui.Rect) {
hues := []ui.Color{ui.RGB(255, 0, 0), ui.RGB(255, 255, 0), ui.RGB(0, 255, 0), ui.RGB(0, 255, 255), ui.RGB(0, 0, 255), ui.RGB(255, 0, 255), ui.RGB(255, 0, 0)}
w := r.W / 6
for i := range 6 {
p.FillGradient(ui.Rect{X: r.X + w*float32(i), Y: r.Y, W: w + 0.5, H: r.H}, ui.LinearGradient{From: hues[i], To: hues[i+1], Angle: 90}, 0)
}
}).Changed() {
set(colorPickerHSVA{h, x.s, x.v, x.a}, true)
}
a := x.a
opaque := colorPickerHSVA{x.h, x.s, x.v, 1}.color()
if colorPickerSlider(c, &a, 1, core.Msg(c, "color_picker.alpha"), func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.FillGradient(r, ui.LinearGradient{From: opaque.Alpha(0), To: opaque, Angle: 90}, 0)
}).Changed() {
set(colorPickerHSVA{x.h, x.s, x.v, a}, true)
}
colorPickerFields(c, st, color, set)
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, sw := range presets {
hex := strings.ToUpper(ColorPickerHex(sw))
b := ui.ButtonBase(c).Height(theme.MinHitSize).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
internal.Ring(b, k.Focus, theme.ControlRadius)
chosen := *color == sw
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.Fill(r, sw, 0)
})
if chosen {
mark := ui.RGB(255, 255, 255)
if colorPickerLuma(sw) > 0.55 {
mark = ui.RGB(0, 0, 0)
}
b.Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(mark) })
}
if b.Clicked() {
set(colorPickerToHSVA(sw), true)
}
}
})
})
if st.wasOpen && !st.open && !trigger.IsDisabled() {
trigger.Focus()
}
st.wasOpen = st.open
return parts
}
// colorPickerSquare picks saturation across and brightness up.
func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPickerHSVA, bool), opened bool) {
k := core.Tokens(c)
x := st.hsv
sq := ui.Box(c).Height(150).Radius(theme.ControlRadius).Focusable().Role(ui.RoleSlider).Cursor(ui.CursorCrosshair).
Label(core.Msg(c, "color_picker.square"))
sq.FocusRing(false)
if opened {
sq.Focus()
}
if _, _, ok := sq.Dragged(); ok {
px, py, _ := sq.PointerPosition()
if b := sq.Bounds(); b.W > 0 && b.H > 0 {
set(colorPickerHSVA{x.h, float64(px / b.W), 1 - float64(py/b.H), x.a}, true)
}
}
switch {
case sq.Shortcut(0, ui.KeyRight):
set(colorPickerHSVA{x.h, x.s + 0.01, x.v, x.a}, true)
case sq.Shortcut(0, ui.KeyLeft):
set(colorPickerHSVA{x.h, x.s - 0.01, x.v, x.a}, true)
case sq.Shortcut(0, ui.KeyUp):
set(colorPickerHSVA{x.h, x.s, x.v + 0.01, x.a}, true)
case sq.Shortcut(0, ui.KeyDown):
set(colorPickerHSVA{x.h, x.s, x.v - 0.01, x.a}, true)
}
x = st.hsv
sq.Description(fmt.Sprintf(core.Msg(c, "color_picker.sv"), x.s*100, x.v*100))
hue := colorPickerHSVA{x.h, 1, 1, 1}.color()
sq.Draw(func(p *ui.Painter, r ui.Rect) {
p.FillGradient(r, ui.LinearGradient{From: ui.RGB(255, 255, 255), To: hue, Angle: 90}, theme.ControlRadius)
p.FillGradient(r, ui.LinearGradient{From: ui.RGBA(0, 0, 0, 0), To: ui.RGB(0, 0, 0), Angle: 180}, theme.ControlRadius)
cx, cy := internal.Snap(p, r.X+r.W*float32(x.s)), internal.Snap(p, r.Y+r.H*float32(1-x.v))
p.FillPath(internal.Diamond(cx, cy, 14), ui.RGB(0, 0, 0).Alpha(0.5))
p.FillPath(internal.Diamond(cx, cy, 11), ui.RGB(255, 255, 255))
p.FillPath(internal.Diamond(cx, cy, 6), x.color().Alpha(1))
if sq.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
}
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
k := core.Tokens(c)
const knob, look = 10, 18
s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name)
frac := float32(*v / hi)
s.Draw(func(p *ui.Painter, r ui.Rect) {
// The hit area is taller than the drawn slider, which sits in its middle.
r = ui.Rect{X: r.X, Y: r.Y + (r.H-look)/2, W: r.W, H: look}
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
x := r.X + knob/2 + (r.W-knob)*frac
thumb := ui.Rect{X: x - knob/2, Y: r.Y, W: knob, H: r.H}
p.Fill(thumb, k.Surface, theme.ControlRadius)
p.Stroke(thumb, k.Text, theme.ControlRadius, 1.5)
if s.FocusVisible() {
internal.FocusRing(p, thumb, theme.ControlRadius, k.Focus)
}
})
return s
}
// sync shows col in every field.
func (st *colorPickerState) sync(col ui.Color) {
st.hex = strings.TrimPrefix(strings.ToUpper(ColorPickerHex(col)), "#")
st.r, st.g, st.b = strconv.Itoa(int(col.R)), strconv.Itoa(int(col.G)), strconv.Itoa(int(col.B))
st.alpha = strconv.Itoa(int(math.Round(float64(col.A) / 255 * 100)))
st.bad = ""
}
// colorPickerFields edits the color as HEX, RGB and opacity text.
func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set func(colorPickerHSVA, bool)) {
k := core.Tokens(c)
d := core.Density(c)
apply := func(field string, text *string, col ui.Color, ok bool) {
if !ok {
st.bad = field
return
}
kept := *text
set(colorPickerToHSVA(col), true)
*text = kept
}
editing := false
channel := func(s string) (uint8, bool) {
n, err := strconv.Atoi(strings.TrimSpace(s))
return uint8(n), err == nil && n >= 0 && n <= 255
}
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
hex := InputGroup(c, &st.hex, InputGroupOptions{Prefix: "#", Label: "HEX", Invalid: st.bad == "hex"})
editing = hex.Input.Focused()
if hex.Changed() {
col, err := colorPickerParseHex(st.hex)
apply("hex", &st.hex, col, err == nil)
}
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, f := range []struct {
name, label string
text *string
}{{"R", core.Msg(c, "color_picker.red"), &st.r}, {"G", core.Msg(c, "color_picker.green"), &st.g},
{"B", core.Msg(c, "color_picker.blue"), &st.b}, {"A %", core.Msg(c, "color_picker.percent"), &st.alpha}} {
ui.Column(c).Gap(d.Unit()).Children(func() {
ui.Text(c, f.name).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
editing = editing || in.Input.Focused()
if !in.Changed() {
return
}
col := *color
v, ok := channel(*f.text)
switch f.name {
case "R":
col.R = v
case "G":
col.G = v
case "B":
col.B = v
default:
ok = ok && v <= 100
col.A = uint8(math.Round(float64(v) / 100 * 255))
}
apply(f.name, f.text, col, ok)
})
}
})
if !editing {
st.sync(*color)
}
if st.bad != "" {
ui.Text(c, core.Msg(c, "color_picker.invalid")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
}
})
}
// ColorPickerHex returns col as #rrggbb, or #rrggbbaa when translucent.
func ColorPickerHex(col ui.Color) string {
if col.A == 255 {
return fmt.Sprintf("#%02x%02x%02x", col.R, col.G, col.B)
}
return fmt.Sprintf("#%02x%02x%02x%02x", col.R, col.G, col.B, col.A)
}
// colorPickerParseHex parses rgb, rrggbb or rrggbbaa, with or without "#".
func colorPickerParseHex(s string) (ui.Color, error) {
h := strings.TrimPrefix(strings.TrimSpace(s), "#")
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
if len(h) == 6 {
h += "ff"
}
if len(h) != 8 {
return ui.Color{}, fmt.Errorf("mujica: color %q is not hex", s)
}
v, err := strconv.ParseUint(h, 16, 32)
if err != nil {
return ui.Color{}, fmt.Errorf("mujica: color %q: %w", s, err)
}
return ui.Color{R: uint8(v >> 24), G: uint8(v >> 16), B: uint8(v >> 8), A: uint8(v)}, nil
}
func colorPickerToHSVA(col ui.Color) colorPickerHSVA {
r, g, b := float64(col.R)/255, float64(col.G)/255, float64(col.B)/255
hi, lo := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
d := hi - lo
var h float64
switch {
case d == 0:
case hi == r:
h = 60 * math.Mod((g-b)/d, 6)
case hi == g:
h = 60 * ((b-r)/d + 2)
default:
h = 60 * ((r-g)/d + 4)
}
if h < 0 {
h += 360
}
s := 0.0
if hi > 0 {
s = d / hi
}
return colorPickerHSVA{h, s, hi, float64(col.A) / 255}
}
func (x colorPickerHSVA) color() ui.Color {
ch := x.v * x.s
h := math.Mod(x.h, 360) / 60
y := ch * (1 - math.Abs(math.Mod(h, 2)-1))
var r, g, b float64
switch {
case h < 1:
r, g = ch, y
case h < 2:
r, g = y, ch
case h < 3:
g, b = ch, y
case h < 4:
g, b = y, ch
case h < 5:
r, b = y, ch
default:
r, b = ch, y
}
m := x.v - ch
byte8 := func(v float64) uint8 { return uint8(math.Round(colorPickerClamp(v) * 255)) }
return ui.Color{R: byte8(r + m), G: byte8(g + m), B: byte8(b + m), A: byte8(x.a)}
}
func colorPickerClamp(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// colorPickerLuma is the relative brightness of col, 0 to 1.
func colorPickerLuma(col ui.Color) float64 {
return (0.2126*float64(col.R) + 0.7152*float64(col.G) + 0.0722*float64(col.B)) / 255
}
// colorPickerCheckers paints the grid that shows transparency.
func colorPickerCheckers(p *ui.Painter, r ui.Rect, size float32) {
p.Fill(r, ui.RGB(255, 255, 255), 0)
for y, row := r.Y, 0; y < r.Y+r.H; y, row = y+size, row+1 {
for x, col := r.X, 0; x < r.X+r.W; x, col = x+size, col+1 {
if (row+col)%2 == 0 {
p.Fill(ui.Rect{X: x, Y: y, W: min(size, r.X+r.W-x), H: min(size, r.Y+r.H-y)}, ui.RGB(204, 204, 204), 0)
}
}
}
}
+176
View File
@@ -0,0 +1,176 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
if ColorPicker(c, col, opts).Changed() {
*changes++
}
})
}, 400, 560)
}
func TestColorPickerPresetAndHex(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
if err := tt.Click("Accent"); err != nil {
t.Fatal(err)
}
if err := tt.Click("#355E80"); err != nil {
t.Fatal(err)
}
if col != ui.Hex("#355E80") || n != 1 {
t.Fatalf("preset: %v changes %d", col, n)
}
if err := tt.Click("HEX"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("ff000080")
if col != (ui.Color{R: 255, A: 128}) {
t.Fatalf("hex: %v", col)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("zz")
if col != (ui.Color{R: 255, A: 128}) || !tt.HasText(colorPickerInvalidEN) {
t.Fatalf("invalid hex changed the color or showed no error: %v", col)
}
}
const colorPickerInvalidEN = "Use HEX RGB, RRGGBB or RRGGBBAA; channels 0–255; opacity 0–100."
func TestColorPickerRGBAndAlphaFields(t *testing.T) {
col, n := ui.RGB(0, 0, 0), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Ink"}, &n)
_ = tt.Click("Ink")
_ = tt.Click("Green")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("200")
_ = tt.Click("Opacity percent")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("50")
if col != (ui.Color{G: 200, A: 128}) {
t.Fatalf("rgb: %v", col)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("300")
if col.A != 128 || !tt.HasText(colorPickerInvalidEN) {
t.Fatalf("opacity 300 accepted: %v", col)
}
}
func TestColorPickerKeyboardAndEscape(t *testing.T) {
col, n := ui.Hex("#808080"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Shade"}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.Focused("Saturation and brightness") {
t.Fatal("square not focused on open")
}
tt.Key(0, ui.KeyUp)
if col == ui.Hex("#808080") || col.R != col.G {
t.Fatalf("up arrow: %v", col)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Hue") {
t.Fatal("hue slider not next")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEscape)
if tt.HasText("HEX") || !tt.Focused("Shade") {
t.Fatal("escape did not close and return focus")
}
}
func TestColorPickerDisabledAndParse(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent", Disabled: true}, &n)
_ = tt.Click("Accent")
if tt.HasText("HEX") {
t.Fatal("disabled picker opened")
}
for in, want := range map[string]ui.Color{"#abc": ui.Hex("#aabbcc"), "7D2034": ui.Hex("#7D2034"), "#00000000": {}} {
got, err := colorPickerParseHex(in)
if err != nil || got != want {
t.Errorf("%q: %v %v", in, got, err)
}
}
for _, in := range []string{"", "#12", "#12345g", "#1234567"} {
if _, err := colorPickerParseHex(in); err == nil {
t.Errorf("%q parsed", in)
}
}
}
func TestColorPickerSnapshot(t *testing.T) {
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
snapshot(t, "color_picker", 340, 560, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
p := ColorPicker(c, &a, ColorPickerOptions{})
st := ui.Local(p.Trigger, "snap", func() bool { return false })
if !*st {
*st = true
ui.Local(p.Trigger, "picker", func() colorPickerState { return colorPickerState{} }).open = true
c.Invalidate()
}
})
})
}
// Disabling an open picker closes it before keys can change the color.
func TestColorPickerDisabledWhileOpen(t *testing.T) {
col, n := ui.Hex("#808080"), 0
opts := ColorPickerOptions{Label: "Shade"}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if ColorPicker(c, &col, opts).Changed() {
n++
}
}, 400, 560)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.Focused("Saturation and brightness") {
t.Fatal("not open")
}
opts.Disabled = true
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyRight)
if col != ui.Hex("#808080") || n != 0 || tt.HasText("HEX") {
t.Fatalf("disabled picker changed to %v", col)
}
}
func TestColorPickerSwatchHitArea(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
_ = tt.Click("Accent")
r, ok := tt.Find("#355E80")
if !ok || r.W < 28 || r.H < 28 {
t.Fatalf("swatch %v", r)
}
}
func TestColorPickerSliderHitAreaCompact(t *testing.T) {
col := ui.Hex("#7D2034")
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: theme.Compact})
ColorPicker(c, &col, ColorPickerOptions{Label: "Accent"})
}, 400, 560)
_ = tt.Click("Accent")
for _, name := range []string{"Hue", "Opacity"} {
if r, ok := tt.Find(name); !ok || r.H < 28 {
t.Fatalf("%s slider %v", name, r)
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package input
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ComboboxOptions configure a Combobox.
type ComboboxOptions struct {
Placeholder string
// AllowCustom lets typed text that matches no option become the value.
AllowCustom bool
// ExternalFilter shows options as given: the caller already filtered them for Query.
ExternalFilter bool
// Loading shows that the caller is fetching options for Query.
Loading bool
Disabled bool
// Label names the field for assistive technology.
Label string
}
// ComboboxResult is a Combobox's field, its value change and its query.
type ComboboxResult struct {
core.ChoiceResult
query string
queryChanged bool
}
// Query returns the text in the field.
func (r ComboboxResult) Query() string { return r.query }
// QueryChanged reports whether the user typed in this frame: fetch new options then.
func (r ComboboxResult) QueryChanged() bool { return r.queryChanged }
// comboboxKey keeps this file's state apart from MyGo's own.
type comboboxKey struct{ name string }
// Combobox chooses *value by typing to filter options and picking one, or typed text with AllowCustom.
func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult {
k := core.Tokens(c)
d := core.Density(c)
old := *value
f := ui.Row(c).MinHeight(d.ControlHeight()).MinWidth(200).AlignItems(ui.Center).Padding(0, d.Unit()*2, 0, 0).
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Background(k.Surface).Cursor(ui.CursorText).Disabled(opts.Disabled)
query := ui.Local(f, comboboxKey{"query"}, func() string { return *value })
wasFocused := ui.Local(f, comboboxKey{"focused"}, func() bool { return false })
// MyGo keeps the caret on replaced text (ui/editor.go:117): a choice builds a fresh input.
gen := ui.Local(f, comboboxKey{"input"}, func() int { return 0 })
refocus := ui.Local(f, comboboxKey{"refocus"}, func() bool { return false })
var p *ui.ComboboxParts
f.Children(func() {
// A focused ComboboxBase takes typing under a disabled wrapper, so it edits a copy.
target := query
if opts.Disabled {
cp := *query
target = &cp
}
ui.Row(c).Key(*gen).Grow(1).Children(func() {
p = ui.ComboboxBase(c, target)
})
if *refocus {
p.Input.Focus()
*refocus = false
}
if opts.Disabled {
p.SetOpen(false)
}
pad := max(0, (d.ControlHeight()-2-theme.BodySize*theme.LineHeight)/2)
p.Input.Grow(1).MinWidth(80).Padding(pad, d.Unit()*3).Placeholder(opts.Placeholder)
if opts.Label != "" {
p.Input.Label(opts.Label)
}
if opts.Loading {
core.Spinner(c, core.SpinnerOptions{Size: 14})
}
arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Cursor(ui.CursorPointer).
Role(ui.RoleButton).Label(core.Msg(c, "combobox.toggle"))
if arrow.Clicked() {
p.SetOpen(!p.Open())
p.Input.Focus()
}
open := p.Open()
arrow.Draw(func(pt *ui.Painter, r ui.Rect) {
internal.Mark(pt, selectCenter(r, 16), selectChevron(open), k.TextMuted)
})
})
in := p.Input
if f.Clicked() {
in.Focus()
}
border := k.ControlBorder
switch {
case in.Focused():
border = k.AccentText
case f.Hovered():
border = k.Text
}
f.Border(theme.BorderWidth, border)
queryChanged := in.Changed()
shown := comboboxShown(options, *query, p.Filtering() && !opts.ExternalFilter)
custom := ""
if q := strings.TrimSpace(*query); opts.AllowCustom && p.Filtering() && q != "" && !comboboxHas(shown, q) {
custom = q
}
lastHi := ui.Local(f, comboboxKey{"shown"}, func() int { return -1 })
if !p.Open() {
*lastHi = -1
}
p.Popup(func(panel *ui.Element) {
SelectPanel(c, panel)
ui.Scroll(c).MaxHeight(240).Children(func() {
if custom != "" {
it := p.Item(custom)
SelectRow(c, it, it.Highlighted(), false, fmt.Sprintf(core.Msg(c, "combobox.use"), custom))
}
for i, o := range shown {
it := p.Item(o)
if it.Highlighted() && *lastHi != i {
*lastHi = i
it.ScrollIntoView()
}
SelectRow(c, it, it.Highlighted(), o == *value, o)
}
})
})
if p.Open() && len(shown) == 0 && custom == "" {
comboboxStatus(c, f.Bounds(), opts.Loading)
}
focused := in.Focused()
if in.IsDisabled() {
// Input delivered as the field turned disabled commits nothing.
*query = *value
} else if v, ok := p.Chosen(); ok {
*value, *query = v, v
*gen, *refocus = *gen+1, focused
} else if in.Submitted() && opts.AllowCustom {
*value = strings.TrimSpace(*query)
} else if in.Submitted() && p.Open() && len(shown) > 0 {
// Results came after typing left nothing highlighted: Enter takes the first.
*value, *query = shown[0], shown[0]
*gen, *refocus = *gen+1, true
p.SetOpen(false)
} else if *wasFocused && !focused {
if opts.AllowCustom {
*value = strings.TrimSpace(*query)
}
*query = *value
} else if !focused && *query != *value {
// The caller set the value.
*query = *value
}
*wasFocused = focused
changed := *value != old
if changed {
slog.Debug("mujica: combobox changed", "label", opts.Label, "value", *value)
}
return ComboboxResult{core.NewChoiceResult(f, changed), *query, queryChanged}
}
// comboboxShown returns the options containing query, those starting with it first.
func comboboxShown(options []string, query string, filter bool) []string {
q := strings.ToLower(strings.TrimSpace(query))
if !filter || q == "" {
return options
}
var starts, contains []string
for _, o := range options {
switch l := strings.ToLower(o); {
case strings.HasPrefix(l, q):
starts = append(starts, o)
case strings.Contains(l, q):
contains = append(contains, o)
}
}
return append(starts, contains...)
}
func comboboxHas(options []string, s string) bool {
for _, o := range options {
if strings.EqualFold(o, s) {
return true
}
}
return false
}
// comboboxStatus shows loading or no matches under the field r; it takes no input.
func comboboxStatus(c *ui.Context, r ui.Rect, loading bool) {
ui.Overlay(c, func() {
panel := ui.Row(c).Absolute().Left(r.X).Top(r.Y + r.H).MinWidth(r.W).PassThrough().Role(ui.RoleStatus)
SelectPanel(c, panel)
panel.Children(func() {
if loading {
ui.Row(c).Gap(8).Padding(8, 12).AlignItems(ui.Center).Children(func() {
core.Spinner(c, core.SpinnerOptions{})
ui.Text(c, core.Msg(c, "combobox.loading")).TextColor(core.Tokens(c).TextMuted)
})
return
}
SelectNote(c, core.Msg(c, "combobox.no_results"))
})
})
}
+164
View File
@@ -0,0 +1,164 @@
package input
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var comboboxTestFonts = []string{"Garamond", "Georgia", "Baskerville", "Didot", "Bodoni"}
type comboboxRun struct {
value, query string
changes int
queries []string
}
func comboboxTester(r *comboboxRun, options func(query string) []string, opts ComboboxOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Gap(10).Children(func() {
res := Combobox(c, &r.value, options(r.query), opts)
if res.Changed() {
r.changes++
}
if res.QueryChanged() {
r.queries = append(r.queries, res.Query())
}
r.query = res.Query()
Button(c, "Other", ButtonOptions{})
})
}, 400, 400)
}
func fixedFonts(string) []string { return comboboxTestFonts }
func TestComboboxFiltersAndChooses(t *testing.T) {
r := &comboboxRun{}
tt := comboboxTester(r, fixedFonts, ComboboxOptions{Label: "Font"})
tt.Key(0, ui.KeyTab)
tt.Type("bo")
if !tt.HasText("Bodoni") || tt.HasText("Garamond") {
t.Fatalf("shown %v", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if r.value != "Bodoni" || r.changes != 1 {
t.Fatalf("value %q changes %d", r.value, r.changes)
}
}
func TestComboboxArrowsAndClick(t *testing.T) {
r := &comboboxRun{}
tt := comboboxTester(r, fixedFonts, ComboboxOptions{Label: "Font"})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if r.value != "Georgia" {
t.Fatalf("arrows chose %q", r.value)
}
tt.Key(0, ui.KeyDown)
if err := tt.Click("Didot"); err != nil {
t.Fatal(err)
}
if r.value != "Didot" || r.query != "Didot" {
t.Fatalf("click chose %q, field %q", r.value, r.query)
}
}
func TestComboboxRevertsUnknownText(t *testing.T) {
r := &comboboxRun{value: "Didot"}
tt := comboboxTester(r, fixedFonts, ComboboxOptions{})
tt.Key(0, ui.KeyTab)
tt.Type("zzz")
if !tt.HasText("No matches") {
t.Fatal("no-match state missing")
}
tt.Key(0, ui.KeyTab)
if r.value != "Didot" || r.query != "Didot" || r.changes != 0 {
t.Fatalf("value %q field %q changes %d", r.value, r.query, r.changes)
}
}
func TestComboboxCustomValue(t *testing.T) {
r := &comboboxRun{}
tt := comboboxTester(r, fixedFonts, ComboboxOptions{AllowCustom: true})
tt.Key(0, ui.KeyTab)
tt.Type("Caslon")
if !tt.HasText("Use “Caslon”") {
t.Fatalf("custom option missing: %v", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if r.value != "Caslon" {
t.Fatalf("value %q", r.value)
}
tt.Type(" Pro")
tt.Key(0, ui.KeyTab)
if r.value != "Caslon Pro" {
t.Fatalf("blur kept %q", r.value)
}
}
func TestComboboxAsyncResults(t *testing.T) {
r := &comboboxRun{}
loading := true
remote := func(q string) []string {
if loading || q == "" {
return nil
}
var out []string
for _, f := range comboboxTestFonts {
if strings.Contains(strings.ToLower(f), strings.ToLower(q)) {
out = append(out, f+" (remote)")
}
}
return out
}
opts := ComboboxOptions{ExternalFilter: true}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
opts.Loading = loading
res := Combobox(c, &r.value, remote(r.query), opts)
if res.QueryChanged() {
r.queries = append(r.queries, res.Query())
}
r.query = res.Query()
}, 400, 400)
tt.Key(0, ui.KeyTab)
tt.Type("di")
if len(r.queries) != 1 || r.queries[0] != "di" {
t.Fatalf("queries %v", r.queries)
}
if !tt.HasText("Loading…") {
t.Fatal("loading state missing")
}
loading = false
tt.Frame()
tt.Key(0, ui.KeyEnter)
if r.value != "Didot (remote)" {
t.Fatalf("value %q", r.value)
}
}
func TestComboboxDisabled(t *testing.T) {
r := &comboboxRun{value: "Didot"}
tt := comboboxTester(r, fixedFonts, ComboboxOptions{Disabled: true})
_ = tt.Click("Didot")
tt.Type("x")
if r.value != "Didot" || r.query != "Didot" || tt.HasText("Bodoni") {
t.Fatalf("disabled edited: %q %q", r.value, r.query)
}
}
func TestComboboxSnapshot(t *testing.T) {
snapshot(t, "combobox", 460, 110, func(c *ui.Context) {
a, b := "", "Didot"
ui.Row(c).Padding(20).Gap(16).Children(func() {
Combobox(c, &a, comboboxTestFonts, ComboboxOptions{Placeholder: "Typeface"})
Combobox(c, &b, comboboxTestFonts, ComboboxOptions{Loading: true})
})
})
}
+65
View File
@@ -0,0 +1,65 @@
package input
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// copyButtonShown is how long the button says Copied.
const copyButtonShown = 2 * time.Second
// CopyButtonOptions configure a CopyButton.
type CopyButtonOptions struct {
// Label shows beside the icon; empty makes a square icon button.
Label string
Disabled bool
}
// CopyButton puts text on the clipboard, then shows and announces Copied for two seconds.
func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) core.ChoiceResult {
copied := core.Msg(c, "copy_button.copied")
name := core.Msg(c, "copy_button.copy")
label, icon := opts.Label, icons.Must("copy")
var b *ui.Element
box := ui.Box(c)
at := ui.Local(box, "copy_button.at", func() time.Time { return time.Time{} })
left := copyButtonShown - c.Now().Sub(*at)
done := !at.IsZero() && left > 0
if done {
icon, name = icons.Must("check"), copied
if label != "" {
label = copied
}
c.After(left)
}
box.Children(func() {
bo := ButtonOptions{Variant: Secondary, Icon: icon, Disabled: opts.Disabled}
if opts.Label == "" {
bo.Label = name
}
b = Button(c, label, bo)
if opts.Label == "" {
b.Padding(0).Size(core.Density(c).ControlHeight(), core.Density(c).ControlHeight()).Tooltip(name)
} else {
// Room for the longer word, so the width holds.
w1, _ := c.MeasureText(0, ui.Span{Text: copied})
w2, _ := c.MeasureText(0, ui.Span{Text: opts.Label})
b.MinWidth(max(w1, w2) + buttonSpinnerSize + core.Density(c).Unit()*10 + 2*theme.BorderWidth)
}
})
if b.Clicked() && !b.IsDisabled() {
c.WriteClipboard(text)
*at = c.Now()
c.Announce(copied)
c.Invalidate()
slog.Debug("mujica: copy_button copied", "bytes", len(text))
return core.NewChoiceResult(box, true)
}
return core.NewChoiceResult(box, false)
}
+79
View File
@@ -0,0 +1,79 @@
package input
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestCopyButtonCopiesAndAnnounces(t *testing.T) {
n := 0
var w float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Children(func() {
r := CopyButton(c, "sk-live-123", CopyButtonOptions{Label: "Copy key"})
if r.Changed() {
n++
}
w = r.Element.Bounds().W
})
}, 400, 200)
tt.Frame()
before := w
if err := tt.Click("Copy key"); err != nil {
t.Fatal(err)
}
if tt.Clipboard() != "sk-live-123" || n != 1 {
t.Fatalf("clipboard %q, copies %d", tt.Clipboard(), n)
}
if !tt.HasText("Copied") || !slices.Contains(tt.Announcements(), "Copied") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Frame()
if w != before {
t.Fatalf("width %v changed from %v", w, before)
}
}
func TestCopyButtonKeyboardRevertAndDisabled(t *testing.T) {
dis := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: core.ZhCN})
CopyButton(c, "abc", CopyButtonOptions{Disabled: dis})
}, 300, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if tt.Clipboard() != "abc" {
t.Fatalf("Space copied %q", tt.Clipboard())
}
if _, ok := tt.Find("已复制"); !ok {
t.Fatal("icon button lacks the copied name")
}
time.Sleep(copyButtonShown + 50*time.Millisecond)
tt.Frame()
if _, ok := tt.Find("复制"); !ok {
t.Fatal("did not revert to Copy")
}
tt.SetClipboard("")
dis = true
tt.Frame()
_ = tt.Click("复制")
if tt.Clipboard() != "" {
t.Fatal("disabled button copied")
}
}
func TestCopyButtonSnapshot(t *testing.T) {
snapshot(t, "copy_button", 360, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).Children(func() {
CopyButton(c, "x", CopyButtonOptions{})
CopyButton(c, "x", CopyButtonOptions{Label: "Copy link"})
CopyButton(c, "x", CopyButtonOptions{Label: "Copy", Disabled: true})
})
})
}
+148
View File
@@ -0,0 +1,148 @@
package input
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// CurrencyInputOptions configure a CurrencyInput.
type CurrencyInputOptions struct {
// Symbol goes before the amount, as "$" or "¥"; empty shows none.
Symbol string
// Decimals is the minor unit digits; zero is 2. NoFraction makes it 0, as for JPY.
Decimals int
NoFraction bool
Label string
Disabled bool
// Error marks the value invalid and shows the message below.
Error string
}
// currencyInputDigits resolves the decimals of opts.
func currencyInputDigits(opts CurrencyInputOptions) int {
switch {
case opts.Decimals < 0 || opts.Decimals > 6:
panic(fmt.Sprintf("mujica: CurrencyInput Decimals %d outside 0..6", opts.Decimals))
case opts.NoFraction && opts.Decimals != 0:
panic("mujica: CurrencyInput NoFraction with Decimals")
case opts.NoFraction:
return 0
case opts.Decimals == 0:
return 2
}
return opts.Decimals
}
// CurrencyFormat writes minor units as an amount with thousands separators.
func CurrencyFormat(minor int64, decimals int, group bool) string {
neg := minor < 0
u := uint64(minor)
if neg {
u = -u
}
p := uint64(math.Pow10(decimals))
whole := strconv.FormatUint(u/p, 10)
if group {
var b strings.Builder
for i, r := range whole {
if i > 0 && (len(whole)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(r)
}
whole = b.String()
}
s := whole
if decimals > 0 {
s += "." + fmt.Sprintf("%0*d", decimals, u%p)
}
if neg {
s = "-" + s
}
return s
}
// currencyInputParse reads an amount into minor units; reason is a message key when it fails.
func currencyInputParse(text string, decimals int) (minor int64, reason string) {
t := strings.NewReplacer(",", "", " ", "", " ", "").Replace(strings.TrimSpace(text))
neg := strings.HasPrefix(t, "-")
t = strings.TrimPrefix(t, "-")
whole, frac, dot := strings.Cut(t, ".")
if whole == "" && frac == "" || strings.Trim(whole+frac, "0123456789") != "" || dot && strings.Contains(frac, ".") {
return 0, "currency_input.invalid"
}
if len(frac) > decimals {
return 0, "currency_input.precision"
}
frac += strings.Repeat("0", decimals-len(frac))
v, err := strconv.ParseInt(whole+frac, 10, 64)
if err != nil {
return 0, "currency_input.invalid"
}
if neg {
v = -v
}
return v, ""
}
// CurrencyInput edits *value, an amount in minor units (cents); it groups thousands while not focused.
func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult {
if value == nil {
panic("mujica: CurrencyInput needs a value")
}
dec := currencyInputDigits(opts)
k := core.Tokens(c)
var r InputResult
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
if opts.Symbol != "" {
ui.Text(c, opts.Symbol).TextColor(k.TextMuted).SingleLine()
}
text := ui.Local(frame, "text", func() string { return CurrencyFormat(*value, dec, false) })
bad := ui.Local(frame, "bad", func() string { return "" })
seen := ui.Local(frame, "seen", func() int64 { return *value })
was := ui.Local(frame, "was", func() bool { return false })
if *seen != *value {
*bad = ""
}
// Grouped while resting; plain digits once focused, for editing.
focused := frame.FocusWithin()
switch {
case *bad != "":
case !focused:
*text = CurrencyFormat(*value, dec, true)
case !*was || *seen != *value:
*text = CurrencyFormat(*value, dec, false)
}
*was = focused
in := TextInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 16).Label(opts.Label)
if in.Changed() && !look.disabled {
v, why := currencyInputParse(*text, dec)
*bad = ""
switch {
case why == "currency_input.precision":
*bad = fmt.Sprintf(core.Msg(c, why), dec)
case why != "":
*bad = core.Msg(c, why)
case v != *value:
*value, r.changed = v, true
}
}
*seen = *value
if look.err == "" {
look.err = *bad
}
return in
}, nil)
if r.changed {
slog.Debug("mujica: currency_input change", "minor", *value)
}
return r
}
+122
View File
@@ -0,0 +1,122 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestCurrencyFormatAndParse(t *testing.T) {
for _, tc := range []struct {
minor int64
dec int
group bool
want string
}{
{123450, 2, true, "1,234.50"}, {123450, 2, false, "1234.50"}, {-5, 2, true, "-0.05"},
{1234567, 0, true, "1,234,567"}, {0, 3, true, "0.000"}, {100000000, 2, true, "1,000,000.00"},
} {
if got := CurrencyFormat(tc.minor, tc.dec, tc.group); got != tc.want {
t.Errorf("CurrencyFormat(%d, %d) = %q, want %q", tc.minor, tc.dec, got, tc.want)
}
}
for text, want := range map[string]int64{"1,234.5": 123450, "0.07": 7, "-12": -1200, " 3 ": 300, "1.": 100} {
if v, why := currencyInputParse(text, 2); v != want || why != "" {
t.Errorf("parse %q = %d %q, want %d", text, v, why, want)
}
}
for text, want := range map[string]string{"1.234": "currency_input.precision", "abc": "currency_input.invalid", "": "currency_input.invalid", "1.2.3": "currency_input.invalid", "99999999999999999999": "currency_input.invalid"} {
if _, why := currencyInputParse(text, 2); why != want {
t.Errorf("parse %q: %q, want %q", text, why, want)
}
}
}
// fieldText copies the focused field's text through the clipboard.
func fieldText(tt *ui.Tester) string {
tt.Command("selectAll")
tt.Command("copy")
return tt.Clipboard()
}
func TestCurrencyInputTypingGroupingAndErrors(t *testing.T) {
v := int64(123450)
dis := false
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "$", Label: "Price", Disabled: dis}).Changed() {
n++
}
Button(c, "Elsewhere", ButtonOptions{})
}, 500, 200)
if !tt.HasText("$") {
t.Fatalf("no symbol: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
if got := fieldText(tt); got != "1234.50" {
t.Fatalf("focused text %q, want ungrouped", got)
}
tt.Command("selectAll")
tt.Type("2500.255")
if v != 123450 || !tt.HasText("Use at most 2 decimal places") {
t.Fatalf("too precise: %d %q", v, tt.Texts())
}
tt.Command("selectAll")
tt.Type("2500.25")
if v != 250025 || n != 1 || tt.HasText("decimal places") {
t.Fatalf("typed: %d (%d)", v, n)
}
tt.Key(0, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyTab)
if got := fieldText(tt); got != "2500.25" {
t.Fatalf("refocused text %q", got)
}
dis = true
tt.Frame()
_ = tt.Click("Price")
tt.Type("9")
if v != 250025 {
t.Fatalf("disabled edit: %d", v)
}
}
func TestCurrencyInputNoFractionAndRejects(t *testing.T) {
v := int64(1500)
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "¥", NoFraction: true, Label: "Amount"})
}, 300, 100)
tt.Key(0, ui.KeyTab)
if got := fieldText(tt); got != "1500" {
t.Fatalf("yen %q", got)
}
tt.Command("selectAll")
tt.Type("12.5")
if v != 1500 || !tt.HasText("Use at most 0 decimal places") {
t.Fatalf("fraction accepted: %d", v)
}
for _, o := range []CurrencyInputOptions{{Decimals: -1}, {Decimals: 7}, {Decimals: 2, NoFraction: true}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", o)
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); CurrencyInput(c, &v, o) }, 200, 100)
}()
}
}
func TestCurrencyInputSnapshot(t *testing.T) {
a, b, d := int64(1234550), int64(98000), int64(0)
snapshot(t, "currency_input", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).Children(func() {
CurrencyInput(c, &a, CurrencyInputOptions{Symbol: "$"}).Element.Width(150)
CurrencyInput(c, &b, CurrencyInputOptions{Symbol: "¥", NoFraction: true}).Element.Width(150)
CurrencyInput(c, &d, CurrencyInputOptions{Symbol: "€", Disabled: true}).Element.Width(150)
})
})
}
+2
View File
@@ -0,0 +1,2 @@
// Package input holds MujicaUI buttons, text fields, choices, form controls and general essentials.
package input
+195
View File
@@ -0,0 +1,195 @@
package input
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// DockZone is an area of a DockPanel.
type DockZone int
const (
DockCenter DockZone = iota
DockLeft
DockRight
DockBottom
)
// DockLayout is the caller-held arrangement: panel IDs per zone in tab order, and each zone's shown tab.
type DockLayout struct {
Zones [4][]string
Active [4]string
}
// DockItem is one dockable panel.
type DockItem struct {
ID, Title string
Build func()
}
// DockPanelOptions configure a DockPanel.
type DockPanelOptions struct {
Disabled bool
}
// dockPanelDrag is a tab being dragged.
type dockPanelDrag struct{ id string }
// DockPanel arranges panels in left, center, right and bottom zones; tabs drag to an edge or zone to dock there.
func DockPanel(c *ui.Context, layout *DockLayout, items []DockItem, opts DockPanelOptions) core.ChoiceResult {
if layout == nil || len(items) == 0 {
panic("mujica: DockPanel needs a layout and items")
}
byID := map[string]DockItem{}
for _, it := range items {
if it.ID == "" || it.Title == "" || it.Build == nil {
panic("mujica: DockPanel item needs an ID, Title and Build")
}
if _, dup := byID[it.ID]; dup {
panic("mujica: DockPanel item ID repeated: " + it.ID)
}
byID[it.ID] = it
}
placed := map[string]bool{}
for z, ids := range layout.Zones {
for _, id := range ids {
if _, ok := byID[id]; !ok || placed[id] {
panic(fmt.Sprintf("mujica: DockPanel zone %d holds unknown or repeated %q", z, id))
}
placed[id] = true
}
}
changed := false
for _, it := range items {
if !placed[it.ID] {
layout.Zones[DockCenter] = append(layout.Zones[DockCenter], it.ID)
changed = true
}
}
for z, ids := range layout.Zones {
if len(ids) > 0 && !slices.Contains(ids, layout.Active[z]) {
layout.Active[z] = ids[0]
}
}
k := core.Tokens(c)
u := core.Density(c).Unit()
root := ui.Box(c).Disabled(opts.Disabled).MinHeight(0)
disabled := root.IsDisabled()
// follow is the tab that moved; it keeps the focus in its new zone.
follow := ui.Local(root, "dock_panel.follow", func() string { return "" })
move := func(id string, to DockZone) {
*follow = id
for z := range layout.Zones {
layout.Zones[z] = slices.DeleteFunc(slices.Clone(layout.Zones[z]), func(s string) bool { return s == id })
}
layout.Zones[to] = append(layout.Zones[to], id)
layout.Active[to] = id
changed = true
slog.Debug("mujica: dock_panel docked", "panel", id, "zone", to)
}
dragging := false
root.Children(func() {
ui.Column(c).Fill().Children(func() {
ui.Row(c).Grow(1).MinHeight(0).AlignItems(ui.Stretch).Children(func() {
for _, z := range []DockZone{DockLeft, DockCenter, DockRight} {
if dockPanelZone(c, k, u, layout, byID, z, disabled, move, &changed, follow) {
dragging = true
}
}
})
if dockPanelZone(c, k, u, layout, byID, DockBottom, disabled, move, &changed, follow) {
dragging = true
}
})
// Edges stay one frame after the drag, when the drop arrives.
was := ui.Local(root, "dock_panel.drag", func() bool { return false })
if (dragging || *was) && !disabled {
dockPanelEdges(c, k, u, move)
}
*was = dragging
})
return core.NewChoiceResult(root, changed)
}
// dockPanelZone builds zone z's tabs and shown panel; it reports a tab of it being dragged.
func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout, byID map[string]DockItem, z DockZone, disabled bool, move func(string, DockZone), changed *bool, follow *string) bool {
ids := layout.Zones[z]
if len(ids) == 0 {
return false
}
box := ui.Column(c).Key(z).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup).
Label(core.Msg(c, fmt.Sprintf("dock_panel.zone%d", z)))
switch z {
case DockLeft, DockRight:
box.Width(220).Shrink(0).Border(theme.BorderWidth, k.Border)
case DockBottom:
box.Height(160).Shrink(0).Border(theme.BorderWidth, k.Border)
default:
box.Grow(1)
}
if d, ok := ui.DragOver[dockPanelDrag](box); ok && !slices.Contains(ids, d.id) {
box.Border(2, k.AccentText)
}
if d, ok := ui.Drop[dockPanelDrag](box); ok && !disabled {
move(d.id, z)
}
dragging := false
box.Children(func() {
at := slices.Index(ids, layout.Active[z])
base := ui.TabsBase(c, &at, len(ids))
base.List.Gap(u).Padding(theme.FocusGap+theme.FocusWidth, u*2, 0, u*2).Children(func() {
for i, id := range ids {
tab := base.Tab(i).Drag(dockPanelDrag{id})
navigation.TabsStyle(c, tab, navigation.Tab{Label: byID[id].Title}, i == at, false)
tab.Description(core.Msg(c, "dock_panel.hint"))
if *follow == id {
tab.Focus()
*follow = ""
}
dragging = dragging || tab.Dragging()
for key, to := range map[ui.Key]DockZone{ui.KeyLeft: DockLeft, ui.KeyRight: DockRight, ui.KeyDown: DockBottom, ui.KeyUp: DockCenter} {
if tab.Shortcut(ui.Shift|ui.Alt, key) && !disabled && to != z {
move(id, to)
}
}
}
})
if base.List.Changed() && !disabled && ids[at] != layout.Active[z] {
layout.Active[z], *changed = ids[at], true
}
ui.Box(c).FillWidth().Height(theme.BorderWidth).Background(k.Border).Role(ui.RoleNone)
ui.Scroll(c).Grow(1).MinHeight(0).Padding(u * 3).Children(byID[layout.Active[z]].Build)
})
return dragging
}
// dockPanelEdges shows drop strips on the four edges while a tab is dragged.
func dockPanelEdges(c *ui.Context, k theme.Tokens, u float32, move func(string, DockZone)) {
for _, z := range []DockZone{DockLeft, DockRight, DockBottom, DockCenter} {
e := ui.Box(c).Key(fmt.Sprint("edge", z)).Absolute().Role(ui.RoleNone)
switch z {
case DockLeft:
e.Left(0).Top(0).Bottom(0).Width(12 * u)
case DockRight:
e.Right(0).Top(0).Bottom(0).Width(12 * u)
case DockBottom:
e.Left(12 * u).Right(12 * u).Bottom(0).Height(10 * u)
default:
e.Left(0).Right(0).Top(0).Height(4 * u)
}
_, over := ui.DragOver[dockPanelDrag](e)
if over {
e.Background(k.Selection).Border(2, k.AccentText)
}
if d, ok := ui.Drop[dockPanelDrag](e); ok {
move(d.id, z)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func dockItems(c *ui.Context) []DockItem {
var out []DockItem
for _, id := range []string{"files", "editor", "terminal"} {
out = append(out, DockItem{ID: id, Title: "Panel " + id, Build: func() { ui.Text(c, "Body of "+id) }})
}
return out
}
func dockTester(l *DockLayout, dis *bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := DockPanel(c, l, dockItems(c), DockPanelOptions{Disabled: *dis})
r.Element.Size(800, 500)
if r.Changed() {
*n++
}
}, 820, 520)
}
func TestDockPanelTabsAndKeyboardDock(t *testing.T) {
var l DockLayout
dis, n := false, 0
tt := dockTester(&l, &dis, &n)
if !slices.Equal(l.Zones[DockCenter], []string{"files", "editor", "terminal"}) || l.Active[DockCenter] != "files" || !tt.HasText("Body of files") {
t.Fatalf("default layout %+v", l)
}
if err := tt.Click("Panel editor"); err != nil {
t.Fatal(err)
}
if l.Active[DockCenter] != "editor" || !tt.HasText("Body of editor") {
t.Fatalf("tab click: %+v", l)
}
tt.Key(ui.Shift|ui.Alt, ui.KeyLeft)
if !slices.Equal(l.Zones[DockLeft], []string{"editor"}) || slices.Contains(l.Zones[DockCenter], "editor") {
t.Fatalf("Shift+Alt+Left: %+v", l)
}
if _, ok := tt.Find("Left dock"); !ok {
t.Fatal("left zone not shown")
}
tt.Key(ui.Shift|ui.Alt, ui.KeyUp)
if len(l.Zones[DockLeft]) != 0 || l.Active[DockCenter] != "editor" {
t.Fatalf("back to center: %+v", l)
}
before := n
dis = true
tt.Frame()
tt.Key(ui.Shift|ui.Alt, ui.KeyDown)
if len(l.Zones[DockBottom]) != 0 || n != before {
t.Fatalf("disabled docked: %+v", l)
}
}
func TestDockPanelDragToEdge(t *testing.T) {
var l DockLayout
dis, n := false, 0
tt := dockTester(&l, &dis, &n)
r, _ := tt.Find("Panel terminal")
x, y := r.X+r.W/2, r.Y+r.H/2
tt.Press(x, y)
for _, px := range []float32{x + 10, x + 200, 600, 790} {
tt.Move(px, 250)
}
tt.Release(790, 250)
tt.Frame()
if !slices.Equal(l.Zones[DockRight], []string{"terminal"}) || n == 0 {
t.Fatalf("drag to the right edge: %+v", l)
}
}
func TestDockPanelRejects(t *testing.T) {
for name, l := range map[string]DockLayout{
"unknown": {Zones: [4][]string{{"nope"}}},
"repeated": {Zones: [4][]string{{"files"}, {"files"}}},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); DockPanel(c, &l, dockItems(c), DockPanelOptions{}) }, 300, 200)
}()
}
}
func TestDockPanelSnapshot(t *testing.T) {
l := DockLayout{Zones: [4][]string{{"editor"}, {"files"}, nil, {"terminal"}}}
snapshot(t, "dock_panel", 640, 360, func(c *ui.Context) {
ui.Column(c).Padding(12).Children(func() {
DockPanel(c, &l, dockItems(c), DockPanelOptions{}).Element.Size(616, 336)
})
})
}
+134
View File
@@ -0,0 +1,134 @@
package input_test
import (
"log"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func ExampleSwitch() {
wifi := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.Switch(c, &wifi, "Wi-Fi", input.SwitchOptions{}).Changed() {
log.Println("Wi-Fi:", wifi)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleToggle() {
bold := false
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Toggle(c, &bold, "", input.ToggleOptions{Icon: icons.Must("bold"), Label: "Bold"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToggleGroup() {
align := []string{"left"}
items := []input.ToggleItem[string]{
{Value: "left", Label: "Left"},
{Value: "center", Label: "Center"},
{Value: "right", Label: "Right"},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.ToggleGroup(c, &align, items, input.ToggleGroupOptions{Label: "Align"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSlider() {
volume := 40.0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Slider(c, &volume, input.SliderOptions{Step: 5, ShowValue: true, Label: "Volume"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRangeSlider() {
low, high := 20.0, 80.0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.RangeSlider(c, &low, &high, input.RangeSliderOptions{Step: 10, Label: "Price"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSelect() {
plan := ""
options := []input.SelectOption[string]{
{Value: "free", Label: "Free"},
{Value: "pro", Label: "Pro"},
{Value: "team", Label: "Team", Disabled: true},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Select(c, &plan, options, input.SelectOptions{Clearable: true, Label: "Plan"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMultiSelect() {
tags := []int{1}
options := []input.SelectOption[int]{
{Value: 1, Label: "Rose"},
{Value: 2, Label: "Lily"},
{Value: 3, Label: "Iris"},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.MultiSelect(c, &tags, options, input.MultiSelectOptions{Max: 2, Label: "Flowers"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCombobox() {
font := ""
fonts := []string{"Garamond", "Georgia", "Didot"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := input.Combobox(c, &font, fonts, input.ComboboxOptions{AllowCustom: true, Label: "Font"})
if r.QueryChanged() {
log.Println("search:", r.Query())
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCascader() {
var path []string
level := func(p []string) input.CascaderLevel {
if len(p) == 0 {
return input.CascaderLevel{Nodes: []input.CascaderNode{{Value: "fr", Label: "France"}}}
}
return input.CascaderLevel{Nodes: []input.CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}}
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Cascader(c, &path, input.CascaderOptions{Level: level, Label: "City"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTreeSelect() {
var roles []string
nodes := []input.TreeSelectNode{
{ID: "court", Label: "Court", Children: []input.TreeSelectNode{
{ID: "king", Label: "King"},
{ID: "queen", Label: "Queen"},
}},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.TreeSelect(c, &roles, nodes, input.TreeSelectOptions{Multiple: true, Label: "Roles"})
}
ui.Render(view, 400, 300, 1)
}
+209
View File
@@ -0,0 +1,209 @@
package input_test
import (
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func ExampleCopyButton() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.CopyButton(c, "https://example.com/invite/42", input.CopyButtonOptions{Label: "Copy link"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleHoldToConfirm() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.HoldToConfirm(c, "Delete project", input.HoldToConfirmOptions{}).Changed() {
// delete the project
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleBulkActionBar() {
selected := 3
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := input.BulkActionBar(c, selected, input.BulkActionBarOptions{}, func() {
input.Button(c, "Archive", input.ButtonOptions{Variant: input.Secondary})
})
if r.Changed() {
selected = 0
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleScrubInput() {
opacity := 0.6
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.ScrubInput(c, "Opacity", &opacity, input.ScrubInputOptions{Max: 1, Step: 0.1})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCurrencyInput() {
cents := int64(123450)
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.CurrencyInput(c, &cents, input.CurrencyInputOptions{Symbol: "$", Label: "Price"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMaskedInput() {
phone := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.MaskedInput(c, &phone, input.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMentionInput() {
text, mentions := "", []input.Mention(nil)
people := []input.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.MentionInput(c, &text, &mentions, input.MentionInputOptions{
Label: "Comment",
Candidates: func(q string) []input.MentionCandidate {
var out []input.MentionCandidate
for _, p := range people {
if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) {
out = append(out, p)
}
}
return out
},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleChoiceChips() {
var status []string
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.ChoiceChips(c, &status, []input.ToggleItem[string]{
{Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"},
}, input.ChoiceChipsOptions{Multiple: true, Label: "Status"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleKnob() {
gain := 35.0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Knob(c, &gain, input.KnobOptions{Label: "Gain", Ticks: 11})
}
ui.Render(view, 400, 300, 1)
}
func ExampleVerticalSlider() {
level := 40.0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.VerticalSlider(c, &level, input.VerticalSliderOptions{Step: 10, Label: "Level"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleColorPalette() {
color := ui.Hex("#7D2034")
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.ColorPalette(c, &color, []input.ColorSwatch{
{Color: ui.Hex("#7D2034"), Name: "Wine"},
{Color: ui.Hex("#AD9164"), Name: "Old gold"},
{Color: ui.Hex("#355E80"), Name: "Slate blue"},
}, input.ColorPaletteOptions{Custom: true, Label: "Tag color"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSignaturePad() {
var strokes [][]input.SignaturePoint
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.SignaturePad(c, &strokes, input.SignaturePadOptions{}).Changed() {
_ = input.SignaturePath(strokes) // SVG path data to store
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleBanner() {
open := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := input.Banner(c, &open, "Maintenance tonight at 22:00.", input.BannerOptions{
Severity: core.SeverityWarning,
Action: "Details",
})
if r.Action {
// show the details
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMasonry() {
titles := []string{"Sea", "Forest", "Desert", "Glacier"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Masonry(c, len(titles), input.MasonryOptions{
Key: func(i int) string { return titles[i] },
}, func(i int) {
ui.Text(c, titles[i])
}).Size(380, 280)
}
ui.Render(view, 400, 300, 1)
}
func ExampleResizablePanelGroup() {
var sizes []float32
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := input.ResizablePanelGroup(c, &sizes, []input.ResizablePanel{
{Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }},
{Label: "Editor", Build: func() { ui.Text(c, "Editor") }},
}, input.ResizablePanelGroupOptions{})
if r.Committed {
// save sizes
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDockPanel() {
var layout input.DockLayout
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := input.DockPanel(c, &layout, []input.DockItem{
{ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }},
{ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }},
}, input.DockPanelOptions{})
if r.Changed() {
// save layout
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleQRCode() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.QRCode(c, "https://example.com/mujica", input.QRCodeOptions{Level: input.QRQuartile})
}
ui.Render(view, 400, 300, 1)
}
+125
View File
@@ -0,0 +1,125 @@
package input_test
import (
"fmt"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func ExampleForm() {
var state input.FormState
name := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
res := input.Form(c, &state, input.FormOptions{}, func(f *input.FormScope) {
input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
return input.InputGroup(c, &name, input.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
})
})
if res.Submitted() {
state.EndSubmit("") // after saving name
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleFormField() {
email := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
return input.InputGroup(c, &email, input.InputGroupOptions{}).Input
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleInputGroup() {
site := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
g := input.InputGroup(c, &site, input.InputGroupOptions{
Prefix: "https://", Label: "Site",
Buttons: []input.InputGroupButton{{Label: "Open"}},
})
if g.Clicked == 0 {
c.OpenURL("https://" + site)
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePinInput() {
code := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.PinInput(c, &code, input.PinInputOptions{Length: 6}).Completed() {
fmt.Println("verify", code)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTagsInput() {
tags := []string{"gothic"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.TagsInput(c, &tags, input.TagsInputOptions{Max: 5, Placeholder: "Add a tag", Label: "Tags"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRating() {
stars := 3.5
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Rating(c, &stars, input.RatingOptions{Step: 0.5, ShowValue: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleColorPicker() {
tint := ui.Hex("#7D2034")
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.ColorPicker(c, &tint, input.ColorPickerOptions{Label: "Tint"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFilePicker() {
var paths []string
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.FilePicker(c, &paths, input.FilePickerOptions{
Multiple: true,
Filters: []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileDropZone() {
var files []input.DroppedFile
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.FileDropZone(c, &files, input.FileDropZoneOptions{
Extensions: []string{"png", "jpg"}, MaxFiles: 3, MaxSize: 5 << 20,
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTransfer() {
chosen := []string{"uika"}
items := []input.TransferItem{{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "nyamu", Label: "Nyamu", Disabled: true}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Transfer(c, &chosen, items, input.TransferOptions{})
}
ui.Render(view, 400, 300, 1)
}
+131
View File
@@ -0,0 +1,131 @@
package input_test
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func ExampleButton() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.Button(c, "Save", input.ButtonOptions{Icon: icons.Must("check")}).Clicked() {
fmt.Println("saved")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleIconButton() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.IconButton(c, icons.Must("settings"), "Settings", input.IconButtonOptions{Variant: input.Ghost}).Clicked() {
fmt.Println("open settings")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleButtonGroup() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
items := []input.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}}
if i := input.ButtonGroup(c, items, input.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) {
core.Use(c, core.Settings{})
r := input.TextInput(c, &name, input.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) {
core.Use(c, core.Settings{})
r := input.TextArea(c, &notes, input.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) {
core.Use(c, core.Settings{})
if input.NumberInput(c, &copies, input.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) {
core.Use(c, core.Settings{})
if input.PasswordInput(c, &password, input.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) {
core.Use(c, core.Settings{})
matches := 0
if query != "" {
matches = strings.Count(text, query)
}
r := input.SearchInput(c, &query, input.SearchInputOptions{Find: true, Matches: matches, Current: &current})
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) {
core.Use(c, core.Settings{})
input.CheckboxGroup(c, "Notifications", []input.CheckboxItem{
{Label: "Mail", Value: &mail},
{Label: "Calendar", Value: &calendar},
}, input.CheckboxGroupOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRadioGroup() {
size := "m"
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
items := []input.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}}
if input.RadioGroup(c, &size, items, input.RadioGroupOptions{Label: "Size"}).Changed() {
fmt.Println("size", size)
}
}
ui.Render(view, 400, 300, 1)
}
+284
View File
@@ -0,0 +1,284 @@
package input
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// DroppedFile is a file a FileDropZone accepted.
type DroppedFile struct {
Path string
Size int64
}
// FileDropZoneOptions configure a FileDropZone. Zero limits accept anything.
type FileDropZoneOptions struct {
// Extensions lists the accepted extensions without the dot, matched case-insensitively.
Extensions []string
// MaxFiles caps how many files the list holds.
MaxFiles int
// MaxSize caps each file's size in bytes.
MaxSize int64
// Hint replaces the built-in "Drop files here".
Hint string
Disabled bool
}
// fileDropZoneReject is a file that failed a check, shown until dismissed.
type fileDropZoneReject struct {
path, reason string
}
type fileDropZoneStat struct {
path string
size int64
dir bool
err error
}
type fileDropZoneState struct {
pending []string
stats chan []fileDropZoneStat
browse chan filePickerResult
rejected []fileDropZoneReject
}
// FileDropZone checks dropped or browsed files and lists them; it never reads or uploads them.
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) core.ChoiceResult {
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
panic("mujica: FileDropZone limits must not be negative")
}
for _, ext := range opts.Extensions {
if ext == "" || strings.HasPrefix(ext, ".") {
panic(fmt.Sprintf("mujica: FileDropZone extension %q: give it without the dot", ext))
}
}
k := core.Tokens(c)
d := core.Density(c)
parts := core.ChoiceResult{}
col := ui.Column(c).Gap(d.Unit() * 2)
parts.Element = col
st := ui.Local(col, "zone", func() fileDropZoneState { return fileDropZoneState{} })
reject := func(path, key string, args ...any) {
st.rejected = append(st.rejected, fileDropZoneReject{path, fmt.Sprintf(core.Msg(c, key), args...)})
slog.Debug("mujica: file drop zone rejected", "reason", key)
}
// take checks types now and sizes off the UI thread.
take := func(paths []string) {
var ok []string
for _, p := range paths {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
switch {
case slices.ContainsFunc(*files, func(f DroppedFile) bool { return f.Path == p }), slices.Contains(st.pending, p), slices.Contains(ok, p):
case len(opts.Extensions) > 0 && !slices.ContainsFunc(opts.Extensions, func(e string) bool { return strings.EqualFold(e, ext) }):
reject(p, "file_drop_zone.type", strings.Join(opts.Extensions, ", "))
default:
ok = append(ok, p)
}
}
if len(ok) == 0 {
return
}
st.pending = append(st.pending, ok...)
if st.stats != nil {
return // the running check picks them up next
}
fileDropZoneCheck(c, st)
}
if st.stats != nil {
select {
case res := <-st.stats:
st.stats = nil
st.pending = st.pending[len(res):]
for _, s := range res {
switch {
case s.err != nil:
err := s.err
if pe, ok := errors.AsType[*fs.PathError](err); ok {
err = pe.Err
}
reject(s.path, "file_drop_zone.unreadable", err)
case s.dir:
reject(s.path, "file_drop_zone.folder")
case opts.MaxSize > 0 && s.size > opts.MaxSize:
reject(s.path, "file_drop_zone.size", fileDropZoneSize(opts.MaxSize))
case opts.MaxFiles > 0 && len(*files) >= opts.MaxFiles:
reject(s.path, "file_drop_zone.count", opts.MaxFiles)
default:
*files = append(slices.Clone(*files), DroppedFile{s.path, s.size})
parts = core.NewChoiceResult(parts.Element, true)
}
}
if len(st.pending) > 0 {
fileDropZoneCheck(c, st)
}
default:
}
}
if st.browse != nil {
select {
case r := <-st.browse:
st.browse = nil
if r.err != nil {
reject("", "file_picker.failed", r.err)
} else {
take(r.paths)
}
default:
}
}
col.Children(func() {
zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5).
Radius(theme.CardRadius).Disabled(opts.Disabled)
disabled := zone.IsDisabled()
if dropped := zone.DroppedFiles(); dropped != nil && !disabled {
slog.Debug("mujica: file drop zone dropped", "count", len(dropped))
take(dropped)
}
over := zone.FileDragOver() && !disabled
bg, border := k.Surface, k.ControlBorder
if over {
bg, border = k.Selection, k.AccentText
}
zone.Background(bg).Draw(func(p *ui.Painter, r ui.Rect) {
p.StrokeDashed(r, border, theme.CardRadius, theme.BorderWidth*1.5)
})
hint := opts.Hint
if hint == "" {
hint = core.Msg(c, "file_drop_zone.hint")
}
zone.Children(func() {
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
ui.Text(c, hint).TextAlign(ui.Center)
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).TextAlign(ui.Center)
b := Button(c, core.Msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
if b.Clicked() {
ch := make(chan filePickerResult, 1)
st.browse = ch
dlg := mygo.OpenDialogOptions{Multiple: opts.MaxFiles != 1}
if len(opts.Extensions) > 0 {
dlg.Filters = []mygo.FileFilter{{Name: hint, Extensions: opts.Extensions}}
}
go func() {
p, err := filePickerOpen(dlg)
ch <- filePickerResult{p, err}
c.Invalidate()
}()
}
})
fileDropZoneList(c, st, files, &parts, opts.Disabled)
})
return parts
}
// fileDropZoneCheck stats the pending files on a goroutine.
func fileDropZoneCheck(c *ui.Context, st *fileDropZoneState) {
ch := make(chan []fileDropZoneStat, 1)
st.stats = ch
paths := slices.Clone(st.pending)
go func() {
res := make([]fileDropZoneStat, len(paths))
for i, p := range paths {
info, err := os.Stat(p)
res[i] = fileDropZoneStat{path: p, err: err}
if err == nil {
res[i].size, res[i].dir = info.Size(), info.IsDir()
}
}
ch <- res
c.Invalidate()
}()
}
// fileDropZoneList shows accepted, checking and rejected files.
func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile, parts *core.ChoiceResult, disabled bool) {
k := core.Tokens(c)
d := core.Density(c)
row := func(key string, icon string, color ui.Color, name, detail string, remove func()) {
ui.Row(c).Key(key).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
ui.Text(c, detail).TextColor(color).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine().Grow(1)
if remove != nil {
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
Label(fmt.Sprintf(core.Msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
internal.Ring(x, k.Focus, theme.ControlRadius)
if x.Hovered() {
x.Background(k.SurfaceHover).TextColor(k.Text)
}
x.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14) })
if x.Clicked() {
remove()
}
}
})
}
ui.Column(c).Children(func() {
for i, f := range *files {
row("ok"+f.Path, "file", k.TextMuted, filepath.Base(f.Path), fileDropZoneSize(f.Size), func() {
*files = slices.Delete(slices.Clone(*files), i, i+1)
*parts = core.NewChoiceResult(parts.Element, true)
slog.Debug("mujica: file drop zone removed", "count", len(*files))
})
}
for _, p := range st.pending {
row("pending"+p, "loader-circle", k.TextMuted, filepath.Base(p), core.Msg(c, "file_drop_zone.checking"), nil)
}
for i, r := range st.rejected {
name := filepath.Base(r.path)
if r.path == "" {
name = core.Msg(c, "file_drop_zone.dialog")
}
row(fmt.Sprint("bad", i, r.path), "circle-alert", k.Danger, name, r.reason, func() {
st.rejected = slices.Delete(slices.Clone(st.rejected), i, i+1)
})
}
})
}
// fileDropZoneRules describes the limits, such as "PDF, PNG · up to 3 files · 2 MB each".
func fileDropZoneRules(c *ui.Context, opts FileDropZoneOptions) string {
var rules []string
if len(opts.Extensions) > 0 {
rules = append(rules, strings.ToUpper(strings.Join(opts.Extensions, ", ")))
}
if opts.MaxFiles > 0 {
rules = append(rules, fmt.Sprintf(core.Msg(c, "file_drop_zone.max_files"), opts.MaxFiles))
}
if opts.MaxSize > 0 {
rules = append(rules, fmt.Sprintf(core.Msg(c, "file_drop_zone.max_size"), fileDropZoneSize(opts.MaxSize)))
}
if len(rules) == 0 {
return core.Msg(c, "file_drop_zone.any")
}
return strings.Join(rules, " · ")
}
// fileDropZoneSize formats n bytes in binary units.
func fileDropZoneSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
v, i := float64(n)/unit, 0
for v >= unit && i < 3 {
v /= unit
i++
}
return fmt.Sprintf("%.1f %s", v, []string{"KB", "MB", "GB", "TB"}[i])
}
+122
View File
@@ -0,0 +1,122 @@
package input
import (
"os"
"path/filepath"
"testing"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// fileDropZoneFiles writes files of the given sizes into a temp dir.
func fileDropZoneFiles(t *testing.T, sizes map[string]int) string {
t.Helper()
dir := t.TempDir()
for name, n := range sizes {
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func fileDropZoneTester(files *[]DroppedFile, opts FileDropZoneOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if FileDropZone(c, files, opts).Changed() {
*changes++
}
}, 560, 600)
}
// fileDropZoneBrowse answers the Browse dialog with paths.
func fileDropZoneBrowse(t *testing.T, paths ...string) *mygo.OpenDialogOptions {
saved := filePickerOpen
t.Cleanup(func() { filePickerOpen = saved })
got := &mygo.OpenDialogOptions{}
filePickerOpen = func(o mygo.OpenDialogOptions) ([]string, error) {
*got = o
return paths, nil
}
return got
}
func TestFileDropZoneChecksTypeSizeAndCount(t *testing.T) {
dir := fileDropZoneFiles(t, map[string]int{"a.pdf": 10, "b.PDF": 3000, "c.pdf": 20, "d.txt": 5, "e.pdf": 30})
p := func(n string) string { return filepath.Join(dir, n) }
got := fileDropZoneBrowse(t, p("a.pdf"), p("b.PDF"), p("c.pdf"), p("d.txt"), p("e.pdf"), p("missing.pdf"), dir+"/sub.pdf")
if err := os.Mkdir(p("sub.pdf"), 0o755); err != nil {
t.Fatal(err)
}
var files []DroppedFile
n := 0
tt := fileDropZoneTester(&files, FileDropZoneOptions{Extensions: []string{"pdf"}, MaxFiles: 2, MaxSize: 1024}, &n)
if err := tt.Click("Browse…"); err != nil {
t.Fatal(err)
}
filePickerWait(t, tt, func() bool { return len(files) == 2 && tt.HasText("More than 2 files") })
if files[0] != (DroppedFile{p("a.pdf"), 10}) || files[1] != (DroppedFile{p("c.pdf"), 20}) || n != 1 {
t.Fatalf("files %+v changes %d", files, n)
}
for _, want := range []string{"Only pdf files", "Larger than 1.0 KB", "Folders are not accepted", "Cannot read: no such file or directory"} {
if !tt.HasText(want) {
t.Errorf("missing %q in %q", want, tt.Texts())
}
}
if !got.Multiple || got.Filters[0].Extensions[0] != "pdf" {
t.Fatalf("dialog options %+v", got)
}
}
func TestFileDropZoneRemoveAndDedupe(t *testing.T) {
dir := fileDropZoneFiles(t, map[string]int{"score.mid": 2048})
path := filepath.Join(dir, "score.mid")
fileDropZoneBrowse(t, path)
files := []DroppedFile{{path, 2048}}
n := 0
tt := fileDropZoneTester(&files, FileDropZoneOptions{}, &n)
_ = tt.Click("Browse…")
for range 20 {
tt.Frame()
}
if len(files) != 1 || n != 0 || !tt.HasText("2.0 KB") {
t.Fatalf("duplicate added: %+v", files)
}
if err := tt.Click("Remove score.mid"); err != nil {
t.Fatal(err)
}
if len(files) != 0 || n != 1 {
t.Fatalf("remove: %+v changes %d", files, n)
}
}
func TestFileDropZoneDisabled(t *testing.T) {
called := false
saved := filePickerOpen
t.Cleanup(func() { filePickerOpen = saved })
filePickerOpen = func(mygo.OpenDialogOptions) ([]string, error) { called = true; return nil, nil }
files := []DroppedFile{{"/x/kept.pdf", 1}}
n := 0
tt := fileDropZoneTester(&files, FileDropZoneOptions{Disabled: true}, &n)
_ = tt.Click("Browse…")
_ = tt.Click("Remove kept.pdf")
tt.Frame()
if called || len(files) != 1 {
t.Fatal("disabled zone acted")
}
if fileDropZoneSize(1536) != "1.5 KB" || fileDropZoneSize(3<<20) != "3.0 MB" || fileDropZoneSize(12) != "12 B" {
t.Fatal("size format")
}
}
func TestFileDropZoneSnapshot(t *testing.T) {
files := []DroppedFile{{"/scores/Imprisoned XII.pdf", 482133}, {"/scores/cover.png", 1 << 20}}
snapshot(t, "file_drop_zone", 520, 330, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() {
FileDropZone(c, &files, FileDropZoneOptions{Extensions: []string{"pdf", "png"}, MaxFiles: 3, MaxSize: 2 << 20})
})
})
}
+137
View File
@@ -0,0 +1,137 @@
package input
import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FilePickerOptions configure a FilePicker.
type FilePickerOptions struct {
// Directory picks folders instead of files.
Directory bool
Multiple bool
// Filters limit the files offered by extension, without the dot.
Filters []mygo.FileFilter
// Title is the system dialog's title.
Title string
// Label is the button text; empty uses the built-in "Choose file…" or "Choose folder…".
Label string
// Parent attaches the dialog to a window, as a sheet on macOS.
Parent *mygo.Window
Disabled bool
}
// FilePickerParts are the parts of a FilePicker.
type FilePickerParts struct {
Element *ui.Element
changed bool
canceled bool
}
// Changed reports whether the user picked new paths this frame.
func (p FilePickerParts) Changed() bool { return p.changed }
// Canceled reports whether the user closed the dialog without picking, which is not an error.
func (p FilePickerParts) Canceled() bool { return p.canceled }
// filePickerOpen shows the system dialog; tests replace it.
var filePickerOpen = mygo.Dialog.Open
type filePickerResult struct {
paths []string
err error
}
type filePickerState struct {
result chan filePickerResult
err string
}
// FilePicker opens the system file dialog off the UI thread and stores the chosen paths.
func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts {
for _, f := range opts.Filters {
for _, ext := range f.Extensions {
if ext == "" || strings.HasPrefix(ext, ".") {
panic(fmt.Sprintf("mujica: FilePicker extension %q: give it without the dot", ext))
}
}
}
k := core.Tokens(c)
d := core.Density(c)
parts := FilePickerParts{}
col := ui.Column(c).Gap(d.Unit() * 2).AlignItems(ui.Start)
parts.Element = col
st := ui.Local(col, "picker", func() filePickerState { return filePickerState{} })
if st.result != nil {
select {
case r := <-st.result:
st.result = nil
switch {
case r.err != nil:
st.err = fmt.Sprintf(core.Msg(c, "file_picker.failed"), r.err)
slog.Debug("mujica: file picker failed", "err", r.err)
case r.paths == nil:
parts.canceled = true
slog.Debug("mujica: file picker canceled")
default:
*paths = r.paths
parts.changed = true
slog.Debug("mujica: file picker picked", "count", len(r.paths))
}
default:
}
}
label := opts.Label
if label == "" {
label = core.Msg(c, "file_picker.choose_file")
if opts.Directory {
label = core.Msg(c, "file_picker.choose_folder")
}
}
icon := icons.Must("file")
if opts.Directory {
icon = icons.Must("folder")
}
col.Children(func() {
b := Button(c, label, ButtonOptions{Variant: Secondary, Icon: icon, Loading: st.result != nil, Disabled: opts.Disabled})
if b.Clicked() {
st.err = ""
ch := make(chan filePickerResult, 1)
st.result = ch
dlg := mygo.OpenDialogOptions{Parent: opts.Parent, Title: opts.Title, Filters: opts.Filters,
Directory: opts.Directory, Multiple: opts.Multiple}
slog.Debug("mujica: file picker opened", "directory", opts.Directory, "multiple", opts.Multiple)
go func() {
p, err := filePickerOpen(dlg)
ch <- filePickerResult{p, err}
c.Invalidate()
}()
}
switch {
case st.err != "":
ui.Text(c, st.err).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
case len(*paths) == 0:
ui.Text(c, core.Msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
default:
ui.Column(c).Gap(d.Unit()).Children(func() {
for _, p := range *paths {
ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
ui.Text(c, filepath.Base(p)).SingleLine()
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
})
}
})
}
})
return parts
}
+133
View File
@@ -0,0 +1,133 @@
package input
import (
"errors"
"slices"
"testing"
"time"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type filePickerProbe struct {
paths []string
changes, cancels int
got mygo.OpenDialogOptions
reply []string
err error
release chan struct{}
}
func (p *filePickerProbe) stub(t *testing.T) {
saved := filePickerOpen
t.Cleanup(func() { filePickerOpen = saved })
filePickerOpen = func(o mygo.OpenDialogOptions) ([]string, error) {
p.got = o
if p.release != nil {
<-p.release
}
return p.reply, p.err
}
}
func (p *filePickerProbe) tester(opts FilePickerOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := FilePicker(c, &p.paths, opts)
if r.Changed() {
p.changes++
}
if r.Canceled() {
p.cancels++
}
}, 500, 200)
}
// filePickerWait draws frames until done holds.
func filePickerWait(t *testing.T, tt *ui.Tester, done func() bool) {
t.Helper()
for range 200 {
tt.Frame()
if done() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("dialog result never arrived")
}
func TestFilePickerPicksFiles(t *testing.T) {
p := &filePickerProbe{reply: []string{"/court/score.pdf", "/court/cast.png"}, release: make(chan struct{})}
p.stub(t)
filters := []mygo.FileFilter{{Name: "Docs", Extensions: []string{"pdf", "png"}}}
tt := p.tester(FilePickerOptions{Multiple: true, Filters: filters, Title: "Score"})
if err := tt.Click("Choose file…"); err != nil {
t.Fatal(err)
}
_ = tt.Click("Choose file…")
close(p.release)
filePickerWait(t, tt, func() bool { return p.changes > 0 })
if !slices.Equal(p.paths, p.reply) || p.changes != 1 || !tt.HasText("score.pdf") {
t.Fatalf("paths %q changes %d", p.paths, p.changes)
}
if !p.got.Multiple || p.got.Directory || p.got.Title != "Score" || p.got.Filters[0].Extensions[1] != "png" {
t.Fatalf("dialog options %+v", p.got)
}
}
func TestFilePickerCancelIsNotAnError(t *testing.T) {
p := &filePickerProbe{paths: []string{"/kept"}}
p.stub(t)
tt := p.tester(FilePickerOptions{Directory: true})
if err := tt.Click("Choose folder…"); err != nil {
t.Fatal(err)
}
filePickerWait(t, tt, func() bool { return p.cancels > 0 })
if p.changes != 0 || p.paths[0] != "/kept" || !p.got.Directory {
t.Fatalf("cancel changed paths: %q", p.paths)
}
}
func TestFilePickerReportsDialogError(t *testing.T) {
p := &filePickerProbe{err: errors.New("no display")}
p.stub(t)
tt := p.tester(FilePickerOptions{})
_ = tt.Click("Choose file…")
want := "Could not open the file dialog: no display"
filePickerWait(t, tt, func() bool { return tt.HasText(want) })
if p.changes != 0 || p.cancels != 0 {
t.Fatalf("error counted as result")
}
}
func TestFilePickerDisabledAndBadFilter(t *testing.T) {
p := &filePickerProbe{}
p.stub(t)
called := false
filePickerOpen = func(mygo.OpenDialogOptions) ([]string, error) { called = true; return nil, nil }
tt := p.tester(FilePickerOptions{Disabled: true})
_ = tt.Click("Choose file…")
tt.Frame()
if called {
t.Fatal("disabled picker opened the dialog")
}
defer func() {
if recover() == nil {
t.Fatal("dotted extension accepted")
}
}()
p.tester(FilePickerOptions{Filters: []mygo.FileFilter{{Name: "PDF", Extensions: []string{".pdf"}}}})
}
func TestFilePickerSnapshot(t *testing.T) {
a, b := []string{"/Users/sakiko/scores/Imprisoned XII.pdf", "/Users/sakiko/scores/Ave Mujica.mid"}, []string{}
snapshot(t, "file_picker", 460, 190, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(20).Children(func() {
FilePicker(c, &a, FilePickerOptions{Multiple: true})
FilePicker(c, &b, FilePickerOptions{Directory: true, Disabled: true})
})
})
}
+330
View File
@@ -0,0 +1,330 @@
package input
import (
"fmt"
"log/slog"
"reflect"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// FormState is a form's check and submit state; use it on the UI thread only.
type FormState struct {
Fields map[string]*FormFieldState
Order []string
resets []func()
seq uint64
tried bool
submitting bool
Awaiting bool
Requested bool
focus string
formError string
generation int
}
type FormFieldState struct {
snapshot string
version uint64
touched bool
focused bool
err string
asyncFor uint64
asyncErr string
pending bool
hasAsync bool
check func() string
refresh func()
startAsync func()
}
// Version returns the version of name's value, which changes whenever the value does.
func (s *FormState) Version(name string) uint64 {
if f := s.Fields[name]; f != nil {
return f.version
}
return 0
}
// Resolve delivers name's async result for version, "" if valid; stale versions return false.
func (s *FormState) Resolve(name string, version uint64, err string) bool {
f := s.Fields[name]
if f == nil || f.version != version {
slog.Debug("mujica: form async result dropped", "field", name, "version", version)
return false
}
f.pending, f.asyncFor, f.asyncErr = false, version, err
slog.Debug("mujica: form async result", "field", name, "valid", err == "")
return true
}
// Submit asks the form to submit in its next frame, as the submit button does.
func (s *FormState) Submit() { s.Requested = true }
// Submitting reports whether a submit is in progress.
func (s *FormState) Submitting() bool { return s.submitting }
// EndSubmit finishes a submit; formError, if any, shows above the buttons and keeps all values.
func (s *FormState) EndSubmit(formError string) {
if !s.submitting {
panic("mujica: FormState.EndSubmit without a submit in progress")
}
s.submitting, s.formError = false, formError
slog.Debug("mujica: form submit ended", "failed", formError != "")
}
// Error returns the error name shows now, "" when valid, untouched or pending.
func (s *FormState) Error(name string) string {
f := s.Fields[name]
if f == nil || !(f.touched || s.tried) {
return ""
}
if f.err != "" {
return f.err
}
if f.asyncFor == f.version {
return f.asyncErr
}
return ""
}
// FormOptions configure a Form.
type FormOptions struct {
// Horizontal puts labels in a column beside the controls.
Horizontal bool
// SubmitLabel and ResetLabel replace the built-in "Submit" and "Reset".
SubmitLabel, ResetLabel string
}
// FormScope registers a Form's fields with FormItem.
type FormScope struct {
st *FormState
horizontal bool
}
// FormParts are the parts of a Form.
type FormParts struct {
Element *ui.Element
submitted, reset bool
}
// Submitted reports a submit that passed every check; call EndSubmit when it finishes.
func (p FormParts) Submitted() bool { return p.submitted }
// Reset reports that the form restored its initial values this frame.
func (p FormParts) Reset() bool { return p.reset }
// FormItemOptions configure one field of a Form.
type FormItemOptions[T any] struct {
Label string
Required bool
Description string
// Initial is the value Reset restores.
Initial T
// Validate returns an error message for v, or ""; cross-field rules read other values here.
Validate func(v T) string
// Async starts a check of v off the UI thread, finished with FormState.Resolve(name, version, msg).
Async func(v T, version uint64)
}
// Form lays out and checks its fields and shows submit and reset buttons.
func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts {
if st.Fields == nil {
st.Fields = map[string]*FormFieldState{}
}
st.Order, st.resets = st.Order[:0], nil
d := core.Density(c)
k := core.Tokens(c)
parts := FormParts{}
col := ui.Column(c).Gap(d.Unit() * 5)
parts.Element = col
scope := &FormScope{st: st, horizontal: opts.Horizontal}
col.Children(func() {
// A new key after Reset drops the controls' drafts and errors.
ui.Box(c).Key(st.generation).Children(func() {
if opts.Horizontal {
formFieldGrid(c, func() { fields(scope) })
} else {
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
}
})
if st.formError != "" {
ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus)
}
ui.Row(c).Gap(d.Unit() * 3).Children(func() {
submit, reset := opts.SubmitLabel, opts.ResetLabel
if submit == "" {
submit = core.Msg(c, "form.submit")
}
if reset == "" {
reset = core.Msg(c, "form.reset")
}
if Button(c, submit, ButtonOptions{Loading: st.submitting || st.Awaiting}).Clicked() {
st.Requested = true
}
if Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting}).Clicked() {
formReset(st)
parts.reset = true
}
})
})
if st.Requested {
st.Requested = false
if !st.submitting && !st.Awaiting {
formStartSubmit(st)
}
}
if st.Awaiting {
parts.submitted = formSettle(c, st)
}
return parts
}
// formStartSubmit validates every field and starts the async checks still owed.
func formStartSubmit(st *FormState) {
st.tried, st.Awaiting, st.formError = true, true, ""
for _, name := range st.Order {
f := st.Fields[name]
f.err = f.check()
if f.err == "" {
f.startAsync()
}
}
slog.Debug("mujica: form submit requested", "fields", len(st.Order))
}
// formSettle finishes a waiting submit once no check is pending; it reports success.
func formSettle(c *ui.Context, st *FormState) bool {
// Current values only: later edits and cross-field rules count.
for _, name := range st.Order {
f := st.Fields[name]
f.refresh()
f.err = f.check()
}
first, pending := "", false
for _, name := range st.Order {
if st.Error(name) != "" && first == "" {
first = name
}
f := st.Fields[name]
// A value edited since its check owes a new one.
if f.hasAsync && f.err == "" && f.asyncFor != f.version {
f.startAsync()
pending = true
}
}
switch {
case first != "":
st.Awaiting, st.focus = false, first
c.Announce(core.Msg(c, "form.failed"))
c.Invalidate()
slog.Debug("mujica: form submit failed", "first", first)
case pending:
default:
st.Awaiting, st.submitting = false, true
slog.Debug("mujica: form submitted")
return true
}
return false
}
// formReset restores the initial values and forgets every check.
func formReset(st *FormState) {
for _, r := range st.resets {
r()
}
st.Fields = map[string]*FormFieldState{}
st.generation++
st.tried, st.Awaiting, st.Requested, st.focus, st.formError = false, false, false, "", ""
slog.Debug("mujica: form reset")
}
// FormItem registers field name around the focusable element control returns.
func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
st := f.st
if name == "" || opts.Label == "" {
panic("mujica: FormItem needs a name and a Label")
}
if slices.Contains(st.Order, name) {
panic("mujica: FormItem " + name + " registered twice")
}
st.Order = append(st.Order, name)
fs := st.Fields[name]
if fs == nil {
st.seq++
fs = &FormFieldState{snapshot: fmt.Sprintf("%#v", *value), version: st.seq}
st.Fields[name] = fs
}
fs.hasAsync = opts.Async != nil
fs.check = func() string {
if opts.Required && formEmpty(*value) {
return core.Msg(c, "form.required")
}
if opts.Validate != nil {
return opts.Validate(*value)
}
return ""
}
fs.startAsync = func() {
if opts.Async != nil && fs.asyncFor != fs.version && !fs.pending {
fs.pending = true
slog.Debug("mujica: form async check started", "field", name, "version", fs.version)
opts.Async(*value, fs.version)
}
}
// refresh gives an edited value a new version; it runs after the control took its input.
fs.refresh = func() {
snap := fmt.Sprintf("%#v", *value)
if snap == fs.snapshot {
return
}
st.seq++
fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, ""
if fs.touched || st.tried {
fs.err = fs.check()
}
c.Invalidate()
}
initial := opts.Initial
st.resets = append(st.resets, func() { *value = initial })
desc := opts.Description
if fs.pending {
desc = core.Msg(c, "form.checking")
}
var ctrl *ui.Element
field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element {
ctrl = control()
return ctrl
})
fs.refresh()
focused := ctrl.FocusWithin()
if fs.focused && !focused {
fs.touched = true
fs.err = fs.check()
if fs.err == "" {
fs.startAsync()
}
slog.Debug("mujica: form field blurred", "field", name, "valid", fs.err == "")
}
fs.focused = focused
if st.focus == name {
st.focus = ""
ctrl.Focus().ScrollIntoView()
}
return field
}
// formEmpty reports whether a required value is missing: zero, or an empty string, slice or map.
func formEmpty(v any) bool {
r := reflect.ValueOf(v)
switch r.Kind() {
case reflect.Invalid:
return true
case reflect.String, reflect.Slice, reflect.Map, reflect.Array:
return r.Len() == 0
}
return r.IsZero()
}
+100
View File
@@ -0,0 +1,100 @@
package input
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FormFieldOptions configure a FormField.
type FormFieldOptions struct {
// Required marks the label; the check itself belongs to the caller or Form.
Required bool
Description string
// Error shows below the control and marks it invalid for assistive technology.
Error string
// Horizontal puts the label beside the control instead of above it.
Horizontal bool
}
// FormField labels the element control returns and shows help and error text for it.
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element {
if !opts.Horizontal {
return formFieldBuild(c, "", label, opts, false, control)
}
var f *ui.Element
formFieldGrid(c, func() { f = formFieldBuild(c, "", label, opts, true, control) })
return f
}
// formFieldGrid holds fields whose labels sit in a column beside their controls.
func formFieldGrid(c *ui.Context, fields func()) *ui.Element {
d := core.Density(c)
return ui.Grid(c).ColumnTracks(ui.FitContent(), ui.Fr(1)).GapX(d.Unit() * 3).GapY(d.Unit() * 4).
AlignItems(ui.Start).Children(fields)
}
// formFieldBuild builds the label and the control column; beside makes them two grid cells.
// formFieldBuild keys its cells by key when set, so focus follows a reordered field.
func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element {
if label == "" {
panic("mujica: FormField needs a label")
}
k := core.Tokens(c)
d := core.Density(c)
var head, content, ctrl *ui.Element
body := func() {
head = ui.Row(c)
if key != "" && beside {
head.Key(key + "/label")
}
head.Gap(d.Unit()).AlignItems(ui.Center)
if beside {
head.MinHeight(d.ControlHeight()).Justify(ui.End)
}
var text *ui.Element
head.Children(func() {
text = ui.Text(c, label).FontWeight(500).SingleLine()
if opts.Required {
ui.Text(c, "*").TextColor(k.Danger).Role(ui.RoleNone)
}
})
clicked := head.Clicked()
content = ui.Column(c)
if key != "" && beside {
content.Key(key + "/control")
}
content.Gap(d.Unit() * 1.5).MinWidth(0)
content.Children(func() {
ctrl = control()
if ctrl == nil {
panic("mujica: FormField " + label + ": control returned nil")
}
if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
}
if opts.Error != "" {
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
}
})
ctrl.Label(label).Description(opts.Description).Error(opts.Error)
if opts.Required {
ctrl.Description(core.Msg(c, "form.required"))
}
if ctrl.IsDisabled() {
text.TextColor(k.TextMuted)
} else if clicked {
ctrl.Focus()
}
}
if beside {
body()
return content
}
root := ui.Column(c)
if key != "" {
root.Key(key)
}
return root.Gap(d.Unit() * 1.5).Children(body)
}
+112
View File
@@ -0,0 +1,112 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestFormFieldLabelFocusesControl(t *testing.T) {
name := ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element {
return InputGroup(c, &name, InputGroupOptions{}).Input
})
}, 400, 200)
if err := tt.Click("Name"); err != nil {
t.Fatal(err)
}
tt.Type("Sakiko")
if name != "Sakiko" {
t.Fatalf("label click did not focus the control: %q", name)
}
if !tt.Focused("Name") || !tt.HasText("As on your seal.") || !tt.HasText("*") {
t.Fatalf("texts %q", tt.Texts())
}
}
// The label goes to the returned input, not the first focusable element before it.
func TestFormFieldTargetsReturnedControl(t *testing.T) {
tags, site := []string{"rose"}, ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Gap(12).Children(func() {
FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() *ui.Element {
return TagsInput(c, &tags, TagsInputOptions{}).Input
})
FormField(c, "Site", FormFieldOptions{}, func() *ui.Element {
return InputGroup(c, &site, InputGroupOptions{Buttons: []InputGroupButton{{Label: "Back", Leading: true}}}).Input
})
})
}, 500, 300)
_ = tt.Click("Flowers")
tt.Type("ivy")
tt.Key(0, ui.KeyEnter)
if !slices.Equal(tags, []string{"rose", "ivy"}) || !tt.Focused("Flowers") {
t.Fatalf("tags %q", tags)
}
if _, ok := tt.Find("Remove rose"); !ok {
t.Fatal("remove button lost its own name")
}
_ = tt.Click("Site")
tt.Type("court")
if site != "court" || tt.Focused("Back") {
t.Fatalf("site %q", site)
}
}
func TestFormFieldErrorAndLayout(t *testing.T) {
v := ""
horizontal := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element {
return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input
})
}, 500, 200)
if !tt.HasText("Enter an email address.") {
t.Fatal("error not shown")
}
label, _ := tt.Find("Email")
input, _ := tt.Find("mailto:")
if !(input.Y > label.Y+label.H/2) {
t.Fatalf("vertical: label %v input %v", label, input)
}
horizontal = true
tt.Frame()
label, _ = tt.Find("Email")
input, _ = tt.Find("mailto:")
if !(input.X > label.X+label.W) || input.Y > label.Y+label.H {
t.Fatalf("horizontal: label %v input %v", label, input)
}
}
func TestFormFieldNeedsLabel(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an empty label")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) })
}, 100, 100, 1)
}
func TestFormFieldSnapshot(t *testing.T) {
a, b := "Sakiko Togawa", "uika@"
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element {
return InputGroup(c, &a, InputGroupOptions{}).Input
})
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element {
return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input
})
})
})
}
+322
View File
@@ -0,0 +1,322 @@
package input
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type formProbe struct {
st FormState
name, email string
tags []string
asyncCalls []uint64
asyncValues []string
submits, resets int
horizontal bool
useAsync bool
}
func (p *formProbe) run(c *ui.Context) {
core.Use(c, core.Settings{})
p.view(c)
}
func (p *formProbe) view(c *ui.Context) {
res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) {
FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element {
return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input
})
opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string {
if v != "" && !strings.Contains(v, "@") {
return "Enter an email address."
}
return ""
}}
if p.useAsync {
opts.Async = func(v string, version uint64) {
p.asyncCalls = append(p.asyncCalls, version)
p.asyncValues = append(p.asyncValues, v)
}
}
FormItem(c, f, "email", &p.email, opts, func() *ui.Element {
return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input
})
FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element {
return TagsInput(c, &p.tags, TagsInputOptions{}).Input
})
})
if res.Submitted() {
p.submits++
}
if res.Reset() {
p.resets++
}
}
func TestFormBlurValidationAndUntouchedFields(t *testing.T) {
p := &formProbe{name: "Sakiko", tags: []string{"rose"}}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Email")
tt.Type("mutsumi")
if tt.HasText("Enter an email address.") {
t.Fatal("error before blur")
}
tt.Key(0, ui.KeyTab)
if !tt.HasText("Enter an email address.") {
t.Fatal("no error after blur")
}
if tt.HasText("Required") {
t.Fatal("untouched field shows an error")
}
_ = tt.Click("Email")
tt.Type("@court")
if tt.HasText("Enter an email address.") || p.email != "mutsumi@court" {
t.Fatalf("touched field did not revalidate: %q", p.email)
}
}
func TestFormSubmitFailureFocusesFirstErrorAndKeepsValues(t *testing.T) {
p := &formProbe{email: "bad"}
tt := ui.NewTester(p.run, 600, 500)
if err := tt.Click("Submit"); err != nil {
t.Fatal(err)
}
if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") {
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
}
if !tt.Focused("Name") || p.email != "bad" {
t.Fatal("first error not focused or value lost")
}
if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Some fields need attention" {
t.Fatalf("announcements %q", got)
}
}
func TestFormSubmitOnceAndEndSubmit(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
if p.submits != 1 || !p.st.Submitting() {
t.Fatalf("submits %d", p.submits)
}
_ = tt.Click("Submit")
p.st.Submit()
tt.Frame()
if p.submits != 1 {
t.Fatalf("re-entered: %d", p.submits)
}
p.st.EndSubmit("The server is away.")
tt.Frame()
if !tt.HasText("The server is away.") || p.name != "Uika" {
t.Fatal("form error not shown")
}
_ = tt.Click("Submit")
if p.submits != 2 || tt.HasText("The server is away.") {
t.Fatalf("second submit: %d", p.submits)
}
}
func TestFormAsyncStaleResultsAndPendingSubmit(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Email")
tt.Type("a@b")
tt.Key(0, ui.KeyTab)
if len(p.asyncCalls) != 1 || p.asyncValues[0] != "a@b" || !tt.HasText("Checking…") {
t.Fatalf("async calls %v", p.asyncCalls)
}
old := p.asyncCalls[0]
_ = tt.Click("Email")
tt.Type("c")
if p.st.Resolve("email", old, "Taken") {
t.Fatal("stale result accepted")
}
tt.Frame()
if tt.HasText("Taken") {
t.Fatal("stale result shown")
}
_ = tt.Click("Submit")
if len(p.asyncCalls) != 2 || p.asyncValues[1] != "a@bc" || p.submits != 0 {
t.Fatalf("submit did not wait for the new check: %v %d", p.asyncValues, p.submits)
}
if !p.st.Resolve("email", p.st.Version("email"), "") {
t.Fatal("current result refused")
}
tt.Frame()
if p.submits != 1 {
t.Fatalf("submit did not finish after the check: %d", p.submits)
}
}
func TestFormAsyncErrorBlocksSubmit(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "a@b", useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
p.st.Resolve("email", p.st.Version("email"), "Already registered")
tt.Frame()
tt.Frame()
if p.submits != 0 || !tt.HasText("Already registered") || !tt.Focused("Email") {
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
}
}
func TestFormResetRestoresInitialValues(t *testing.T) {
p := &formProbe{name: "", email: "bad", tags: nil}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
if err := tt.Click("Reset"); err != nil {
t.Fatal(err)
}
tt.Frame()
if p.name != "Sakiko" || p.email != "" || len(p.tags) != 1 || p.tags[0] != "rose" || p.resets != 1 {
t.Fatalf("reset values %q %q %q", p.name, p.email, p.tags)
}
if tt.HasText("Required") || p.st.Error("email") != "" {
t.Fatal("errors survive reset")
}
}
func TestFormHorizontalLayout(t *testing.T) {
p := &formProbe{horizontal: true}
tt := ui.NewTester(p.run, 600, 500)
star, _ := tt.Find("*")
email, _ := tt.Find("Email")
input, _ := tt.Find("Name")
if star.X+star.W != email.X+email.W || input.X >= email.X {
t.Fatalf("labels not right-aligned: %v %v", star, email)
}
}
// An edit during a pending check must be checked again before the submit goes out.
func TestFormEditWhilePendingRechecks(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "a@b", useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
if len(p.asyncCalls) != 1 {
t.Fatalf("calls %v", p.asyncCalls)
}
_ = tt.Click("Email")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("taken@b")
tt.Frame()
if p.submits != 0 || len(p.asyncCalls) != 2 || p.asyncValues[1] != "taken@b" {
t.Fatalf("submits %d values %q", p.submits, p.asyncValues)
}
if p.st.Resolve("email", p.asyncCalls[0], "") {
t.Fatal("stale result accepted")
}
p.st.Resolve("email", p.asyncCalls[1], "Taken")
tt.Frame()
tt.Frame()
if p.submits != 0 || !tt.HasText("Taken") {
t.Fatalf("submitted unchecked value: %d", p.submits)
}
}
// Reset also drops drafts and errors kept inside the controls.
func TestFormResetClearsControlState(t *testing.T) {
var st FormState
tags, seen := []string{}, ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Form(c, &st, FormOptions{}, func(f *FormScope) {
FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element {
return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string {
seen = s
return "Invalid tag"
}}).Input
})
})
}, 600, 400)
_ = tt.Click("Tags")
tt.Type("bad tag")
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Invalid tag") {
t.Fatal("no tag error")
}
_ = tt.Click("Reset")
tt.Frame()
if tt.HasText("Invalid tag") {
t.Fatal("tag error survived reset")
}
seen = ""
_ = tt.Click("Tags")
tt.Type("x")
tt.Key(0, ui.KeyEnter)
if seen != "x" {
t.Fatalf("draft survived reset: %q", seen)
}
}
func TestFormSnapshot(t *testing.T) {
p := &formProbe{name: "Sakiko Togawa", email: "oblivionis", tags: []string{"Ave Mujica"}}
snapshot(t, "form", 560, 420, func(c *ui.Context) {
ui.Column(c).Padding(24).Children(func() {
p.view(c)
if !p.st.tried {
p.st.Submit()
c.Invalidate()
}
})
})
}
// Text typed in the frame the check passes gets its own check before submitting.
func TestFormInputAfterAsyncPassIsRechecked(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "good@x", useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Email")
p.st.Submit()
tt.Frame()
if len(p.asyncCalls) != 1 {
t.Fatalf("calls %v", p.asyncCalls)
}
p.st.Resolve("email", p.asyncCalls[0], "")
tt.Type("bad")
if p.submits != 0 || len(p.asyncValues) != 2 || p.asyncValues[1] != "good@xbad" {
t.Fatalf("submits %d checked %q", p.submits, p.asyncValues)
}
}
// A cross-field rule sees the other field's latest value when the submit settles.
func TestFormCrossFieldRuleAtSettle(t *testing.T) {
var st FormState
user, pw, confirm := "uika", "a", "a"
var version uint64
submits := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
res := Form(c, &st, FormOptions{}, func(f *FormScope) {
FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element {
return InputGroup(c, &user, InputGroupOptions{}).Input
})
FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element {
return InputGroup(c, &pw, InputGroupOptions{}).Input
})
FormItem(c, f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string {
if v != pw {
return "Passwords differ."
}
return ""
}}, func() *ui.Element {
return InputGroup(c, &confirm, InputGroupOptions{}).Input
})
})
if res.Submitted() {
submits++
}
}, 600, 500)
_ = tt.Click("Submit")
pw = "b"
tt.Frame()
st.Resolve("user", version, "")
tt.Frame()
tt.Frame()
if submits != 0 || !tt.HasText("Passwords differ.") {
t.Fatalf("mismatched pair submitted: %d", submits)
}
}
+36
View File
@@ -0,0 +1,36 @@
package input
import (
"image"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
// snapshotAfter is snapshot with drive sending input before the capture.
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
}
func encode(t *testing.T, img image.Image) []byte {
t.Helper()
return snaptest.Encode(t, img)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
+114
View File
@@ -0,0 +1,114 @@
package input
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// HoldToConfirmOptions configure a HoldToConfirm.
type HoldToConfirmOptions struct {
// Duration is how long to hold; zero is 1.5 s.
Duration time.Duration
Disabled bool
}
// holdToConfirmState tracks one hold: by key or pointer, since start.
type holdToConfirmState struct {
key, done bool
start time.Time
}
// step advances the hold to now; it reports completion once per hold.
func (s *holdToConfirmState) step(held bool, now time.Time, d time.Duration) (progress float64, confirmed bool) {
switch {
case !held:
s.start, s.done = time.Time{}, false
return 0, false
case s.done:
return 1, false
case s.start.IsZero():
s.start = now
}
progress = min(1, float64(now.Sub(s.start))/float64(d))
if progress >= 1 {
s.done = true
return 1, true
}
return progress, false
}
// HoldToConfirm runs a dangerous action only after the pointer or Space is held for Duration.
func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) core.ChoiceResult {
if label == "" {
panic("mujica: HoldToConfirm needs a label")
}
if opts.Duration < 0 {
panic("mujica: negative HoldToConfirm Duration")
}
d := opts.Duration
if d == 0 {
d = 1500 * time.Millisecond
}
k := core.Tokens(c)
den := core.Density(c)
b := ui.ButtonBase(c).MinHeight(den.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, den.Unit()*4).
Radius(theme.ControlRadius).Disabled(opts.Disabled).Description(core.Msg(c, "hold_to_confirm.hint")).Clip()
internal.Ring(b, k.Focus, theme.ControlRadius)
st := ui.Local(b, "hold_to_confirm", func() *holdToConfirmState { return &holdToConfirmState{} })
s := *st
// Space holds; Enter and Space never click, so no single key press confirms.
b.HandleInput(func(ev ui.InputEvent) bool {
if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || b.IsDisabled() {
return false
}
if ev.Key == ui.KeySpace && (ev.Kind == ui.InputKeyDown || ev.Kind == ui.InputKeyUp) {
s.key = ev.Kind == ui.InputKeyDown
}
return true
})
_ = b.Clicked() // a click alone does nothing; it keeps the base pressable
disabled := b.IsDisabled()
held := !disabled && (s.key || b.Pressed())
if !b.Focused() {
s.key = false
held = !disabled && b.Pressed()
}
progress, confirmed := s.step(held, c.Now(), d)
still := c.Preferences().ReduceMotion
if held && !confirmed && progress < 1 {
if still {
c.After(100 * time.Millisecond)
} else {
c.AnimationFrame()
}
}
bg, fg, _ := ButtonColors(k, Danger, b.Hovered() && !disabled, false)
b.Background(bg).TextColor(fg)
text := label
if held && still && progress < 1 {
left := d - time.Duration(progress*float64(d))
text = fmt.Sprintf(core.Msg(c, "hold_to_confirm.left"), left.Seconds())
}
b.Draw(func(p *ui.Painter, r ui.Rect) {
if progress > 0 && !still {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(progress), H: r.H}, k.OnDanger.Alpha(0.28), 0)
}
})
// Room for the countdown, so the width holds.
w1, _ := c.MeasureText(0, ui.Span{Text: label})
w2, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf(core.Msg(c, "hold_to_confirm.left"), d.Seconds())})
b.MinWidth(max(w1, w2) + den.Unit()*8)
b.Children(func() { ui.Text(c, text).SingleLine() })
if confirmed {
c.Announce(core.Msg(c, "hold_to_confirm.done"))
slog.Debug("mujica: hold_to_confirm confirmed", "label", label)
}
return core.NewChoiceResult(b, confirmed)
}
+126
View File
@@ -0,0 +1,126 @@
package input
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestHoldToConfirmStep(t *testing.T) {
var s holdToConfirmState
t0 := time.Unix(0, 0)
d := time.Second
if p, ok := s.step(true, t0, d); p != 0 || ok {
t.Fatalf("start: %v %v", p, ok)
}
if p, ok := s.step(true, t0.Add(d/2), d); p != 0.5 || ok {
t.Fatalf("half: %v %v", p, ok)
}
if _, ok := s.step(true, t0.Add(d), d); !ok {
t.Fatal("full hold did not confirm")
}
if _, ok := s.step(true, t0.Add(2*d), d); ok {
t.Fatal("one hold confirmed twice")
}
s.step(false, t0.Add(2*d), d)
if p, _ := s.step(true, t0.Add(3*d), d); p != 0 {
t.Fatalf("release did not restart: %v", p)
}
}
func holdTester(dis *bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Padding(20).Children(func() {
if HoldToConfirm(c, "Delete repo", HoldToConfirmOptions{Duration: 60 * time.Millisecond, Disabled: *dis}).Changed() {
*n++
}
})
}, 400, 200)
}
func TestHoldToConfirmPointerHold(t *testing.T) {
dis, n := false, 0
tt := holdTester(&dis, &n)
r, _ := tt.Find("Delete repo")
cx, cy := r.X+r.W/2, r.Y+r.H/2
_ = tt.Click("Delete repo")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if n != 0 {
t.Fatalf("a click or key tap confirmed: %d", n)
}
tt.Press(cx, cy)
time.Sleep(20 * time.Millisecond)
tt.Release(cx, cy)
time.Sleep(60 * time.Millisecond)
tt.Frame()
if n != 0 {
t.Fatal("an early release confirmed")
}
tt.Press(cx, cy)
time.Sleep(80 * time.Millisecond)
tt.Frame()
tt.Frame()
tt.Release(cx, cy)
if n != 1 {
t.Fatalf("full hold confirmed %d times", n)
}
dis = true
tt.Frame()
tt.Press(cx, cy)
time.Sleep(80 * time.Millisecond)
tt.Frame()
tt.Release(cx, cy)
if n != 1 {
t.Fatal("disabled hold confirmed")
}
}
func TestHoldToConfirmSpaceHold(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
// A nanosecond hold completes in the frames between Space down and up.
if HoldToConfirm(c, "Drop table", HoldToConfirmOptions{Duration: time.Nanosecond}).Changed() {
n++
}
}, 300, 100)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if n != 0 {
t.Fatal("Enter confirmed")
}
tt.Key(0, ui.KeySpace)
if n != 1 {
t.Fatalf("Space hold confirmed %d times", n)
}
}
func TestHoldToConfirmReducedMotionCountdown(t *testing.T) {
dis, n := false, 0
tt := holdTester(&dis, &n)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
r, _ := tt.Find("Delete repo")
tt.Press(r.X+r.W/2, r.Y+r.H/2)
found := false
for _, s := range tt.Texts() {
found = found || strings.HasPrefix(s, "Keep holding… 0.")
}
if !found {
t.Fatalf("no countdown text: %q", tt.Texts())
}
}
func TestHoldToConfirmSnapshot(t *testing.T) {
snapshot(t, "hold_to_confirm", 400, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).Children(func() {
HoldToConfirm(c, "Delete project", HoldToConfirmOptions{})
HoldToConfirm(c, "Wipe", HoldToConfirmOptions{Disabled: true})
})
})
}
+35
View File
@@ -0,0 +1,35 @@
package input
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// 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 := core.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)
}
+79
View File
@@ -0,0 +1,79 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func TestIconButtonClickByNameAndKeyboard(t *testing.T) {
n := 0
var w, h float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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) {
core.Use(c, core.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) {
core.Use(c, core.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})
})
})
}
+199
View File
@@ -0,0 +1,199 @@
package input
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// InputGroupButton is a button joined to an InputGroup's border.
type InputGroupButton struct {
// Label is shown, or names an icon-only button for assistive technology.
Label string
Icon *ui.SVG
// IconOnly hides Label on screen; Icon is then required.
IconOnly bool
// Leading puts the button before the input.
Leading bool
Disabled bool
}
// InputGroupOptions configure an InputGroup.
type InputGroupOptions struct {
// Prefix and Suffix are fixed text beside the input, such as "https://".
Prefix, Suffix string
Buttons []InputGroupButton
Placeholder string
// Label names the input when it is not inside a FormField.
Label string
Disabled bool
// ReadOnly shows the value as selectable text that cannot change.
ReadOnly bool
// Invalid draws the border in the danger color.
Invalid bool
}
// InputGroupParts are the parts of an InputGroup.
type InputGroupParts struct {
Group *ui.Element
// Input takes the focus; it is selectable text when ReadOnly.
Input *ui.Element
// Clicked is the index in Buttons of the button clicked, or -1.
Clicked int
locked bool
}
// Changed reports whether the user edited the value this frame.
func (p InputGroupParts) Changed() bool { return !p.locked && p.Input.Changed() }
// Submitted reports whether Enter was pressed in the input.
func (p InputGroupParts) Submitted() bool { return !p.locked && p.Input.Submitted() }
// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border.
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
for _, b := range opts.Buttons {
if b.IconOnly && b.Icon == nil {
panic("mujica: InputGroupButton IconOnly needs an Icon")
}
if b.Label == "" {
panic("mujica: InputGroupButton needs a Label")
}
}
k := core.Tokens(c)
d := core.Density(c)
parts := InputGroupParts{Clicked: -1}
g := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).Radius(theme.ControlRadius).
Padding(theme.BorderWidth).Background(k.Surface).Disabled(opts.Disabled)
parts.Group = g
r := theme.ControlRadius - theme.BorderWidth
// Index of each segment, for its corners and separators.
n := len(opts.Buttons) + 1
if opts.Prefix != "" {
n++
}
if opts.Suffix != "" {
n++
}
seg := 0
segment := func(e *ui.Element) {
var left, right float32
if seg == 0 {
left = r
}
if seg == n-1 {
right = r
}
e.Radius(left, right, right, left)
if seg > 0 {
e.BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.ControlBorder)
}
seg++
}
button := func(i int) {
b := opts.Buttons[i]
bb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label)
segment(bb)
internal.Ring(bb, k.Focus, theme.ControlRadius)
if bb.Pressed() {
bb.Background(k.Selection)
} else if bb.Hovered() {
bb.Background(k.SurfaceHover)
}
bb.Children(func() {
if b.Icon != nil {
ui.Icon(c, b.Icon).Size(16, 16)
}
if !b.IconOnly {
ui.Text(c, b.Label).SingleLine()
}
})
if bb.Clicked() {
parts.Clicked = i
}
}
addon := func(s string) {
a := ui.Row(c).Padding(0, d.Unit()*3).Background(k.Background).Shrink(0)
segment(a)
a.Children(func() { ui.Text(c, s).TextColor(k.TextMuted).SingleLine() })
}
g.Children(func() {
for i, b := range opts.Buttons {
if b.Leading {
button(i)
}
}
if opts.Prefix != "" {
addon(opts.Prefix)
}
box := ui.Row(c).Grow(1).MinWidth(d.Unit()*12).Padding(0, d.Unit()*3)
segment(box)
box.Children(func() {
if opts.ReadOnly {
// As wide as MyGo makes a text input.
parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0)
} else {
parts.locked = box.IsDisabled()
parts.Input = inputGroupEditor(c, box, value, parts.locked).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
}
if opts.Label != "" {
parts.Input.Label(opts.Label)
}
})
if box.Clicked() {
parts.Input.Focus()
}
if opts.Suffix != "" {
addon(opts.Suffix)
}
for i, b := range opts.Buttons {
if !b.Leading {
button(i)
}
}
})
in := parts.Input
border := k.ControlBorder
switch {
case opts.Invalid:
border = k.Danger
case in.Focused():
border = k.Focus
case g.Hovered() && !opts.Disabled:
border = k.ControlBorder.Mix(k.Text, 0.25)
}
g.Border(theme.BorderWidth, border)
g.DrawOver(func(p *ui.Painter, r ui.Rect) {
if in.Focused() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
return parts
}
// inputGroupLock remembers locks so an unlocked input starts afresh.
type inputGroupLock struct {
locked bool
gen int
}
// inputGroupEditor builds the input, or plain selectable text while locked, so no edit command runs.
func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element {
lk := ui.Local(box, "lock", func() inputGroupLock { return inputGroupLock{} })
if lk.locked && !locked {
// A new identity drops the edits queued in the old editor while locked.
lk.gen++
}
lk.locked = locked
var in *ui.Element
ui.Row(c).Key(lk.gen).FillWidth().MinWidth(0).Children(func() {
if locked {
in = ui.Text(c, *value).SingleLine().Selectable()
} else {
in = ui.TextInputBase(c, value)
}
})
return in
}
+174
View File
@@ -0,0 +1,174 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestInputGroupTypingAndButtons(t *testing.T) {
value, clicked, submitted := "", -1, 0
opts := InputGroupOptions{
Prefix: "https://", Suffix: ".com", Label: "Site",
Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}, {Label: "Go"}},
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
p := InputGroup(c, &value, opts)
if p.Clicked >= 0 {
clicked = p.Clicked
if p.Clicked == 0 {
value = ""
}
}
if p.Submitted() {
submitted++
}
}, 500, 120)
if err := tt.Click("Site"); err != nil {
t.Fatal(err)
}
tt.Type("mujica")
tt.Key(0, ui.KeyEnter)
if value != "mujica" || submitted != 1 {
t.Fatalf("value %q submitted %d", value, submitted)
}
if err := tt.Click("Go"); err != nil {
t.Fatal(err)
}
if clicked != 1 {
t.Fatalf("clicked %d, want 1", clicked)
}
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if clicked != 0 || value != "" {
t.Fatalf("clear: clicked %d value %q", clicked, value)
}
if !tt.HasText("https://") || !tt.HasText(".com") {
t.Fatal("addons missing")
}
}
func TestInputGroupTabOrder(t *testing.T) {
value := ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Back", Leading: true}, {Label: "Search"}}})
}, 500, 120)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Back") {
t.Fatal("leading button is not first")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Query") {
t.Fatal("input is not second")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Search") {
t.Fatal("trailing button is not third")
}
}
func TestInputGroupBlockedStates(t *testing.T) {
for _, opts := range []InputGroupOptions{{Label: "Code", Disabled: true}, {Label: "Code", ReadOnly: true}} {
value, clicked := "abc", -1
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
opts.Buttons = []InputGroupButton{{Label: "Copy"}}
if p := InputGroup(c, &value, opts); p.Clicked >= 0 {
clicked = p.Clicked
}
}, 400, 100)
_ = tt.Click("Code")
tt.Type("zz")
_ = tt.Click("Copy")
if value != "abc" {
t.Fatalf("%+v: value changed to %q", opts, value)
}
if opts.Disabled && clicked != -1 {
t.Fatal("disabled button clicked")
}
if opts.ReadOnly && clicked != 0 {
t.Fatal("read-only group blocked its button")
}
}
}
func TestInputGroupSnapshot(t *testing.T) {
a, b, e := "mujica", "", "bad value"
snapshot(t, "input_group", 520, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).Children(func() {
InputGroup(c, &a, InputGroupOptions{Prefix: "https://", Suffix: ".com", Label: "Site"})
InputGroup(c, &b, InputGroupOptions{Placeholder: "Search the archive", Label: "Search",
Buttons: []InputGroupButton{{Label: "Search", Icon: icons.Must("search"), IconOnly: true, Leading: true}, {Label: "Go"}}})
InputGroup(c, &e, InputGroupOptions{Invalid: true, Suffix: "kg", Label: "Weight"})
})
})
}
// Input queued while focused must not edit a group disabled since.
func TestInputGroupDisabledAfterFocus(t *testing.T) {
value, disabled, changes := "abc", false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
if InputGroup(c, &value, InputGroupOptions{Label: "Code"}).Changed() {
changes++
}
})
}, 400, 100)
_ = tt.Click("Code")
disabled = true
tt.Type("zz")
tt.Key(0, ui.KeyBackspace)
tt.Frame()
if value != "abc" || changes != 0 {
t.Fatalf("value %q changes %d", value, changes)
}
}
func TestInputGroupHitAreasCompact(t *testing.T) {
value := ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: theme.Compact})
InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}}})
}, 400, 100)
if r, ok := tt.Find("Clear"); !ok || r.W < 28 || r.H < 28 {
t.Fatalf("button %v", r)
}
}
// Cut and paste after disabling a field with a selection change neither value nor clipboard.
func TestInputGroupDisabledEditCommands(t *testing.T) {
value, disabled, changes := "secret", false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if InputGroup(c, &value, InputGroupOptions{Label: "Code", Disabled: disabled}).Changed() {
changes++
}
}, 400, 100)
_ = tt.Click("Code")
tt.Key(ui.Cmd, ui.KeyA)
tt.SetClipboard("clip")
disabled = true
tt.Frame()
tt.Key(ui.Cmd, ui.KeyX)
tt.Command("cut")
tt.Command("paste")
tt.Command("undo")
tt.Frame()
if value != "secret" || tt.Clipboard() != "clip" || changes != 0 {
t.Fatalf("value %q clipboard %q changes %d", value, tt.Clipboard(), changes)
}
disabled = false
tt.Frame()
tt.Frame()
if value != "secret" || tt.Clipboard() != "clip" || changes != 0 {
t.Fatalf("after enabling: value %q clipboard %q", value, tt.Clipboard())
}
}
+204
View File
@@ -0,0 +1,204 @@
package input
import (
"image/color"
"runtime"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestInputsObeyDisabledAncestor(t *testing.T) {
text, area, num, find, cur := "", "", 5.0, "a", 0
off, events := false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Gap(8).Disabled(off).Children(func() {
for _, r := range []InputResult{
TextInput(c, &text, TextInputOptions{Label: "Text"}),
TextArea(c, &area, TextAreaOptions{Label: "Area", SubmitKey: ui.KeyEnter}),
NumberInput(c, &num, NumberInputOptions{Label: "Num"}),
} {
if r.Changed() || r.Submitted() {
events++
}
}
if SearchInput(c, &find, SearchInputOptions{Label: "Find", Find: true, Matches: 3, Current: &cur}).Moved() {
events++
}
})
}, 400, 500)
next := func() {
if runtime.GOOS == "darwin" {
tt.Key(ui.Cmd, ui.KeyG)
} else {
tt.Key(0, ui.KeyF3)
}
}
for _, name := range []string{"Text", "Area", "Num"} {
_ = tt.Click(name)
events = 0
off = true
tt.Type("x")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyUp)
next()
if events != 0 {
t.Errorf("%s: %d events under a disabled ancestor", name, events)
}
off = false
tt.Frame()
}
if text != "" || area != "" || num != 5 || cur != 0 {
t.Fatalf("values changed: %q %q %v current %d", text, area, num, cur)
}
}
// buttonLabelInk counts pixels in the label's box that differ from its first pixel.
func buttonLabelInk(tt *ui.Tester, label string) int {
r, _ := tt.Find(label)
img := tt.Image()
bg := img.At(int(r.X), int(r.Y))
n := 0
for y := int(r.Y); y < int(r.Y+r.H); y++ {
for x := int(r.X); x < int(r.X+r.W); x++ {
if !sameColor(img.At(x, y), bg) {
n++
}
}
}
return n
}
func sameColor(a, b color.Color) bool {
r1, g1, b1, a1 := a.RGBA()
r2, g2, b2, a2 := b.RGBA()
return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2
}
func TestButtonLoadingKeepsLabelAndWidth(t *testing.T) {
for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
for _, icon := range []*ui.SVG{nil, icons.Must("check")} {
loading := false
var w float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: d})
ui.Row(c).Children(func() {
w = Button(c, "Save", ButtonOptions{Icon: icon, Loading: loading}).Bounds().W
})
}, 300, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
before := w
loading = true
tt.Frame()
tt.Frame()
if w != before {
t.Errorf("%v icon %v: width %v -> %v", d, icon != nil, before, w)
}
// The spinner sits beside the label, never over it, and the label has ink.
sp, _ := tt.Find("Loading")
lb, _ := tt.Find("Save")
if sp.X+sp.W > lb.X || buttonLabelInk(tt, "Save") == 0 {
t.Errorf("%v icon %v: label hidden while loading (spinner %v, label %v)", d, icon != nil, sp, lb)
}
}
}
}
func numberInputStepTester(v *float64, opts NumberInputOptions) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
NumberInput(c, v, opts)
}, 400, 200)
tt.Key(0, ui.KeyTab)
return tt
}
func TestNumberInputStepGrid(t *testing.T) {
for _, stepper := range []bool{false, true} {
v := 0.2
tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 0.29999999, Step: 0.1, StepperOnly: stepper})
tt.Key(0, ui.KeyUp)
if v != 0.2 {
t.Errorf("stepper %v: Up past Max 0.29999999 gave %v", stepper, v)
}
v = 0
tt = numberInputStepTester(&v, NumberInputOptions{Min: -1, Max: 1, Step: 1e-10, StepperOnly: stepper})
tt.Key(0, ui.KeyUp)
if v != 1e-10 {
t.Errorf("stepper %v: Up by 1e-10 from 0 gave %v", stepper, v)
}
}
v := 0.0
tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 10, Step: 3, StepperOnly: true})
var got []float64
for _, k := range []ui.Key{ui.KeyUp, ui.KeyEnd, ui.KeyDown, ui.KeyHome, ui.KeyDown} {
tt.Key(0, k)
got = append(got, v)
}
want := []float64{3, 10, 9, 0, 0}
for i := range want {
if got[i] != want[i] {
t.Fatalf("stepper [0,10] by 3: got %v, want %v", got, want)
}
}
}
// radioSameName prints the same for every value, as fmt keys would see it.
type radioSameName struct{ id int }
func (radioSameName) String() string { return "same" }
func TestRadioGroupKeysByEquality(t *testing.T) {
v := radioSameName{0}
items := []RadioItem[radioSameName]{{Value: radioSameName{1}, Label: "One"}, {Value: radioSameName{2}, Label: "Two"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
RadioGroup(c, &v, items, RadioGroupOptions{})
}, 300, 200)
_ = tt.Click("Two")
if v.id != 2 {
t.Fatalf("click chose %v", v)
}
tt.Key(0, ui.KeyUp)
if v.id != 1 {
t.Fatalf("Up chose %v", v)
}
items[0], items[1] = items[1], items[0]
tt.Frame()
tt.Key(0, ui.KeySpace)
if v.id != 1 || !tt.Focused("One") {
t.Fatalf("after reorder chose %v, One focused %v", v, tt.Focused("One"))
}
}
func TestNumberInputStepExactBounds(t *testing.T) {
for _, stepper := range []bool{false, true} {
v := 1e12
tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 2e12, Step: 1, StepperOnly: stepper})
tt.Key(0, ui.KeyUp)
if v != 1e12+1 {
t.Errorf("stepper %v: Up from 1e12 by 1 gave %.0f", stepper, v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != 1e12-1 {
t.Errorf("stepper %v: two Downs gave %.0f", stepper, v)
}
v = 0.2
tt = numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 0.29999999995, Step: 0.1, StepperOnly: stepper})
tt.Key(0, ui.KeyUp)
if v != 0.2 {
t.Errorf("stepper %v: Up past Max 0.29999999995 gave %v", stepper, v)
}
tt.Key(0, ui.KeyEnd)
if stepper && v != 0.2 {
t.Errorf("End went to %v, past the largest value at precision 1", v)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package input
import (
"bytes"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
func TestButtonLoadingKeepsWidth(t *testing.T) {
for _, icon := range []*ui.SVG{nil, icons.Must("check")} {
loading := false
var w float32
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Children(func() {
w = Button(c, "Save", ButtonOptions{Icon: icon, Loading: loading}).Bounds().W
})
}, 300, 200)
tt.Frame()
before := w
loading = true
tt.Frame()
tt.Frame()
if w != before || !tt.HasText("Save") {
t.Fatalf("icon %v: width %v -> %v, texts %q", icon != nil, before, w, tt.Texts())
}
}
}
func inputReviewPanics(t *testing.T, name string, view func(c *ui.Context)) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
view(c)
}, 200, 100)
}
func TestIconOnlyButtonsNeedNames(t *testing.T) {
inputReviewPanics(t, "Button", func(c *ui.Context) {
Button(c, "", ButtonOptions{Icon: icons.Must("x")})
})
inputReviewPanics(t, "ButtonGroup", func(c *ui.Context) {
ButtonGroup(c, []ButtonGroupItem{{Icon: icons.Must("bold")}}, ButtonGroupOptions{})
})
}
func TestTextInputDisabledWhileFocused(t *testing.T) {
s, off, focused, changes := "", false, false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := TextInput(c, &s, TextInputOptions{Label: "Name", Disabled: off})
focused = r.Input.Focused()
if r.Changed() || r.Submitted() {
changes++
}
}, 400, 200)
_ = tt.Click("Name")
tt.Type("a")
off = true
tt.Type("b")
tt.Key(0, ui.KeyEnter)
tt.Frame()
if s != "a" || changes != 1 || focused {
t.Fatalf("value %q events %d focused %v", s, changes, focused)
}
}
func TestTextAreaDisabledWhileFocused(t *testing.T) {
s, off := "", false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
TextArea(c, &s, TextAreaOptions{Label: "Notes", Disabled: off})
}, 400, 300)
_ = tt.Click("Notes")
off = true
tt.Type("late")
if s != "" {
t.Fatalf("disabled area took %q", s)
}
}
func TestCheckboxDisabledWhileFocused(t *testing.T) {
on, off, focused := false, false, false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
focused = Checkbox(c, &on, "Sync", CheckboxOptions{Disabled: off}).Focused()
}, 300, 200)
tt.Key(0, ui.KeyTab)
off = true
tt.Key(0, ui.KeySpace)
tt.Frame()
if on || focused {
t.Fatalf("checked %v focused %v", on, focused)
}
}
func TestRadioGroupDisabledWhileFocused(t *testing.T) {
v, off := "a", false
items := []RadioItem[string]{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
RadioGroup(c, &v, items, RadioGroupOptions{Disabled: off})
}, 300, 200)
tt.Key(0, ui.KeyTab)
off = true
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
if v != "a" {
t.Fatalf("disabled group chose %q", v)
}
}
func TestTextAreaPlainEnterSubmits(t *testing.T) {
s, submits := "", 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if TextArea(c, &s, TextAreaOptions{Label: "Chat", SubmitKey: ui.KeyEnter}).Submitted() {
submits++
}
}, 400, 300)
_ = tt.Click("Chat")
tt.Type("hi")
tt.Key(0, ui.KeyEnter)
tt.Key(ui.Shift, ui.KeyEnter)
if submits != 1 || s != "hi\n" {
t.Fatalf("submits %d value %q", submits, s)
}
}
func TestRadioGroupFocusFollowsValue(t *testing.T) {
v := ""
items := []RadioItem[string]{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
RadioGroup(c, &v, items, RadioGroupOptions{})
}, 300, 200)
tt.Key(0, ui.KeyTab)
items[0], items[1] = items[1], items[0]
tt.Frame()
tt.Key(0, ui.KeySpace)
if v != "a" {
t.Fatalf("Space after reordering chose %q, want a", v)
}
}
// inputReviewStates renders view plain, hovered and pressed at the named element.
func inputReviewStates(t *testing.T, name string, view func(c *ui.Context)) (plain, hover, pressed []byte) {
t.Helper()
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Dark})
view(c)
}, 300, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
r, ok := tt.Find(name)
if !ok {
t.Fatalf("no %q", name)
}
plain = encode(t, tt.Image())
tt.Move(r.X+r.W/2, r.Y+r.H/2)
hover = encode(t, tt.Image())
tt.Press(r.X+r.W/2, r.Y+r.H/2)
pressed = encode(t, tt.Image())
return
}
func TestCheckedMarksShowHoverAndPress(t *testing.T) {
on, v := true, "a"
for name, view := range map[string]func(c *ui.Context){
"Sync": func(c *ui.Context) { Checkbox(c, &on, "Sync", CheckboxOptions{}) },
"A": func(c *ui.Context) {
RadioGroup(c, &v, []RadioItem[string]{{Value: "a", Label: "A"}}, RadioGroupOptions{})
},
} {
plain, hover, pressed := inputReviewStates(t, name, view)
if bytes.Equal(plain, hover) || bytes.Equal(hover, pressed) {
t.Errorf("%s: checked state hides hover (%v) or press (%v)", name, bytes.Equal(plain, hover), bytes.Equal(hover, pressed))
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package input
import (
"cmp"
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// KnobOptions configure a Knob. Min and Max both zero mean 0 to 100.
type KnobOptions struct {
Min, Max float64
// Step snaps the value; zero is continuous, moving 1% per arrow press.
Step float64
// Ticks draws that many evenly spaced marks around the dial; zero draws none.
Ticks int
// Size is the dial diameter; zero is 56.
Size float32
// Format writes the value under the dial; nil prints as many decimals as Min and Step need.
Format func(float64) string
Disabled bool
// Label names the knob and shows above it.
Label string
}
// knobSweep is the dial's travel, centered on twelve o'clock, in turns.
const knobSweep = 0.75
// knobDragPixels is how far a vertical drag travels the whole range.
const knobDragPixels float32 = 160
// Knob sets *value by dragging up and down, the arrows, Home and End; the dial shows the value and ticks.
func Knob(c *ui.Context, value *float64, opts KnobOptions) core.ChoiceResult {
if value == nil {
panic("mujica: Knob needs a value")
}
if opts.Ticks < 0 || !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
panic(fmt.Sprintf("mujica: Knob Ticks %d or Size %v invalid", opts.Ticks, opts.Size))
}
g, _ := SliderSetup(opts.Min, opts.Max, opts.Step, nil, false, c)
format := sliderFormat(opts.Format, g)
size := cmp.Or(opts.Size, 56)
k := core.Tokens(c)
old := g.snap(*value)
*value = old
col := ui.Column(c).AlignItems(ui.Center).Gap(core.Density(c).Unit()).Disabled(opts.Disabled)
disabled := col.IsDisabled()
var s *ui.Element
col.Children(func() {
if opts.Label != "" {
ui.Text(c, opts.Label).TextColor(k.Text).FontSize(core.FontSize(c, theme.CaptionSize))
}
var next float64
var dragged bool
s, next, dragged = SliderControl(c, old, g, g.lo, g.hi)
want := ui.Local(s, "knob.want", func() float64 { return math.NaN() })
if dragged {
if math.IsNaN(*want) {
*want = old
}
_, dy, _ := s.Dragged()
*want = max(g.lo, min(g.hi, *want-float64(dy)*(g.hi-g.lo)/float64(knobDragPixels)))
next = g.snap(*want)
} else {
*want = math.NaN()
}
if !disabled {
*value = next
}
s.Size(size, size).FocusRing(false).Cursor(ui.CursorResizeNS).Label(cmp.Or(opts.Label, core.Msg(c, "knob.label")))
hot := s.Hovered() || dragged
v := *value
s.Draw(func(p *ui.Painter, r ui.Rect) { knobPaint(p, r, k, g, v, opts.Ticks, hot, s.FocusVisible()) })
ui.Text(c, format(*value)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
})
changed := *value != old
if changed {
slog.Debug("mujica: knob changed", "label", opts.Label, "value", *value)
}
return core.NewChoiceResult(col, changed)
}
// knobPaint draws the track arc, the value arc, ticks and the diamond pointer.
func knobPaint(p *ui.Painter, r ui.Rect, k theme.Tokens, g SliderGeom, v float64, ticks int, hot, focus bool) {
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
rad := r.W/2 - 6
start := float32(-knobSweep / 2)
frac := float32((v - g.lo) / (g.hi - g.lo))
p.StrokePath(core.ProgressArc(p, cx, cy, rad, start, start+knobSweep), 3, k.Border)
if frac > 0 {
p.StrokePath(core.ProgressArc(p, cx, cy, rad, start, start+knobSweep*frac), 3, k.AccentText)
}
for i := range ticks {
t := float64(start) + knobSweep*float64(i)/float64(max(ticks-1, 1))
sin, cos := float32(math.Sin(2*math.Pi*t)), float32(math.Cos(2*math.Pi*t))
p.Line(cx+sin*(rad+3), cy-cos*(rad+3), cx+sin*(rad+6), cy-cos*(rad+6), 1, k.TextMuted)
}
face := k.Surface
if hot {
face = k.SurfaceHover
}
var dial ui.Path
dial.Circle(cx, cy, rad-6)
p.FillPath(&dial, face)
p.StrokePath(&dial, 1, k.ControlBorder)
t := float64(start + knobSweep*frac)
px := internal.Snap(p, cx+float32(math.Sin(2*math.Pi*t))*(rad-12))
py := internal.Snap(p, cy-float32(math.Cos(2*math.Pi*t))*(rad-12))
p.FillPath(internal.Diamond(px, py, 7), k.AccentText)
if focus {
internal.FocusRing(p, r, r.W/2, k.Focus)
}
}
+100
View File
@@ -0,0 +1,100 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func knobTester(v *float64, opts *KnobOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Padding(40).Children(func() {
if Knob(c, v, *opts).Changed() {
*n++
}
})
}, 300, 300)
}
func TestKnobKeysAndStepGrid(t *testing.T) {
v := 0.0
opts := KnobOptions{Max: 1, Step: 0.1, Label: "Gain"}
n := 0
tt := knobTester(&v, &opts, &n)
tt.Key(0, ui.KeyTab)
for range 3 {
tt.Key(0, ui.KeyUp)
}
if v != 0.3 || n != 3 || !tt.HasText("0.3") {
t.Fatalf("three Ups: %v (%d) %q", v, n, tt.Texts())
}
tt.Key(0, ui.KeyLeft)
if v != 0.2 {
t.Fatalf("Left: %v", v)
}
tt.Key(0, ui.KeyEnd)
if v != 1 {
t.Fatalf("End: %v", v)
}
tt.Key(0, ui.KeyUp)
if v != 1 || n != 5 {
t.Fatalf("past the end: %v %d", v, n)
}
tt.Key(0, ui.KeyHome)
if v != 0 {
t.Fatalf("Home: %v", v)
}
}
func TestKnobDragAndDisabled(t *testing.T) {
v := 50.0
opts := KnobOptions{Step: 1, Label: "Volume"}
n := 0
tt := knobTester(&v, &opts, &n)
// The dial's center: padding 40, caption and gap above it.
x, y := float32(68), float32(40+21+30)
tt.Press(x, y)
tt.Move(x, y-32) // a fifth of 160 DIPs: 20 of 100
tt.Release(x, y-32)
if v != 70 {
t.Fatalf("drag up 32 DIPs: %v", v)
}
opts.Disabled = true
tt.Frame()
tt.Press(x, y)
tt.Move(x, y-32)
tt.Release(x, y-32)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyUp)
if v != 70 {
t.Fatalf("disabled knob moved: %v", v)
}
}
func TestKnobRejects(t *testing.T) {
v := 0.0
for name, o := range map[string]KnobOptions{"ticks": {Ticks: -1}, "size": {Size: -1}, "range": {Min: 5, Max: 1}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); Knob(c, &v, o) }, 200, 200)
}()
}
}
func TestKnobSnapshot(t *testing.T) {
a, b, d := 35.0, 0.8, 10.0
snapshot(t, "knob", 320, 130, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(24).Children(func() {
Knob(c, &a, KnobOptions{Label: "Gain", Ticks: 11})
Knob(c, &b, KnobOptions{Max: 1, Step: 0.1, Label: "Mix", Size: 72})
Knob(c, &d, KnobOptions{Label: "Off", Disabled: true})
})
})
}
+210
View File
@@ -0,0 +1,210 @@
package input
import (
"log/slog"
"strings"
"unicode"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MaskedInputOptions configure a MaskedInput.
type MaskedInputOptions struct {
// Mask is required: 9 takes a digit, a a letter, * either; anything else shows as is.
Mask string
Label string
Disabled bool
// Error marks the value invalid and shows the message below.
Error string
}
// maskedInputState is the caret over the typed characters and any input method composition.
type maskedInputState struct {
caret int
compose string
changed, submitted bool
}
// maskedInputFits reports whether r may fill the mask slot m.
func maskedInputFits(m, r rune) bool {
switch m {
case '9':
return unicode.IsDigit(r)
case 'a':
return unicode.IsLetter(r)
case '*':
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
return false
}
func maskedInputSlot(m rune) bool { return m == '9' || m == 'a' || m == '*' }
// MaskFormat lays raw into mask; unfilled slots show as underscores.
func MaskFormat(mask, raw string) string {
rs := []rune(raw)
var b strings.Builder
i := 0
for _, m := range mask {
switch {
case !maskedInputSlot(m):
b.WriteRune(m)
case i < len(rs):
b.WriteRune(rs[i])
i++
default:
b.WriteRune('_')
}
}
return b.String()
}
// maskedInputAt is the mask index of slot n, or the mask's length past the last.
func maskedInputAt(mask []rune, n int) int {
for i, m := range mask {
if maskedInputSlot(m) {
if n == 0 {
return i
}
n--
}
}
return len(mask)
}
// MaskedInput edits *value, the characters typed into Mask's slots; an input method's composition commits only when done.
func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputResult {
if value == nil {
panic("mujica: MaskedInput needs a value")
}
mask := []rune(opts.Mask)
var slots []rune
for _, m := range mask {
if maskedInputSlot(m) {
slots = append(slots, m)
}
}
if len(slots) == 0 {
panic("mujica: MaskedInput Mask has no slots")
}
raw := []rune(*value)
if len(raw) > len(slots) {
panic("mujica: MaskedInput value is longer than its Mask")
}
for i, r := range raw {
if !maskedInputFits(slots[i], r) {
panic("mujica: MaskedInput value does not fit its Mask")
}
}
k := core.Tokens(c)
var r InputResult
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
in := ui.Row(c).Grow(1).Focusable().Role(ui.RoleTextField).Label(opts.Label).Description(MaskFormat(opts.Mask, *value))
st := ui.Local(in, "masked_input", func() *maskedInputState { return &maskedInputState{caret: len(raw)} })
s := *st
s.caret = min(s.caret, len(raw))
insert := func(text string) {
for _, ch := range text {
if s.caret < len(slots) && len(raw) < len(slots) && maskedInputFits(slots[s.caret], ch) {
raw = append(raw[:s.caret], append([]rune{ch}, raw[s.caret:]...)...)
s.caret++
s.changed = true
}
}
}
in.HandleInput(func(ev ui.InputEvent) bool {
if in.IsDisabled() {
return false
}
raw = []rune(*value)
switch ev.Kind {
case ui.InputText:
s.compose = ""
insert(ev.Text)
case ui.InputCompose:
s.compose = ev.Text
case ui.InputCommand:
switch ev.Text {
case "paste":
insert(c.ReadClipboard())
case "copy":
c.WriteClipboard(*value)
default:
return false
}
case ui.InputKeyDown:
if s.compose != "" {
return false // the input method owns the keys while composing
}
switch ev.Key {
case ui.KeyBackspace:
if s.caret > 0 {
raw = append(raw[:s.caret-1], raw[s.caret:]...)
s.caret--
s.changed = true
}
case ui.KeyDelete:
if s.caret < len(raw) {
raw = append(raw[:s.caret], raw[s.caret+1:]...)
s.changed = true
}
case ui.KeyLeft:
s.caret = max(0, s.caret-1)
case ui.KeyRight:
s.caret = min(len(raw), s.caret+1)
case ui.KeyHome:
s.caret = 0
case ui.KeyEnd:
s.caret = len(raw)
case ui.KeyEnter:
s.submitted = true
default:
return false
}
default:
return false
}
*value = string(raw)
return true
})
if s.changed {
r.changed, s.changed = true, false
}
if s.submitted {
r.submitted, s.submitted = true, false
}
shown := []rune(MaskFormat(opts.Mask, *value))
at := maskedInputAt(mask, s.caret)
size := core.FontSize(c, theme.BodySize)
w, h := c.MeasureText(0, ui.Span{Text: string(shown[:at]), Size: size})
if h == 0 {
_, h = c.MeasureText(0, ui.Span{Text: "0", Size: size})
}
in.MinHeight(h).TextCaret(ui.Rect{X: w, Y: 0, W: 1, H: h}).Cursor(ui.CursorText)
filled := maskedInputAt(mask, len([]rune(*value)))
in.Children(func() {
ui.Text(c, string(shown[:at])).TextColor(k.Text).FontSize(size).SingleLine()
if s.compose != "" {
ui.Text(c, s.compose).TextColor(k.Text).FontSize(size).Underline().SingleLine()
}
if at < filled {
ui.Text(c, string(shown[at:filled])).TextColor(k.Text).FontSize(size).SingleLine()
}
ui.Text(c, string(shown[max(at, filled):])).TextColor(k.TextMuted).FontSize(size).SingleLine()
})
in.DrawOver(func(p *ui.Painter, rr ui.Rect) {
if in.Focused() && !in.IsDisabled() && s.compose == "" {
p.Fill(ui.Rect{X: rr.X + w, Y: rr.Y + (rr.H-h)/2, W: 1, H: h}, k.Text, 0)
}
})
return in
}, nil)
if r.changed {
slog.Debug("mujica: masked_input change", "filled", len([]rune(*value)), "slots", len(slots))
}
return r
}
+127
View File
@@ -0,0 +1,127 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestMaskFormat(t *testing.T) {
for _, tc := range [][3]string{
{"(999) 999-9999", "555123", "(555) 123-____"},
{"9999 9999 9999 9999", "", "____ ____ ____ ____"},
{"aa-99", "AB12", "AB-12"},
} {
if got := MaskFormat(tc[0], tc[1]); got != tc[2] {
t.Errorf("MaskFormat(%q, %q) = %q, want %q", tc[0], tc[1], got, tc[2])
}
}
}
func maskedTester(v *string, dis *bool, n, submits *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := MaskedInput(c, v, MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone", Disabled: *dis})
if r.Changed() {
*n++
}
if r.Submitted() {
*submits++
}
}, 400, 120)
}
func TestMaskedInputTypingEditingAndPaste(t *testing.T) {
v := ""
dis := false
n, submits := 0, 0
tt := maskedTester(&v, &dis, &n, &submits)
tt.Key(0, ui.KeyTab)
tt.Type("55x5-12")
if v != "55512" {
t.Fatalf("typed: %q", v)
}
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
tt.Type("9")
if v != "55951" {
t.Fatalf("insert at caret: %q", v)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyDelete)
if v != "5951" {
t.Fatalf("Delete at start: %q", v)
}
tt.Key(0, ui.KeyEnd)
tt.SetClipboard("123-456-789")
tt.Command("paste")
if v != "5951123456" {
t.Fatalf("paste fills to the end: %q", v)
}
tt.Type("7")
if v != "5951123456" {
t.Fatalf("typed past the mask: %q", v)
}
tt.Key(0, ui.KeyEnter)
if submits != 1 || n == 0 {
t.Fatalf("submits %d changes %d", submits, n)
}
if _, ok := tt.Find("Phone"); !ok {
t.Fatal("no field name")
}
}
func TestMaskedInputCompositionAndDisabled(t *testing.T) {
v := "12"
dis := false
n, submits := 0, 0
tt := maskedTester(&v, &dis, &n, &submits)
tt.Key(0, ui.KeyTab)
tt.Compose("3", 1)
if v != "12" || !tt.HasText("3") {
t.Fatalf("composition committed early: %q", v)
}
tt.Key(0, ui.KeyBackspace) // the input method's, while composing
if v != "12" {
t.Fatalf("Backspace during composition edited: %q", v)
}
tt.Type("3")
if v != "123" {
t.Fatalf("commit: %q", v)
}
dis = true
tt.Frame()
tt.Type("4")
tt.Key(0, ui.KeyBackspace)
if v != "123" {
t.Fatalf("disabled edit: %q", v)
}
}
func TestMaskedInputRejects(t *testing.T) {
for name, tc := range map[string][2]string{"no slots": {"---", ""}, "too long": {"99", "123"}, "wrong kind": {"99", "1a"}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
v := tc[1]
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); MaskedInput(c, &v, MaskedInputOptions{Mask: tc[0]}) }, 200, 100)
}()
}
}
func TestMaskedInputSnapshot(t *testing.T) {
a, b, d := "5551234", "", "4111111111111111"
snapshot(t, "masked_input", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).Children(func() {
MaskedInput(c, &a, MaskedInputOptions{Mask: "(999) 999-9999"}).Element.Width(160)
MaskedInput(c, &b, MaskedInputOptions{Mask: "99/99/9999"}).Element.Width(130)
MaskedInput(c, &d, MaskedInputOptions{Mask: "9999 9999 9999 9999", Disabled: true}).Element.Width(190)
})
})
}
+141
View File
@@ -0,0 +1,141 @@
package input
import (
"cmp"
"log/slog"
"math"
"sort"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// MasonryOptions configure a Masonry.
type MasonryOptions struct {
// Key is required: a stable, unique key per item.
Key func(i int) string
// ColumnWidth is the narrowest column; zero is 220. Columns fill the width.
ColumnWidth float32
// Gap is between items; zero is 3 spacing units.
Gap float32
// Estimate is the height assumed until an item is measured; zero is 160.
Estimate float32
}
// masonryState is the scroll, the measured heights and the cached placement.
type masonryState struct {
scroll ui.ScrollState
heights map[string]float32
version int
laidOut masonryLayout
// built are the items built last frame; their keys are checked for changed data.
built []int
// sized: a frame was asked for once the box had a width.
sized bool
}
// masonryLayout places n items in cols columns; byCol lists each column's items top to bottom.
type masonryLayout struct {
n, cols, version int
colW, gap float32
keys []string
x, y, h []float32
byCol [][]int
total float32
}
// Masonry lays n items in as many columns as fit, each into the shortest column, building only those in view.
func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element {
if opts.Key == nil || item == nil {
panic("mujica: Masonry needs Key and item")
}
if n < 0 || opts.ColumnWidth < 0 || opts.Gap < 0 || opts.Estimate < 0 {
panic("mujica: Masonry count and sizes must not be negative")
}
u := core.Density(c).Unit()
colMin := cmp.Or(opts.ColumnWidth, 220)
gap := cmp.Or(opts.Gap, 3*u)
est := cmp.Or(opts.Estimate, 160)
sc := ui.Scroll(c)
st := ui.Local(sc, "masonry", func() *masonryState { return &masonryState{heights: map[string]float32{}} })
s := *st
sc.TrackScroll(&s.scroll)
view := sc.Bounds()
if view.W == 0 && !s.sized {
// Unmeasured: lay out again once the box has its width.
s.sized = true
c.Invalidate()
}
cols := max(1, int((view.W+gap)/(colMin+gap)))
colW := (view.W - gap*float32(cols-1)) / float32(cols)
l := &s.laidOut
stale := l.n != n || l.cols != cols || l.colW != colW || l.gap != gap || l.version != s.version
for _, i := range s.built {
stale = stale || i >= n || l.keys[i] != opts.Key(i)
}
if stale {
*l = masonryPlace(n, cols, colW, gap, est, s.heights, opts.Key)
l.version = s.version
slog.Debug("mujica: masonry layout", "items", n, "columns", cols)
}
top, bottom := s.scroll.Y-view.H/2, s.scroll.Y+view.H*1.5
var seen []int
sc.Children(func() {
ui.Box(c).FillWidth().Height(l.total).Children(func() { masonryBuild(c, s, l, colW, top, bottom, item, &seen) })
})
s.built = seen
return sc
}
// masonryBuild builds the items between top and bottom and records measured heights.
func masonryBuild(c *ui.Context, s *masonryState, l *masonryLayout, colW, top, bottom float32, item func(int), seen *[]int) {
for _, list := range l.byCol {
from := sort.Search(len(list), func(j int) bool { i := list[j]; return l.y[i]+l.h[i] >= top })
for _, i := range list[from:] {
if l.y[i] > bottom {
break
}
*seen = append(*seen, i)
key := l.keys[i]
box := ui.Column(c).Key(key).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW)
box.Children(func() { item(i) })
if h := box.Bounds().H; h > 0 && math.Abs(float64(h-l.h[i])) > 0.5 {
s.heights[key] = h
s.version++
c.Invalidate()
}
}
}
}
// masonryPlace puts each item into the shortest column, using measured heights or est.
func masonryPlace(n, cols int, colW, gap, est float32, heights map[string]float32, key func(int) string) masonryLayout {
l := masonryLayout{n: n, cols: cols, colW: colW, gap: gap, keys: make([]string, n), x: make([]float32, n), y: make([]float32, n), h: make([]float32, n), byCol: make([][]int, cols)}
colH := make([]float32, cols)
seen := make(map[string]bool, n)
for i := range n {
k := key(i)
if seen[k] {
panic("mujica: Masonry key repeated: " + k)
}
seen[k] = true
col := 0
for j := range cols {
if colH[j] < colH[col] {
col = j
}
}
h, ok := heights[k]
if !ok {
h = est
}
l.keys[i], l.x[i], l.y[i], l.h[i] = k, float32(col)*(colW+gap), colH[col], h
l.byCol[col] = append(l.byCol[col], i)
colH[col] += h + gap
}
for _, h := range colH {
l.total = max(l.total, h-gap)
}
return l
}
+80
View File
@@ -0,0 +1,80 @@
package input
import (
"strconv"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestMasonryPlaceShortestColumn(t *testing.T) {
hs := map[string]float32{"0": 100, "1": 40, "2": 40, "3": 50}
l := masonryPlace(4, 2, 100, 10, 160, hs, strconv.Itoa)
// 0 left; 1 right; 2 right again (50 < 110); 3 right (100 = 110? no: left 110, right 100).
want := [][2]float32{{0, 0}, {110, 0}, {110, 50}, {110, 100}}
for i, w := range want {
if l.x[i] != w[0] || l.y[i] != w[1] {
t.Errorf("item %d at %v,%v, want %v", i, l.x[i], l.y[i], w)
}
}
if l.total != 150 {
t.Errorf("total %v", l.total)
}
defer func() {
if recover() == nil {
t.Error("repeated key accepted")
}
}()
masonryPlace(2, 1, 100, 0, 10, nil, func(int) string { return "same" })
}
func TestMasonryVirtualizesAndMeasures(t *testing.T) {
built := map[int]bool{}
n := 100000
width := 700
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Masonry(c, n, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 200, Gap: 10}, func(i int) {
built[i] = true
ui.Box(c).Height(float32(40 + i%3*30)).Label("tile " + strconv.Itoa(i))
}).Size(float32(width), 500)
}, 800, 600)
tt.Frame()
clear(built)
tt.Frame()
if len(built) == 0 || len(built) > 120 {
t.Fatalf("built %d of %d", len(built), n)
}
if !built[0] || !built[2] || built[5000] {
t.Fatalf("wrong items built: %v", built[5000])
}
a, _ := tt.Find("tile 0")
b, _ := tt.Find("tile 1")
d, _ := tt.Find("tile 3")
if a.W < 226 || a.W > 227 || b.X < 236 || b.X > 237 {
t.Fatalf("three columns in 700: tile 0 %v, tile 1 %v", a, b)
}
// Measured: tile 0 is 40 high, so tile 3 goes under it at 40 + 10.
if d.X != 0 || d.Y != 50 {
t.Fatalf("tile 3 at %v; texts %q", d, tt.Texts())
}
tt.Scroll(100, 100, 0, 600000)
clear(built)
tt.Frame()
if len(built) == 0 || len(built) > 120 || built[0] {
t.Fatalf("after scrolling built %d, includes first %v", len(built), built[0])
}
}
func TestMasonrySnapshot(t *testing.T) {
snapshot(t, "masonry", 520, 300, func(c *ui.Context) {
k := core.Tokens(c)
Masonry(c, 9, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 150}, func(i int) {
ui.Column(c).Height(float32(50+i%4*25)).Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).Children(func() {
ui.Text(c, "Card "+strconv.Itoa(i+1))
})
}).Size(500, 280).Margin(10)
})
}
+336
View File
@@ -0,0 +1,336 @@
package input
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MentionCandidate is someone a MentionInput may mention.
type MentionCandidate struct {
ID, Name string
}
// MentionInputOptions configure a MentionInput.
type MentionInputOptions struct {
// Candidates is required: it returns matches for the text after "@", called when that text changes.
Candidates func(query string) []MentionCandidate
Placeholder string
Label string
Disabled bool
// Error marks the value invalid and shows the message below.
Error string
}
// mentionInputState is one field's document, history and popup.
type mentionInputState struct {
doc mentionDoc
synced string // the value as last read or written
syncedMentions []Mention
undo, redo []mentionDoc
compose string
query string
at, pick int
found []MentionCandidate
dismissed bool
panel ui.Rect // the popup's box last frame, which keeps it through a click
offset float32
changed, submitted bool
}
// MentionInput edits *value with "@" mentions kept in *mentions; a mention is deleted and stepped over whole.
func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts MentionInputOptions) InputResult {
if value == nil || mentions == nil || opts.Candidates == nil {
panic("mujica: MentionInput needs a value, mentions and Candidates")
}
text := []rune(*value)
for _, m := range *mentions {
if m.Start < 0 || m.End > len(text) || m.Start >= m.End || string(text[m.Start:m.End]) != "@"+m.Name {
panic(fmt.Sprintf("mujica: MentionInput mention %+v does not match its text", m))
}
}
k := core.Tokens(c)
var r InputResult
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
in := ui.Box(c).Grow(1).MinWidth(core.Density(c).Unit() * 20).Focusable().Clip().Role(ui.RoleTextField).Label(opts.Label).Cursor(ui.CursorText)
s := *ui.Local(in, "mention_input", func() *mentionInputState { return &mentionInputState{at: -1} })
if s.doc.text == nil || s.synced != *value || !slices.Equal(s.syncedMentions, *mentions) {
// Set from outside: start over at the end.
s.doc = mentionDoc{text: text, mentions: slices.Clone(*mentions), caret: len(text), anchor: len(text)}
s.undo, s.redo = nil, nil
s.synced, s.syncedMentions = *value, slices.Clone(*mentions)
}
mentionInputQuery(s, opts)
px, py, _ := frame.PointerPosition()
fb := frame.Bounds()
onPanel := s.panel.Contains(fb.X+px, fb.Y+py)
popup := (in.Focused() || onPanel) && s.at >= 0 && !s.dismissed && len(s.found) > 0
in.HandleInput(func(ev ui.InputEvent) bool {
if in.IsDisabled() {
return false
}
return mentionInputEvent(c, s, ev, popup, in)
})
if s.changed && !look.disabled {
*value, *mentions = string(s.doc.text), slices.Clone(s.doc.mentions)
s.synced, s.syncedMentions = *value, slices.Clone(*mentions)
r.changed = true
}
r.submitted = s.submitted && !look.disabled
s.changed, s.submitted = false, false
mentionInputView(c, k, s, in, opts.Placeholder)
s.panel = ui.Rect{}
if popup {
mentionInputPopup(c, k, s, frame, in)
}
return in
}, nil)
if r.changed {
slog.Debug("mujica: mention_input change", "mentions", len(*mentions))
}
return r
}
// mentionInputQuery refreshes the candidates when the "@" text changes.
func mentionInputQuery(s *mentionInputState, opts MentionInputOptions) {
at, q := s.doc.query()
if at != s.at || q != s.query {
s.at, s.query, s.pick, s.dismissed = at, q, 0, false
s.found = nil
if at >= 0 {
s.found = opts.Candidates(q)
}
}
}
// mentionInputEvent applies one input event to the document and reports whether it took it.
func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, popup bool, in *ui.Element) bool {
edit := func(f func()) {
s.undo = append(s.undo, s.doc.clone())
s.redo = nil
f()
s.changed = true
}
extend := ev.Mods&ui.Shift != 0
switch ev.Kind {
case ui.InputText:
s.compose = ""
edit(func() { s.doc.insert(ev.Text) })
case ui.InputCompose:
s.compose = ev.Text
case ui.InputPointerDown:
in.Focus()
s.doc.moveTo(mentionInputHit(c, s, ev.X), extend)
case ui.InputPointerMove:
if ev.Button != 0 {
return false
}
s.doc.moveTo(mentionInputHit(c, s, ev.X), true)
case ui.InputCommand:
a, b := s.doc.span()
switch ev.Text {
case "copy", "cut":
c.WriteClipboard(string(s.doc.text[a:b]))
if ev.Text == "cut" && a != b {
edit(func() { s.doc.erase(false) })
}
case "paste":
edit(func() { s.doc.insert(c.ReadClipboard()) })
case "selectAll":
s.doc.anchor, s.doc.caret = 0, len(s.doc.text)
case "delete":
edit(func() { s.doc.erase(true) })
case "undo", "redo":
from, to := &s.undo, &s.redo
if ev.Text == "redo" {
from, to = to, from
}
if n := len(*from); n > 0 {
*to = append(*to, s.doc.clone())
s.doc, *from = (*from)[n-1], (*from)[:n-1]
s.changed = true
}
default:
return false
}
case ui.InputKeyDown:
return s.compose == "" && mentionInputKey(s, ev.Key, extend, popup, edit)
default:
return false
}
return true
}
// mentionInputKey handles editing and popup keys; others go on to Tab and shortcuts.
func mentionInputKey(s *mentionInputState, key ui.Key, extend, popup bool, edit func(func())) bool {
if popup {
switch key {
case ui.KeyDown, ui.KeyUp:
n := len(s.found)
if key == ui.KeyDown {
s.pick = (s.pick + 1) % n
} else {
s.pick = (s.pick + n - 1) % n
}
return true
case ui.KeyEnter, ui.KeyTab:
m := s.found[s.pick]
edit(func() { s.doc.choose(s.at, m.ID, m.Name) })
slog.Debug("mujica: mention_input mention", "id", m.ID)
return true
case ui.KeyEscape:
s.dismissed = true
return true
}
}
switch key {
case ui.KeyBackspace, ui.KeyDelete:
if s.doc.caret == s.doc.anchor && (key == ui.KeyBackspace && s.doc.caret == 0 || key == ui.KeyDelete && s.doc.caret == len(s.doc.text)) {
return true
}
edit(func() { s.doc.erase(key == ui.KeyDelete) })
case ui.KeyLeft:
s.doc.move(-1, extend)
case ui.KeyRight:
s.doc.move(1, extend)
case ui.KeyHome:
s.doc.moveTo(0, extend)
case ui.KeyEnd:
s.doc.moveTo(len(s.doc.text), extend)
case ui.KeyEnter:
s.submitted = true
default:
return false
}
return true
}
// mentionInputHit is the rune boundary nearest x in the field, outside any mention.
func mentionInputHit(c *ui.Context, s *mentionInputState, x float32) int {
x += s.offset
best, bestD := 0, float32(-1)
for i := 0; i <= len(s.doc.text); i++ {
w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: core.FontSize(c, theme.BodySize)})
if d := max(w-x, x-w); bestD < 0 || d < bestD {
best, bestD = i, d
}
}
for _, m := range s.doc.mentions {
if best > m.Start && best < m.End {
best = m.End
}
}
return best
}
// mentionInputView draws the text with mention chips, the selection and the caret, scrolled to keep the caret in view.
func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in *ui.Element, placeholder string) {
size := core.FontSize(c, theme.BodySize)
measure := func(i int) float32 {
w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: size})
return w
}
_, h := c.MeasureText(0, ui.Span{Text: "@", Size: size})
x := measure(s.doc.caret)
if box := in.Bounds().W; box > 0 {
s.offset = max(0, min(s.offset, x), x-box+2)
}
in.MinHeight(h).TextCaret(ui.Rect{X: x - s.offset, Y: 0, W: 1, H: h})
a, b := min(s.doc.caret, s.doc.anchor), max(s.doc.caret, s.doc.anchor)
sel := [2]float32{measure(a), measure(b)}
chips := make([][2]float32, len(s.doc.mentions))
for i, m := range s.doc.mentions {
chips[i] = [2]float32{measure(m.Start), measure(m.End)}
}
spans := mentionInputSpans(k, s, size, placeholder)
in.Draw(func(p *ui.Painter, r ui.Rect) {
y := r.Y + (r.H-h)/2
for _, ch := range chips {
p.Fill(ui.Rect{X: r.X + ch[0] - s.offset - 1, Y: y, W: ch[1] - ch[0] + 2, H: h}, k.Selection, theme.ControlRadius)
}
if a != b && in.Focused() {
p.Fill(ui.Rect{X: r.X + sel[0] - s.offset, Y: y, W: sel[1] - sel[0], H: h}, k.Selection.Mix(k.AccentText, 0.25), 0)
}
p.RichText(r.X-s.offset, y, 0, spans...)
})
in.DrawOver(func(p *ui.Painter, r ui.Rect) {
if in.Focused() && s.compose == "" {
p.Fill(ui.Rect{X: r.X + x - s.offset, Y: r.Y + (r.H-h)/2, W: 1, H: h}, k.Text, 0)
}
})
}
// mentionInputSpans is the field's text: mentions in AccentText, a composition underlined.
func mentionInputSpans(k theme.Tokens, s *mentionInputState, size float32, placeholder string) []ui.Span {
if len(s.doc.text) == 0 && s.compose == "" {
return []ui.Span{{Text: placeholder, Size: size, Color: k.TextMuted}}
}
var spans []ui.Span
at := 0
seg := func(to int, fg ui.Color) {
if to > at {
spans = append(spans, ui.Span{Text: string(s.doc.text[at:to]), Size: size, Color: fg})
at = to
}
}
composed := s.compose == ""
for _, m := range s.doc.mentions {
if !composed && s.doc.caret <= m.Start {
seg(s.doc.caret, k.Text)
spans = append(spans, ui.Span{Text: s.compose, Size: size, Color: k.Text, Underline: true})
composed = true
}
seg(m.Start, k.Text)
seg(m.End, k.AccentText)
}
if !composed {
seg(s.doc.caret, k.Text)
spans = append(spans, ui.Span{Text: s.compose, Size: size, Color: k.Text, Underline: true})
}
seg(len(s.doc.text), k.Text)
return spans
}
// mentionInputPopup lists the candidates under the field; a click or Enter picks one.
func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in *ui.Element) {
b := frame.Bounds()
u := core.Density(c).Unit()
ui.Overlay(c, func() {
ww, wh := c.Size()
h := float32(len(s.found)) * core.Density(c).ControlHeight()
x, y := core.PlaceOverlay(c, b, min(b.W, 320), min(h+2*u, wh/2), 4)
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(min(max(b.W, 200), ww-8)).MaxHeight(wh / 2).Padding(u).Role(ui.RoleList)
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
s.panel = panel.Bounds()
panel.Children(func() {
ui.Scroll(c).Shrink(1).Children(func() {
for i, m := range s.found {
it := ui.Row(c).Key(m.ID).MinHeight(core.Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2).
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer)
if i == s.pick {
it.Selected(true).Background(k.Selection).TextColor(k.AccentText)
it.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 6, W: 2, H: r.H - 12}, k.AccentText, 0)
})
} else if it.Hovered() {
it.Background(k.SurfaceHover)
}
it.Children(func() { ui.Text(c, "@"+m.Name).SingleLine() })
if it.Clicked() {
s.undo = append(s.undo, s.doc.clone())
s.doc.choose(s.at, m.ID, m.Name)
s.changed = true
in.Focus()
c.Invalidate()
}
}
})
})
})
}
+142
View File
@@ -0,0 +1,142 @@
package input
import (
"slices"
"unicode"
)
// Mention is a whole "@Name" token in a MentionInput's text, at rune offsets [Start, End).
type Mention struct {
ID string
Name string
Start, End int
}
// mentionDoc is a MentionInput's text, mentions, caret and selection anchor, in runes.
type mentionDoc struct {
text []rune
mentions []Mention
caret, anchor int
}
func (d mentionDoc) clone() mentionDoc {
d.text = slices.Clone(d.text)
d.mentions = slices.Clone(d.mentions)
return d
}
// span returns the selection, widened to whole mentions it touches.
func (d *mentionDoc) span() (int, int) {
a, b := min(d.caret, d.anchor), max(d.caret, d.anchor)
for _, m := range d.mentions {
if m.Start < b && m.End > a {
a, b = min(a, m.Start), max(b, m.End)
}
}
return a, b
}
// replace puts ins in place of [a, b), dropping mentions inside and shifting later ones; the caret lands after ins.
func (d *mentionDoc) replace(a, b int, ins []rune) {
d.text = slices.Concat(d.text[:a], ins, d.text[b:])
shift := len(ins) - (b - a)
kept := d.mentions[:0]
for _, m := range d.mentions {
switch {
case m.End <= a:
kept = append(kept, m)
case m.Start >= b:
m.Start, m.End = m.Start+shift, m.End+shift
kept = append(kept, m)
}
}
d.mentions = kept
d.caret = a + len(ins)
d.anchor = d.caret
}
// insert types s at the caret, over any selection.
func (d *mentionDoc) insert(s string) {
a, b := d.span()
d.replace(a, b, []rune(s))
}
// erase deletes the selection, else one rune or one whole mention back (or forward).
func (d *mentionDoc) erase(forward bool) bool {
if d.caret != d.anchor {
a, b := d.span()
d.replace(a, b, nil)
return true
}
for _, m := range d.mentions {
if !forward && m.End == d.caret || forward && m.Start == d.caret {
d.replace(m.Start, m.End, nil)
return true
}
}
switch {
case !forward && d.caret > 0:
d.replace(d.caret-1, d.caret, nil)
case forward && d.caret < len(d.text):
d.replace(d.caret, d.caret+1, nil)
default:
return false
}
return true
}
// move steps the caret by dir runes, over whole mentions; extend keeps the anchor.
func (d *mentionDoc) move(dir int, extend bool) {
at := max(0, min(len(d.text), d.caret+dir))
for _, m := range d.mentions {
if at > m.Start && at < m.End {
if dir < 0 {
at = m.Start
} else {
at = m.End
}
}
}
d.moveTo(at, extend)
}
func (d *mentionDoc) moveTo(at int, extend bool) {
d.caret = at
if !extend {
d.anchor = at
}
}
// query returns where an "@" being typed starts and what follows it, or -1.
func (d *mentionDoc) query() (int, string) {
if d.caret != d.anchor {
return -1, ""
}
for i := d.caret - 1; i >= 0; i-- {
r := d.text[i]
if unicode.IsSpace(r) {
return -1, ""
}
if r != '@' {
continue
}
for _, m := range d.mentions {
if i >= m.Start && i < m.End {
return -1, ""
}
}
if i > 0 && !unicode.IsSpace(d.text[i-1]) {
return -1, ""
}
return i, string(d.text[i+1 : d.caret])
}
return -1, ""
}
// choose turns the "@query" before the caret into a mention of id, followed by a space.
func (d *mentionDoc) choose(at int, id, name string) {
tok := []rune("@" + name)
d.replace(at, d.caret, append(slices.Clone(tok), ' '))
d.mentions = append(d.mentions, Mention{ID: id, Name: name, Start: at, End: at + len(tok)})
slices.SortFunc(d.mentions, func(a, b Mention) int { return a.Start - b.Start })
}
+151
View File
@@ -0,0 +1,151 @@
package input
import (
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var mentionPeople = []MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Albert"}, {ID: "u3", Name: "Bob"}}
func mentionFind(q string) []MentionCandidate {
var out []MentionCandidate
for _, p := range mentionPeople {
if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) {
out = append(out, p)
}
}
return out
}
func TestMentionDocWholeTokens(t *testing.T) {
d := mentionDoc{text: []rune("hi @al"), caret: 6, anchor: 6}
at, q := d.query()
if at != 3 || q != "al" {
t.Fatalf("query %d %q", at, q)
}
d.choose(at, "u1", "Alice")
d.insert("there")
if string(d.text) != "hi @Alice there" || d.mentions[0] != (Mention{ID: "u1", Name: "Alice", Start: 3, End: 9}) {
t.Fatalf("after choose: %q %+v", string(d.text), d.mentions)
}
d.moveTo(10, false)
d.move(-1, false) // to the mention's end
d.move(-1, false) // over the whole mention
if d.caret != 3 {
t.Fatalf("Left over a mention lands at %d", d.caret)
}
d.moveTo(9, false)
d.erase(false)
if string(d.text) != "hi there" || len(d.mentions) != 0 || d.caret != 3 {
t.Fatalf("Backspace after a mention: %q %+v %d", string(d.text), d.mentions, d.caret)
}
if at, _ := (&mentionDoc{text: []rune("a@b"), caret: 3, anchor: 3}).query(); at != -1 {
t.Fatal("an email-like @ started a query")
}
}
func mentionTester(v *string, ms *[]Mention, dis *bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(400).Children(func() {
if MentionInput(c, v, ms, MentionInputOptions{Candidates: mentionFind, Label: "Comment", Disabled: *dis}).Changed() {
*n++
}
})
}, 500, 400)
}
func TestMentionInputCandidatesKeyboard(t *testing.T) {
v, ms := "", []Mention(nil)
dis, n := false, 0
tt := mentionTester(&v, &ms, &dis, &n)
tt.Key(0, ui.KeyTab)
tt.Type("ping @al")
if !tt.HasText("@Alice") || !tt.HasText("@Albert") || tt.HasText("@Bob") {
t.Fatalf("candidates %q", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if v != "ping @Albert " || len(ms) != 1 || ms[0].ID != "u2" || ms[0].Start != 5 || ms[0].End != 12 {
t.Fatalf("chose: %q %+v", v, ms)
}
if tt.HasText("@Alice") {
t.Fatal("popup stayed open")
}
tt.Type("ok")
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace) // the space
tt.Key(0, ui.KeyBackspace) // the whole mention
if v != "ping " || len(ms) != 0 {
t.Fatalf("whole delete: %q %+v", v, ms)
}
tt.Command("undo")
if v != "ping @Albert" || len(ms) != 1 {
t.Fatalf("undo: %q %+v", v, ms)
}
tt.Type(" @b")
tt.Key(0, ui.KeyEscape)
if tt.HasText("@Bob") {
t.Fatal("Escape did not close the candidates")
}
}
func TestMentionInputClickCompositionDisabled(t *testing.T) {
v, ms := "", []Mention(nil)
dis, n := false, 0
tt := mentionTester(&v, &ms, &dis, &n)
tt.Key(0, ui.KeyTab)
tt.Type("@b")
if err := tt.Click("Bob"); err != nil {
t.Fatal(err)
}
if v != "@Bob " || !slices.Equal(ms, []Mention{{ID: "u3", Name: "Bob", Start: 0, End: 4}}) {
t.Fatalf("click: %q %+v", v, ms)
}
tt.Compose("你", 1)
if v != "@Bob " {
t.Fatalf("composition committed early: %q", v)
}
tt.Type("你好")
if v != "@Bob 你好" {
t.Fatalf("commit: %q", v)
}
before := n
dis = true
tt.Frame()
tt.Type("x")
tt.Key(0, ui.KeyBackspace)
if v != "@Bob 你好" || n != before {
t.Fatalf("disabled edit: %q", v)
}
}
func TestMentionInputRejectsBadMentions(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
v, ms := "hi Bob", []Mention{{ID: "u3", Name: "Bob", Start: 3, End: 6}}
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind})
}, 300, 100)
}
func TestMentionInputSnapshot(t *testing.T) {
v, ms := "thanks @Alice, see @Bob", []Mention{{ID: "u1", Name: "Alice", Start: 7, End: 13}, {ID: "u3", Name: "Bob", Start: 19, End: 23}}
e, none := "", []Mention(nil)
snapshot(t, "mention_input", 420, 120, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Children(func() {
MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind})
MentionInput(c, &e, &none, MentionInputOptions{Candidates: mentionFind, Placeholder: "Write a comment…"})
})
})
}
+287
View File
@@ -0,0 +1,287 @@
package input
import (
"fmt"
"runtime"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
)
type menubarApp struct {
wrap bool
zoom string
chosen []string
}
func (a *menubarApp) menus() []navigation.MenubarMenu {
return []navigation.MenubarMenu{
{Label: "File", Items: []navigation.MenubarItem{
{ID: "new", Label: "New", Shortcut: navigation.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyN}},
{ID: "open", Label: "Open…", Shortcut: navigation.MenubarShortcut{Mods: ui.Cmd, Key: ui.KeyO}},
{Label: "Export", Items: []navigation.MenubarItem{
{ID: "pdf", Label: "PDF"},
{ID: "png", Label: "Image", Disabled: true},
{ID: "svg", Label: "Vector"},
}},
{Kind: navigation.MenubarSeparator},
{ID: "quit", Label: "Quit"},
}},
{Label: "View", Items: []navigation.MenubarItem{
{ID: "wrap", Label: "Word wrap", Kind: navigation.MenubarCheck, Checked: a.wrap},
{Kind: navigation.MenubarSeparator},
{ID: "zoom-fit", Label: "Fit", Kind: navigation.MenubarRadio, Checked: a.zoom == "zoom-fit"},
{ID: "zoom-100", Label: "Actual size", Kind: navigation.MenubarRadio, Checked: a.zoom == "zoom-100"},
}},
{Label: "Help", Items: []navigation.MenubarItem{{ID: "about", Label: "About"}}},
}
}
func (a *menubarApp) view(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Fill().Children(func() {
if id, ok := navigation.Menubar(c, a.menus(), navigation.MenubarOptions{}).Chosen(); ok {
a.chosen = append(a.chosen, id)
switch id {
case "wrap":
a.wrap = !a.wrap
case "zoom-fit", "zoom-100":
a.zoom = id
}
}
})
}
func TestMenubarPointer(t *testing.T) {
a := &menubarApp{zoom: "zoom-fit"}
tt := ui.NewTester(a.view, 700, 500)
if err := tt.Click("View"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Word wrap"); err != nil {
t.Fatal(err)
}
if !a.wrap || tt.HasText("Actual size") {
t.Fatalf("check item: wrap %v, menu still open %v", a.wrap, tt.HasText("Actual size"))
}
_ = tt.Click("View")
if _, ok := tt.Find("Word wrap, checked"); !ok {
t.Fatal("checked state not named")
}
if err := tt.Click("Actual size"); err != nil {
t.Fatal(err)
}
if a.zoom != "zoom-100" {
t.Fatalf("radio chose %q", a.zoom)
}
_ = tt.Click("File")
r, _ := tt.Find("Export")
tt.Move(r.X+r.W/2, r.Y+r.H/2)
tt.Move(r.X+r.W/2+1, r.Y+r.H/2)
if !tt.HasText("Vector") {
t.Fatalf("hover did not open submenu: %q", tt.Texts())
}
_ = tt.Click("Image")
if err := tt.Click("Vector"); err != nil {
t.Fatal(err)
}
if got := a.chosen; len(got) != 3 || got[2] != "svg" {
t.Fatalf("chosen %q", got)
}
_ = tt.Click("Help")
tt.ClickAt(600, 400)
if tt.HasText("About") {
t.Fatal("click outside did not close")
}
}
func TestMenubarHoverSwitchesMenus(t *testing.T) {
a := &menubarApp{}
tt := ui.NewTester(a.view, 700, 500)
_ = tt.Click("File")
r, _ := tt.Find("Help")
tt.Move(r.X+2, r.Y+2)
if tt.HasText("Quit") || !tt.HasText("About") {
t.Fatalf("hover did not switch: %q", tt.Texts())
}
}
func TestMenubarKeyboard(t *testing.T) {
a := &menubarApp{}
tt := ui.NewTester(a.view, 700, 500)
tt.Key(0, ui.KeyTab)
if !tt.Focused("File") {
t.Fatal("Tab did not reach the bar")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyLeft)
if !tt.Focused("File") {
t.Fatal("arrows did not move between titles")
}
tt.Key(0, ui.KeyDown) // opens File on New
tt.Key(0, ui.KeyDown) // Open
tt.Key(0, ui.KeyDown) // Export
tt.Key(0, ui.KeyRight)
if !tt.HasText("Vector") {
t.Fatal("Right did not open the submenu")
}
tt.Key(0, ui.KeyDown) // skips disabled Image to Vector
tt.Key(0, ui.KeyEnter)
if len(a.chosen) != 1 || a.chosen[0] != "svg" {
t.Fatalf("chosen %q", a.chosen)
}
if tt.HasText("Quit") || !tt.Focused("File") {
t.Fatal("menu did not close back to its title")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyUp) // wraps past the separator to Quit
tt.Key(0, ui.KeyRight)
if !tt.HasText("Word wrap") {
t.Fatal("Right on a plain item did not move to the next menu")
}
tt.Key(0, ui.KeySpace)
if !a.wrap {
t.Fatal("Space did not toggle the check item")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEscape)
if tt.HasText("Word wrap") {
t.Fatal("Escape did not close")
}
}
func TestMenubarKeysAfterClickInMenu(t *testing.T) {
a := &menubarApp{}
tt := ui.NewTester(a.view, 700, 500)
_ = tt.Click("File")
if err := tt.Click("Export"); err != nil {
t.Fatal(err)
}
if !tt.Focused("File") {
t.Fatal("click into the menu left the title without focus")
}
tt.Key(0, ui.KeyDown) // PDF to Vector, skipping disabled Image
tt.Key(0, ui.KeyEnter)
if len(a.chosen) != 1 || a.chosen[0] != "svg" {
t.Fatalf("arrows after a click chose %q", a.chosen)
}
_ = tt.Click("File")
_ = tt.Click("Export")
_ = tt.Click("Image") // disabled: the press still keeps keys working
tt.Key(0, ui.KeyEscape)
if tt.HasText("Vector") || !tt.HasText("Quit") {
t.Fatalf("Escape did not close only the submenu: %q", tt.Texts())
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Quit") {
t.Fatal("second Escape did not close the menu")
}
}
func TestMenubarTabOutClosesMenu(t *testing.T) {
for _, back := range []bool{false, true} {
a := &menubarApp{}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Fill().Children(func() {
Button(c, "Before", ButtonOptions{})
navigation.Menubar(c, a.menus(), navigation.MenubarOptions{})
Button(c, "After", ButtonOptions{})
})
}, 700, 500)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.HasText("Quit") {
t.Fatal("menu did not open")
}
mods, want := ui.Modifiers(0), "After"
if back {
mods, want = ui.Shift, "Before"
}
tt.Key(mods, ui.KeyTab)
if tt.HasText("Quit") || !tt.Focused(want) {
t.Fatalf("shift=%v: menu open %v, focus on %s %v", back, tt.HasText("Quit"), want, tt.Focused(want))
}
tt.Key(0, ui.KeyEscape)
if !tt.Focused(want) {
t.Fatalf("shift=%v: Escape moved focus off %s", back, want)
}
}
}
func TestMenubarLongMenuScrolls(t *testing.T) {
var items []navigation.MenubarItem
for i := range 30 {
items = append(items, navigation.MenubarItem{ID: fmt.Sprint(i), Label: fmt.Sprintf("Entry %02d", i)})
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
navigation.Menubar(c, []navigation.MenubarMenu{{Label: "Long", Items: items}}, navigation.MenubarOptions{})
}, 400, 300)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnd)
tt.Frame()
r, ok := tt.Find("Entry 29")
if !ok || r.H == 0 || r.Y+r.H > 300 {
t.Fatalf("last item outside the window: %+v", r)
}
}
func TestMenubarAnnouncesHighlight(t *testing.T) {
a := &menubarApp{wrap: true}
tt := ui.NewTester(a.view, 700, 500)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown) // View opens on Word wrap
tt.Key(0, ui.KeyDown) // Fit
got := tt.Announcements()
if len(got) < 2 || got[len(got)-2] != "Word wrap, checked" || got[len(got)-1] != "Fit" {
t.Fatalf("announcements %q", got)
}
}
func TestMenubarShortcutText(t *testing.T) {
got := navigation.MenubarShortcutText(navigation.MenubarShortcut{Mods: ui.Ctrl | ui.Shift, Key: ui.KeyF5})
want := "Ctrl+Shift+F5"
if runtime.GOOS == "darwin" {
want = "⌃⇧F5"
}
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestMenubarInvalid(t *testing.T) {
for name, menus := range map[string][]navigation.MenubarMenu{
"empty": nil,
"title": {{Items: []navigation.MenubarItem{{ID: "a", Label: "A"}}}},
"id": {{Label: "M", Items: []navigation.MenubarItem{{Label: "A"}}}},
"submenu": {{Label: "M", Items: []navigation.MenubarItem{{Label: "A", Kind: navigation.MenubarCheck, Items: []navigation.MenubarItem{}}}}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
navigation.Menubar(c, menus, navigation.MenubarOptions{})
}, 300, 100, 1)
})
}
}
func TestMenubarSnapshot(t *testing.T) {
a := &menubarApp{wrap: true, zoom: "zoom-fit"}
snapshot(t, "menubar", 480, 100, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
navigation.Menubar(c, a.menus(), navigation.MenubarOptions{})
})
})
}
+246
View File
@@ -0,0 +1,246 @@
package input
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MultiSelectOptions configure a MultiSelect.
type MultiSelectOptions struct {
// Placeholder shows while nothing is chosen; empty uses "Select…".
Placeholder string
// Max limits how many values may be chosen; zero is no limit.
Max int
// MaxTags is how many chosen values show as tags before "+N"; zero is 3.
MaxTags int
Disabled bool
// Label names the field for assistive technology.
Label string
}
// multiSelectKey keeps this file's state apart from MyGo's own.
type multiSelectKey struct{ name string }
// MultiSelect chooses several *values from options in a popup with search and "Select all".
func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) core.ChoiceResult {
if opts.Max < 0 || opts.MaxTags < 0 {
panic("mujica: MultiSelect Max and MaxTags must not be negative")
}
k := core.Tokens(c)
before := slices.Clone(*values)
tr := ui.ButtonBase(c).Disabled(opts.Disabled).Role(ui.RolePopUpButton)
if opts.Label != "" {
tr.Label(opts.Label)
}
open := ui.Local(tr, multiSelectKey{"open"}, func() bool { return false })
if opts.Disabled {
*open = false
}
query := ui.Local(tr, multiSelectKey{"query"}, func() string { return "" })
if tr.Clicked() || (!*open && tr.Shortcut(0, ui.KeyDown) && !tr.IsDisabled()) {
*open = !*open
*query = ""
}
if n := len(*values); n > 0 && tr.Shortcut(0, ui.KeyBackspace) && !tr.IsDisabled() {
*values = slices.Clone((*values)[:n-1])
}
SelectField(c, tr, *open, func() {
MultiSelectTags(c, values, options, opts)
})
ui.PopoverBase(c, tr, open, func(panel *ui.Element) {
SelectPanel(c, panel)
panel.MinWidth(tr.Bounds().W).MaxWidth(max(tr.Bounds().W, 320))
multiSelectPopup(c, k, values, options, query, opts)
})
changed := !slices.Equal(before, *values)
if changed {
slog.Debug("mujica: multi select changed", "label", opts.Label, "count", len(*values))
}
return core.NewChoiceResult(tr, changed)
}
// MultiSelectTags shows the chosen values as removable tags, collapsing the rest into "+N".
func MultiSelectTags[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) {
k := core.Tokens(c)
if len(*values) == 0 {
ui.Text(c, selectOr(opts.Placeholder, core.Msg(c, "select.placeholder"))).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1)
return
}
limit := opts.MaxTags
if limit == 0 {
limit = 3
}
ui.Row(c).Gap(4).Grow(1).Shrink(1).ClipX().Children(func() {
for i, v := range *values {
if i == limit {
break
}
label, _ := SelectLabel(options, v)
ui.Row(c).Key(v).Shrink(0).Children(func() {
if multiSelectTag(c, label, opts.Disabled) {
*values = slices.Delete(slices.Clone(*values), i, i+1)
}
})
}
if n := len(*values) - limit; n > 0 {
ui.Text(c, fmt.Sprintf(core.Msg(c, "multi_select.more"), n)).TextColor(k.TextMuted).SingleLine().Shrink(0)
}
})
}
// multiSelectTag draws a chosen value with a remove cross outside the Tab order; it reports a removal.
func multiSelectTag(c *ui.Context, label string, disabled bool) bool {
k := core.Tokens(c)
removed := false
ui.Row(c).Gap(2).Padding(1, 2, 1, 6).Radius(theme.ControlRadius).Background(k.Selection).
Border(theme.BorderWidth, k.Border).Shrink(0).Children(func() {
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
if !disabled {
removed = selectClear(c, k, fmt.Sprintf(core.Msg(c, "multi_select.remove"), label))
}
})
return removed
}
func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T, options []SelectOption[T], query *string, opts MultiSelectOptions) {
d := core.Density(c)
var shown []SelectOption[T]
q := strings.ToLower(strings.TrimSpace(*query))
for _, o := range options {
if strings.Contains(strings.ToLower(o.Label), q) {
shown = append(shown, o)
}
}
full := opts.Max > 0 && len(*values) >= opts.Max
into := false
ui.Column(c).Gap(4).Children(func() {
search := SelectSearch(c, query, core.Msg(c, "multi_select.search"))
into = search.Shortcut(0, ui.KeyDown)
if len(shown) == 0 {
SelectNote(c, core.Msg(c, "multi_select.no_results"))
return
}
list := ui.Column(c).FocusGroup(ui.Vertical)
list.Children(func() {
all, some := true, false
for _, o := range shown {
on := slices.Contains(*values, o.Value)
all = all && (on || o.Disabled)
some = some || on
}
state := internal.Unchecked
if all && some {
state = internal.Checked
} else if some {
state = internal.Mixed
}
tick := state == internal.Checked
ui.Column(c).PaddingX(4).Children(func() {
b := MultiSelectCheck(c, k, &tick, state, core.Msg(c, "multi_select.all"), opts.Max > 0 && !some && full)
if into {
b.Focus()
}
if b.Changed() {
*values = MultiSelectAll(*values, shown, tick, opts.Max)
}
})
ui.Box(c).Height(1).Background(k.Border).MarginY(2)
ui.Scroll(c).MaxHeight(240).Padding(4).Children(func() {
for _, o := range shown {
on := slices.Contains(*values, o.Value)
st := internal.Unchecked
if on {
st = internal.Checked
}
row := ui.Row(c).Key(o.Value)
row.Children(func() {
cb := MultiSelectCheck(c, k, &on, st, o.Label, o.Disabled || (full && !on))
if cb.Changed() {
*values = MultiSelectSet(*values, o.Value, on)
}
})
}
})
})
if opts.Max > 0 {
ui.Text(c, fmt.Sprintf(core.Msg(c, "multi_select.limit"), len(*values), opts.Max)).
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(2, d.Unit()*2)
}
})
}
// MultiSelectCheck is a CheckboxBase row drawing its own box.
func MultiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element {
d := core.Density(c)
var cb *ui.Element
// A disabled wrapper blocks the base, which toggles as it is built.
ui.Row(c).Grow(1).Disabled(disabled).Children(func() {
cb = ui.CheckboxBase(c, on).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1)
})
if st == internal.Mixed {
// MyGo sets the mixed state only inside its own widgets (ui/element.go:282).
cb.Description(core.Msg(c, "multi_select.mixed"))
}
internal.Ring(cb, k.Focus, theme.ControlRadius)
hover := cb.Hovered()
if hover {
cb.Background(k.SurfaceHover)
}
cb.Children(func() {
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
internal.CheckBox(p, r, st, k, false)
})
ui.Text(c, label).SingleLine()
})
return cb
}
// MultiSelectSet adds or removes v.
func MultiSelectSet[T comparable](values []T, v T, on bool) []T {
if on {
return append(slices.Clone(values), v)
}
return slices.DeleteFunc(slices.Clone(values), func(x T) bool { return x == v })
}
// MultiSelectAll checks every enabled shown option up to max, or unchecks them all.
func MultiSelectAll[T comparable](values []T, shown []SelectOption[T], on bool, limit int) []T {
for _, o := range shown {
if o.Disabled || slices.Contains(values, o.Value) == on {
continue
}
if on && limit > 0 && len(values) >= limit {
break
}
values = MultiSelectSet(values, o.Value, on)
}
return values
}
// SelectSearch is the search field at the top of a popup; it takes the focus as the popup opens.
func SelectSearch(c *ui.Context, query *string, placeholder string) *ui.Element {
k := core.Tokens(c)
d := core.Density(c)
f := ui.Row(c).Gap(d.Unit()*2).Padding(0, d.Unit()*2).MinHeight(d.ControlHeight()-4).AlignItems(ui.Center).
Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.ControlBorder)
var in *ui.Element
f.Children(func() {
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
internal.Mark(p, r, "search", k.TextMuted)
})
in = ui.TextInputBase(c, query).Grow(1).Placeholder(placeholder).Label(placeholder).AutoFocus()
})
if in.Focused() {
f.Border(theme.BorderWidth, k.AccentText)
}
return in
}
+143
View File
@@ -0,0 +1,143 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var multiSelectTestOptions = []SelectOption[int]{
{Value: 1, Label: "Rose"},
{Value: 2, Label: "Lily"},
{Value: 3, Label: "Iris"},
{Value: 4, Label: "Violet", Disabled: true},
{Value: 5, Label: "Lilac"},
}
func multiSelectTester(values *[]int, opts MultiSelectOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Children(func() {
if MultiSelect(c, values, multiSelectTestOptions, opts).Changed() {
*changes++
}
})
}, 500, 500)
}
func TestMultiSelectCheckAndRemove(t *testing.T) {
var values []int
changes := 0
tt := multiSelectTester(&values, MultiSelectOptions{Label: "Flowers"}, &changes)
if err := tt.Click("Select…"); err != nil {
t.Fatal(err)
}
_ = tt.Click("Iris")
_ = tt.Click("Rose")
_ = tt.Click("Violet")
if !slices.Equal(values, []int{3, 1}) || changes != 2 {
t.Fatalf("values %v changes %d", values, changes)
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Lily") {
t.Fatal("Escape did not close")
}
if err := tt.Click("Remove Iris"); err != nil {
t.Fatal(err)
}
if !slices.Equal(values, []int{1}) {
t.Fatalf("after remove %v", values)
}
}
func TestMultiSelectSearchAndSelectAll(t *testing.T) {
var values []int
changes := 0
tt := multiSelectTester(&values, MultiSelectOptions{}, &changes)
_ = tt.Click("Select…")
tt.Type("li")
if tt.HasText("Rose") || !tt.HasText("Lily") || !tt.HasText("Lilac") {
t.Fatalf("filter shows %v", tt.Texts())
}
_ = tt.Click("Select all")
if !slices.Equal(values, []int{2, 5}) {
t.Fatalf("select all of matches: %v", values)
}
_ = tt.Click("Select all")
if len(values) != 0 {
t.Fatalf("second select all left %v", values)
}
_ = tt.Click("Search")
tt.Type("zz")
if !tt.HasText("No results") {
t.Fatal("no-results state missing")
}
}
func TestMultiSelectLimit(t *testing.T) {
values := []int{1}
changes := 0
tt := multiSelectTester(&values, MultiSelectOptions{Max: 2}, &changes)
_ = tt.Click("Rose")
_ = tt.Click("Select all")
if !slices.Equal(values, []int{1, 2}) {
t.Fatalf("select all past limit: %v", values)
}
if !tt.HasText("2 of 2 chosen") {
t.Fatal("limit note missing")
}
_ = tt.Click("Lilac")
if len(values) != 2 {
t.Fatalf("limit broken: %v", values)
}
}
func TestMultiSelectKeyboard(t *testing.T) {
var values []int
changes := 0
tt := multiSelectTester(&values, MultiSelectOptions{Label: "Flowers"}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.Focused("Search") {
t.Fatal("search not focused on open")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
if !slices.Equal(values, []int{1, 2}) {
t.Fatalf("values %v", values)
}
tt.Key(0, ui.KeyEscape)
if !tt.Focused("Flowers") {
t.Fatal("focus did not return to the field")
}
tt.Key(0, ui.KeyBackspace)
if !slices.Equal(values, []int{1}) {
t.Fatalf("Backspace left %v", values)
}
}
func TestMultiSelectCollapsesTags(t *testing.T) {
values := []int{1, 2, 3, 5}
changes := 0
tt := multiSelectTester(&values, MultiSelectOptions{MaxTags: 2}, &changes)
if !tt.HasText("+2") || tt.HasText("Iris") {
t.Fatalf("texts %v", tt.Texts())
}
}
func TestMultiSelectSnapshot(t *testing.T) {
snapshot(t, "multi_select", 460, 110, func(c *ui.Context) {
var none []int
some := []int{1, 2, 3, 5}
ui.Row(c).Padding(20).Gap(16).Children(func() {
MultiSelect(c, &none, multiSelectTestOptions, MultiSelectOptions{})
MultiSelect(c, &some, multiSelectTestOptions, MultiSelectOptions{MaxTags: 2})
})
})
}
+321
View File
@@ -0,0 +1,321 @@
package input
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 and hi are the bounds on the precision grid; min and max the original ones.
Lo, Hi, min, max, Step float64
Decimals int
}
func (s NumberInputSpec) format(v float64) string {
if math.IsNaN(v) {
return ""
}
return strconv.FormatFloat(v, 'f', s.Decimals, 64)
}
// Round rounds v to the precision.
func (s NumberInputSpec) Round(v float64) float64 {
p := math.Pow(10, float64(s.Decimals))
return math.Round(v*p)/p + 0 // + 0 turns -0 into 0
}
// clamp rounds v to the precision, then bounds it; the bounds sit on that grid.
func (s NumberInputSpec) clamp(v float64) float64 {
return math.Max(s.Lo, math.Min(s.Hi, s.Round(v)))
}
// StepBy moves v n grid steps from Min, exactly checked against the original bounds.
func (s NumberInputSpec) StepBy(v float64, n int) float64 {
if math.IsNaN(v) {
// From empty, the first step lands next to zero.
v = s.clamp(0)
}
base := s.Lo
if math.IsInf(base, 0) {
base = 0
}
at := func(k float64) float64 { return s.Round(base + k*s.Step) }
k := math.Round((v - base) / s.Step)
// Off the grid, the nearest point in the direction is the first step.
if n > 0 && at(k) <= v || n < 0 && at(k) >= v {
k += float64(n)
}
r := at(k)
switch {
case r > s.max:
r = at(k - 1)
case r < s.min:
r = at(k + 1)
}
if r > s.max || r < s.min {
return v
}
return r
}
// numberInputSame reports equal values, counting NaN (empty) as equal.
func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) }
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
}
}
// Bounds move inward onto the precision grid, compared exactly with the originals.
s.min, s.max = s.Lo, s.Hi
p := math.Pow(10, float64(s.Decimals))
if s.Lo = s.Round(s.Lo); s.Lo < s.min {
s.Lo = s.Round(s.Lo + 1/p)
}
if s.Hi = s.Round(s.Hi); s.Hi > s.max {
s.Hi = s.Round(s.Hi - 1/p)
}
if s.Lo > s.Hi {
panic("mujica: NumberInput has no value in range at this precision")
}
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 int) { set(s.StepBy(*value, n)) }
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 "" })
seen := ui.Local(frame, "seen", func() float64 { return *value })
if !numberInputSame(*seen, *value) {
// Set from outside: the text follows, and a typing error goes.
*text, *invalid = s.format(*value), ""
}
in := TextInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 12).
Placeholder(opts.Placeholder).Label(opts.Label)
if in.Changed() && !look.disabled {
*invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set)
}
switch {
case look.disabled:
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() && *invalid == "" {
// Leaving shows the value rounded; invalid text stays to be fixed.
*text = s.format(*value)
}
defer func() { *seen = *value }()
for _, b := range []struct {
name string
icon string
delta int
can bool
}{
{core.Msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.Lo},
{core.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 core.Msg(c, "number_input.invalid")
}
if v < s.Lo || v > s.Hi {
return fmt.Sprintf(core.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 := core.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 up and down arrows; it steps itself.
func numberInputStepper(c *ui.Context, value *float64, s NumberInputSpec, opts NumberInputOptions) InputResult {
k := core.Tokens(c)
d := core.Density(c)
var r InputResult
r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
set := func(v float64) {
if v != *value && !disabled {
*value = v
r.changed = true
}
}
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)
border := k.ControlBorder
if opts.Error != "" {
border = k.Danger
}
st := ui.Column(c).Focusable().Shrink(0).Width(d.Unit()*6).Radius(theme.ControlRadius).Clip().
Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleStepper).Label(opts.Label).
Description(s.format(*value)).Error(opts.Error)
internal.Ring(st, k.Focus, theme.ControlRadius)
switch {
case st.Shortcut(0, ui.KeyUp), st.Shortcut(0, ui.KeyRight):
set(s.StepBy(*value, 1))
case st.Shortcut(0, ui.KeyDown), st.Shortcut(0, ui.KeyLeft):
set(s.StepBy(*value, -1))
case st.Shortcut(0, ui.KeyHome):
set(s.Lo)
case st.Shortcut(0, ui.KeyEnd):
set(s.Hi)
}
st.Children(func() {
if numberInputArrow(c, "chevron-up", *value < s.Hi, false) {
set(s.StepBy(*value, 1))
st.Focus()
}
if numberInputArrow(c, "chevron-down", *value > s.Lo, true) {
set(s.StepBy(*value, -1))
st.Focus()
}
})
r.Input = st
})
if opts.Error != "" {
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize))
}
})
if r.changed {
slog.Debug("mujica: number_input step", "value", *value)
}
return r
}
// numberInputArrow builds one half of the stepper and reports a click.
func numberInputArrow(c *ui.Context, icon string, can, lower bool) bool {
k := core.Tokens(c)
h := core.Density(c).ControlHeight()/2 - theme.BorderWidth
a := ui.Box(c).Height(h).Center().Role(ui.RoleNone).Cursor(ui.CursorPointer).Disabled(!can)
if lower {
a.BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border)
}
fg := k.TextMuted
if a.Pressed() {
a.Background(k.Selection)
fg = k.Text
} else if a.Hovered() {
a.Background(k.SurfaceHover)
fg = k.Text
}
a.Children(func() { ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(fg) })
return a.Clicked()
}
+216
View File
@@ -0,0 +1,216 @@
package input
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func numberInputTester(v *float64, changes *int, opts NumberInputOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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 v != 1.25 || !tt.HasText("Enter a number from 0.00 to 2.00") {
t.Fatalf("leaving dropped the error: value %v texts %q", v, tt.Texts())
}
}
func TestNumberInputKeepsInvalidTextAfterBlur(t *testing.T) {
v, n := 1.0, 0
tt := numberInputTester(&v, &n, NumberInputOptions{Label: "Count"})
_ = tt.Click("Count")
tt.Command("selectAll")
tt.Type("abc")
_ = tt.Click("Elsewhere")
if !tt.HasText("Enter a number") || v != 1 {
t.Fatalf("after blur: value %v texts %q", v, tt.Texts())
}
_ = tt.Click("Count")
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "abc" {
t.Fatalf("invalid text replaced by %q", tt.Clipboard())
}
tt.Type("4")
if v != 4 || tt.HasText("Enter a number") {
t.Fatalf("correction: value %v texts %q", v, tt.Texts())
}
}
func TestNumberInputSyncsExternalValue(t *testing.T) {
v, n := 1.0, 0
tt := numberInputTester(&v, &n, NumberInputOptions{Label: "Level"})
_ = tt.Click("Level")
v = 9
tt.Frame()
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "9" {
t.Fatalf("focused field shows %q after the value became 9", tt.Clipboard())
}
tt.Key(0, ui.KeyUp)
if v != 10 {
t.Fatalf("Up after external change gave %v", v)
}
}
func TestNumberInputRangeOnPrecisionGrid(t *testing.T) {
v, n := 0.2, 0
tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0.11, Max: 0.29, Step: 0.1, Label: "Fine"})
_ = tt.Click("Fine")
tt.Key(0, ui.KeyUp)
if v != 0.2 {
t.Fatalf("stepping up left the range: %v", v)
}
tt.Key(0, ui.KeyDown)
if v != 0.2 {
t.Fatalf("stepping down left the range: %v", v)
}
}
func TestNumberInputDisabledWhileFocused(t *testing.T) {
v, n, off, focused := 5.0, 0, false, false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := NumberInput(c, &v, NumberInputOptions{Label: "Live", Disabled: off})
if r.Changed() {
n++
}
focused = r.Input.Focused()
}, 400, 300)
_ = tt.Click("Live")
off = true
tt.Key(0, ui.KeyUp)
tt.Type("7")
tt.Frame()
if v != 5 || n != 0 || focused {
t.Fatalf("disabled while focused: value %v changes %d focused %v", v, n, focused)
}
}
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}, {Min: 0.11, Max: 0.19, Precision: 1}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", opts)
}
}()
v := 0.0
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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"})
})
})
}
+62
View File
@@ -0,0 +1,62 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
// 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 = TextInputEditor(c, frame, value, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 10).
Placeholder(opts.Placeholder).Label(opts.Label)
if !*shown {
in.Password()
}
})
r.changed = in.Changed() && !look.disabled
r.submitted = in.Submitted() && !look.disabled
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 {
name, icon := core.Msg(c, "password_input.show"), "eye"
if shown {
name, icon = core.Msg(c, "password_input.hide"), "eye-off"
}
return core.IconAction(c, icons.Must(icon), name).Tooltip(name).Clicked()
}
+103
View File
@@ -0,0 +1,103 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestPasswordInputMaskPasteAndReveal(t *testing.T) {
pw, changes, submits := "", 0, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := PasswordInput(c, &pw, PasswordInputOptions{Label: "Password"})
if r.Changed() {
changes++
}
if r.Submitted() {
submits++
}
}, 400, 200)
_ = tt.Click("Password")
tt.SetClipboard("s3cr")
tt.Command("paste")
tt.Type("et")
if pw != "s3cret" || changes != 2 {
t.Fatalf("value matched %v (length %d), changes %d", pw == "s3cret", len(pw), changes)
}
tt.SetClipboard("")
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "" {
t.Fatalf("masked field copied %d characters", len(tt.Clipboard()))
}
for _, s := range tt.Texts() {
if s == "s3cret" {
t.Fatal("masked password shows in the texts")
}
}
if err := tt.Click("Show password"); err != nil {
t.Fatal(err)
}
if _, ok := tt.Find("Hide password"); !ok || pw != "s3cret" {
t.Fatalf("after reveal: value matched %v, hide toggle shown %v", pw == "s3cret", ok)
}
_ = tt.Click("Password")
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "s3cret" {
t.Fatalf("revealed copy matched %v (length %d)", tt.Clipboard() == "s3cret", len(tt.Clipboard()))
}
tt.Key(0, ui.KeyEnter)
if submits != 1 {
t.Fatalf("submits %d", submits)
}
_ = tt.Click("Hide password")
if _, ok := tt.Find("Show password"); !ok {
t.Fatal("hide did not mask again")
}
}
func TestPasswordInputToggleByKeyboard(t *testing.T) {
pw := "x"
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
PasswordInput(c, &pw, PasswordInputOptions{Label: "PIN"})
}, 400, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if _, ok := tt.Find("Hide password"); !ok {
t.Fatal("Space on the toggle did not reveal")
}
}
func TestPasswordInputDisabled(t *testing.T) {
pw := "locked"
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
PasswordInput(c, &pw, PasswordInputOptions{Label: "Off", Disabled: true})
}, 400, 200)
_ = tt.Click("Show password")
_ = tt.Click("Off")
tt.Type("zz")
if pw != "locked" {
t.Fatalf("disabled value matched %v (length %d)", pw == "locked", len(pw))
}
if _, ok := tt.Find("Show password"); !ok {
t.Fatal("disabled toggle revealed")
}
}
func TestPasswordInputSnapshot(t *testing.T) {
snapshot(t, "password_input", 420, 220, func(c *ui.Context) {
a, b, e := "", "versailles", "short"
ui.Column(c).Padding(24).Gap(12).Width(320).Children(func() {
PasswordInput(c, &a, PasswordInputOptions{Label: "A", Placeholder: "Password"})
PasswordInput(c, &b, PasswordInputOptions{Label: "B"})
PasswordInput(c, &e, PasswordInputOptions{Label: "E", Error: "At least 8 characters"})
})
})
}
+167
View File
@@ -0,0 +1,167 @@
package input
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PinInputOptions configure a PinInput. Length is 6 when zero.
type PinInputOptions struct {
Length int
// Label names the input; empty uses the built-in "Verification code".
Label string
Disabled bool
// Invalid draws the cells in the danger color.
Invalid bool
}
// PinInputParts are the parts of a PinInput.
type PinInputParts struct {
Element *ui.Element
changed, completed bool
}
// Changed reports whether the user changed the code this frame.
func (p PinInputParts) Changed() bool { return p.changed }
// Completed reports whether the user filled the last cell this frame.
func (p PinInputParts) Completed() bool { return p.completed }
// pinInputEvent is input taken between frames, applied in the next one.
type pinInputEvent struct {
text string
backspace bool
paste bool
}
type pinInputState struct {
queue []pinInputEvent
compose string
}
// PinInput edits a fixed-length digit code, one cell per digit, keeping leading zeros.
func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts {
n := opts.Length
if n == 0 {
n = 6
}
if n < 0 || len(*value) > n || strings.Trim(*value, "0123456789") != "" {
panic(fmt.Sprintf("mujica: PinInput value %q does not fit %d digits", *value, n))
}
k := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.Msg(c, "pin_input.label")
}
parts := PinInputParts{}
e := ui.Row(c).Gap(d.Unit() * 2).Shrink(0).Role(ui.RoleTextField).Label(label).Disabled(opts.Disabled)
parts.Element = e
st := ui.Local(e, "pin", func() pinInputState { return pinInputState{} })
put := func(s string) {
var b strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
next := b.String()
if len(next) > n {
next = next[:n]
}
if next == *value {
return
}
was := len(*value)
*value = next
parts.changed = true
if len(next) == n && was < n {
parts.completed = true
slog.Debug("mujica: pin input completed", "length", n)
}
}
disabled := e.IsDisabled()
if disabled {
st.queue, st.compose = st.queue[:0], ""
}
for _, ev := range st.queue {
switch {
case ev.paste:
put(c.ReadClipboard())
case ev.backspace && *value != "":
put((*value)[:len(*value)-1])
case ev.text != "":
put(*value + ev.text)
}
}
st.queue = st.queue[:0]
if !disabled {
e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool {
switch {
case ev.Kind == ui.InputText:
st.queue = append(st.queue, pinInputEvent{text: ev.Text})
st.compose = ""
case ev.Kind == ui.InputCompose:
st.compose = ev.Text
case ev.Kind == ui.InputCommand && ev.Text == "paste",
ev.Kind == ui.InputKeyDown && ev.Mods == ui.Cmd && ev.Key == ui.KeyV:
st.queue = append(st.queue, pinInputEvent{paste: true})
case ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace:
st.queue = append(st.queue, pinInputEvent{backspace: true})
default:
return false
}
return true
})
}
size := d.ControlHeight() + d.Unit()*2
active := min(len(*value), n-1)
focused := e.Focused()
compose := st.compose
e.Description(fmt.Sprintf(core.Msg(c, "pin_input.filled"), len(*value), n))
e.TextCaret(ui.Rect{X: float32(active) * (size + d.Unit()*2), Y: 0, W: size, H: size})
e.Children(func() {
for i := range n {
cell := ui.Box(c).Size(size, size).Center().Radius(theme.ControlRadius).Background(k.Surface).Role(ui.RoleNone)
border := k.ControlBorder
switch {
case opts.Invalid:
border = k.Danger
case focused && i == active:
border = k.Focus
}
cell.Border(theme.BorderWidth, border)
ch := ""
if i < len(*value) {
ch = (*value)[i : i+1]
}
showCompose := focused && i == active && compose != ""
cell.Children(func() {
switch {
case showCompose:
ui.Text(c, compose).FontSize(core.FontSize(c, theme.H3Size)).Underline().SingleLine()
case ch != "":
ui.Text(c, ch).FontSize(core.FontSize(c, theme.H3Size)).FontFeatures("tnum").SingleLine()
}
})
caret := focused && i == active && len(*value) < n && !showCompose
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if focused && i == active {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
if caret {
h := r.H * 0.4
p.Fill(ui.Rect{X: r.X + r.W/2 - 0.5, Y: r.Y + (r.H-h)/2, W: 1, H: h}, k.Text, 0)
}
})
}
})
return parts
}
+126
View File
@@ -0,0 +1,126 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type pinInputProbe struct {
value string
changes, completes int
}
func (p *pinInputProbe) tester(opts PinInputOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := PinInput(c, &p.value, opts)
if r.Changed() {
p.changes++
}
if r.Completed() {
p.completes++
}
}, 480, 120)
}
func TestPinInputTypingAndBackspace(t *testing.T) {
p := &pinInputProbe{}
tt := p.tester(PinInputOptions{Length: 4, Label: "Code"})
tt.Key(0, ui.KeyTab)
if !tt.Focused("Code") {
t.Fatal("not focusable")
}
tt.TypeKey(0, ui.Key0, "0")
tt.Type("a")
tt.Type("07")
if p.value != "007" || p.completes != 0 {
t.Fatalf("value %q completes %d", p.value, p.completes)
}
tt.Type("9")
tt.Type("5")
if p.value != "0079" || p.completes != 1 {
t.Fatalf("full: value %q completes %d", p.value, p.completes)
}
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
if p.value != "00" {
t.Fatalf("backspace: %q", p.value)
}
tt.Type("12")
if p.value != "0012" || p.completes != 2 {
t.Fatalf("refill: %q completes %d", p.value, p.completes)
}
}
func TestPinInputPasteAndCompose(t *testing.T) {
p := &pinInputProbe{value: "1"}
tt := p.tester(PinInputOptions{Label: "Code"})
tt.Key(0, ui.KeyTab)
tt.SetClipboard(" 012-345 9")
tt.Command("paste")
if p.value != "012345" || p.completes != 1 || p.changes != 1 {
t.Fatalf("paste: %q completes %d changes %d", p.value, p.completes, p.changes)
}
tt.Key(0, ui.KeyBackspace)
tt.Compose("4", 1)
if p.value != "01234" || !tt.HasText("4") {
t.Fatalf("compose committed early: %q", p.value)
}
tt.Type("8")
if p.value != "012348" {
t.Fatalf("commit: %q", p.value)
}
}
func TestPinInputDisabled(t *testing.T) {
p := &pinInputProbe{value: "12"}
tt := p.tester(PinInputOptions{Label: "Code", Disabled: true})
tt.Key(0, ui.KeyTab)
tt.Type("3")
if tt.Focused("Code") || p.value != "12" {
t.Fatalf("disabled took input: %q", p.value)
}
}
func TestPinInputRejectsBadValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("letters accepted")
}
}()
p := &pinInputProbe{value: "12a"}
p.tester(PinInputOptions{})
}
func TestPinInputSnapshot(t *testing.T) {
a, b := "0420", "13"
snapshot(t, "pin_input", 420, 150, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
PinInput(c, &a, PinInputOptions{}).Element.Focus()
PinInput(c, &b, PinInputOptions{Length: 4, Invalid: true})
})
})
}
func TestPinInputDisabledAfterFocus(t *testing.T) {
p := &pinInputProbe{value: "12"}
opts := PinInputOptions{Label: "Code"}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := PinInput(c, &p.value, opts)
if r.Changed() {
p.changes++
}
}, 480, 120)
tt.Key(0, ui.KeyTab)
opts.Disabled = true
tt.Type("3")
tt.Key(0, ui.KeyBackspace)
tt.Frame()
if p.value != "12" || p.changes != 0 {
t.Fatalf("value %q changes %d", p.value, p.changes)
}
}
+80
View File
@@ -0,0 +1,80 @@
package input
import (
"cmp"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// QRCodeOptions configure a QRCode.
type QRCodeOptions struct {
Level QRLevel
// Size is the side in DIPs with the quiet zone; zero is 160.
Size float32
// Label names the code for assistive technology; empty uses "QR code".
Label string
}
// qrCodeCache keeps the last encoding so it runs only when the text or level changes.
type qrCodeCache struct {
text string
level QRLevel
m QRMatrix
err error
done bool
}
// QRCode draws text as a QR code, encoded offline; too long a text shows an error.
func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element {
_ = opts.Level.String()
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
panic("mujica: QRCode Size must be finite and >= 0")
}
size := opts.Size
if size == 0 {
size = 160
}
k := core.Tokens(c)
e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(cmp.Or(opts.Label, core.Msg(c, "qr_code.label")))
cache := ui.Local(e, "qr_code", func() qrCodeCache { return qrCodeCache{} })
if !cache.done || cache.text != text || cache.level != opts.Level {
m, err := EncodeQR([]byte(text), opts.Level)
*cache = qrCodeCache{text: text, level: opts.Level, m: m, err: err, done: true}
slog.Debug("mujica: qr_code encoded", "version", m.Version, "level", opts.Level, "err", err)
}
if cache.err != nil {
e.Center().Padding(core.Density(c).Unit()*2).Background(k.Surface).Border(theme.BorderWidth, k.Danger).Radius(theme.CardRadius).
Description(cache.err.Error())
e.Children(func() {
ui.Text(c, core.Msg(c, "qr_code.too_long")).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).TextAlign(ui.Center)
})
return e
}
m := cache.m
// Scanners need dark modules on a light field, in either theme.
light, dark := theme.Light().Surface, theme.Light().Text
e.Draw(func(p *ui.Painter, r ui.Rect) {
n := m.Size() + 8
s := p.Scale()
mod := float32(math.Floor(float64(r.W*s)/float64(n))) / s
if mod <= 0 {
return
}
ox := float32(math.Round(float64((r.X+(r.W-mod*float32(n))/2)*s))) / s
oy := float32(math.Round(float64((r.Y+(r.H-mod*float32(n))/2)*s))) / s
p.Fill(r, light, theme.ControlRadius)
for y, row := range m.Modules {
for x, on := range row {
if on {
p.Fill(ui.Rect{X: ox + float32(x+4)*mod, Y: oy + float32(y+4)*mod, W: mod, H: mod}, dark, 0)
}
}
}
})
return e
}
+262
View File
@@ -0,0 +1,262 @@
package input
import (
"fmt"
)
// QRLevel is a QR code's error correction level. The zero value is QRMedium.
type QRLevel int
const (
QRMedium QRLevel = iota // M: about 15% recoverable
QRLow // L: about 7%
QRQuartile // Q: about 25%
QRHigh // H: about 30%
)
func (l QRLevel) String() string {
switch l {
case QRLow:
return "L"
case QRMedium:
return "M"
case QRQuartile:
return "Q"
case QRHigh:
return "H"
}
panic(fmt.Sprintf("mujica: unknown QRLevel %d", int(l)))
}
// index is the level's column in qrCodeTable.
func (l QRLevel) index() int { return [4]int{1, 0, 2, 3}[l] }
// formatBits are the level's two format information bits.
func (l QRLevel) formatBits() int { return [4]int{0, 1, 3, 2}[l] }
// qrCodeBlocks is one version and level: EC codewords per block, then two block groups.
type qrCodeBlocks struct{ ec, n1, d1, n2, d2 int }
func (b qrCodeBlocks) data() int { return b.n1*b.d1 + b.n2*b.d2 }
// qrCodeTable is ISO/IEC 18004 Table 9, indexed [version-1][L, M, Q, H].
var qrCodeTable = [40][4]qrCodeBlocks{
{{7, 1, 19, 0, 0}, {10, 1, 16, 0, 0}, {13, 1, 13, 0, 0}, {17, 1, 9, 0, 0}},
{{10, 1, 34, 0, 0}, {16, 1, 28, 0, 0}, {22, 1, 22, 0, 0}, {28, 1, 16, 0, 0}},
{{15, 1, 55, 0, 0}, {26, 1, 44, 0, 0}, {18, 2, 17, 0, 0}, {22, 2, 13, 0, 0}},
{{20, 1, 80, 0, 0}, {18, 2, 32, 0, 0}, {26, 2, 24, 0, 0}, {16, 4, 9, 0, 0}},
{{26, 1, 108, 0, 0}, {24, 2, 43, 0, 0}, {18, 2, 15, 2, 16}, {22, 2, 11, 2, 12}},
{{18, 2, 68, 0, 0}, {16, 4, 27, 0, 0}, {24, 4, 19, 0, 0}, {28, 4, 15, 0, 0}},
{{20, 2, 78, 0, 0}, {18, 4, 31, 0, 0}, {18, 2, 14, 4, 15}, {26, 4, 13, 1, 14}},
{{24, 2, 97, 0, 0}, {22, 2, 38, 2, 39}, {22, 4, 18, 2, 19}, {26, 4, 14, 2, 15}},
{{30, 2, 116, 0, 0}, {22, 3, 36, 2, 37}, {20, 4, 16, 4, 17}, {24, 4, 12, 4, 13}},
{{18, 2, 68, 2, 69}, {26, 4, 43, 1, 44}, {24, 6, 19, 2, 20}, {28, 6, 15, 2, 16}},
{{20, 4, 81, 0, 0}, {30, 1, 50, 4, 51}, {28, 4, 22, 4, 23}, {24, 3, 12, 8, 13}},
{{24, 2, 92, 2, 93}, {22, 6, 36, 2, 37}, {26, 4, 20, 6, 21}, {28, 7, 14, 4, 15}},
{{26, 4, 107, 0, 0}, {22, 8, 37, 1, 38}, {24, 8, 20, 4, 21}, {22, 12, 11, 4, 12}},
{{30, 3, 115, 1, 116}, {24, 4, 40, 5, 41}, {20, 11, 16, 5, 17}, {24, 11, 12, 5, 13}},
{{22, 5, 87, 1, 88}, {24, 5, 41, 5, 42}, {30, 5, 24, 7, 25}, {24, 11, 12, 7, 13}},
{{24, 5, 98, 1, 99}, {28, 7, 45, 3, 46}, {24, 15, 19, 2, 20}, {30, 3, 15, 13, 16}},
{{28, 1, 107, 5, 108}, {28, 10, 46, 1, 47}, {28, 1, 22, 15, 23}, {28, 2, 14, 17, 15}},
{{30, 5, 120, 1, 121}, {26, 9, 43, 4, 44}, {28, 17, 22, 1, 23}, {28, 2, 14, 19, 15}},
{{28, 3, 113, 4, 114}, {26, 3, 44, 11, 45}, {26, 17, 21, 4, 22}, {26, 9, 13, 16, 14}},
{{28, 3, 107, 5, 108}, {26, 3, 41, 13, 42}, {30, 15, 24, 5, 25}, {28, 15, 15, 10, 16}},
{{28, 4, 116, 4, 117}, {26, 17, 42, 0, 0}, {28, 17, 22, 6, 23}, {30, 19, 16, 6, 17}},
{{28, 2, 111, 7, 112}, {28, 17, 46, 0, 0}, {30, 7, 24, 16, 25}, {24, 34, 13, 0, 0}},
{{30, 4, 121, 5, 122}, {28, 4, 47, 14, 48}, {30, 11, 24, 14, 25}, {30, 16, 15, 14, 16}},
{{30, 6, 117, 4, 118}, {28, 6, 45, 14, 46}, {30, 11, 24, 16, 25}, {30, 30, 16, 2, 17}},
{{26, 8, 106, 4, 107}, {28, 8, 47, 13, 48}, {30, 7, 24, 22, 25}, {30, 22, 15, 13, 16}},
{{28, 10, 114, 2, 115}, {28, 19, 46, 4, 47}, {28, 28, 22, 6, 23}, {30, 33, 16, 4, 17}},
{{30, 8, 122, 4, 123}, {28, 22, 45, 3, 46}, {30, 8, 23, 26, 24}, {30, 12, 15, 28, 16}},
{{30, 3, 117, 10, 118}, {28, 3, 45, 23, 46}, {30, 4, 24, 31, 25}, {30, 11, 15, 31, 16}},
{{30, 7, 116, 7, 117}, {28, 21, 45, 7, 46}, {30, 1, 23, 37, 24}, {30, 19, 15, 26, 16}},
{{30, 5, 115, 10, 116}, {28, 19, 47, 10, 48}, {30, 15, 24, 25, 25}, {30, 23, 15, 25, 16}},
{{30, 13, 115, 3, 116}, {28, 2, 46, 29, 47}, {30, 42, 24, 1, 25}, {30, 23, 15, 28, 16}},
{{30, 17, 115, 0, 0}, {28, 10, 46, 23, 47}, {30, 10, 24, 35, 25}, {30, 19, 15, 35, 16}},
{{30, 17, 115, 1, 116}, {28, 14, 46, 21, 47}, {30, 29, 24, 19, 25}, {30, 11, 15, 46, 16}},
{{30, 13, 115, 6, 116}, {28, 14, 46, 23, 47}, {30, 44, 24, 7, 25}, {30, 59, 16, 1, 17}},
{{30, 12, 121, 7, 122}, {28, 12, 47, 26, 48}, {30, 39, 24, 14, 25}, {30, 22, 15, 41, 16}},
{{30, 6, 121, 14, 122}, {28, 6, 47, 34, 48}, {30, 46, 24, 10, 25}, {30, 2, 15, 64, 16}},
{{30, 17, 122, 4, 123}, {28, 29, 46, 14, 47}, {30, 49, 24, 10, 25}, {30, 24, 15, 46, 16}},
{{30, 4, 122, 18, 123}, {28, 13, 46, 32, 47}, {30, 48, 24, 14, 25}, {30, 42, 15, 32, 16}},
{{30, 20, 117, 4, 118}, {28, 40, 47, 7, 48}, {30, 43, 24, 22, 25}, {30, 10, 15, 67, 16}},
{{30, 19, 118, 6, 119}, {28, 18, 47, 31, 48}, {30, 34, 24, 34, 25}, {30, 20, 15, 61, 16}},
}
// QRMatrix is an encoded QR code: Modules[y][x] is true for dark.
type QRMatrix struct {
Version int
Level QRLevel
Mask int
Modules [][]bool
}
// Size is the side of the symbol in modules, without the quiet zone.
func (m QRMatrix) Size() int { return len(m.Modules) }
// EncodeQR encodes data in byte mode at the smallest version that fits, choosing the mask by penalty.
func EncodeQR(data []byte, level QRLevel) (QRMatrix, error) {
_ = level.String()
for v := 1; v <= 40; v++ {
cc := 8
if v >= 10 {
cc = 16
}
if len(data) >= 1<<cc {
continue
}
if 4+cc+8*len(data) <= 8*qrCodeTable[v-1][level.index()].data() {
return qrCodeEncodeAt(data, level, v), nil
}
}
return QRMatrix{}, fmt.Errorf("mujica: %d bytes do not fit a QR code at level %v", len(data), level)
}
// qrCodeEncodeAt builds version v with the lowest-penalty mask.
func qrCodeEncodeAt(data []byte, level QRLevel, v int) QRMatrix {
b := qrCodeTable[v-1][level.index()]
var w qrCodeBits
w.put(4, 4)
if v >= 10 {
w.put(len(data), 16)
} else {
w.put(len(data), 8)
}
for _, d := range data {
w.put(int(d), 8)
}
capBits := 8 * b.data()
w.put(0, min(4, capBits-w.n))
w.put(0, (8-w.n%8)%8)
for pad := 0xEC; w.n < capBits; pad ^= 0xEC ^ 0x11 {
w.put(pad, 8)
}
words := qrCodeInterleave(w.bytes, b)
g := newQRCodeGrid(v)
g.place(words)
best, bestPenalty := 0, -1
for m := range 8 {
t := g.clone()
t.apply(m)
if p := t.penalty(); bestPenalty < 0 || p < bestPenalty {
best, bestPenalty = m, p
}
}
g.apply(best)
g.format(level.formatBits(), best)
g.version(true)
return QRMatrix{Version: v, Level: level, Mask: best, Modules: g.dark}
}
// qrCodeBits appends bits most significant first.
type qrCodeBits struct {
bytes []byte
n int
}
func (w *qrCodeBits) put(v, count int) {
for i := count - 1; i >= 0; i-- {
if w.n%8 == 0 {
w.bytes = append(w.bytes, 0)
}
if v>>i&1 == 1 {
w.bytes[w.n/8] |= 0x80 >> (w.n % 8)
}
w.n++
}
}
// qrCodeInterleave splits data into blocks, adds Reed-Solomon codewords and interleaves both.
func qrCodeInterleave(data []byte, b qrCodeBlocks) []byte {
var blocks, ecs [][]byte
gen := qrCodeGenerator(b.ec)
at := 0
for i := range b.n1 + b.n2 {
n := b.d1
if i >= b.n1 {
n = b.d2
}
blk := data[at : at+n]
at += n
blocks = append(blocks, blk)
ecs = append(ecs, qrCodeRemainder(blk, gen))
}
var out []byte
for i := range max(b.d1, b.d2) {
for _, blk := range blocks {
if i < len(blk) {
out = append(out, blk[i])
}
}
}
for i := range b.ec {
for _, e := range ecs {
out = append(out, e[i])
}
}
return out
}
// qrCodeMul multiplies in GF(256) modulo x^8+x^4+x^3+x^2+1.
func qrCodeMul(x, y byte) byte {
var z byte
for i := 7; i >= 0; i-- {
hi := z & 0x80
z <<= 1
if hi != 0 {
z ^= 0x1D
}
if y>>i&1 == 1 {
z ^= x
}
}
return z
}
// qrCodeGenerator returns the degree-n generator's coefficients, highest first, leading 1 dropped.
func qrCodeGenerator(n int) []byte {
g := make([]byte, n)
g[n-1] = 1
root := byte(1)
for range n {
for j := range n {
g[j] = qrCodeMul(g[j], root)
if j+1 < n {
g[j] ^= g[j+1]
}
}
root = qrCodeMul(root, 2)
}
return g
}
func qrCodeRemainder(data, gen []byte) []byte {
r := make([]byte, len(gen))
for _, d := range data {
f := d ^ r[0]
copy(r, r[1:])
r[len(r)-1] = 0
for i := range r {
r[i] ^= qrCodeMul(gen[i], f)
}
}
return r
}
// qrCodeAlignment returns the alignment pattern centers of version v.
func qrCodeAlignment(v int) []int {
if v == 1 {
return nil
}
n := v/7 + 2
step := 26
if v != 32 {
step = (v*4 + n*2 + 1) / (n*2 - 2) * 2
}
pos := make([]int, n)
pos[0] = 6
for i, p := n-1, v*4+10; i >= 1; i, p = i-1, p-step {
pos[i] = p
}
return pos
}
+81
View File
@@ -0,0 +1,81 @@
package input
import (
"encoding/hex"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// testdata/qr: segno 1.6.6 byte-mode symbols, padding fixed to ISO 7.4.10.
func TestEncodeQRMatchesReference(t *testing.T) {
files, err := filepath.Glob("testdata/qr/*.txt")
if err != nil || len(files) < 6 {
t.Fatalf("reference files: %v %v", files, err)
}
levels := map[string]QRLevel{"L": QRLow, "M": QRMedium, "Q": QRQuartile, "H": QRHigh}
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
head := strings.Fields(lines[0])
version, _ := strconv.Atoi(head[0])
mask, _ := strconv.Atoi(head[2])
data, err := hex.DecodeString(head[3])
if err != nil {
t.Fatal(err)
}
m, err := EncodeQR(data, levels[head[1]])
if err != nil {
t.Fatalf("%s: %v", f, err)
}
if m.Version != version || m.Mask != mask {
t.Errorf("%s: version %d mask %d, want %d %d", f, m.Version, m.Mask, version, mask)
continue
}
for y, row := range lines[1:] {
for x, ch := range row {
if m.Modules[y][x] != (ch == '1') {
t.Fatalf("%s: module (%d,%d) differs", f, x, y)
}
}
}
}
}
func TestQRCodeAlignmentPositions(t *testing.T) {
for v, want := range map[int]string{1: "[]", 2: "[6 18]", 7: "[6 22 38]", 32: "[6 34 60 86 112 138]", 40: "[6 30 58 86 114 142 170]"} {
if got := fmtInts(qrCodeAlignment(v)); got != want {
t.Errorf("version %d: %s, want %s", v, got, want)
}
}
}
func fmtInts(v []int) string {
s := make([]string, len(v))
for i, n := range v {
s[i] = strconv.Itoa(n)
}
return "[" + strings.Join(s, " ") + "]"
}
func TestEncodeQRLimits(t *testing.T) {
m, err := EncodeQR(make([]byte, 2953), QRLow)
if err != nil || m.Version != 40 || m.Size() != 177 {
t.Fatalf("largest L symbol: %v %v", m.Version, err)
}
if _, err := EncodeQR(make([]byte, 2954), QRLow); err == nil {
t.Fatal("2954 bytes fit at L")
}
if _, err := EncodeQR(make([]byte, 1274), QRHigh); err == nil {
t.Fatal("1274 bytes fit at H")
}
m, err = EncodeQR(nil, QRHigh)
if err != nil || m.Version != 1 {
t.Fatalf("empty data: %v %v", m.Version, err)
}
}
+242
View File
@@ -0,0 +1,242 @@
package input
// qrCodeGrid is a symbol under construction; fixed marks function modules.
type qrCodeGrid struct {
v int
dark, fixed [][]bool
}
func newQRCodeGrid(v int) *qrCodeGrid {
n := 4*v + 17
g := &qrCodeGrid{v: v, dark: make([][]bool, n), fixed: make([][]bool, n)}
for i := range n {
g.dark[i], g.fixed[i] = make([]bool, n), make([]bool, n)
}
for i := range n {
g.set(6, i, i%2 == 0)
g.set(i, 6, i%2 == 0)
}
g.finder(3, 3)
g.finder(n-4, 3)
g.finder(3, n-4)
al := qrCodeAlignment(v)
for i, x := range al {
for j, y := range al {
if i == 0 && j == 0 || i == 0 && j == len(al)-1 || i == len(al)-1 && j == 0 {
continue
}
for dy := -2; dy <= 2; dy++ {
for dx := -2; dx <= 2; dx++ {
g.set(x+dx, y+dy, max(qrCodeAbs(dx), qrCodeAbs(dy)) != 1)
}
}
}
}
// Reserve format and version areas; apply fills them.
g.format(-1, 0)
g.version(false)
return g
}
func qrCodeAbs(v int) int { return max(v, -v) }
func (g *qrCodeGrid) set(x, y int, dark bool) {
g.dark[y][x], g.fixed[y][x] = dark, true
}
// finder draws a finder pattern and its separator centered at (cx, cy).
func (g *qrCodeGrid) finder(cx, cy int) {
n := len(g.dark)
for dy := -4; dy <= 4; dy++ {
for dx := -4; dx <= 4; dx++ {
x, y := cx+dx, cy+dy
if x < 0 || y < 0 || x >= n || y >= n {
continue
}
d := max(qrCodeAbs(dx), qrCodeAbs(dy))
g.set(x, y, d != 2 && d != 4)
}
}
}
// format writes the 15 format bits and the dark module; levelBits -1 leaves them light.
func (g *qrCodeGrid) format(levelBits, mask int) {
data := levelBits<<3 | mask
rem := data
for range 10 {
rem = rem<<1 ^ (rem>>9)*0x537
}
b := (data<<10 | rem) ^ 0x5412
bit := func(i int) bool { return levelBits >= 0 && b>>i&1 == 1 }
n := len(g.dark)
for i := 0; i <= 5; i++ {
g.set(8, i, bit(i))
}
g.set(8, 7, bit(6))
g.set(8, 8, bit(7))
g.set(7, 8, bit(8))
for i := 9; i < 15; i++ {
g.set(14-i, 8, bit(i))
}
for i := range 8 {
g.set(n-1-i, 8, bit(i))
}
for i := 8; i < 15; i++ {
g.set(8, n-15+i, bit(i))
}
g.set(8, n-8, levelBits >= 0)
}
// version writes the 18 version bits from version 7 on, or light ones.
func (g *qrCodeGrid) version(on bool) {
if g.v < 7 {
return
}
rem := g.v
for range 12 {
rem = rem<<1 ^ (rem>>11)*0x1F25
}
b := g.v<<12 | rem
n := len(g.dark)
for i := range 18 {
dark := on && b>>i&1 == 1
a, c := n-11+i%3, i/3
g.set(a, c, dark)
g.set(c, a, dark)
}
}
// place lays the codewords in the zigzag from the bottom right; remainder bits stay light.
func (g *qrCodeGrid) place(words []byte) {
n := len(g.dark)
i := 0
for right := n - 1; right >= 1; right -= 2 {
if right == 6 {
right = 5
}
for vert := range n {
for j := range 2 {
x := right - j
up := (right+1)&2 == 0
y := vert
if up {
y = n - 1 - vert
}
if g.fixed[y][x] || i >= len(words)*8 {
continue
}
g.dark[y][x] = words[i/8]>>(7-i%8)&1 == 1
i++
}
}
}
}
func (g *qrCodeGrid) clone() *qrCodeGrid {
t := &qrCodeGrid{v: g.v, dark: make([][]bool, len(g.dark)), fixed: g.fixed}
for i, row := range g.dark {
t.dark[i] = append([]bool(nil), row...)
}
return t
}
// apply XORs mask over the data modules; the information areas stay light, as 7.8.3 evaluates them.
func (g *qrCodeGrid) apply(mask int) {
for y, row := range g.dark {
for x := range row {
if g.fixed[y][x] {
continue
}
var flip bool
switch mask {
case 0:
flip = (x+y)%2 == 0
case 1:
flip = y%2 == 0
case 2:
flip = x%3 == 0
case 3:
flip = (x+y)%3 == 0
case 4:
flip = (x/3+y/2)%2 == 0
case 5:
flip = x*y%2+x*y%3 == 0
case 6:
flip = (x*y%2+x*y%3)%2 == 0
case 7:
flip = ((x+y)%2+x*y%3)%2 == 0
}
row[x] = row[x] != flip
}
}
}
// penalty scores the symbol by the four rules of ISO/IEC 18004 7.8.3.
func (g *qrCodeGrid) penalty() int {
n := len(g.dark)
p, dark := 0, 0
row, col := make([]bool, n), make([]bool, n)
for i := range n {
for j := range n {
row[j], col[j] = g.dark[i][j], g.dark[j][i]
if row[j] {
dark++
}
if i > 0 && j > 0 && row[j] == row[j-1] && row[j] == g.dark[i-1][j] && row[j] == g.dark[i-1][j-1] {
p += 3
}
}
p += qrCodeRuns(row) + qrCodeFinders(row) + qrCodeRuns(col) + qrCodeFinders(col)
}
return p + 10*(qrCodeAbs(dark*100-50*n*n)/(5*n*n))
}
// qrCodeRuns scores runs of five or more equal modules.
func qrCodeRuns(seq []bool) int {
p, run := 0, 1
for j := 1; j <= len(seq); j++ {
if j < len(seq) && seq[j] == seq[j-1] {
run++
continue
}
if run >= 5 {
p += run - 2
}
run = 1
}
return p
}
// qrCodeFinders scores 1:1:3:1:1 patterns with four light modules on a side.
func qrCodeFinders(seq []bool) int {
core := [7]bool{true, false, true, true, true, false, true}
n, p := len(seq), 0
light := func(a, b int) bool {
for i := max(a, 0); i < min(b, n); i++ {
if seq[i] {
return false
}
}
return true
}
for idx := 0; idx+7 <= n; {
match := true
for i, d := range core {
if seq[idx+i] != d {
match = false
break
}
}
if !match {
idx++
continue
}
if idx == 0 || idx == n-7 || light(idx-4, idx) || light(idx+7, idx+11) {
p += 40
idx += 7
} else {
idx += 4
}
}
return p
}
+75
View File
@@ -0,0 +1,75 @@
package input
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestQRCodeDrawsModulesAndCaches(t *testing.T) {
text := "HELLO"
var box ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Dark})
ui.Row(c).Children(func() { box = QRCode(c, text, QRCodeOptions{Level: QRLow, Size: 116}).Bounds() })
}, 200, 200)
tt.Frame()
if box.W != 116 {
t.Fatalf("size %v", box)
}
m, _ := EncodeQR([]byte(text), QRLow)
// 21 modules plus 8 of quiet zone in 116 DIPs: 4 DIPs each.
px := func(x, y int) ui.Color {
r, g, b, _ := tt.Image().At(int(box.X)+(x+4)*4+2, int(box.Y)+(y+4)*4+2).RGBA()
return ui.RGB(uint8(r>>8), uint8(g>>8), uint8(b>>8))
}
for _, at := range [][2]int{{0, 0}, {3, 3}, {7, 7}, {10, 6}, {12, 12}} {
want := theme.Light().Surface
if m.Modules[at[1]][at[0]] {
want = theme.Light().Text
}
if got := px(at[0], at[1]); got != want {
t.Errorf("module %v is %v, want %v", at, got, want)
}
}
if _, ok := tt.Find("QR code"); !ok {
t.Fatal("no accessible name")
}
}
func TestQRCodeTooLong(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
QRCode(c, strings.Repeat("x", 1300), QRCodeOptions{Level: QRHigh})
}, 300, 300)
if !tt.HasText("Text too long for a QR code") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestQRCodeRejectsBadOptions(t *testing.T) {
for name, o := range map[string]QRCodeOptions{"level": {Level: 9}, "size": {Size: -1}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); QRCode(c, "x", o) }, 200, 200)
}()
}
}
func TestQRCodeSnapshot(t *testing.T) {
snapshot(t, "qr_code", 420, 200, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
QRCode(c, "https://example.com/mujica", QRCodeOptions{})
QRCode(c, "MujicaUI", QRCodeOptions{Level: QRHigh, Size: 100})
QRCode(c, strings.Repeat("x", 1300), QRCodeOptions{Level: QRHigh, Size: 100})
})
})
}
+111
View File
@@ -0,0 +1,111 @@
package input
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// RadioItem is one choice of a RadioGroup; Value is its stable identity.
type RadioItem[T comparable] struct {
Value T
Label string
Disabled bool
}
// RadioGroupOptions configure a RadioGroup.
type RadioGroupOptions struct {
// Horizontal lays the choices in a row; the default is a column.
Horizontal bool
// Label names the group for assistive technology.
Label string
Disabled bool
}
// RadioGroup chooses one item's Value into *value; arrows move the choice.
func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opts RadioGroupOptions) core.ChoiceResult {
if value == nil {
panic("mujica: RadioGroup needs a value")
}
if len(items) == 0 {
panic("mujica: RadioGroup needs items")
}
seen := make(map[T]bool, len(items))
for _, it := range items {
if seen[it.Value] {
panic("mujica: RadioGroup has a duplicate value")
}
seen[it.Value] = true
}
k := core.Tokens(c)
d := core.Density(c)
var r core.ChoiceResult
// Disabled on a wrapper blocks the bases' clicks before they choose.
wrap := ui.Box(c).Shrink(0).Disabled(opts.Disabled)
// Keys by first appearance of each value: fmt keys would merge equal-looking values.
known := ui.Local(wrap, "values", func() []T { return nil })
wrap.Children(func() {
r.Element = ui.RadioGroup(c, func() {
for _, it := range items {
if radioGroupItem(c, k, d, value, it, stableKey(known, it.Value)) {
r = core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: radio_group choose", "label", it.Label)
}
}
}).Label(opts.Label).Gap(0)
})
if opts.Horizontal {
r.Element.Row().Gap(d.Unit() * 6)
}
if wrap.IsDisabled() && wrap.FocusWithin() {
wrap.FocusRing(false).Focus()
}
return r
}
// radioGroupItem builds one radio and reports whether it was chosen.
func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T], key int) bool {
// Keyed by value so focus follows the item, not its position.
var row *ui.Element
item := ui.Box(c).Key(key).Disabled(it.Disabled)
item.Children(func() {
row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
})
if item.IsDisabled() && item.FocusWithin() {
item.FocusRing(false).Focus()
}
on := *value == it.Value
hover, pressed := row.Hovered(), row.Pressed()
row.Children(func() {
bg, border := checkboxColors(k, on, hover, pressed)
dot := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(checkboxSize/2).
Background(bg).Border(theme.BorderWidth, border)
dot.Transition(core.ColorFade)
dot.DrawOver(func(p *ui.Painter, r ui.Rect) {
if on {
s := r.W * 0.375
p.Fill(ui.Rect{X: r.X + (r.W-s)/2, Y: r.Y + (r.H-s)/2, W: s, H: s}, k.OnAccent, s/2)
}
if row.FocusVisible() {
internal.FocusRing(p, r, checkboxSize/2, k.Focus)
}
})
ui.Text(c, it.Label)
})
return row.Changed()
}
// stableKey keys v by its first appearance in *known, so equal-looking values stay apart.
func stableKey[T comparable](known *[]T, v T) int {
i := slices.Index(*known, v)
if i < 0 {
i = len(*known)
*known = append(*known, v)
}
return i
}
+101
View File
@@ -0,0 +1,101 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var radioGroupTestItems = []RadioItem[string]{
{Value: "s", Label: "Small"},
{Value: "m", Label: "Medium", Disabled: true},
{Value: "l", Label: "Large"},
}
func radioGroupTester(v *string, changes *int, opts RadioGroupOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if RadioGroup(c, v, radioGroupTestItems, opts).Changed() {
*changes++
}
Button(c, "After", ButtonOptions{})
}, 400, 300)
}
func TestRadioGroupClickAndArrows(t *testing.T) {
v, n := "s", 0
tt := radioGroupTester(&v, &n, RadioGroupOptions{Label: "Size"})
_ = tt.Click("Medium")
if v != "s" || n != 0 {
t.Fatalf("disabled item chosen: %q", v)
}
_ = tt.Click("Large")
if v != "l" || n != 1 {
t.Fatalf("click: %q changes %d", v, n)
}
tt.Key(0, ui.KeyDown)
if v != "s" || n != 2 {
t.Fatalf("Down wraps past the disabled item to Small, got %q changes %d", v, n)
}
tt.Key(0, ui.KeyUp)
if v != "l" {
t.Fatalf("Up skipped to %q", v)
}
tt.Key(0, ui.KeyHome)
if v != "s" {
t.Fatalf("Home chose %q", v)
}
}
func TestRadioGroupTabStopsAtChosen(t *testing.T) {
v, n := "l", 0
tt := radioGroupTester(&v, &n, RadioGroupOptions{Horizontal: true})
tt.Key(0, ui.KeyTab)
if !tt.Focused("Large") {
t.Fatal("Tab did not go to the chosen radio")
}
tt.Key(0, ui.KeyLeft)
if v != "s" {
t.Fatalf("Left in a row chose %q", v)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("After") {
t.Fatal("Tab did not leave the group")
}
}
func TestRadioGroupDisabledGroup(t *testing.T) {
v, n := "s", 0
tt := radioGroupTester(&v, &n, RadioGroupOptions{Disabled: true})
_ = tt.Click("Large")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if v != "s" || n != 0 {
t.Fatalf("disabled group chose %q", v)
}
}
func TestRadioGroupDuplicateValuePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for duplicate values")
}
}()
v := 1
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
RadioGroup(c, &v, []RadioItem[int]{{Value: 1, Label: "A"}, {Value: 1, Label: "B"}}, RadioGroupOptions{})
}, 200, 100)
}
func TestRadioGroupSnapshot(t *testing.T) {
snapshot(t, "radio_group", 460, 220, func(c *ui.Context) {
v, h := "m", 2
ui.Column(c).Padding(24).Gap(16).Children(func() {
RadioGroup(c, &v, []RadioItem[string]{{Value: "s", Label: "Sonnet"}, {Value: "m", Label: "Madrigal"}, {Value: "o", Label: "Ode", Disabled: true}}, RadioGroupOptions{})
RadioGroup(c, &h, []RadioItem[int]{{Value: 1, Label: "Matins"}, {Value: 2, Label: "Vespers"}, {Value: 3, Label: "Compline"}}, RadioGroupOptions{Horizontal: true})
})
})
}
+123
View File
@@ -0,0 +1,123 @@
package input
import (
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// RangeSliderOptions configure a RangeSlider. Min and Max both zero mean 0 to 100.
type RangeSliderOptions struct {
Min, Max float64
// Step snaps both ends to Min plus multiples of it; zero is continuous.
Step float64
Marks []SliderMark
ShowValue bool
Format func(float64) string
Disabled bool
// Label names the range; the thumbs read "<Label> minimum/maximum, value".
Label string
}
// RangeSlider sets *low and *high, which never cross; a press moves the nearer thumb, each takes the keys.
func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) core.ChoiceResult {
g, h := SliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
format := sliderFormat(opts.Format, g)
size := core.FontSize(c, theme.CaptionSize)
k := core.Tokens(c)
*low, *high = g.snap(math.Min(*low, *high)), g.snap(math.Max(*low, *high))
oldLow, oldHigh := *low, *high
e := ui.Box(c).Height(h).MinWidth(120).Shrink(0).Role(ui.RoleGroup).Disabled(opts.Disabled).Cursor(ui.CursorPointer)
if opts.Label != "" {
e.Label(opts.Label)
}
drag := ui.Local(e, rangeSliderDrag{}, func() int { return -1 })
px, _, _ := e.PointerPosition()
r := e.Bounds()
at := g.snap(g.lo + float64((px-SliderThumb/2)/max(r.W-SliderThumb, 1))*(g.hi-g.lo))
var knobs [2]*ui.Element
pressed := false
e.Children(func() {
for i, v := range []*float64{low, high} {
from, to := g.lo, *high
if i == 1 {
from, to = *low, g.hi
}
x := g.X(ui.Rect{W: r.W}, *v) - SliderThumb/2
var kb *ui.Element
var next float64
var held bool
// Each thumb is a SliderBase, which reports the range and value to assistive technology.
ui.Box(c).Key(i).Absolute().Left(x).Top(g.TrackY-SliderThumb/2).Size(SliderThumb, SliderThumb).Children(func() {
kb, next, held = SliderControl(c, *v, g, from, to)
})
kb.Size(SliderThumb, SliderThumb).FocusRing(false)
name := core.Msg(c, []string{"range_slider.min", "range_slider.max"}[i])
if opts.Label != "" {
name = opts.Label + " " + name
}
kb.Label(name)
if held {
pressed = true
*drag = i
} else if !opts.Disabled {
*v = next
}
knobs[i] = kb
}
})
if _, _, ok := e.Dragged(); ok {
pressed = true
}
if !pressed || opts.Disabled {
*drag = -1
} else {
if *drag < 0 {
*drag = 0
if math.Abs(at-*high) < math.Abs(at-*low) || at > *high {
*drag = 1
}
}
if *drag == 0 {
*low = min(at, *high)
} else {
*high = max(at, *low)
}
knobs[*drag].Focus()
}
changed := *low != oldLow || *high != oldHigh
if changed {
slog.Debug("mujica: range slider changed", "label", opts.Label, "low", *low, "high", *high)
}
lo, hi, which := *low, *high, *drag
hover := e.Hovered()
e.Draw(func(p *ui.Painter, r ui.Rect) {
x0, x1 := g.X(r, lo), g.X(r, hi)
SliderTrackPaint(p, r, g, k, opts.Marks, size, x0, x1)
for i, x := range []float32{x0, x1} {
hot := which == i || knobs[i].Hovered()
SliderThumbPaint(p, k, x, r.Y+g.TrackY, hot, knobs[i].FocusVisible())
if opts.ShowValue && (hot || hover || knobs[i].Focused()) {
rangeSliderBubble(p, k, x, r.Y+g.TrackY, format([]float64{lo, hi}[i]), i, size)
}
}
})
return core.NewChoiceResult(e, changed)
}
// rangeSliderDrag keys which thumb the pointer drags, -1 for none.
type rangeSliderDrag struct{}
// rangeSliderBubble keeps the two tips apart: the low one leans left, the high one right.
func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int, size float32) {
w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: size})
shift := (w+12)/2 - SliderThumb/2
if i == 0 {
shift = -shift
}
sliderBubblePaint(p, k, x+shift, y, s, size)
}
+92
View File
@@ -0,0 +1,92 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func rangeTester(low, high *float64, opts RangeSliderOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(216).Children(func() {
if RangeSlider(c, low, high, opts).Changed() {
*changes++
}
})
}, 300, 120)
}
func TestRangeSliderKeysPerThumb(t *testing.T) {
low, high, changes := 20.0, 80.0, 0
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 10, Label: "Price"}, &changes)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Price minimum") {
t.Fatal("first Tab did not focus the low thumb")
}
tt.Key(0, ui.KeyRight)
if low != 30 {
t.Fatalf("low %v", low)
}
tt.Key(0, ui.KeyEnd)
if low != 80 || high != 80 {
t.Fatalf("End took low to %v, high %v", low, high)
}
tt.Key(0, ui.KeyRight)
if low != 80 {
t.Fatalf("low crossed high: %v", low)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Price maximum") {
t.Fatal("second Tab did not focus the high thumb")
}
tt.Key(0, ui.KeyLeft)
if high != 80 {
t.Fatalf("high crossed low: %v", high)
}
tt.Key(0, ui.KeyEnd)
if high != 100 || changes != 3 {
t.Fatalf("high %v changes %d", high, changes)
}
}
func TestRangeSliderDragMovesNearerThumb(t *testing.T) {
low, high, changes := 0.0, 100.0, 0
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 1}, &changes)
// The track runs from x=8 to x=208.
tt.Press(8+200*0.9, 18)
tt.Move(8+200*0.7, 18)
tt.Release(8+200*0.7, 18)
if low != 0 || high != 70 {
t.Fatalf("low %v high %v", low, high)
}
tt.Press(8+200*0.1, 18)
tt.Move(8+200*0.95, 18)
tt.Release(8+200*0.95, 18)
if low != 70 || high != 70 {
t.Fatalf("dragged low past high: low %v high %v", low, high)
}
}
func TestRangeSliderOrdersSwappedEnds(t *testing.T) {
low, high, changes := 60.0, 10.0, 0
tt := rangeTester(&low, &high, RangeSliderOptions{}, &changes)
tt.Frame()
if low != 10 || high != 60 {
t.Fatalf("low %v high %v", low, high)
}
}
func TestRangeSliderSnapshot(t *testing.T) {
snapshot(t, "range_slider", 420, 170, func(c *ui.Context) {
a, b := 20.0, 70.0
d, e := 30.0, 60.0
marks := []SliderMark{{0, "0"}, {50, "50"}, {100, "100"}}
ui.Column(c).Padding(20).Gap(8).Width(380).Children(func() {
RangeSlider(c, &a, &b, RangeSliderOptions{Step: 5, Marks: marks, ShowValue: true, Label: "Price"})
RangeSlider(c, &d, &e, RangeSliderOptions{Disabled: true})
})
})
}
+138
View File
@@ -0,0 +1,138 @@
package input
import (
"fmt"
"log/slog"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// RatingOptions configure a Rating. Max is 5 and Step 1 when zero.
type RatingOptions struct {
Max int
// Step is the smallest change, such as 0.5 for half stars; it must divide 1.
Step float64
ReadOnly bool
Disabled bool
// Label names the rating; empty uses the built-in "Rating".
Label string
// ShowValue prints "3.5 / 5" beside the stars.
ShowValue bool
}
// Rating shows *value as stars and lets the pointer and arrows set it in steps.
func Rating(c *ui.Context, value *float64, opts RatingOptions) core.ChoiceResult {
n, step := opts.Max, opts.Step
if n == 0 {
n = 5
}
if step == 0 {
step = 1
}
if n < 0 || step < 0 || step > 1 || math.Abs(1/step-math.Round(1/step)) > 1e-9 {
panic(fmt.Sprintf("mujica: Rating Max %d, Step %v: Max must be positive and Step divide 1", opts.Max, opts.Step))
}
k := core.Tokens(c)
size := core.Density(c).ControlHeight() * 0.6
// Each star sits centered in a hit area of at least the minimum size.
hit := max(size, theme.MinHitSize)
label := opts.Label
if label == "" {
label = core.Msg(c, "rating.label")
}
parts := core.ChoiceResult{}
set := func(v float64) {
v = math.Max(0, math.Min(float64(n), math.Round(v/step)*step))
if v != *value {
slog.Debug("mujica: rating changed", "from", *value, "to", v)
*value = v
parts = core.NewChoiceResult(parts.Element, true)
}
}
e := ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit() * 2).Shrink(0)
parts.Element = e
e.Label(label)
e.Disabled(opts.Disabled)
disabled := e.IsDisabled()
interactive := !opts.ReadOnly && !disabled
if !disabled {
e.Focusable()
internal.Ring(e, k.Focus, theme.ControlRadius)
}
if interactive {
e.Role(ui.RoleSlider).Cursor(ui.CursorPointer)
switch {
case e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyUp):
set(*value + step)
case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown):
set(*value - step)
case e.Shortcut(0, ui.KeyHome):
set(0)
case e.Shortcut(0, ui.KeyEnd):
set(float64(n))
}
}
e.Children(func() {
stars := ui.Box(c).Size(hit*float32(n), hit).Shrink(0)
shown, fill := *value, k.AccentText
if interactive {
at := func(x float32) float64 { return math.Ceil(float64(x/hit)/step-1e-9) * step }
if _, _, ok := stars.Dragged(); ok {
x, _, _ := stars.PointerPosition()
set(at(x))
shown = *value
} else if x, _, over := stars.PointerPosition(); over {
shown, fill = math.Max(step, math.Min(float64(n), at(x))), k.AccentText.Alpha(0.6)
}
}
stars.Draw(func(p *ui.Painter, r ui.Rect) {
for i := range n {
cell := ui.Rect{X: r.X + float32(i)*hit + (hit-size)/2, Y: r.Y + (hit-size)/2, W: size, H: size}
path := ratingStar(cell)
p.StrokePath(path, 1.25, k.ControlBorder)
part := float32(math.Max(0, math.Min(1, shown-float64(i))))
if part > 0 {
p.Clip(ui.Rect{X: cell.X, Y: cell.Y, W: cell.W * part, H: cell.H}, 0, func() {
p.FillPath(path, fill)
p.StrokePath(path, 1.25, fill)
})
}
}
})
if opts.ShowValue {
ui.Text(c, fmt.Sprintf("%s / %d", strconv.FormatFloat(*value, 'f', -1, 64), n)).
TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
}
})
e.Description(fmt.Sprintf(core.Msg(c, "rating.value"), strconv.FormatFloat(*value, 'f', -1, 64), n))
return parts
}
// ratingStar returns a five-pointed star inset in r.
func ratingStar(r ui.Rect) *ui.Path {
var p ui.Path
cx, cy := r.X+r.W/2, r.Y+r.H*0.54
outer := r.W * 0.42
inner := outer * 0.45
for i := range 10 {
rad := outer
if i%2 == 1 {
rad = inner
}
a := -math.Pi/2 + float64(i)*math.Pi/5
x, y := cx+rad*float32(math.Cos(a)), cy+rad*float32(math.Sin(a))
if i == 0 {
p.MoveTo(x, y)
} else {
p.LineTo(x, y)
}
}
p.Close()
return &p
}
+120
View File
@@ -0,0 +1,120 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
if Rating(c, v, opts).Changed() {
*changes++
}
})
}, 300, 100)
}
func TestRatingKeyboardSteps(t *testing.T) {
v, n := 2.0, 0
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Taste") {
t.Fatal("rating not focusable")
}
tt.Key(0, ui.KeyRight)
if v != 2.5 {
t.Fatalf("right: %v", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != 1.5 {
t.Fatalf("down twice: %v", v)
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyRight)
if v != 5 {
t.Fatalf("end then right: %v", v)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyLeft)
if v != 0 || n != 5 {
t.Fatalf("home then left: %v, %d changes", v, n)
}
}
func TestRatingPointer(t *testing.T) {
v, n := 0.0, 0
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
size := theme.MinHitSize
// The left half of the third star.
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
if v != 2.5 || n != 1 {
t.Fatalf("click: %v, %d changes", v, n)
}
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
if n != 1 {
t.Fatalf("same value reported a change")
}
}
func TestRatingReadOnlyAndDisabled(t *testing.T) {
for _, opts := range []RatingOptions{{ReadOnly: true, Label: "Score"}, {Disabled: true, Label: "Score"}} {
v, n := 3.0, 0
tt := ratingTester(&v, opts, &n)
tt.ClickAt(12, 20)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if v != 3 || n != 0 {
t.Fatalf("%+v: value %v changes %d", opts, v, n)
}
if opts.ReadOnly != tt.Focused("Score") {
t.Fatalf("%+v: focus %v", opts, tt.Focused("Score"))
}
}
}
func TestRatingShowsValueAndPanics(t *testing.T) {
v, n := 3.5, 0
tt := ratingTester(&v, RatingOptions{Step: 0.5, ShowValue: true}, &n)
if !tt.HasText("3.5 / 5") {
t.Fatalf("texts %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("Step 0.3 accepted")
}
}()
ratingTester(&v, RatingOptions{Step: 0.3}, &n)
}
func TestRatingSnapshot(t *testing.T) {
a, b, d := 3.5, 4.0, 2.0
snapshot(t, "rating", 360, 170, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(14).AlignItems(ui.Start).Children(func() {
Rating(c, &a, RatingOptions{Step: 0.5, ShowValue: true})
Rating(c, &b, RatingOptions{ReadOnly: true, Max: 7, ShowValue: true})
Rating(c, &d, RatingOptions{Disabled: true})
})
})
}
func TestRatingHitAreaCompact(t *testing.T) {
v := 2.0
var r core.ChoiceResult
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: theme.Compact})
ui.Column(c).AlignItems(ui.Start).Children(func() {
r = Rating(c, &v, RatingOptions{})
})
}, 300, 100)
tt.Frame()
if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 {
t.Fatalf("stars %v", b)
}
}
+257
View File
@@ -0,0 +1,257 @@
package input
import (
"cmp"
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ResizablePanel is one panel of a ResizablePanelGroup.
type ResizablePanel struct {
// Label names the panel; dividers read it out.
Label string
// Min is the smallest size in DIPs; zero is 10 spacing units.
Min float32
Collapsible bool
Build func()
}
// ResizablePanelGroupOptions configure a ResizablePanelGroup.
type ResizablePanelGroupOptions struct {
// Vertical stacks the panels top to bottom.
Vertical bool
Disabled bool
}
// ResizablePanelGroupResult reports a group's changes: Resized while moving, Committed when a change is done, to save.
type ResizablePanelGroupResult struct {
Element *ui.Element
Resized bool
Committed bool
}
// resizablePanelState keeps drags and the sizes collapsed panels return to.
type resizablePanelState struct {
held int // divider being dragged, -1 for none
restored map[int]float32
}
// ResizablePanelGroup lays panels in a row with dividers; *sizes holds their fractions, summing to 1, for the caller to save.
func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePanel, opts ResizablePanelGroupOptions) ResizablePanelGroupResult {
if sizes == nil || len(panels) < 2 {
panic("mujica: ResizablePanelGroup needs sizes and two panels or more")
}
n := len(panels)
if len(*sizes) == 0 {
*sizes = make([]float32, n)
for i := range *sizes {
(*sizes)[i] = 1 / float32(n)
}
}
if len(*sizes) != n {
panic(fmt.Sprintf("mujica: ResizablePanelGroup has %d sizes for %d panels", len(*sizes), n))
}
var sum float32
for i, f := range *sizes {
if !(f >= 0) || f == 0 && !panels[i].Collapsible || panels[i].Label == "" || panels[i].Build == nil || panels[i].Min < 0 {
panic(fmt.Sprintf("mujica: ResizablePanelGroup panel %d is invalid", i))
}
sum += f
}
if math.Abs(float64(sum-1)) > 1e-3 {
panic(fmt.Sprintf("mujica: ResizablePanelGroup sizes sum to %v, not 1", sum))
}
u := core.Density(c).Unit()
k := core.Tokens(c)
var res ResizablePanelGroupResult
box := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled)
if opts.Vertical {
box.Column()
}
disabled := box.IsDisabled()
st := *ui.Local(box, "resizable_panel_group", func() *resizablePanelState { return &resizablePanelState{held: -1, restored: map[int]float32{}} })
was := box.Bounds()
internal.Remeasure(c, box, was.W, was.H)
total := was.W
if opts.Vertical {
total = was.H
}
avail := max(0, total-float32(n-1)*theme.BorderWidth)
mins := make([]float32, n)
for i, p := range panels {
mins[i] = cmp.Or(p.Min, 10*u)
}
along := func(e *ui.Element, v float32) *ui.Element {
if opts.Vertical {
return e.Height(v).FillWidth()
}
return e.Width(v).FillHeight()
}
box.Children(func() {
var grips []func()
defer func() {
// Grips go last, above the panels they overlap.
for _, g := range grips {
g()
}
}()
for i, p := range panels {
if i > 0 {
grips = append(grips, resizablePanelDivider(c, k, u, st, sizes, panels, mins, avail, i-1, opts.Vertical, disabled, &res))
}
if (*sizes)[i] == 0 {
continue
}
pane := ui.Box(c).Key(i).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label)
if avail > 0 {
along(pane, (*sizes)[i]*avail)
} else {
pane.Grow((*sizes)[i])
}
pane.Children(p.Build)
}
})
if res.Resized || res.Committed {
c.Invalidate()
}
res.Element = box
return res
}
// resizablePanelDivider builds the divider after panel i, stepped with arrows, Enter collapsing; it returns the grip's builder.
func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizablePanelState, sizes *[]float32, panels []ResizablePanel, mins []float32, avail float32, i int, vertical, disabled bool, res *ResizablePanelGroupResult) func() {
s := *sizes
div := ui.Box(c).Key(fmt.Sprint("div", i)).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).
Label(fmt.Sprintf(core.Msg(c, "resizable_panel_group.divider"), panels[i].Label, panels[i+1].Label))
if vertical {
div.Height(theme.BorderWidth).FillWidth()
} else {
div.Width(theme.BorderWidth).FillHeight()
}
// Moving the boundary by d DIPs trades size between panels i and i+1 within their minimums.
move := func(d float32) bool {
if avail <= 0 {
return false
}
a, b := s[i]*avail, s[i+1]*avail
pair := a + b
na := max(0, min(pair, a+d))
collapse := func(size, min float32, ok bool) bool { return ok && size < min/2 }
switch {
case collapse(na, mins[i], panels[i].Collapsible):
na = 0
case collapse(pair-na, mins[i+1], panels[i+1].Collapsible):
na = pair
default:
na = max(min(mins[i], pair-mins[i+1]), min(na, pair-mins[i+1]))
}
if math.Abs(float64(na-a)) < 0.01 {
return false
}
s[i], s[i+1] = na/avail, (pair-na)/avail
return true
}
toggle := func() {
j := i + 1
if !panels[j].Collapsible {
j = i
}
if !panels[j].Collapsible {
return
}
other := i + i + 1 - j
if s[j] > 0 {
st.restored[j] = s[j]
s[other] += s[j]
s[j] = 0
} else {
back := min(cmp.Or(st.restored[j], mins[j]/max(avail, 1)), s[other]-mins[other]/max(avail, 1))
s[j], s[other] = back, s[other]-back
}
res.Committed = true
slog.Debug("mujica: resizable_panel_group toggled", "panel", panels[j].Label, "size", s[j])
}
if !disabled {
back, forth := ui.KeyLeft, ui.KeyRight
if vertical {
back, forth = ui.KeyUp, ui.KeyDown
}
switch {
case div.Shortcut(0, back):
res.Committed = move(-4 * u)
case div.Shortcut(0, forth):
res.Committed = move(4 * u)
case div.Shortcut(0, ui.KeyHome):
res.Committed = move(-s[i] * avail)
case div.Shortcut(0, ui.KeyEnd):
res.Committed = move(s[i+1] * avail)
case div.Shortcut(0, ui.KeyEnter):
toggle()
}
}
if s[i] > 0 && s[i+1] > 0 {
div.Description(fmt.Sprintf("%.0f%%", 100*s[i]/(s[i]+s[i+1])))
} else {
div.Description(core.Msg(c, "split_pane.collapsed"))
}
return func() {
grip := ui.Box(c).Key(fmt.Sprint("grip", i)).Absolute().Cursor(ui.CursorResizeEW)
if vertical {
grip.Cursor(ui.CursorResizeNS)
}
dx, dy, held := grip.Dragged()
held = held && !disabled
switch {
case held:
if st.held != i {
st.held = i
div.Focus()
}
d := dx
if vertical {
d = dy
}
if d != 0 && move(d) {
res.Resized = true
}
case st.held == i:
st.held = -1
res.Committed = true
slog.Debug("mujica: resizable_panel_group resized", "divider", i, "sizes", s)
}
resizablePanelGrip(c, k, u, s, avail, i, vertical, grip, div, st.held == i)
}
}
// resizablePanelGrip places the grip over divider i and draws the divider.
func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div *ui.Element, held bool) {
// The grip is wider than the 1 DIP line, centered on the boundary.
var at float32
for _, f := range s[:i+1] {
at += f * avail
}
at += float32(i) * theme.BorderWidth
if vertical {
grip.Left(0).Right(0).Top(at - 1.5*u).Height(3 * u)
} else {
grip.Top(0).Bottom(0).Left(at - 1.5*u).Width(3 * u)
}
active := held || grip.Hovered()
div.Draw(func(p *ui.Painter, r ui.Rect) {
line := k.ControlBorder
if active {
line = k.AccentText
}
p.Fill(r, line, 0)
if div.FocusVisible() {
internal.FocusRing(p, r, 0, k.Focus)
}
})
}
+140
View File
@@ -0,0 +1,140 @@
package input
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func panelTester(sizes *[]float32, opts *ResizablePanelGroupOptions, commits *int) *ui.Tester {
panels := []ResizablePanel{
{Label: "Files", Min: 100, Collapsible: true, Build: func() {}},
{Label: "Editor", Min: 150, Build: func() {}},
{Label: "Outline", Min: 80, Build: func() {}},
}
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := ResizablePanelGroup(c, sizes, panels, *opts)
r.Element.Size(602, 300)
if r.Committed {
*commits++
}
}, 700, 400)
}
func near(a, b float32) bool { return math.Abs(float64(a-b)) < 0.002 }
func TestResizablePanelGroupKeyboard(t *testing.T) {
var sizes []float32
opts := ResizablePanelGroupOptions{}
commits := 0
tt := panelTester(&sizes, &opts, &commits)
tt.Frame()
if len(sizes) != 3 || !near(sizes[0], 1.0/3) {
t.Fatalf("default sizes %v", sizes)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Resize Files and Editor") {
t.Fatal("first divider not focused")
}
tt.Key(0, ui.KeyRight) // 16 DIPs of 600
if !near(sizes[0], (200.0+16)/600) || !near(sizes[1], (200.0-16)/600) || commits != 1 {
t.Fatalf("Right: %v (%d)", sizes, commits)
}
tt.Key(0, ui.KeyEnd) // Editor keeps its 150 minimum
if !near(sizes[1], 150.0/600) {
t.Fatalf("End: %v", sizes)
}
tt.Key(0, ui.KeyEnter)
if sizes[0] != 0 || !near(sizes[1], 1-sizes[2]) {
t.Fatalf("collapse: %v", sizes)
}
tt.Key(0, ui.KeyEnter)
if !near(sizes[0], 250.0/600) {
t.Fatalf("restore: %v", sizes)
}
opts.Disabled = true
tt.Frame()
before := sizes[0]
tt.Key(0, ui.KeyLeft)
if sizes[0] != before {
t.Fatal("disabled group resized")
}
}
func TestResizablePanelGroupDrag(t *testing.T) {
sizes := []float32{0.5, 0.25, 0.25}
opts := ResizablePanelGroupOptions{}
commits := 0
tt := panelTester(&sizes, &opts, &commits)
tt.Frame()
x := float32(300) // the first boundary
tt.Press(x, 100)
tt.Move(x-60, 100)
if commits != 0 || !near(sizes[0], 240.0/600) {
t.Fatalf("while dragging: %v (%d)", sizes, commits)
}
tt.Release(x-60, 100)
if commits != 1 {
t.Fatalf("release did not commit: %d", commits)
}
tt.Press(x-60, 100)
tt.Move(x-60-200, 100) // past half of Files' minimum: it collapses
tt.Release(x-60-200, 100)
if sizes[0] != 0 {
t.Fatalf("drag collapse: %v", sizes)
}
}
func TestResizablePanelGroupRejects(t *testing.T) {
ok := ResizablePanel{Label: "A", Build: func() {}}
for name, tc := range map[string]struct {
sizes []float32
panels []ResizablePanel
}{
"one panel": {nil, []ResizablePanel{ok}},
"count": {[]float32{1}, []ResizablePanel{ok, ok}},
"sum": {[]float32{0.5, 0.2}, []ResizablePanel{ok, ok}},
"zero fixed": {[]float32{0, 1}, []ResizablePanel{ok, ok}},
"no label": {nil, []ResizablePanel{ok, {Build: func() {}}}},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
s := tc.sizes
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ResizablePanelGroup(c, &s, tc.panels, ResizablePanelGroupOptions{})
}, 300, 200)
}()
}
}
func TestResizablePanelGroupSnapshot(t *testing.T) {
sizes := []float32{0.25, 0.5, 0.25}
vs := []float32{0.6, 0.4}
snapshot(t, "resizable_panel_group", 560, 260, func(c *ui.Context) {
k := core.Tokens(c)
pane := func(s string) func() {
return func() {
ui.Column(c).Fill().Padding(12).Background(k.Surface).Children(func() { ui.Text(c, s) })
}
}
ui.Row(c).Padding(16).Children(func() {
ResizablePanelGroup(c, &sizes, []ResizablePanel{
{Label: "Files", Build: pane("Files")},
{Label: "Editor", Build: func() {
ResizablePanelGroup(c, &vs, []ResizablePanel{{Label: "Code", Build: pane("Code")}, {Label: "Terminal", Build: pane("Terminal")}},
ResizablePanelGroupOptions{Vertical: true}).Element.Fill()
}},
{Label: "Outline", Build: pane("Outline")},
}, ResizablePanelGroupOptions{}).Element.Size(528, 228)
})
})
}
+76
View File
@@ -0,0 +1,76 @@
package input
import (
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ScrubInputOptions configure a ScrubInput; the range and step rules are NumberInput's.
type ScrubInputOptions struct {
Min, Max float64
Step float64
Precision int
Disabled bool
}
// scrubInputPixels is how far a drag moves for one step.
const scrubInputPixels float32 = 4
// ScrubInput edits *value by dragging its label sideways, one Step per 4 DIPs, or by typing in its field.
func ScrubInput(c *ui.Context, label string, value *float64, opts ScrubInputOptions) InputResult {
if label == "" || value == nil {
panic("mujica: ScrubInput needs a label and a value")
}
nopts := NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: opts.Step, Precision: opts.Precision, Label: label, Disabled: opts.Disabled}
s := NumberInputResolve(nopts)
k := core.Tokens(c)
var r InputResult
row := ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit() * 2).Disabled(opts.Disabled)
row.Children(func() {
h := ui.Box(c).Shrink(0).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Center().Cursor(ui.CursorResizeEW).Role(ui.RoleNone)
drag := ui.Local(h, "scrub_input.drag", func() float64 { return math.NaN() })
dx, _, held := h.Dragged()
scrubbed := false
switch {
case !held || h.IsDisabled():
*drag = math.NaN()
case math.IsNaN(*drag):
*drag = 0
fallthrough
default:
start := *value
*drag += float64(dx)
if n := int(*drag / float64(scrubInputPixels)); n != 0 {
*drag -= float64(n) * float64(scrubInputPixels)
// One grid step at a time, so a long drag stops at the bound.
v := start
for range max(n, -n) {
v = s.StepBy(v, max(-1, min(1, n)))
}
if v != *value {
*value, scrubbed = v, true
}
}
}
fg := k.TextMuted
if held || h.Hovered() {
fg = k.AccentText
}
h.Children(func() {
ui.Text(c, label).TextColor(fg).Underline().DecorationColor(k.Border).SingleLine()
})
r = NumberInput(c, value, nopts)
r.Element.Width(core.Density(c).Unit() * 36)
r.changed = r.changed || scrubbed
if scrubbed {
slog.Debug("mujica: scrub_input scrubbed", "label", label, "value", *value)
}
})
r.Element = row
return r
}
+84
View File
@@ -0,0 +1,84 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func scrubTester(v *float64, opts *ScrubInputOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Padding(20).Children(func() {
if ScrubInput(c, "Opacity", v, *opts).Changed() {
*n++
}
})
}, 500, 200)
}
func TestScrubInputDragAndRange(t *testing.T) {
v := 0.5
opts := ScrubInputOptions{Min: 0, Max: 1, Step: 0.1}
n := 0
tt := scrubTester(&v, &opts, &n)
r, _ := tt.Find("Opacity")
x, y := r.X+r.W/2, r.Y+r.H/2
tt.Press(x, y)
tt.Move(x+6, y) // one step, 2 DIPs carried
tt.Move(x+10, y)
tt.Release(x+10, y)
if v != 0.7 {
t.Fatalf("10 DIPs right: %v", v)
}
tt.Press(x, y)
tt.Move(x+400, y)
tt.Release(x+400, y)
if v != 1 {
t.Fatalf("past the max: %v", v)
}
tt.Press(x, y)
tt.Move(x-8, y)
tt.Release(x-8, y)
if v != 0.8 || n != 4 {
t.Fatalf("8 DIPs left: %v (%d)", v, n)
}
}
func TestScrubInputTypingAndDisabled(t *testing.T) {
v := 2.0
opts := ScrubInputOptions{Step: 1}
n := 0
tt := scrubTester(&v, &opts, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyUp)
if v != 3 {
t.Fatalf("Up in the field: %v", v)
}
tt.Command("selectAll")
tt.Type("12")
if v != 12 {
t.Fatalf("typed: %v", v)
}
opts.Disabled = true
tt.Frame()
r, _ := tt.Find("Opacity")
tt.Press(r.X+r.W/2, r.Y+r.H/2)
tt.Move(r.X+r.W/2+40, r.Y+r.H/2)
tt.Release(r.X+r.W/2+40, r.Y+r.H/2)
if v != 12 {
t.Fatalf("disabled scrub: %v", v)
}
}
func TestScrubInputSnapshot(t *testing.T) {
a, b := 0.6, 24.0
snapshot(t, "scrub_input", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ScrubInput(c, "Opacity", &a, ScrubInputOptions{Max: 1, Step: 0.1})
ScrubInput(c, "Size", &b, ScrubInputOptions{Disabled: true})
})
})
}
+142
View File
@@ -0,0 +1,142 @@
package input
import (
"fmt"
"log/slog"
"runtime"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SearchInputOptions configure a SearchInput.
type SearchInputOptions struct {
// Placeholder is empty for the built-in "Search" or "Find".
Placeholder string
// Label names the field; empty uses the placeholder.
Label string
Disabled bool
// Find turns on find-bar mode: match count, previous and next.
Find bool
// Matches is how many matches the caller found for the query.
Matches int
// Current is the chosen match index; Find mode requires it.
Current *int
}
// SearchInputResult adds find-bar moves to InputResult.
type SearchInputResult struct {
InputResult
moved bool
}
// Moved reports that the user went to another match this frame.
func (r SearchInputResult) Moved() bool { return r.moved }
// SearchInput is a search field with clear, or a find bar with match navigation.
func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchInputResult {
if query == nil {
panic("mujica: SearchInput needs a query")
}
if opts.Find && opts.Current == nil {
panic("mujica: SearchInput Find mode needs Current")
}
if opts.Matches < 0 {
panic("mujica: SearchInput Matches is negative")
}
k := core.Tokens(c)
place := opts.Placeholder
if place == "" {
place = core.Msg(c, "search_input.search")
if opts.Find {
place = core.Msg(c, "search_input.find")
}
}
name := opts.Label
if name == "" {
name = place
}
var r SearchInputResult
look := &textInputLook{disabled: opts.Disabled}
step := func(d int) {
if opts.Matches == 0 || look.disabled {
return
}
*opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches
r.moved = true
slog.Debug("mujica: search_input move", "current", *opts.Current)
}
if opts.Find {
*opts.Current = max(0, min(*opts.Current, opts.Matches-1))
}
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
if opts.Find {
// Built inside the frame, where step knows of disabled ancestors.
mac := runtime.GOOS == "darwin"
switch {
case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3):
step(1)
case mac && c.Shortcut(ui.Shift|ui.Cmd, ui.KeyG), !mac && c.Shortcut(ui.Shift, ui.KeyF3):
step(-1)
}
}
ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted)
back := ui.Local(frame, "back", func() bool { return false })
in := TextInputEditor(c, frame, query, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 10).Placeholder(place).Label(name)
r.changed = in.Changed() && !look.disabled
if opts.Find {
// Shift+Enter would reach the editor as a plain submit.
in.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyEnter && ev.Mods == ui.Shift {
*back = true
return true
}
return false
})
switch {
case *back:
*back = false
step(-1)
case in.Submitted():
step(1)
}
} else {
r.submitted = in.Submitted() && !look.disabled
}
if *query != "" && !look.disabled && textInputClear(c, in) {
*query = ""
r.changed = true
slog.Debug("mujica: search_input clear")
}
if opts.Find {
searchInputFindControls(c, *query, opts.Matches, *opts.Current, step)
}
return in
}, nil)
return r
}
// searchInputFindControls shows the match count with previous and next buttons.
func searchInputFindControls(c *ui.Context, query string, matches, current int, step func(int)) {
k := core.Tokens(c)
status := core.Msg(c, "search_input.none")
switch {
case query == "":
status = ""
case matches > 0:
status = fmt.Sprintf(core.Msg(c, "search_input.count"), current+1, matches)
}
ui.Text(c, status).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
SingleLine().Shrink(0).Role(ui.RoleStatus)
ui.Box(c).Width(theme.BorderWidth).Height(16).Background(k.Border).Shrink(0)
opts := IconButtonOptions{Variant: Ghost, Disabled: matches == 0}
if IconButton(c, icons.Must("chevron-up"), core.Msg(c, "search_input.previous"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() {
step(-1)
}
if IconButton(c, icons.Must("chevron-down"), core.Msg(c, "search_input.next"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() {
step(1)
}
}
+131
View File
@@ -0,0 +1,131 @@
package input
import (
"runtime"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSearchInputSearchClearSubmit(t *testing.T) {
q, changes, submits := "", 0, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := SearchInput(c, &q, SearchInputOptions{})
if r.Changed() {
changes++
}
if r.Submitted() {
submits++
}
}, 400, 200)
_ = tt.Click("Search")
tt.Type("rose")
tt.Key(0, ui.KeyEnter)
if q != "rose" || changes != 1 || submits != 1 {
t.Fatalf("query %q changes %d submits %d", q, changes, submits)
}
_ = tt.Click("Clear")
if q != "" || changes != 2 {
t.Fatalf("after clear %q changes %d", q, changes)
}
tt.Type("lys")
tt.Key(0, ui.KeyEscape)
if q != "" || changes != 4 {
t.Fatalf("after Escape %q changes %d", q, changes)
}
}
// searchInputFindTester counts matches of the query in a fixed text.
func searchInputFindTester(q *string, cur, moves *int, locale core.Locale) *ui.Tester {
const text = "rose lys rose crown rose"
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: locale})
n := 0
if *q != "" {
n = strings.Count(text, *q)
}
if SearchInput(c, q, SearchInputOptions{Find: true, Matches: n, Current: cur, Label: "Find in page"}).Moved() {
*moves++
}
}, 500, 200)
}
func TestSearchInputFindBarNavigation(t *testing.T) {
q, cur, moves := "", 0, 0
tt := searchInputFindTester(&q, &cur, &moves, "")
_ = tt.Click("Find in page")
tt.Type("rose")
if !tt.HasText("1 of 3") {
t.Fatalf("count missing: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEnter)
if cur != 2 || !tt.HasText("3 of 3") {
t.Fatalf("after two Enters current %d texts %q", cur, tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if cur != 0 {
t.Fatalf("Enter did not wrap, current %d", cur)
}
tt.Key(ui.Shift, ui.KeyEnter)
if cur != 2 || q != "rose" {
t.Fatalf("Shift+Enter: current %d query %q", cur, q)
}
_ = tt.Click("Previous match")
_ = tt.Click("Next match")
_ = tt.Click("Next match")
if cur != 0 || moves != 7 {
t.Fatalf("buttons: current %d moves %d", cur, moves)
}
if runtime.GOOS == "darwin" {
tt.Key(ui.Cmd, ui.KeyG)
} else {
tt.Key(0, ui.KeyF3)
}
if cur != 1 {
t.Fatalf("find-next shortcut: current %d", cur)
}
}
func TestSearchInputFindNoMatches(t *testing.T) {
q, cur, moves := "", 0, 0
tt := searchInputFindTester(&q, &cur, &moves, core.ZhCN)
_ = tt.Click("Find in page")
tt.Type("tulip")
if !tt.HasText("无匹配") {
t.Fatalf("no-match status missing: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
_ = tt.Click("下一项")
if cur != 0 || moves != 0 {
t.Fatalf("moved without matches: current %d moves %d", cur, moves)
}
}
func TestSearchInputFindNeedsCurrent(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without Current")
}
}()
q := ""
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SearchInput(c, &q, SearchInputOptions{Find: true})
}, 200, 100)
}
func TestSearchInputSnapshot(t *testing.T) {
snapshot(t, "search_input", 460, 220, func(c *ui.Context) {
a, b, f, cur := "", "Marie", "rose", 1
ui.Column(c).Padding(24).Gap(12).Width(400).Children(func() {
SearchInput(c, &a, SearchInputOptions{})
SearchInput(c, &b, SearchInputOptions{})
SearchInput(c, &f, SearchInputOptions{Find: true, Matches: 3, Current: &cur})
})
})
}
+291
View File
@@ -0,0 +1,291 @@
package input
import (
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SelectOption is one choice of a Select or MultiSelect.
type SelectOption[T comparable] struct {
Value T
Label string
Disabled bool
}
// SelectOptions configure a Select.
type SelectOptions struct {
// Placeholder shows while no option holds the value; empty uses "Select…".
Placeholder string
// Clearable adds a clear button and Backspace/Delete, which set the zero value.
Clearable bool
Disabled bool
// Empty shows in the popup when there are no options; empty uses "No options".
Empty string
// Label names the select for assistive technology.
Label string
}
// selectKey keeps this file's state apart from SelectBase's own.
type selectKey struct{ name string }
// selectTypeahead is the prefix typed on a closed select, and when.
type selectTypeahead struct {
prefix string
pending bool
at time.Time
}
type selectLifetime struct {
disabled bool
generation int
}
// Select chooses *value from options in a popup; typing on the closed select jumps to a match.
func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) core.ChoiceResult {
k := core.Tokens(c)
old := *value
// SelectBase chooses into a copy as it is built; a choice counts once the option is known enabled.
pick := old
var s *ui.SelectParts[T]
site := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
life := ui.Local(site, selectKey{"lifetime"}, func() selectLifetime { return selectLifetime{} })
if disabled := site.IsDisabled(); disabled != life.disabled {
life.disabled = disabled
life.generation++
}
site.Children(func() {
ui.Row(c).Key(life.generation).Children(func() { s = ui.SelectBase(c, &pick) })
})
tr := s.Trigger
commit := func() {
if pick != *value && !tr.IsDisabled() && selectEnabled(options, pick) {
*value = pick
}
pick = *value
}
commit()
if opts.Label != "" {
tr.Label(opts.Label)
}
ta := ui.Local(tr, selectKey{"typeahead"}, func() selectTypeahead { return selectTypeahead{} })
tr.TextCaret(ui.Rect{W: 1, H: core.Density(c).ControlHeight()}).HandleInput(func(ev ui.InputEvent) bool {
if tr.IsDisabled() {
return false
}
if ev.Kind != ui.InputText || strings.TrimSpace(ev.Text) == "" {
return false
}
if time.Since(ta.at) > time.Second {
ta.prefix = ""
}
ta.prefix += strings.ToLower(ev.Text)
ta.pending, ta.at = true, time.Now()
return true
})
if ta.pending {
ta.pending = false
if !s.Open() && !tr.IsDisabled() {
if i := selectFind(options, *value, ta.prefix); i >= 0 {
*value = options[i].Value
}
}
}
var zero T
label, has := SelectLabel(options, *value)
if opts.Clearable && has && (tr.Shortcut(0, ui.KeyBackspace) || tr.Shortcut(0, ui.KeyDelete)) && !tr.IsDisabled() {
*value, has = zero, false
}
SelectField(c, tr, s.Open(), func() {
if has {
ui.Text(c, label).SingleLine().Grow(1).Shrink(1)
} else {
ui.Text(c, selectOr(opts.Placeholder, core.Msg(c, "select.placeholder"))).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1)
}
if opts.Clearable && has && !opts.Disabled && selectClear(c, k, core.Msg(c, "select.clear")) {
*value = zero
}
})
pick = *value
lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 })
if tr.IsDisabled() {
return core.NewChoiceResult(tr, false)
}
s.Popup(func(panel *ui.Element) {
SelectPanel(c, panel)
ui.Scroll(c).MaxHeight(280).Children(func() {
if len(options) == 0 {
SelectNote(c, selectOr(opts.Empty, core.Msg(c, "select.empty")))
}
for i, o := range options {
if o.Disabled {
SelectRow(c, ui.Row(c).Disabled(true), false, false, o.Label)
continue
}
it := s.Item(o.Value)
if it.Highlighted() && *lastHi != i {
*lastHi = i
it.ScrollIntoView()
}
SelectRow(c, it, it.Highlighted(), o.Value == *value, o.Label)
}
})
})
commit()
if !s.Open() {
*lastHi = -1
}
changed := *value != old
if changed {
slog.Debug("mujica: select changed", "label", opts.Label, "value", *value)
}
return core.NewChoiceResult(tr, changed)
}
// selectEnabled reports whether v is an option that is not disabled.
func selectEnabled[T comparable](options []SelectOption[T], v T) bool {
for _, o := range options {
if o.Value == v {
return !o.Disabled
}
}
return false
}
// selectFind returns the enabled option matching typed prefix.
func selectFind[T comparable](options []SelectOption[T], current T, prefix string) int {
start := 0
// Repeating one letter cycles through the options starting with it.
if r := []rune(prefix); strings.Count(prefix, string(r[0])) == len(r) {
prefix = string(r[0])
for i, o := range options {
if o.Value == current {
start = i + 1
}
}
}
for n := range options {
i := (start + n) % len(options)
if !options[i].Disabled && strings.HasPrefix(strings.ToLower(options[i].Label), prefix) {
return i
}
}
return -1
}
func SelectLabel[T comparable](options []SelectOption[T], v T) (string, bool) {
for _, o := range options {
if o.Value == v {
return o.Label, true
}
}
return "", false
}
func selectOr(s, fallback string) string {
if s != "" {
return s
}
return fallback
}
// SelectField styles tr as a field that opens a popup, with content and a chevron.
func SelectField(c *ui.Context, tr *ui.Element, open bool, content func()) {
k := core.Tokens(c)
d := core.Density(c)
tr.MinHeight(d.ControlHeight()).MinWidth(180).Padding(0, d.Unit()*2, 0, d.Unit()*3).Gap(d.Unit() * 2).
Justify(ui.Start).Radius(theme.ControlRadius).Background(k.Surface)
internal.Ring(tr, k.Focus, theme.ControlRadius)
border := k.ControlBorder
switch {
case open:
border = k.AccentText
case tr.Hovered():
border = k.Text
}
tr.Border(theme.BorderWidth, border)
tr.Children(func() {
content()
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
internal.Mark(p, r, selectChevron(open), k.TextMuted)
})
})
}
// selectChevron names the arrow of a field: up while its popup shows.
func selectChevron(open bool) string {
if open {
return "chevron-up"
}
return "chevron-down"
}
// selectClear draws a clear cross outside the Tab order, hit area 28 DIP laid out as 18; it reports a click.
func selectClear(c *ui.Context, k theme.Tokens, name string) bool {
x := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Margin(-5).Shrink(0).Radius(theme.ControlRadius).
Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer)
clicked := x.Clicked()
if x.Hovered() {
x.Background(k.SurfaceHover)
}
x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, selectCenter(r, 14), "x", k.TextMuted) })
return clicked
}
// selectCenter returns the size×size square centered in r.
func selectCenter(r ui.Rect, size float32) ui.Rect {
return ui.Rect{X: r.X + (r.W-size)/2, Y: r.Y + (r.H-size)/2, W: size, H: size}
}
// SelectCheck reserves room for the chosen mark and draws it when on.
func SelectCheck(c *ui.Context, on bool) {
k := core.Tokens(c)
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
internal.Mark(p, r, "check", k.AccentText)
}
})
}
// SelectPanel styles a popup panel.
func SelectPanel(c *ui.Context, panel *ui.Element) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Margin(4, 0, 0, 0).Padding(4)
}
// SelectRow styles an option row: chosen has the selection fill and a check, highlighted a side bar.
func SelectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) {
k := core.Tokens(c)
d := core.Density(c)
row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2).
AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleListItem)
switch {
case chosen:
row.Background(k.Selection).TextColor(k.AccentText)
case highlighted:
row.Background(k.SurfaceHover)
}
if highlighted {
row.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
})
}
row.Children(func() {
SelectCheck(c, chosen)
ui.Text(c, label).SingleLine()
})
}
// SelectNote shows a muted line in a popup, as an empty state.
func SelectNote(c *ui.Context, s string) {
d := core.Density(c)
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).AlignItems(ui.Center).Children(func() {
ui.Text(c, s).TextColor(core.Tokens(c).TextMuted)
})
}
+150
View File
@@ -0,0 +1,150 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var selectTestOptions = []SelectOption[string]{
{Value: "apple", Label: "Apple"},
{Value: "banana", Label: "Banana"},
{Value: "blueberry", Label: "Blueberry"},
{Value: "cherry", Label: "Cherry", Disabled: true},
{Value: "date", Label: "Date"},
}
func selectTester(value *string, options []SelectOption[string], opts SelectOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Children(func() {
if Select(c, value, options, opts).Changed() {
*changes++
}
})
}, 400, 400)
}
func TestSelectClickChooses(t *testing.T) {
v, changes := "", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{Label: "Fruit"}, &changes)
if !tt.HasText("Select…") {
t.Fatal("placeholder missing")
}
if err := tt.Click("Select…"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Banana"); err != nil {
t.Fatal(err)
}
if v != "banana" || changes != 1 {
t.Fatalf("value %q changes %d", v, changes)
}
if tt.HasText("Apple") {
t.Fatal("popup stayed open")
}
}
func TestSelectDisabledOptionNotChosen(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
_ = tt.Click("Apple")
_ = tt.Click("Cherry")
if v != "apple" {
t.Fatalf("disabled option chosen: %q", v)
}
// The arrows skip it: Banana, Blueberry, then Date.
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
for range 3 {
tt.Key(0, ui.KeyDown)
}
tt.Key(0, ui.KeyEnter)
if v != "date" {
t.Fatalf("arrows chose %q, want date", v)
}
}
func TestSelectKeyboard(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.HasText("Banana") {
t.Fatal("Down did not open the popup")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if v != "banana" {
t.Fatalf("value %q", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEscape)
if tt.HasText("Date") || v != "banana" {
t.Fatalf("Escape: open=%v value %q", tt.HasText("Date"), v)
}
}
func TestSelectTypeahead(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
tt.Key(0, ui.KeyTab)
tt.TypeKey(0, ui.KeyB, "b")
if v != "banana" {
t.Fatalf("b chose %q", v)
}
tt.TypeKey(0, ui.KeyB, "b")
if v != "blueberry" {
t.Fatalf("second b chose %q", v)
}
tt.TypeKey(0, ui.KeyC, "c")
if v != "blueberry" {
t.Fatalf("typed bbc chose %q", v)
}
}
func TestSelectClear(t *testing.T) {
v, changes := "date", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{Clearable: true}, &changes)
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if v != "" || tt.HasText("Banana") {
t.Fatalf("value %q after clear, popup open %v", v, tt.HasText("Banana"))
}
v = "apple"
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyBackspace)
if v != "" || changes != 2 {
t.Fatalf("Backspace left %q, changes %d", v, changes)
}
}
func TestSelectEmptyAndDisabled(t *testing.T) {
v, changes := "", 0
tt := selectTester(&v, nil, SelectOptions{}, &changes)
_ = tt.Click("Select…")
if !tt.HasText("No options") {
t.Fatal("empty state missing")
}
v2 := "apple"
tt = selectTester(&v2, selectTestOptions, SelectOptions{Disabled: true}, &changes)
_ = tt.Click("Apple")
if tt.HasText("Banana") {
t.Fatal("disabled select opened")
}
}
func TestSelectSnapshot(t *testing.T) {
snapshot(t, "select", 660, 90, func(c *ui.Context) {
a, b, d := "", "banana", "apple"
ui.Row(c).Padding(20).Gap(16).Wrap().Children(func() {
Select(c, &a, selectTestOptions, SelectOptions{})
Select(c, &b, selectTestOptions, SelectOptions{Clearable: true})
Select(c, &d, selectTestOptions, SelectOptions{Disabled: true})
})
})
}
+154
View File
@@ -0,0 +1,154 @@
package input
import (
"cmp"
"log/slog"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SignaturePoint is a point of a stroke, in DIPs from the pad's top left.
type SignaturePoint struct{ X, Y float32 }
// SignaturePadOptions configure a SignaturePad.
type SignaturePadOptions struct {
// Height is the drawing area's height; zero is 160. The pad fills its parent's width.
Height float32
// Label names the pad; empty uses "Signature".
Label string
Disabled bool
}
// SignaturePath writes strokes as SVG path data: "M x y L x y …" per stroke.
func SignaturePath(strokes [][]SignaturePoint) string {
var b strings.Builder
num := func(v float32) string { return strconv.FormatFloat(float64(v), 'f', -1, 32) }
for _, st := range strokes {
for i, p := range st {
if b.Len() > 0 {
b.WriteByte(' ')
}
if i == 0 {
b.WriteString("M ")
} else {
b.WriteString("L ")
}
b.WriteString(num(p.X) + " " + num(p.Y))
}
}
return b.String()
}
// signaturePadDrawing is the stroke in progress.
type signaturePadDrawing struct {
active, ended bool
}
// SignaturePad records pointer strokes into *strokes; Changed reports a finished stroke or a clear.
func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadOptions) core.ChoiceResult {
if strokes == nil {
panic("mujica: SignaturePad needs strokes")
}
if !(opts.Height >= 0) || math.IsInf(float64(opts.Height), 1) {
panic("mujica: SignaturePad Height must be finite and >= 0")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
name := cmp.Or(opts.Label, core.Msg(c, "signature_pad.label"))
col := ui.Column(c).FillWidth().Gap(u * 2).Disabled(opts.Disabled)
disabled := col.IsDisabled()
changed := false
col.Children(func() {
pad := ui.Box(c).FillWidth().Height(cmp.Or(opts.Height, 160)).Focusable().Radius(theme.CardRadius).Clip().
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Cursor(ui.CursorCrosshair).
Role(ui.RoleImage).Label(name).Description(core.Msg(c, "signature_pad.hint"))
internal.Ring(pad, k.Focus, theme.CardRadius)
d := ui.Local(pad, "signature_pad", func() *signaturePadDrawing { return &signaturePadDrawing{} })
s := *d
pad.HandleInput(func(ev ui.InputEvent) bool {
if pad.IsDisabled() {
return false
}
pt := SignaturePoint{ev.X, ev.Y}
switch {
case ev.Kind == ui.InputPointerDown && ev.Button == 0:
*strokes = append(*strokes, []SignaturePoint{pt})
s.active = true
pad.Focus()
case ev.Kind == ui.InputPointerMove && s.active && len(*strokes) > 0:
last := &(*strokes)[len(*strokes)-1]
if q := (*last)[len(*last)-1]; math.Hypot(float64(pt.X-q.X), float64(pt.Y-q.Y)) >= 1 {
*last = append(*last, pt)
}
case ev.Kind == ui.InputPointerUp && s.active:
s.active, s.ended = false, true
default:
return false
}
return true
})
if s.ended {
s.ended, changed = false, true
slog.Debug("mujica: signature_pad stroke", "strokes", len(*strokes))
}
clear := func() {
if len(*strokes) > 0 {
*strokes, changed = nil, true
slog.Debug("mujica: signature_pad clear")
}
}
if !disabled && (pad.Shortcut(0, ui.KeyDelete) || pad.Shortcut(0, ui.KeyBackspace)) {
clear()
}
ink := signaturePadClone(*strokes)
empty := len(ink) == 0
size := core.FontSize(c, theme.CaptionSize)
pad.Draw(func(p *ui.Painter, r ui.Rect) {
line := r.Y + r.H - 28
p.Fill(ui.Rect{X: r.X + 16, Y: line, W: r.W - 32, H: 1}, k.Border, 0)
p.Text(r.X+16, line+4, "×", size, k.TextMuted)
if empty {
w, _ := p.MeasureText(0, ui.Span{Text: core.Msg(c, "signature_pad.empty"), Size: size})
p.Text(r.X+(r.W-w)/2, r.Y+r.H/2-size, core.Msg(c, "signature_pad.empty"), size, k.TextMuted)
}
for _, st := range ink {
var path ui.Path
for i, q := range st {
x, y := internal.Snap(p, r.X+q.X), internal.Snap(p, r.Y+q.Y)
if i == 0 {
path.MoveTo(x, y)
} else {
path.LineTo(x, y)
}
}
if len(st) == 1 {
path.Circle(internal.Snap(p, r.X+st[0].X), internal.Snap(p, r.Y+st[0].Y), 1)
}
p.StrokePath(&path, 2, k.Text)
}
})
ui.Row(c).Children(func() {
if Button(c, core.Msg(c, "signature_pad.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("trash-2"), Disabled: empty}).Clicked() && !disabled {
clear()
}
})
})
return core.NewChoiceResult(col, changed)
}
// signaturePadClone copies strokes so painting never sees later edits.
func signaturePadClone(s [][]SignaturePoint) [][]SignaturePoint {
out := make([][]SignaturePoint, len(s))
for i, st := range s {
out[i] = append([]SignaturePoint(nil), st...)
}
return out
}
+74
View File
@@ -0,0 +1,74 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSignaturePath(t *testing.T) {
got := SignaturePath([][]SignaturePoint{{{1, 2}, {3.5, 4}}, {{10, 10}}})
if want := "M 1 2 L 3.5 4 M 10 10"; got != want {
t.Fatalf("path %q, want %q", got, want)
}
if SignaturePath(nil) != "" {
t.Fatal("empty strokes made a path")
}
}
func TestSignaturePadStrokesAndClear(t *testing.T) {
var strokes [][]SignaturePoint
dis := false
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(20).Width(400).Children(func() {
if SignaturePad(c, &strokes, SignaturePadOptions{Disabled: dis}).Changed() {
n++
}
})
}, 500, 300)
tt.Press(50, 60)
tt.Move(80, 70)
tt.Move(80.5, 70) // under a DIP: not kept
tt.Move(120, 90)
tt.Release(120, 90)
if len(strokes) != 1 || len(strokes[0]) != 3 || strokes[0][0] != (SignaturePoint{30, 40}) || n != 1 {
t.Fatalf("stroke %+v, changes %d", strokes, n)
}
tt.Press(200, 60)
tt.Release(200, 60)
if len(strokes) != 2 || n != 2 {
t.Fatalf("dot: %+v", strokes)
}
tt.Key(0, ui.KeyDelete) // drawing focused the pad
if len(strokes) != 0 || n != 3 {
t.Fatalf("Delete kept %d strokes", len(strokes))
}
tt.Press(50, 60)
tt.Release(50, 60)
if err := tt.Click("Clear"); err != nil || len(strokes) != 0 {
t.Fatalf("Clear button: %v %d", err, len(strokes))
}
dis = true
tt.Frame()
tt.Press(50, 60)
tt.Move(90, 60)
tt.Release(90, 60)
if len(strokes) != 0 {
t.Fatal("disabled pad drew")
}
}
func TestSignaturePadSnapshot(t *testing.T) {
strokes := [][]SignaturePoint{{{30, 90}, {50, 60}, {60, 95}, {80, 55}, {95, 100}, {120, 70}, {150, 85}}, {{170, 60}, {175, 100}}}
var empty [][]SignaturePoint
snapshot(t, "signature_pad", 560, 260, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(250).Children(func() { SignaturePad(c, &strokes, SignaturePadOptions{Height: 180}) })
ui.Column(c).Width(250).Children(func() { SignaturePad(c, &empty, SignaturePadOptions{Height: 180}) })
})
})
}
+247
View File
@@ -0,0 +1,247 @@
package input
import (
"log/slog"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SliderMark is a tick on a slider's track, with an optional caption.
type SliderMark struct {
Value float64
Label string
}
// SliderOptions configure a Slider. Min and Max both zero mean 0 to 100.
type SliderOptions struct {
Min, Max float64
// Step snaps the value to Min plus multiples of it; zero is continuous.
Step float64
Marks []SliderMark
// ShowValue shows the value above the thumb while hovered, dragged or focused.
ShowValue bool
// Format writes the value; nil prints as many decimals as Min and Step need.
Format func(float64) string
Disabled bool
// Label names the slider for assistive technology.
Label string
}
const (
SliderThumb float32 = 16
SliderTrack float32 = 4
sliderBubble float32 = 26
sliderMarkH float32 = 18
)
// SliderGeom maps values to x positions across a slider's box; grid holds the step points.
type SliderGeom struct {
lo, hi, step float64
grid NumberInputSpec
TrackY float32
}
func (g SliderGeom) X(r ui.Rect, v float64) float32 {
f := float32((v - g.lo) / (g.hi - g.lo))
return r.X + SliderThumb/2 + (r.W-SliderThumb)*max(0, min(1, f))
}
// snap moves v to the nearest step point inside the range.
func (g SliderGeom) snap(v float64) float64 {
v = max(g.lo, min(g.hi, v))
if g.step <= 0 {
return v
}
return g.grid.StepBy(v, 0)
}
// move goes n steps from v, 1% of the range each when continuous, staying in range.
func (g SliderGeom) move(v float64, n int) float64 {
if g.step <= 0 {
return max(g.lo, min(g.hi, v+float64(n)*(g.hi-g.lo)/100))
}
return g.grid.StepBy(v, n)
}
// sliderKey is the last key released on a slider and how many presses MyGo saw before it.
type sliderKey struct {
up ui.Key
presses int
}
type sliderKeyID struct{}
// SliderControl builds a SliderBase on a copy of v and returns where the pointer or a key moves v.
func SliderControl(c *ui.Context, v float64, g SliderGeom, from, to float64) (s *ui.Element, next float64, dragged bool) {
raw := v
s = ui.SliderBase(c, &raw, g.lo, g.hi)
ks := *ui.Local(s, sliderKeyID{}, func() *sliderKey { return &sliderKey{} })
// MyGo gives key downs to the base alone (ui/base.go:118); releases still reach this handler.
s.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputKeyUp {
return false
}
ks.up = ev.Key
return true
})
up := ks.up
ks.up = 0
if _, _, dragged = s.Dragged(); dragged {
ks.presses = 0
return s, max(from, min(to, g.snap(raw))), true
}
if raw != v {
// A key press MyGo applied to the copy; its rounded result (ui/widgets.go:375) says nothing of the direction.
ks.presses++
}
if up == 0 {
return s, v, false
}
n := max(1, ks.presses)
ks.presses = 0
switch up {
case ui.KeyLeft, ui.KeyDown:
return s, max(from, min(to, g.move(v, -n))), false
case ui.KeyRight, ui.KeyUp:
return s, max(from, min(to, g.move(v, n))), false
case ui.KeyHome:
return s, g.snap(from), false
case ui.KeyEnd:
return s, g.snap(to), false
}
return s, v, false
}
func SliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c *ui.Context) (SliderGeom, float32) {
if min == 0 && max == 0 {
max = 100
}
if max <= min || step < 0 {
panic("mujica: slider needs Min < Max and Step >= 0")
}
g := SliderGeom{lo: min, hi: max, step: step}
if step > 0 {
// NumberInput's grid: exact bounds and integer step indexes at the decimals Min and Step need.
g.grid = NumberInputResolve(NumberInputOptions{Min: min, Max: max, Step: step, Precision: SliderDecimals(min, step)})
}
h := core.Density(c).ControlHeight()
g.TrackY = h / 2
if showValue {
g.TrackY += sliderBubble
h += sliderBubble
}
for _, m := range marks {
if m.Label != "" {
return g, h + sliderMarkH
}
}
return g, h
}
// SliderDecimals counts the decimals the shortest form of each value needs.
func SliderDecimals(vs ...float64) int {
n := 0
for _, v := range vs {
s := strconv.FormatFloat(v, 'f', -1, 64)
if i := strings.IndexByte(s, '.'); i >= 0 {
n = max(n, len(s)-i-1)
}
}
return n
}
func sliderFormat(format func(float64) string, g SliderGeom) func(float64) string {
if format != nil {
return format
}
prec := SliderDecimals(g.lo, g.step)
if g.step == 0 {
prec = max(prec, 1)
}
return func(v float64) string { return strconv.FormatFloat(v, 'f', prec, 64) }
}
// Slider sets *value by dragging anywhere on its box, the arrows (one Step), Home and End.
func Slider(c *ui.Context, value *float64, opts SliderOptions) core.ChoiceResult {
g, h := SliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
format := sliderFormat(opts.Format, g)
size := core.FontSize(c, theme.CaptionSize)
k := core.Tokens(c)
old := g.snap(*value)
*value = old
var s *ui.Element
var next float64
var held bool
// Disabled on a wrapper blocks the base before it takes input.
ui.Column(c).Disabled(opts.Disabled).Children(func() {
s, next, held = SliderControl(c, old, g, g.lo, g.hi)
})
s.Height(h).MinWidth(120).PaddingX(SliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer)
if opts.Label != "" {
s.Label(opts.Label)
}
if !opts.Disabled {
*value = next
}
changed := *value != old
if changed {
slog.Debug("mujica: slider changed", "label", opts.Label, "value", *value)
}
hover := s.Hovered()
v := *value
s.Draw(func(p *ui.Painter, r ui.Rect) {
SliderTrackPaint(p, r, g, k, opts.Marks, size, g.X(r, g.lo), g.X(r, v))
x := g.X(r, v)
SliderThumbPaint(p, k, x, r.Y+g.TrackY, hover || held, s.FocusVisible())
if opts.ShowValue && (hover || held || s.Focused()) {
sliderBubblePaint(p, k, x, r.Y+g.TrackY, format(v), size)
}
})
return core.NewChoiceResult(s, changed)
}
// SliderTrackPaint draws the rail, the filled part from x0 to x1, and the marks.
func SliderTrackPaint(p *ui.Painter, r ui.Rect, g SliderGeom, k theme.Tokens, marks []SliderMark, size, x0, x1 float32) {
y := r.Y + g.TrackY
left, right := g.X(r, g.lo), g.X(r, g.hi)
p.Fill(ui.Rect{X: left, Y: y - SliderTrack/2, W: right - left, H: SliderTrack}, k.Border, 0)
p.Stroke(ui.Rect{X: left, Y: y - SliderTrack/2, W: right - left, H: SliderTrack}, k.ControlBorder, 0, 1)
p.Fill(ui.Rect{X: x0, Y: y - SliderTrack/2, W: x1 - x0, H: SliderTrack}, k.AccentText, 0)
for _, m := range marks {
x := g.X(r, m.Value)
p.Fill(ui.Rect{X: x - 0.5, Y: y + SliderTrack/2 + 2, W: 1, H: 5}, k.TextMuted, 0)
if m.Label != "" {
w, _ := p.MeasureText(1000, ui.Span{Text: m.Label, Size: size})
p.Text(x-w/2, y+SliderTrack/2+8, m.Label, size, k.TextMuted)
}
}
}
// SliderThumbPaint draws the diamond thumb at (x, y), with its focus ring.
func SliderThumbPaint(p *ui.Painter, k theme.Tokens, x, y float32, hot, focus bool) {
x, y = internal.Snap(p, x), internal.Snap(p, y)
size := SliderThumb
if hot {
size += 2
}
p.FillPath(internal.Diamond(x, y, size+3), k.Surface)
p.FillPath(internal.Diamond(x, y, size), k.AccentText)
if focus {
p.StrokePath(internal.Diamond(x, y, size+3+2*(theme.FocusGap+theme.FocusWidth)), theme.FocusWidth, k.Focus)
}
}
// sliderBubblePaint draws the value tip above the thumb at (x, y).
func sliderBubblePaint(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) {
w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size})
b := ui.Rect{X: x - w/2 - 6, Y: y - SliderThumb/2 - h - 10, W: w + 12, H: h + 4}
p.Fill(b, k.Surface, theme.ControlRadius)
p.Stroke(b, k.ControlBorder, theme.ControlRadius, theme.BorderWidth)
p.Text(b.X+6, b.Y+2, s, size, k.Text)
}
+28
View File
@@ -0,0 +1,28 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
)
// MyGo rounds its 1% move to 1e-9, which can flip the apparent direction; the key decides it.
func sliderDirectionRun(t *testing.T, key ui.Key, want float64) {
t.Helper()
r := &sliderRecord{v: 6e-10, low: 6e-10, high: 8e-10}
tt := sliderGridTester(r, 0, 1e-8, 1e-10)
tt.Key(0, ui.KeyTab)
tt.Key(0, key)
if r.v != want || len(r.single) != 1 {
t.Errorf("Slider: %v, reported %v; want %v", r.v, r.single, want)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, key)
if r.low != want || len(r.lows) != 1 {
t.Errorf("RangeSlider low: %v, reported %v; want %v", r.low, r.lows, want)
}
}
func TestSliderLeftUnderMyGoRounding(t *testing.T) { sliderDirectionRun(t, ui.KeyLeft, 5e-10) }
func TestSliderRightUnderMyGoRounding(t *testing.T) { sliderDirectionRun(t, ui.KeyRight, 7e-10) }
+76
View File
@@ -0,0 +1,76 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// sliderRecord builds a Slider and a RangeSlider and records the values each Changed reported.
type sliderRecord struct {
v, low, high float64
single, lows, his []float64
}
func sliderGridTester(r *sliderRecord, min, max, step float64) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(216).Gap(10).Children(func() {
if Slider(c, &r.v, SliderOptions{Min: min, Max: max, Step: step, Label: "One"}).Changed() {
r.single = append(r.single, r.v)
}
if RangeSlider(c, &r.low, &r.high, RangeSliderOptions{Min: min, Max: max, Step: step, Label: "Two"}).Changed() {
r.lows, r.his = append(r.lows, r.low), append(r.his, r.high)
}
})
}, 300, 300)
}
func TestSliderEndSnapsToStepGrid(t *testing.T) {
r := &sliderRecord{v: 3, low: 0, high: 3}
tt := sliderGridTester(r, 0, 10, 3)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
if len(r.single) != 1 || r.single[0] != 9 || r.v != 9 {
t.Fatalf("Slider End reported %v, value %v", r.single, r.v)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
if len(r.his) != 1 || r.his[0] != 9 || r.high != 9 {
t.Fatalf("RangeSlider End reported %v, high %v", r.his, r.high)
}
}
func TestSliderTinyStep(t *testing.T) {
r := &sliderRecord{v: 0, low: 0, high: 1e-9}
tt := sliderGridTester(r, 0, 1e-9, 1e-10)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if r.v != 1e-10 {
t.Fatalf("Slider Right: %v", r.v)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if r.low != 1e-10 {
t.Fatalf("RangeSlider Right: %v", r.low)
}
}
func TestSliderKeepsPreciseMin(t *testing.T) {
const lo = 0.1234567891
r := &sliderRecord{v: 0.5, low: 0.5, high: 0.6}
tt := sliderGridTester(r, lo, 1, 0.1)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyHome)
if r.v < lo || r.v != lo {
t.Fatalf("Slider Home: %v, Min %v", r.v, lo)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyHome)
if r.low < lo || r.low != lo {
t.Fatalf("RangeSlider Home: %v, Min %v", r.low, lo)
}
}
+85
View File
@@ -0,0 +1,85 @@
package input
import (
"bytes"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSliderFineStepNearEnds(t *testing.T) {
v, changes := 99.2, 0
tt := sliderTester(&v, SliderOptions{Step: 0.1, Label: "Gain"}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if v != 99.3 {
t.Fatalf("Right from 99.2: %v", v)
}
tt.Key(0, ui.KeyEnd)
if v != 100 {
t.Fatalf("End: %v", v)
}
v = 0.5
tt.Frame()
tt.Key(0, ui.KeyLeft)
if v != 0.4 {
t.Fatalf("Left from 0.5: %v", v)
}
tt.Key(0, ui.KeyHome)
if v != 0 {
t.Fatalf("Home: %v", v)
}
}
func TestRangeSliderFineStepNearEnds(t *testing.T) {
low, high, changes := 0.3, 99.8, 0
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 0.1, Label: "Band"}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyLeft)
if low != 0.2 {
t.Fatalf("Left from 0.3: %v", low)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if high != 99.9 {
t.Fatalf("Right from 99.8: %v", high)
}
}
func TestSliderFormatKeepsStepDecimals(t *testing.T) {
for _, tc := range []struct {
g SliderGeom
v float64
want string
}{
{SliderGeom{lo: 0, hi: 1, step: 0.25}, 0.75, "0.75"},
{SliderGeom{lo: 0.5, hi: 10, step: 1}, 2.5, "2.5"},
{SliderGeom{lo: 0, hi: 100, step: 5}, 35, "35"},
} {
if got := sliderFormat(nil, tc.g)(tc.v); got != tc.want {
t.Errorf("%+v: %q, want %q", tc.g, got, tc.want)
}
}
}
func TestSliderMarksFollowTextScale(t *testing.T) {
render := func(scale float32) []byte {
v, low, high := 50.0, 20.0, 80.0
marks := []SliderMark{{Value: 0, Label: "Low"}, {Value: 100, Label: "High"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(300).Children(func() {
Slider(c, &v, SliderOptions{Marks: marks})
RangeSlider(c, &low, &high, RangeSliderOptions{Marks: marks})
})
}, 320, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: scale})
tt.Frame()
return encode(t, tt.Image())
}
if bytes.Equal(render(1), render(2)) {
t.Fatal("mark captions ignore the text scale")
}
}
+99
View File
@@ -0,0 +1,99 @@
package input
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func sliderTester(value *float64, opts SliderOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(0, 0).Width(216).Children(func() {
if Slider(c, value, opts).Changed() {
*changes++
}
})
}, 300, 120)
}
func TestSliderKeysMoveOneStep(t *testing.T) {
v, changes := 40.0, 0
tt := sliderTester(&v, SliderOptions{Step: 10, Label: "Volume"}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if v != 50 {
t.Fatalf("Right: %v, want 50", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != 30 {
t.Fatalf("Down twice: %v, want 30", v)
}
tt.Key(0, ui.KeyEnd)
if v != 100 {
t.Fatalf("End: %v", v)
}
tt.Key(0, ui.KeyRight)
if v != 100 || changes != 4 {
t.Fatalf("past the end: %v, changes %d", v, changes)
}
tt.Key(0, ui.KeyHome)
if v != 0 {
t.Fatalf("Home: %v", v)
}
}
func TestSliderDragSnaps(t *testing.T) {
v, changes := 0.0, 0
tt := sliderTester(&v, SliderOptions{Min: 0, Max: 10, Step: 2}, &changes)
// The track runs from x=8 to x=208: 200 DIPs for 0 to 10.
tt.Press(8, 18)
tt.Move(8+200*0.55, 18)
if v != 6 {
t.Fatalf("drag to 5.5: %v, want 6", v)
}
tt.Move(400, 18)
tt.Release(400, 18)
if v != 10 {
t.Fatalf("drag past the end: %v", v)
}
}
func TestSliderClampsOutOfRangeValue(t *testing.T) {
v, changes := 250.0, 0
tt := sliderTester(&v, SliderOptions{}, &changes)
tt.Frame()
if v != 100 || changes != 0 {
t.Fatalf("value %v changes %d", v, changes)
}
}
func TestSliderBadRange(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
v := 0.0
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
Slider(c, &v, SliderOptions{Min: 5, Max: 1})
}, 100, 100, 1)
}
func TestSliderSnapshot(t *testing.T) {
snapshot(t, "slider", 420, 230, func(c *ui.Context) {
a, b, d := 30.0, 60.0, 50.0
marks := []SliderMark{{0, "0"}, {25, "25"}, {50, "50"}, {75, "75"}, {100, "100"}}
ui.Column(c).Padding(20).Gap(8).Width(380).Children(func() {
Slider(c, &a, SliderOptions{Step: 5})
s := Slider(c, &b, SliderOptions{Step: 25, Marks: marks, ShowValue: true, Format: func(v float64) string { return fmt.Sprintf("%.0f%%", v) }})
s.Element.Focus()
Slider(c, &d, SliderOptions{Disabled: true})
})
})
}
+80
View File
@@ -0,0 +1,80 @@
package input
import (
"bytes"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSpinnerLabelAndSize(t *testing.T) {
var mark ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: core.ZhCN})
core.Spinner(c, core.SpinnerOptions{})
core.Spinner(c, core.SpinnerOptions{Size: 32, Label: "Fetching mail", ShowLabel: true})
}, 400, 300)
r, ok := tt.Find("加载中")
if !ok || r.W != 16 || r.H != 16 {
t.Fatalf("default mark %v %v", r, ok)
}
if n := len(slices.DeleteFunc(tt.Texts(), func(s string) bool { return s != "加载中" })); n != 1 {
t.Fatalf("hidden label painted: %d", n)
}
mark, ok = tt.Find("Fetching mail")
if !ok || mark.W != 32 {
t.Fatalf("sized mark %v %v", mark, ok)
}
n := 0
for _, s := range tt.Texts() {
if s == "Fetching mail" {
n++
}
}
if n != 2 {
t.Fatalf("label as name and text: %d of %q", n, tt.Texts())
}
}
func TestSpinnerMotion(t *testing.T) {
show := true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if show {
core.Spinner(c, core.SpinnerOptions{Size: 40})
}
}, 100, 100)
a := encode(t, tt.Image())
time.Sleep(150 * time.Millisecond)
tt.Frame()
if bytes.Equal(a, encode(t, tt.Image())) {
t.Fatal("spinner did not turn")
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
a = encode(t, tt.Image())
time.Sleep(150 * time.Millisecond)
tt.Frame()
if !bytes.Equal(a, encode(t, tt.Image())) {
t.Fatal("spinner turned under reduced motion")
}
show = false
tt.Frame()
if _, ok := tt.Find("Loading"); ok {
t.Fatal("hidden spinner still built")
}
}
func TestSpinnerSnapshot(t *testing.T) {
snapshot(t, "spinner", 360, 100, func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(24).AlignItems(ui.Center).Children(func() {
core.Spinner(c, core.SpinnerOptions{})
core.Spinner(c, core.SpinnerOptions{Size: 24})
core.Spinner(c, core.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})
Button(c, "Saving", ButtonOptions{Loading: true})
})
})
}
+65
View File
@@ -0,0 +1,65 @@
package input
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestFormFocusFollowsFieldAfterReorder(t *testing.T) {
var st FormState
first, second := "", ""
swapped := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Form(c, &st, FormOptions{}, func(f *FormScope) {
fields := []func(){
func() {
FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() *ui.Element {
return TextInput(c, &first, TextInputOptions{Label: "First"}).Input
})
},
func() {
FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() *ui.Element {
return TextInput(c, &second, TextInputOptions{Label: "Second"}).Input
})
},
}
if swapped {
slices.Reverse(fields)
}
for _, fn := range fields {
fn()
}
})
}, 500, 400)
if err := tt.Click("First"); err != nil {
t.Fatal(err)
}
tt.Type("a")
swapped = true
tt.Frame()
tt.Type("b")
if first != "ab" || second != "" {
t.Fatalf("first %q second %q, want typing to stay in First", first, second)
}
}
func TestToggleGroupFocusFollowsValueAfterReorder(t *testing.T) {
var on []string
items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToggleGroup(c, &on, items, ToggleGroupOptions{Multiple: true, Label: "Align"})
}, 400, 200)
tt.Key(0, ui.KeyTab)
slices.Reverse(items)
tt.Frame()
tt.Key(0, ui.KeySpace)
if !slices.Equal(on, []string{"left"}) {
t.Fatalf("pressed %v, want [left]", on)
}
}
+91
View File
@@ -0,0 +1,91 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SwitchOptions configure a Switch.
type SwitchOptions struct {
Disabled bool
// Loading shows a pending result: the switch keeps its place and takes no input.
Loading bool
// Description is read out after the label.
Description string
}
const (
switchW float32 = 36
switchH float32 = 20
switchK float32 = 14
)
// Switch turns *on over by click or Space; Changed reports it, the caller applies it.
func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element {
k := core.Tokens(c)
d := core.Density(c)
blocked := opts.Disabled || opts.Loading
var sw *ui.Element
// Disabled on a wrapper blocks the base before it takes input.
ui.Row(c).Shrink(0).Disabled(blocked).Children(func() {
sw = ui.SwitchBase(c, on).Gap(d.Unit() * 2).AlignItems(ui.Center).
MinHeight(d.ControlHeight()).FocusRing(false)
})
if opts.Description != "" {
sw.Description(opts.Description)
}
if sw.Changed() {
slog.Debug("mujica: switch changed", "label", label, "on", *on)
}
hover := sw.Hovered()
pos := sw.Animate("on", switchFloat(*on), core.Motion(c, theme.StateDuration))
sw.Children(func() {
track := ui.Box(c).Size(switchW, switchH).Shrink(0).Radius(theme.CardRadius)
bg, border := k.Surface, k.ControlBorder
thumb := k.ControlBorder
if *on {
bg, border, thumb = k.Accent, k.Accent, k.OnAccent
if hover {
bg, border = k.AccentHover, k.AccentHover
}
} else if hover {
bg, border, thumb = k.SurfaceHover, k.Text, k.Text
}
track.Background(bg).Border(theme.BorderWidth, border)
track.Draw(func(p *ui.Painter, r ui.Rect) {
if *on {
// A short upright bar marks "on" without relying on color.
p.Fill(ui.Rect{X: r.X + 9, Y: r.Y + 6, W: 1.5, H: r.H - 12}, k.OnAccent, 0)
}
})
track.DrawOver(func(p *ui.Painter, r ui.Rect) {
if sw.FocusVisible() {
internal.FocusRing(p, r, theme.CardRadius, k.Focus)
}
})
inset := (switchH - switchK) / 2
x := inset + pos*(switchW-switchK-2*inset)
track.Children(func() {
t := ui.Box(c).Absolute().Left(x).Top(inset).Size(switchK, switchK).Radius(theme.ControlRadius).Background(thumb).Center()
if opts.Loading {
t.Children(func() { core.Spinner(c, core.SpinnerOptions{Size: 10, Color: bg}) })
}
})
if label != "" {
ui.Text(c, label).SingleLine()
}
})
return sw
}
func switchFloat(b bool) float32 {
if b {
return 1
}
return 0
}
+81
View File
@@ -0,0 +1,81 @@
package input
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSwitchToggles(t *testing.T) {
on, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if Switch(c, &on, "Wi-Fi", SwitchOptions{}).Changed() {
changes++
}
}, 300, 120)
if err := tt.Click("Wi-Fi"); err != nil {
t.Fatal(err)
}
if !on || changes != 1 {
t.Fatalf("on=%v changes=%d after click", on, changes)
}
tt.Key(0, ui.KeySpace)
if on || changes != 2 {
t.Fatalf("on=%v changes=%d after Space", on, changes)
}
}
func TestSwitchKeyboardFocus(t *testing.T) {
on := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Switch(c, &on, "Sync", SwitchOptions{})
}, 300, 120)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Sync") {
t.Fatal("Tab did not focus the switch")
}
tt.Key(0, ui.KeySpace)
if !on {
t.Fatal("Space did not turn it on")
}
}
func TestSwitchBlockedStates(t *testing.T) {
for _, opts := range []SwitchOptions{{Disabled: true}, {Loading: true}} {
on := true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Switch(c, &on, "Backup", opts)
}, 300, 120)
_ = tt.Click("Backup")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !on {
t.Fatalf("%+v: value changed", opts)
}
if !tt.HasText("Backup") {
t.Fatalf("%+v: label lost", opts)
}
}
}
func TestSwitchSnapshot(t *testing.T) {
snapshot(t, "switch", 480, 120, func(c *ui.Context) {
on, off := true, false
ui.Column(c).Padding(20).Gap(12).Children(func() {
ui.Row(c).Gap(24).Children(func() {
Switch(c, &on, "On", SwitchOptions{})
Switch(c, &off, "Off", SwitchOptions{})
Switch(c, &on, "Saving", SwitchOptions{Loading: true})
})
ui.Row(c).Gap(24).Children(func() {
Switch(c, &on, "Disabled on", SwitchOptions{Disabled: true})
Switch(c, &off, "Disabled off", SwitchOptions{Disabled: true})
})
})
})
}
Loaded 100 of 1822 files, more files were not shown because too many files have changed in this diff. Show more