Forms: components 041-050

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:24:13 -04:00
1 parent bff4c3f10d
commit e82a1ae6c2
53 files changed
+4341 -1

No files matched your search

+396
View File
@@ -0,0 +1,396 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ColorPickerOptions configure a ColorPicker.
type ColorPickerOptions struct {
// Presets are offered as swatches; nil uses the built-in court palette.
Presets []ui.Color
// Label names the trigger; empty uses the built-in "Color".
Label string
Disabled bool
}
// ColorPickerParts are the parts of a ColorPicker.
type ColorPickerParts struct {
Trigger *ui.Element
changed bool
}
// Changed reports whether the user picked a new color this frame.
func (p ColorPickerParts) Changed() bool { return p.changed }
// colorPickerPresets are the default swatches: court tones and neutrals.
var colorPickerPresets = []ui.Color{
ui.Hex("#7D2034"), ui.Hex("#A12635"), ui.Hex("#AD9164"), ui.Hex("#805714"),
ui.Hex("#326247"), ui.Hex("#355E80"), ui.Hex("#4B3A5A"), ui.Hex("#211C1D"),
ui.Hex("#6A5B55"), ui.Hex("#D2C3AF"), ui.Hex("#F2EBDD"), ui.Hex("#FFFFFF"),
}
// colorPickerHSVA keeps hue for grays, which RGB loses.
type colorPickerHSVA struct{ h, s, v, a float64 }
type colorPickerState struct {
open, wasOpen bool
hsv colorPickerHSVA
last ui.Color
hex, r, g, b string
alpha string
bad string
}
// ColorPicker is a swatch button opening a panel to pick *color: a saturation and
// brightness square, hue and opacity sliders, HEX, RGB and opacity fields, and presets.
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "color_picker.label")
}
presets := opts.Presets
if presets == nil {
presets = colorPickerPresets
}
parts := ColorPickerParts{}
trigger := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*1.5).Gap(d.Unit() * 2).
Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled).Label(label)
parts.Trigger = trigger
internal.Ring(trigger, k.Focus, theme.ControlRadius)
st := ui.Local(trigger, "picker", func() colorPickerState {
s := colorPickerState{last: *color, hsv: colorPickerToHSVA(*color)}
s.sync(*color)
return s
})
if *color != st.last {
st.hsv, st.last = colorPickerToHSVA(*color), *color
st.sync(*color)
}
set := func(x colorPickerHSVA, fields bool) {
x.s, x.v, x.a = colorPickerClamp(x.s), colorPickerClamp(x.v), colorPickerClamp(x.a)
st.hsv = x
next := x.color()
if next != *color {
slog.Debug("mujica: color picker changed", "color", colorPickerHex(next))
*color = next
parts.changed = true
}
st.last = *color
if fields {
st.sync(*color)
}
}
if trigger.Clicked() {
st.open = !st.open
}
border := k.ControlBorder
if trigger.Hovered() && !opts.Disabled {
border = k.ControlBorder.Mix(k.Text, 0.25)
trigger.Background(k.SurfaceHover)
}
trigger.Border(theme.BorderWidth, border).Description(colorPickerHex(*color))
now := *color
trigger.Children(func() {
ui.Box(c).Size(d.Unit()*6, d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Clip().Shrink(0).
Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.Fill(r, now, 0)
})
ui.Text(c, strings.ToUpper(colorPickerHex(now))).Font(theme.MonoFont).SingleLine()
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted)
})
opened := st.open && !st.wasOpen
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()*3).Gap(d.Unit()*3).Width(260).Radius(theme.PopupRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.18))
colorPickerSquare(c, st, set, opened)
x := st.hsv
h := x.h
if colorPickerSlider(c, &h, 360, msg(c, "color_picker.hue"), func(p *ui.Painter, r ui.Rect) {
hues := []ui.Color{ui.RGB(255, 0, 0), ui.RGB(255, 255, 0), ui.RGB(0, 255, 0), ui.RGB(0, 255, 255), ui.RGB(0, 0, 255), ui.RGB(255, 0, 255), ui.RGB(255, 0, 0)}
w := r.W / 6
for i := range 6 {
p.FillGradient(ui.Rect{X: r.X + w*float32(i), Y: r.Y, W: w + 0.5, H: r.H}, ui.LinearGradient{From: hues[i], To: hues[i+1], Angle: 90}, 0)
}
}).Changed() {
set(colorPickerHSVA{h, x.s, x.v, x.a}, true)
}
a := x.a
opaque := colorPickerHSVA{x.h, x.s, x.v, 1}.color()
if colorPickerSlider(c, &a, 1, msg(c, "color_picker.alpha"), func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.FillGradient(r, ui.LinearGradient{From: opaque.Alpha(0), To: opaque, Angle: 90}, 0)
}).Changed() {
set(colorPickerHSVA{x.h, x.s, x.v, a}, true)
}
colorPickerFields(c, st, color, set)
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, sw := range presets {
hex := strings.ToUpper(colorPickerHex(sw))
b := ui.ButtonBase(c).Height(d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
internal.Ring(b, k.Focus, theme.ControlRadius)
chosen := *color == sw
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
colorPickerCheckers(p, r, 4)
p.Fill(r, sw, 0)
})
if chosen {
mark := ui.RGB(255, 255, 255)
if colorPickerLuma(sw) > 0.55 {
mark = ui.RGB(0, 0, 0)
}
b.Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(mark) })
}
if b.Clicked() {
set(colorPickerToHSVA(sw), true)
}
}
})
})
if st.wasOpen && !st.open {
trigger.Focus()
}
st.wasOpen = st.open
return parts
}
// colorPickerSquare picks saturation across and brightness up.
func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPickerHSVA, bool), opened bool) {
k := Tokens(c)
x := st.hsv
sq := ui.Box(c).Height(150).Radius(theme.ControlRadius).Focusable().Role(ui.RoleSlider).Cursor(ui.CursorCrosshair).
Label(msg(c, "color_picker.square"))
sq.FocusRing(false)
if opened {
sq.Focus()
}
if _, _, ok := sq.Dragged(); ok {
px, py, _ := sq.PointerPosition()
if b := sq.Bounds(); b.W > 0 && b.H > 0 {
set(colorPickerHSVA{x.h, float64(px / b.W), 1 - float64(py/b.H), x.a}, true)
}
}
switch {
case sq.Shortcut(0, ui.KeyRight):
set(colorPickerHSVA{x.h, x.s + 0.01, x.v, x.a}, true)
case sq.Shortcut(0, ui.KeyLeft):
set(colorPickerHSVA{x.h, x.s - 0.01, x.v, x.a}, true)
case sq.Shortcut(0, ui.KeyUp):
set(colorPickerHSVA{x.h, x.s, x.v + 0.01, x.a}, true)
case sq.Shortcut(0, ui.KeyDown):
set(colorPickerHSVA{x.h, x.s, x.v - 0.01, x.a}, true)
}
x = st.hsv
sq.Description(fmt.Sprintf(msg(c, "color_picker.sv"), x.s*100, x.v*100))
hue := colorPickerHSVA{x.h, 1, 1, 1}.color()
sq.Draw(func(p *ui.Painter, r ui.Rect) {
p.FillGradient(r, ui.LinearGradient{From: ui.RGB(255, 255, 255), To: hue, Angle: 90}, theme.ControlRadius)
p.FillGradient(r, ui.LinearGradient{From: ui.RGBA(0, 0, 0, 0), To: ui.RGB(0, 0, 0), Angle: 180}, theme.ControlRadius)
cx, cy := internal.Snap(p, r.X+r.W*float32(x.s)), internal.Snap(p, r.Y+r.H*float32(1-x.v))
p.FillPath(internal.Diamond(cx, cy, 14), ui.RGB(0, 0, 0).Alpha(0.5))
p.FillPath(internal.Diamond(cx, cy, 11), ui.RGB(255, 255, 255))
p.FillPath(internal.Diamond(cx, cy, 6), x.color().Alpha(1))
if sq.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
}
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
k := Tokens(c)
const knob = 10
s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name)
frac := float32(*v / hi)
s.Draw(func(p *ui.Painter, r ui.Rect) {
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
x := r.X + knob/2 + (r.W-knob)*frac
thumb := ui.Rect{X: x - knob/2, Y: r.Y, W: knob, H: r.H}
p.Fill(thumb, k.Surface, theme.ControlRadius)
p.Stroke(thumb, k.Text, theme.ControlRadius, 1.5)
if s.FocusVisible() {
internal.FocusRing(p, thumb, theme.ControlRadius, k.Focus)
}
})
return s
}
// sync shows col in every field.
func (st *colorPickerState) sync(col ui.Color) {
st.hex = strings.TrimPrefix(strings.ToUpper(colorPickerHex(col)), "#")
st.r, st.g, st.b = strconv.Itoa(int(col.R)), strconv.Itoa(int(col.G)), strconv.Itoa(int(col.B))
st.alpha = strconv.Itoa(int(math.Round(float64(col.A) / 255 * 100)))
st.bad = ""
}
// colorPickerFields edits the color as HEX, RGB and opacity text.
func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set func(colorPickerHSVA, bool)) {
k := Tokens(c)
d := Density(c)
apply := func(field string, text *string, col ui.Color, ok bool) {
if !ok {
st.bad = field
return
}
kept := *text
set(colorPickerToHSVA(col), true)
*text = kept
}
editing := false
channel := func(s string) (uint8, bool) {
n, err := strconv.Atoi(strings.TrimSpace(s))
return uint8(n), err == nil && n >= 0 && n <= 255
}
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
hex := InputGroup(c, &st.hex, InputGroupOptions{Prefix: "#", Label: "HEX", Invalid: st.bad == "hex"})
editing = hex.Input.Focused()
if hex.Changed() {
col, err := colorPickerParseHex(st.hex)
apply("hex", &st.hex, col, err == nil)
}
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, f := range []struct {
name, label string
text *string
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
ui.Column(c).Gap(d.Unit()).Children(func() {
ui.Text(c, f.name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
editing = editing || in.Input.Focused()
if !in.Changed() {
return
}
col := *color
v, ok := channel(*f.text)
switch f.name {
case "R":
col.R = v
case "G":
col.G = v
case "B":
col.B = v
default:
ok = ok && v <= 100
col.A = uint8(math.Round(float64(v) / 100 * 255))
}
apply(f.name, f.text, col, ok)
})
}
})
if !editing {
st.sync(*color)
}
if st.bad != "" {
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(theme.CaptionSize).TextColor(k.Danger).Role(ui.RoleStatus)
}
})
}
// colorPickerHex returns col as #rrggbb, or #rrggbbaa when translucent.
func colorPickerHex(col ui.Color) string {
if col.A == 255 {
return fmt.Sprintf("#%02x%02x%02x", col.R, col.G, col.B)
}
return fmt.Sprintf("#%02x%02x%02x%02x", col.R, col.G, col.B, col.A)
}
// colorPickerParseHex parses rgb, rrggbb or rrggbbaa, with or without "#".
func colorPickerParseHex(s string) (ui.Color, error) {
h := strings.TrimPrefix(strings.TrimSpace(s), "#")
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
if len(h) == 6 {
h += "ff"
}
if len(h) != 8 {
return ui.Color{}, fmt.Errorf("mujica: color %q is not hex", s)
}
v, err := strconv.ParseUint(h, 16, 32)
if err != nil {
return ui.Color{}, fmt.Errorf("mujica: color %q: %w", s, err)
}
return ui.Color{R: uint8(v >> 24), G: uint8(v >> 16), B: uint8(v >> 8), A: uint8(v)}, nil
}
func colorPickerToHSVA(col ui.Color) colorPickerHSVA {
r, g, b := float64(col.R)/255, float64(col.G)/255, float64(col.B)/255
hi, lo := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
d := hi - lo
var h float64
switch {
case d == 0:
case hi == r:
h = 60 * math.Mod((g-b)/d, 6)
case hi == g:
h = 60 * ((b-r)/d + 2)
default:
h = 60 * ((r-g)/d + 4)
}
if h < 0 {
h += 360
}
s := 0.0
if hi > 0 {
s = d / hi
}
return colorPickerHSVA{h, s, hi, float64(col.A) / 255}
}
func (x colorPickerHSVA) color() ui.Color {
ch := x.v * x.s
h := math.Mod(x.h, 360) / 60
y := ch * (1 - math.Abs(math.Mod(h, 2)-1))
var r, g, b float64
switch {
case h < 1:
r, g = ch, y
case h < 2:
r, g = y, ch
case h < 3:
g, b = ch, y
case h < 4:
g, b = y, ch
case h < 5:
r, b = y, ch
default:
r, b = ch, y
}
m := x.v - ch
byte8 := func(v float64) uint8 { return uint8(math.Round(colorPickerClamp(v) * 255)) }
return ui.Color{R: byte8(r + m), G: byte8(g + m), B: byte8(b + m), A: byte8(x.a)}
}
func colorPickerClamp(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// colorPickerLuma is the relative brightness of col, 0 to 1.
func colorPickerLuma(col ui.Color) float64 {
return (0.2126*float64(col.R) + 0.7152*float64(col.G) + 0.0722*float64(col.B)) / 255
}
// colorPickerCheckers paints the grid that shows transparency.
func colorPickerCheckers(p *ui.Painter, r ui.Rect, size float32) {
p.Fill(r, ui.RGB(255, 255, 255), 0)
for y, row := r.Y, 0; y < r.Y+r.H; y, row = y+size, row+1 {
for x, col := r.X, 0; x < r.X+r.W; x, col = x+size, col+1 {
if (row+col)%2 == 0 {
p.Fill(ui.Rect{X: x, Y: y, W: min(size, r.X+r.W-x), H: min(size, r.Y+r.H-y)}, ui.RGB(204, 204, 204), 0)
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
if ColorPicker(c, col, opts).Changed() {
*changes++
}
})
}, 400, 560)
}
func TestColorPickerPresetAndHex(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
if err := tt.Click("Accent"); err != nil {
t.Fatal(err)
}
if err := tt.Click("#355E80"); err != nil {
t.Fatal(err)
}
if col != ui.Hex("#355E80") || n != 1 {
t.Fatalf("preset: %v changes %d", col, n)
}
if err := tt.Click("HEX"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("ff000080")
if col != (ui.Color{R: 255, A: 128}) {
t.Fatalf("hex: %v", col)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("zz")
if col != (ui.Color{R: 255, A: 128}) || !tt.HasText(colorPickerInvalidEN) {
t.Fatalf("invalid hex changed the color or showed no error: %v", col)
}
}
const colorPickerInvalidEN = "Use HEX RGB, RRGGBB or RRGGBBAA; channels 0–255; opacity 0–100."
func TestColorPickerRGBAndAlphaFields(t *testing.T) {
col, n := ui.RGB(0, 0, 0), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Ink"}, &n)
_ = tt.Click("Ink")
_ = tt.Click("Green")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("200")
_ = tt.Click("Opacity percent")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("50")
if col != (ui.Color{G: 200, A: 128}) {
t.Fatalf("rgb: %v", col)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("300")
if col.A != 128 || !tt.HasText(colorPickerInvalidEN) {
t.Fatalf("opacity 300 accepted: %v", col)
}
}
func TestColorPickerKeyboardAndEscape(t *testing.T) {
col, n := ui.Hex("#808080"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Shade"}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.Focused("Saturation and brightness") {
t.Fatal("square not focused on open")
}
tt.Key(0, ui.KeyUp)
if col == ui.Hex("#808080") || col.R != col.G {
t.Fatalf("up arrow: %v", col)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Hue") {
t.Fatal("hue slider not next")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEscape)
if tt.HasText("HEX") || !tt.Focused("Shade") {
t.Fatal("escape did not close and return focus")
}
}
func TestColorPickerDisabledAndParse(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent", Disabled: true}, &n)
_ = tt.Click("Accent")
if tt.HasText("HEX") {
t.Fatal("disabled picker opened")
}
for in, want := range map[string]ui.Color{"#abc": ui.Hex("#aabbcc"), "7D2034": ui.Hex("#7D2034"), "#00000000": {}} {
got, err := colorPickerParseHex(in)
if err != nil || got != want {
t.Errorf("%q: %v %v", in, got, err)
}
}
for _, in := range []string{"", "#12", "#12345g", "#1234567"} {
if _, err := colorPickerParseHex(in); err == nil {
t.Errorf("%q parsed", in)
}
}
}
func TestColorPickerSnapshot(t *testing.T) {
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
snapshot(t, "color_picker", 340, 520, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
p := ColorPicker(c, &a, ColorPickerOptions{})
st := ui.Local(p.Trigger, "snap", func() bool { return false })
if !*st {
*st = true
ui.Local(p.Trigger, "picker", func() colorPickerState { return colorPickerState{} }).open = true
c.Invalidate()
}
})
})
}
+59
View File
@@ -0,0 +1,59 @@
# 041 Form
表单。
## 用途
组合多个字段:注册字段、同步与调用方提供的异步校验、提交与重置、提交中防重入、提交失败时定位第一个错误字段。Form 不保存业务模型,不调用远程接口,也不做持久化;值始终由应用持有。
## 最小示例
```go
var state mujica.FormState
name := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
})
})
if res.Submitted() {
state.EndSubmit("") // after saving name
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐(MyGo `ui.Form` 布局)。 |
| `FormOptions.SubmitLabel` | `string` | 内置“Submit/提交” | 提交按钮文字。 |
| `FormOptions.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 |
| `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 |
| `FormItemOptions[T].Required` | `bool` | false | 标记必填;零值、空字符串、空切片或空映射视为缺失。 |
| `FormItemOptions[T].Description` | `string` | "" | 说明文字;异步校验进行中显示“Checking…”。 |
| `FormItemOptions[T].Initial` | `T` | 零值 | 重置时写回的初始值。 |
| `FormItemOptions[T].Validate` | `func(T) string` | nil | 同步校验,返回错误文字或 "";跨字段规则在此读取其他值。 |
| `FormItemOptions[T].Async` | `func(v T, version uint64)` | nil | 启动异步校验;完成后在界面线程调用 `FormState.Resolve(name, version, msg)`。 |
## 状态
默认;字段错误(触碰后或首次提交后才显示);异步校验中(说明显示 Checking…);提交中(提交按钮显示加载并阻止重复提交,重置禁用);提交失败(表单级错误显示在按钮上方,保留全部值)。
## 事件
`FormParts.Submitted()`:全部校验通过的一次提交,处理完毕后调用 `FormState.EndSubmit(err)`。`FormParts.Reset()`:已恢复初始值并清除校验与提交状态。`FormState.Submit()` 可在 Enter 等处请求提交。`FormState.Error(name)` 返回当前应显示的错误,用于给控件设置 Invalid。
## 键盘操作
Tab / Shift+Tab 在字段间移动,离开字段即校验。提交失败后焦点移到第一个错误字段并滚动到可见处,同时向屏幕阅读器播报。点击标签聚焦控件。
## 限制
- 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。
- 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。
- 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。
- 必填标记以“ *”附在标签文字后,屏幕阅读器会一起读出;MyGo 的 Field 标签不支持富文本,无法单独着色。
+49
View File
@@ -0,0 +1,49 @@
# 042 FormField
表单字段。
## 用途
为单个控件提供标签、必填标记、说明与错误文字。标签通过 MyGo `ui.Field` 关联控件:屏幕阅读器以标签命名控件,并读出说明与错误;点击标签聚焦控件。
## 最小示例
```go
email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `label` | `string` | 必填 | 标签文字;空值 panic。 |
| `FormFieldOptions.Required` | `bool` | false | 在标签后加“ *”。 |
| `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 |
| `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 |
| `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 |
| `control` | `func()` | 必填 | 构建控件;其中第一个可获取焦点的元素即被关联的控件。 |
## 状态
默认、必填、带说明、错误;禁用由控件自身决定,标签随之变淡。
## 事件
无自身事件;事件来自内部控件。
## 键盘操作
Tab 直接到达控件;点击标签聚焦控件,或切换复选框、开关。
## 限制
- 标签、说明与错误文字由 MyGo `ui.Field` 绘制,使用主题颜色(文字、TextMuted、Danger);字号与间距跟随 MyGo。
- 错误只把边框染红的效果对 MyGo 自带输入框生效;MujicaUI 的 InputGroup 等需同时传入 `Invalid: true`。
- 左右布局时多个字段需放在同一个 `ui.Form` 或 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
+54
View File
@@ -0,0 +1,54 @@
# 043 InputGroup
输入组合。
## 用途
把单行输入框与前后缀文字、按钮放进同一个边框,例如网址前缀、单位、清空或搜索按钮。每个按钮保留独立焦点。
## 最小示例
```go
site := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
g := mujica.InputGroup(c, &site, mujica.InputGroupOptions{
Prefix: "https://", Label: "Site",
Buttons: []mujica.InputGroupButton{{Label: "Open"}},
})
if g.Clicked == 0 {
c.OpenURL("https://" + site)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 输入框的值。 |
| `Prefix / Suffix` | `string` | "" | 输入框前后的固定文字。 |
| `Buttons` | `[]InputGroupButton` | nil | 按钮:Label(必填,文字或无障碍名称)、Icon、IconOnly、Leading(放在输入框前)、Disabled。 |
| `Placeholder` | `string` | "" | 占位文字。 |
| `Label` | `string` | "" | 不在 FormField 内时为输入框命名。 |
| `Disabled` | `bool` | false | 禁用整个组合。 |
| `ReadOnly` | `bool` | false | 值显示为可选择、可复制、可聚焦的文字,不能修改;按钮仍可用。 |
| `Invalid` | `bool` | false | 边框显示危险色。 |
## 状态
默认、悬停(边框加深)、聚焦(强调色边框与焦点环)、禁用、只读、错误。
## 事件
`InputGroupParts.Changed()` 值被编辑;`Submitted()` 在输入框中按 Enter;`Clicked` 为被点击按钮在 Buttons 中的下标,未点击为 -1。`Input` 为可聚焦的输入元素。
## 键盘操作
输入框与每个按钮都是独立的 Tab 停靠点;按钮响应 Enter 和 Space;输入框保留选择、剪贴板、撤销与输入法。
## 限制
- MyGo 没有公开的只读输入框,ReadOnly 改用可选择文字实现,宽度与输入框一致。
- IconOnly 按钮必须提供 Icon,Label 作为无障碍名称;缺失时 panic。
+48
View File
@@ -0,0 +1,48 @@
# 044 PinInput
验证码输入。
## 用途
输入固定位数的数字验证码。值是字符串,保留前导零。
## 最小示例
```go
code := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PinInput(c, &code, mujica.PinInputOptions{Length: 6}).Completed() {
fmt.Println("verify", code)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*string` | 必填 | 当前数字串;含非数字或超出位数时 panic。 |
| `Length` | `int` | 6 | 位数。 |
| `Label` | `string` | 内置“Verification code/验证码” | 无障碍名称。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Invalid` | `bool` | false | 格子边框显示危险色。 |
## 状态
默认、聚焦(当前格强调边框、焦点环与光标)、输入法组合中(当前格显示带下划线的组合文字)、禁用、错误。
## 事件
`PinInputParts.Changed()` 值改变;`Completed()` 在用户填满最后一格的那一帧为 true。
## 键盘操作
数字键依次填格;非数字被忽略;Backspace 清除最后一位;Cmd/Ctrl+V 或编辑菜单粘贴整段,取其中的数字填满各格。
## 限制
- 只支持数字。编辑按顺序进行:只能在末尾追加或删除,不能单独改中间某一格。
- 输入法组合期间不提交、不跳格;组合结束后才写入值。
- 整个组件是一个焦点停靠点,屏幕阅读器以文本框读出名称与“已输入 n / N 位”的说明。
+48
View File
@@ -0,0 +1,48 @@
# 045 TagsInput
标签输入。
## 用途
编辑一组不重复的标签,对应 MyGo 的 Token field。支持 Enter 或分隔符添加、整段粘贴拆分、去重、数量上限与添加前校验。
## 最小示例
```go
tags := []string{"gothic"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TagsInput(c, &tags, mujica.TagsInputOptions{Max: 5, Placeholder: "Add a tag", Label: "Tags"})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `tags` | `*[]string` | 必填 | 标签列表。 |
| `Max` | `int` | 0(不限) | 最多标签数;负数 panic。 |
| `Separators` | `string` | ",;,;" 与换行、制表符 | 输入或粘贴时用于拆分的字符。 |
| `Validate` | `func(string) string` | nil | 添加前校验,返回错误文字或 ""。 |
| `Placeholder` | `string` | "" | 占位文字。 |
| `Label` | `string` | "" | 不在 FormField 内时为输入框命名。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Invalid` | `bool` | false | 边框显示危险色。 |
## 状态
默认、聚焦、错误(重复、超出上限、校验失败时显示具体原因并保留输入文字)、禁用。
## 事件
`TagsInputParts.Changed()` 添加或移除了标签;`Input` 为输入框元素。
## 键盘操作
Enter 或分隔符添加;输入框为空时 Backspace 移除最后一个标签;Tab 可到达每个标签的移除按钮,Enter/Space 移除。
## 限制
- 粘贴含分隔符的文本时,未通过检查的片段保留在输入框中,供用户修改。
- MyGo 单行输入框会处理粘贴文本中的换行,换行能否作为分隔符取决于平台粘贴行为;逗号、分号总是有效。
+46
View File
@@ -0,0 +1,46 @@
# 046 Rating
评分。
## 用途
以星形显示和设置评分,支持半星等步长、只读展示和数值说明。
## 最小示例
```go
stars := 3.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Rating(c, &stars, mujica.RatingOptions{Step: 0.5, ShowValue: true})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*float64` | 必填 | 评分,0 到 Max。 |
| `Max` | `int` | 5 | 星数;负数 panic。 |
| `Step` | `float64` | 1 | 最小变化量,须整除 1(如 0.5、0.25);否则 panic。 |
| `ReadOnly` | `bool` | false | 只显示,可聚焦以便读出数值。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“Rating/评分” | 无障碍名称。 |
| `ShowValue` | `bool` | false | 在星形右侧显示“3.5 / 5”。 |
## 状态
默认、悬停(预览指针处的数值)、按下拖动、聚焦(焦点环)、只读、禁用。
## 事件
`RatingParts.Changed()` 用户设置了新值;同值点击不触发。
## 键盘操作
→/↑ 增加一个步长,←/↓ 减少,Home 清零,End 满分。
## 限制
- 屏幕阅读器以滑块角色读出名称,数值通过说明“3.5 of 5”提供;MyGo 未公开为自定义元素设置数值范围的接口。
+45
View File
@@ -0,0 +1,45 @@
# 047 ColorPicker
颜色选择器。
## 用途
选择带透明度的颜色:色板按钮弹出面板,包含饱和度/亮度方块、色相与透明度滑块、HEX 与 RGB、透明度输入以及预设色。透明部分显示棋盘格。
## 最小示例
```go
tint := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ColorPicker(c, &tint, mujica.ColorPickerOptions{Label: "Tint"})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `color` | `*ui.Color` | 必填 | 当前颜色。 |
| `Presets` | `[]ui.Color` | 内置 12 色宫廷色板 | 预设色。 |
| `Label` | `string` | 内置“Color/颜色” | 按钮的无障碍名称。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、悬停、聚焦、展开、输入无效(对应输入框边框变红并显示格式说明,颜色不变)、预设选中(边框加勾选标记)、禁用。
## 事件
`ColorPickerParts.Changed()` 用户选择了新颜色;`Trigger` 为按钮元素。
## 键盘操作
Enter/Space 打开;打开后焦点在方块上,方向键调整饱和度与亮度;Tab 依次到达色相、透明度滑块(方向键、Home、End)、HEX、R、G、B、A 输入框与预设色;Escape 或点击外部关闭并把焦点还给按钮。
## 限制
- HEX 接受 RGB、RRGGBB、RRGGBBAA,可带或不带 #;RGB 为 0–255 整数,透明度为 0–100。无效输入保留在输入框中直到失焦,不修改颜色。
- 色谱渐变是 PRD 允许的例外。
- 弹层位置由 MyGo PopoverBase 决定,空间不足时显示在按钮上方。
+52
View File
@@ -0,0 +1,52 @@
# 048 FilePicker
文件选择。
## 用途
调用 MyGo 的系统文件或文件夹选择对话框(`mygo.Dialog.Open`),支持单选、多选与扩展名筛选,只返回所选路径。
## 最小示例
```go
var paths []string
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FilePicker(c, &paths, mujica.FilePickerOptions{
Multiple: true,
Filters: []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}},
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `paths` | `*[]string` | 必填 | 所选路径;取消时不变。 |
| `Directory` | `bool` | false | 选择文件夹。 |
| `Multiple` | `bool` | false | 允许多选。 |
| `Filters` | `[]mygo.FileFilter` | nil | 扩展名筛选,扩展名不带点;带点时 panic。 |
| `Title` | `string` | "" | 对话框标题。 |
| `Label` | `string` | 内置“Choose file…/选择文件…” | 按钮文字。 |
| `Parent` | `*mygo.Window` | nil | 附着的窗口(macOS 显示为表单)。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、对话框打开中(按钮显示加载并阻止重复打开)、已选择(列出文件名与所在目录)、对话框失败(危险色说明)、禁用。
## 事件
`FilePickerParts.Changed()` 选中了新路径;`Canceled()` 用户取消,不视为错误。
## 键盘操作
Enter/Space 打开系统对话框;对话框内的键盘操作由系统提供。
## 限制
- 对话框为系统样式,不换肤。
- 对话框在后台 goroutine 中等待,结果经 `Context.Invalidate` 回到界面线程;需要应用已通过 `mygo.App.Run` 启动,无窗口测试中无法打开真实对话框。
- 不读取文件内容。
+49
View File
@@ -0,0 +1,49 @@
# 049 FileDropZone
文件拖放区。
## 用途
接收从系统拖入或通过“浏览…”选择的文件,检查类型、数量和大小,显示文件列表与错误,可逐项移除。不上传,不读取内容。
## 最小示例
```go
var files []mujica.DroppedFile
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FileDropZone(c, &files, mujica.FileDropZoneOptions{
Extensions: []string{"png", "jpg"}, MaxFiles: 3, MaxSize: 5 << 20,
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `files` | `*[]DroppedFile` | 必填 | 已接受的文件(Path、Size)。 |
| `Extensions` | `[]string` | nil(不限) | 允许的扩展名,不带点,不区分大小写。 |
| `MaxFiles` | `int` | 0(不限) | 列表最多文件数。 |
| `MaxSize` | `int64` | 0(不限) | 单个文件的最大字节数。 |
| `Hint` | `string` | 内置“Drop files here/将文件拖到此处” | 提示文字。 |
| `Disabled` | `bool` | false | 禁用拖放、浏览与移除。 |
## 状态
默认、文件拖到上方(选中底色与强调色虚线边框)、检查中(列表显示“Checking…”)、已接受、被拒绝(危险色图标与具体原因)、禁用。
## 事件
`FileDropZoneParts.Changed()` 添加或移除了文件。被拒绝的条目只显示在组件内,可单独关闭。
## 键盘操作
Tab 到达“浏览…”与每个移除按钮;拖放需要指针,键盘用户通过“浏览…”添加文件。
## 限制
- 文件大小通过后台 goroutine 中的 `os.Stat` 获取,不阻塞界面线程;文件夹、无法读取的路径会被拒绝。
- 重复路径静默忽略。
- MyGo 的无窗口测试器不能模拟系统拖放,自动测试通过“浏览…”路径覆盖同一检查流程。
+46
View File
@@ -0,0 +1,46 @@
# 050 Transfer
穿梭框。
## 用途
在左右两个列表间移动条目,选出一个子集。两侧各有搜索、复选与全选,中间按钮批量移动。以稳定 ID 保存状态。
## 最小示例
```go
chosen := []string{"uika"}
items := []mujica.TransferItem{{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "nyamu", Label: "Nyamu", Disabled: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Transfer(c, &chosen, items, mujica.TransferOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `target` | `*[]string` | 必填 | 右侧已选条目的 ID,按加入顺序;含未知 ID 时 panic。 |
| `items` | `[]TransferItem` | 必填 | 全部条目:ID(唯一、非空)、Label、Disabled。 |
| `SourceTitle / TargetTitle` | `string` | 内置“Available/可选”“Chosen/已选” | 两侧标题。 |
| `Height` | `float32` | 240 | 列表高度(DIP)。 |
| `Disabled` | `bool` | false | 禁用整个组件。 |
## 状态
默认、悬停、勾选(底色加勾选标记)、半选(全选框显示减号)、禁用条目(不可勾选、不随全选移动)、空列表、无搜索结果、禁用。
## 事件
`TransferParts.Changed()` 有条目移动。
## 键盘操作
Tab 进入列表,↑/↓ 在条目间移动,Space 勾选;Tab 到达全选框、搜索框与移动按钮。
## 限制
- 条目逐项创建,适合数百条以内;大数据量请使用虚拟化列表组合。
- 全选只作用于当前搜索结果中的可用条目。
+124
View File
@@ -0,0 +1,124 @@
package mujica_test
import (
"fmt"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleForm() {
var state mujica.FormState
name := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
})
})
if res.Submitted() {
state.EndSubmit("") // after saving name
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleFormField() {
email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleInputGroup() {
site := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
g := mujica.InputGroup(c, &site, mujica.InputGroupOptions{
Prefix: "https://", Label: "Site",
Buttons: []mujica.InputGroupButton{{Label: "Open"}},
})
if g.Clicked == 0 {
c.OpenURL("https://" + site)
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePinInput() {
code := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PinInput(c, &code, mujica.PinInputOptions{Length: 6}).Completed() {
fmt.Println("verify", code)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTagsInput() {
tags := []string{"gothic"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TagsInput(c, &tags, mujica.TagsInputOptions{Max: 5, Placeholder: "Add a tag", Label: "Tags"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRating() {
stars := 3.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Rating(c, &stars, mujica.RatingOptions{Step: 0.5, ShowValue: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleColorPicker() {
tint := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ColorPicker(c, &tint, mujica.ColorPickerOptions{Label: "Tint"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFilePicker() {
var paths []string
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FilePicker(c, &paths, mujica.FilePickerOptions{
Multiple: true,
Filters: []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileDropZone() {
var files []mujica.DroppedFile
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FileDropZone(c, &files, mujica.FileDropZoneOptions{
Extensions: []string{"png", "jpg"}, MaxFiles: 3, MaxSize: 5 << 20,
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTransfer() {
chosen := []string{"uika"}
items := []mujica.TransferItem{{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "nyamu", Label: "Nyamu", Disabled: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Transfer(c, &chosen, items, mujica.TransferOptions{})
}
ui.Render(view, 400, 300, 1)
}
+415 -1
View File
@@ -1,3 +1,417 @@
package main
var formPages = []page{}
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/icons"
)
// formStates shows a component's states side by side.
func formStates(c *ui.Context, fn func()) {
ui.Column(c).Gap(8).Children(func() {
ui.Text(c, "States").FontWeight(600)
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(fn)
})
}
// formToggles shows parameter check boxes in one row.
func formToggles(c *ui.Context, names []string, values ...*bool) {
ui.Row(c).Gap(16).Wrap().Children(func() {
for i, n := range names {
ui.Checkbox(c, values[i], n)
}
})
}
type formDemo struct {
st mujica.FormState
name, email, password string
tags []string
horizontal, slowServer bool
pending int
}
type fieldDemo struct {
value string
required, horizontal bool
withError bool
}
type groupDemo struct {
value string
disabled, readOnly, invalid bool
}
type pinDemo struct {
code string
length4, disabled bool
invalid bool
}
type tagsDemo struct {
tags []string
limit, disabled bool
}
type ratingDemo struct {
value float64
half, readOnly, disabled bool
}
type colorDemo struct {
color ui.Color
disabled bool
}
type pickerDemo struct {
paths []string
directory, multiple, filter bool
disabled bool
}
type dropDemo struct {
files []mujica.DroppedFile
images bool
disabled bool
}
type transferDemo struct {
chosen []string
disabled bool
}
var transferDemoItems = []mujica.TransferItem{
{ID: "sakiko", Label: "Sakiko Togawa"}, {ID: "uika", Label: "Uika Misumi"}, {ID: "mutsumi", Label: "Mutsumi Wakaba"},
{ID: "umiri", Label: "Umiri Yahata"}, {ID: "nyamu", Label: "Nyamu Yutenji"}, {ID: "tomori", Label: "Tomori Takamatsu", Disabled: true},
{ID: "anon", Label: "Anon Chihaya"}, {ID: "rana", Label: "Rana Kaname"}, {ID: "soyo", Label: "Soyo Nagasaki"},
}
type formAsync struct {
version uint64
err string
}
var formPages = []page{
{ID: "041", Name: "Form", ZhName: "表单", Category: "Forms", Code: formCode["Form"],
Keys: "Tab moves between fields; leaving a field validates it. Enter in a field submits. A failed submit focuses the first error.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "041", func() formDemo {
return formDemo{name: "Sakiko", tags: []string{"keyboard"}}
})
async := *ui.Local(c.Root(), "041.async", func() chan formAsync { return make(chan formAsync, 8) })
saved := *ui.Local(c.Root(), "041.save", func() chan string { return make(chan string, 1) })
for drained := false; !drained; {
select {
case r := <-async:
log(fmt.Sprintf("async name check v%d: %q, kept %v", r.version, r.err, d.st.Resolve("name", r.version, r.err)))
case e := <-saved:
d.st.EndSubmit(e)
log(fmt.Sprintf("submit ended, error %q", e))
default:
drained = true
}
}
formToggles(c, []string{"Labels beside fields", "Server rejects the submit"}, &d.horizontal, &d.slowServer)
res := mujica.Form(c, &d.st, mujica.FormOptions{Horizontal: d.horizontal}, func(f *mujica.FormScope) {
mujica.FormItem(f, "name", &d.name, mujica.FormItemOptions[string]{Label: "Stage name", Required: true, Initial: "Sakiko",
Description: "Checked against the archive; \"Oblivionis\" is taken.",
Async: func(v string, version uint64) {
log(fmt.Sprintf("async check started v%d", version))
go func() {
time.Sleep(700 * time.Millisecond)
msg := ""
if strings.EqualFold(v, "Oblivionis") {
msg = "That name is taken."
}
async <- formAsync{version, msg}
c.Invalidate()
}()
}}, func() *ui.Element {
return mujica.InputGroup(c, &d.name, mujica.InputGroupOptions{Invalid: d.st.Error("name") != ""}).Input
})
mujica.FormItem(f, "email", &d.email, mujica.FormItemOptions[string]{Label: "Email", Required: true,
Validate: func(v string) string {
if !strings.Contains(v, "@") {
return "Enter an email address."
}
return ""
}}, func() *ui.Element {
g := mujica.InputGroup(c, &d.email, mujica.InputGroupOptions{Invalid: d.st.Error("email") != ""})
if g.Submitted() {
d.st.Submit()
}
return g.Input
})
mujica.FormItem(f, "tags", &d.tags, mujica.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element {
return mujica.TagsInput(c, &d.tags, mujica.TagsInputOptions{Max: 4, Invalid: d.st.Error("tags") != ""}).Input
})
})
if res.Submitted() {
log(fmt.Sprintf("submitted %q %q %q", d.name, d.email, d.tags))
fail := ""
if d.slowServer {
fail = "The archive refused the entry. Your values are kept."
}
go func() {
time.Sleep(900 * time.Millisecond)
saved <- fail
c.Invalidate()
}()
}
if res.Reset() {
log("reset to initial values")
}
}},
{ID: "042", Name: "FormField", ZhName: "表单字段", Category: "Forms", Code: formCode["FormField"],
Keys: "Clicking the label focuses the control; Tab reaches the control directly.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "042", func() fieldDemo { return fieldDemo{} })
formToggles(c, []string{"Required", "Label beside", "Show error"}, &d.required, &d.horizontal, &d.withError)
e := ""
if d.withError {
e = "This name is already in the guest list."
}
mujica.FormField(c, "Guest name", mujica.FormFieldOptions{Required: d.required, Horizontal: d.horizontal,
Description: "As written on the invitation.", Error: e}, func() {
if mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError}).Changed() {
log(fmt.Sprintf("value %q", d.value))
}
})
}},
{ID: "043", Name: "InputGroup", ZhName: "输入组合", Category: "Forms", Code: formCode["InputGroup"],
Keys: "Each button and the input are separate Tab stops. Enter in the input submits.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "043", func() groupDemo { return groupDemo{value: "mujica"} })
formToggles(c, []string{"Disabled", "Read-only", "Invalid"}, &d.disabled, &d.readOnly, &d.invalid)
opts := mujica.InputGroupOptions{Prefix: "https://", Suffix: ".org", Label: "Site", Disabled: d.disabled, ReadOnly: d.readOnly, Invalid: d.invalid,
Buttons: []mujica.InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}, {Label: "Copy", Icon: icons.Must("copy")}}}
g := mujica.InputGroup(c, &d.value, opts)
switch g.Clicked {
case 0:
d.value = ""
log("clear")
case 1:
c.WriteClipboard("https://" + d.value + ".org")
log("copied")
}
if g.Changed() {
log(fmt.Sprintf("value %q", d.value))
}
if g.Submitted() {
log("submitted")
}
formStates(c, func() {
for _, o := range []mujica.InputGroupOptions{{Label: "Disabled", Disabled: true}, {Label: "Read-only", ReadOnly: true}, {Label: "Invalid", Invalid: true}} {
v := o.Label
mujica.InputGroup(c, &v, o)
}
})
}},
{ID: "044", Name: "PinInput", ZhName: "验证码输入", Category: "Forms", Code: formCode["PinInput"],
Keys: "Type digits to fill cells, Backspace clears the last, Cmd/Ctrl+V pastes the whole code.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "044", func() pinDemo { return pinDemo{} })
formToggles(c, []string{"4 digits", "Disabled", "Invalid"}, &d.length4, &d.disabled, &d.invalid)
n := 6
if d.length4 {
n = 4
}
if len(d.code) > n {
d.code = d.code[:n]
}
p := mujica.PinInput(c, &d.code, mujica.PinInputOptions{Length: n, Disabled: d.disabled, Invalid: d.invalid})
if p.Changed() {
log(fmt.Sprintf("code %q", d.code))
}
if p.Completed() {
log(fmt.Sprintf("completed %q", d.code))
}
formStates(c, func() {
a, b := "12", "0420"
mujica.PinInput(c, &a, mujica.PinInputOptions{Length: 4, Disabled: true, Label: "Disabled code"})
mujica.PinInput(c, &b, mujica.PinInputOptions{Length: 4, Invalid: true, Label: "Invalid code"})
})
}},
{ID: "045", Name: "TagsInput", ZhName: "标签输入", Category: "Forms", Code: formCode["TagsInput"],
Keys: "Enter or a comma adds a tag; Backspace in the empty input removes the last; Tab reaches each remove button.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "045", func() tagsDemo { return tagsDemo{tags: []string{"rose", "thorn"}} })
formToggles(c, []string{"At most 5", "Disabled"}, &d.limit, &d.disabled)
opts := mujica.TagsInputOptions{Placeholder: "Add a flower", Label: "Flowers", Disabled: d.disabled,
Validate: func(s string) string {
if len([]rune(s)) > 16 {
return "Keep tags under 16 characters."
}
return ""
}}
if d.limit {
opts.Max = 5
}
if mujica.TagsInput(c, &d.tags, opts).Changed() {
log(fmt.Sprintf("tags %q", d.tags))
}
}},
{ID: "046", Name: "Rating", ZhName: "评分", Category: "Forms", Code: formCode["Rating"],
Keys: "Arrows change by one step, Home clears, End fills.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "046", func() ratingDemo { return ratingDemo{value: 3, half: true} })
formToggles(c, []string{"Half stars", "Read-only", "Disabled"}, &d.half, &d.readOnly, &d.disabled)
step := 1.0
if d.half {
step = 0.5
}
if !d.half {
d.value = float64(int(d.value + 0.5))
}
if mujica.Rating(c, &d.value, mujica.RatingOptions{Step: step, ReadOnly: d.readOnly, Disabled: d.disabled, ShowValue: true}).Changed() {
log(fmt.Sprintf("rating %v", d.value))
}
formStates(c, func() {
a, b := 4.5, 2.0
mujica.Rating(c, &a, mujica.RatingOptions{Step: 0.5, ReadOnly: true, Label: "Read-only rating"})
mujica.Rating(c, &b, mujica.RatingOptions{Disabled: true, Label: "Disabled rating"})
})
}},
{ID: "047", Name: "ColorPicker", ZhName: "颜色选择器", Category: "Forms", Code: formCode["ColorPicker"],
Keys: "Enter or Space opens; arrows move in the square and sliders; Escape closes and returns focus.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "047", func() colorDemo { return colorDemo{color: ui.Hex("#7D2034")} })
formToggles(c, []string{"Disabled"}, &d.disabled)
if mujica.ColorPicker(c, &d.color, mujica.ColorPickerOptions{Label: "Banner color", Disabled: d.disabled}).Changed() {
log(fmt.Sprintf("color %v", d.color))
}
}},
{ID: "048", Name: "FilePicker", ZhName: "文件选择", Category: "Forms", Code: formCode["FilePicker"],
Keys: "Enter or Space opens the system dialog; the dialog's own keys apply.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "048", func() pickerDemo { return pickerDemo{multiple: true} })
formToggles(c, []string{"Folders", "Multiple", "PDF and MIDI only", "Disabled"}, &d.directory, &d.multiple, &d.filter, &d.disabled)
opts := mujica.FilePickerOptions{Directory: d.directory, Multiple: d.multiple, Disabled: d.disabled, Title: "Choose scores"}
if d.filter {
opts.Filters = []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}}
}
p := mujica.FilePicker(c, &d.paths, opts)
if p.Changed() {
log(fmt.Sprintf("picked %d: %q", len(d.paths), d.paths))
}
if p.Canceled() {
log("canceled (not an error)")
}
}},
{ID: "049", Name: "FileDropZone", ZhName: "文件拖放区", Category: "Forms", Code: formCode["FileDropZone"],
Keys: "Tab reaches Browse… and each remove button; dropping needs a pointer, Browse is the keyboard path.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "049", func() dropDemo { return dropDemo{} })
formToggles(c, []string{"Images only", "Disabled"}, &d.images, &d.disabled)
opts := mujica.FileDropZoneOptions{MaxFiles: 4, MaxSize: 10 << 20, Disabled: d.disabled}
if d.images {
opts.Extensions = []string{"png", "jpg", "jpeg", "gif"}
}
if mujica.FileDropZone(c, &d.files, opts).Changed() {
log(fmt.Sprintf("files %d", len(d.files)))
}
}},
{ID: "050", Name: "Transfer", ZhName: "穿梭框", Category: "Forms", Code: formCode["Transfer"],
Keys: "Up and Down move within a list, Space checks; Tab reaches the move buttons.",
Demo: func(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "050", func() transferDemo { return transferDemo{chosen: []string{"sakiko"}} })
formToggles(c, []string{"Disabled"}, &d.disabled)
if mujica.Transfer(c, &d.chosen, transferDemoItems, mujica.TransferOptions{SourceTitle: "Auditions", TargetTitle: "Band", Disabled: d.disabled}).Changed() {
log(fmt.Sprintf("band %q", d.chosen))
}
}},
}
// formCode holds each page's copyable snippet, the same as its doc and Example.
var formCode = map[string]string{
"Form": `var state mujica.FormState
name := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
})
})
if res.Submitted() {
state.EndSubmit("") // after saving name
}
}
ui.Render(view, 400, 300, 1)`,
"FormField": `email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
})
}
ui.Render(view, 400, 300, 1)`,
"InputGroup": `site := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
g := mujica.InputGroup(c, &site, mujica.InputGroupOptions{
Prefix: "https://", Label: "Site",
Buttons: []mujica.InputGroupButton{{Label: "Open"}},
})
if g.Clicked == 0 {
c.OpenURL("https://" + site)
}
}
ui.Render(view, 400, 300, 1)`,
"PinInput": `code := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PinInput(c, &code, mujica.PinInputOptions{Length: 6}).Completed() {
fmt.Println("verify", code)
}
}
ui.Render(view, 400, 300, 1)`,
"TagsInput": `tags := []string{"gothic"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TagsInput(c, &tags, mujica.TagsInputOptions{Max: 5, Placeholder: "Add a tag", Label: "Tags"})
}
ui.Render(view, 400, 300, 1)`,
"Rating": `stars := 3.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Rating(c, &stars, mujica.RatingOptions{Step: 0.5, ShowValue: true})
}
ui.Render(view, 400, 300, 1)`,
"ColorPicker": `tint := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ColorPicker(c, &tint, mujica.ColorPickerOptions{Label: "Tint"})
}
ui.Render(view, 400, 300, 1)`,
"FilePicker": `var paths []string
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FilePicker(c, &paths, mujica.FilePickerOptions{
Multiple: true,
Filters: []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}},
})
}
ui.Render(view, 400, 300, 1)`,
"FileDropZone": `var files []mujica.DroppedFile
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FileDropZone(c, &files, mujica.FileDropZoneOptions{
Extensions: []string{"png", "jpg"}, MaxFiles: 3, MaxSize: 5 << 20,
})
}
ui.Render(view, 400, 300, 1)`,
"Transfer": `chosen := []string{"uika"}
items := []mujica.TransferItem{{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "nyamu", Label: "Nyamu", Disabled: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Transfer(c, &chosen, items, mujica.TransferOptions{})
}
ui.Render(view, 400, 300, 1)`,
}
+292
View File
@@ -0,0 +1,292 @@
package mujica
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// DroppedFile is a file a FileDropZone accepted.
type DroppedFile struct {
Path string
Size int64
}
// FileDropZoneOptions configure a FileDropZone. Zero limits accept anything.
type FileDropZoneOptions struct {
// Extensions lists the accepted extensions without the dot, matched case-insensitively.
Extensions []string
// MaxFiles caps how many files the list holds.
MaxFiles int
// MaxSize caps each file's size in bytes.
MaxSize int64
// Hint replaces the built-in "Drop files here".
Hint string
Disabled bool
}
// FileDropZoneParts are the parts of a FileDropZone.
type FileDropZoneParts struct {
Element *ui.Element
changed bool
}
// Changed reports whether files were added or removed this frame.
func (p FileDropZoneParts) Changed() bool { return p.changed }
// fileDropZoneReject is a file that failed a check, shown until dismissed.
type fileDropZoneReject struct {
path, reason string
}
type fileDropZoneStat struct {
path string
size int64
dir bool
err error
}
type fileDropZoneState struct {
pending []string
stats chan []fileDropZoneStat
browse chan filePickerResult
rejected []fileDropZoneReject
}
// FileDropZone takes files dropped from the system or chosen with its Browse button,
// checks type, count and size, and lists them in *files. It never reads or uploads them.
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts {
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
panic("mujica: FileDropZone limits must not be negative")
}
for _, ext := range opts.Extensions {
if ext == "" || strings.HasPrefix(ext, ".") {
panic(fmt.Sprintf("mujica: FileDropZone extension %q: give it without the dot", ext))
}
}
k := Tokens(c)
d := Density(c)
parts := FileDropZoneParts{}
col := ui.Column(c).Gap(d.Unit() * 2)
parts.Element = col
st := ui.Local(col, "zone", func() fileDropZoneState { return fileDropZoneState{} })
reject := func(path, key string, args ...any) {
st.rejected = append(st.rejected, fileDropZoneReject{path, fmt.Sprintf(msg(c, key), args...)})
slog.Debug("mujica: file drop zone rejected", "reason", key)
}
// take checks types now and sizes off the UI thread.
take := func(paths []string) {
var ok []string
for _, p := range paths {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
switch {
case slices.ContainsFunc(*files, func(f DroppedFile) bool { return f.Path == p }), slices.Contains(st.pending, p), slices.Contains(ok, p):
case len(opts.Extensions) > 0 && !slices.ContainsFunc(opts.Extensions, func(e string) bool { return strings.EqualFold(e, ext) }):
reject(p, "file_drop_zone.type", strings.Join(opts.Extensions, ", "))
default:
ok = append(ok, p)
}
}
if len(ok) == 0 {
return
}
st.pending = append(st.pending, ok...)
if st.stats != nil {
return // the running check picks them up next
}
fileDropZoneCheck(c, st)
}
if st.stats != nil {
select {
case res := <-st.stats:
st.stats = nil
st.pending = st.pending[len(res):]
for _, s := range res {
switch {
case s.err != nil:
err := s.err
if pe, ok := errors.AsType[*fs.PathError](err); ok {
err = pe.Err
}
reject(s.path, "file_drop_zone.unreadable", err)
case s.dir:
reject(s.path, "file_drop_zone.folder")
case opts.MaxSize > 0 && s.size > opts.MaxSize:
reject(s.path, "file_drop_zone.size", fileDropZoneSize(opts.MaxSize))
case opts.MaxFiles > 0 && len(*files) >= opts.MaxFiles:
reject(s.path, "file_drop_zone.count", opts.MaxFiles)
default:
*files = append(slices.Clone(*files), DroppedFile{s.path, s.size})
parts.changed = true
}
}
if len(st.pending) > 0 {
fileDropZoneCheck(c, st)
}
default:
}
}
if st.browse != nil {
select {
case r := <-st.browse:
st.browse = nil
if r.err != nil {
reject("", "file_picker.failed", r.err)
} else {
take(r.paths)
}
default:
}
}
col.Children(func() {
zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5).
Radius(theme.CardRadius).Disabled(opts.Disabled)
if dropped := zone.DroppedFiles(); dropped != nil && !opts.Disabled {
slog.Debug("mujica: file drop zone dropped", "count", len(dropped))
take(dropped)
}
over := zone.FileDragOver() && !opts.Disabled
bg, border := k.Surface, k.ControlBorder
if over {
bg, border = k.Selection, k.AccentText
}
zone.Background(bg).Draw(func(p *ui.Painter, r ui.Rect) {
p.StrokeDashed(r, border, theme.CardRadius, theme.BorderWidth*1.5)
})
hint := opts.Hint
if hint == "" {
hint = msg(c, "file_drop_zone.hint")
}
zone.Children(func() {
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
ui.Text(c, hint).TextAlign(ui.Center)
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).TextAlign(ui.Center)
b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
if b.Clicked() {
ch := make(chan filePickerResult, 1)
st.browse = ch
dlg := mygo.OpenDialogOptions{Multiple: opts.MaxFiles != 1}
if len(opts.Extensions) > 0 {
dlg.Filters = []mygo.FileFilter{{Name: hint, Extensions: opts.Extensions}}
}
go func() {
p, err := filePickerOpen(dlg)
ch <- filePickerResult{p, err}
c.Invalidate()
}()
}
})
fileDropZoneList(c, st, files, &parts, opts.Disabled)
})
return parts
}
// fileDropZoneCheck stats the pending files on a goroutine.
func fileDropZoneCheck(c *ui.Context, st *fileDropZoneState) {
ch := make(chan []fileDropZoneStat, 1)
st.stats = ch
paths := slices.Clone(st.pending)
go func() {
res := make([]fileDropZoneStat, len(paths))
for i, p := range paths {
info, err := os.Stat(p)
res[i] = fileDropZoneStat{path: p, err: err}
if err == nil {
res[i].size, res[i].dir = info.Size(), info.IsDir()
}
}
ch <- res
c.Invalidate()
}()
}
// fileDropZoneList shows accepted, checking and rejected files.
func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile, parts *FileDropZoneParts, disabled bool) {
k := Tokens(c)
d := Density(c)
row := func(key string, icon string, color ui.Color, name, detail string, remove func()) {
ui.Row(c).Key(key).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
ui.Text(c, detail).TextColor(color).FontSize(theme.CaptionSize).SingleLine().Grow(1)
if remove != nil {
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
internal.Ring(x, k.Focus, theme.ControlRadius)
if x.Hovered() {
x.Background(k.SurfaceHover).TextColor(k.Text)
}
x.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14) })
if x.Clicked() {
remove()
}
}
})
}
ui.Column(c).Children(func() {
for i, f := range *files {
row("ok"+f.Path, "file", k.TextMuted, filepath.Base(f.Path), fileDropZoneSize(f.Size), func() {
*files = slices.Delete(slices.Clone(*files), i, i+1)
parts.changed = true
slog.Debug("mujica: file drop zone removed", "count", len(*files))
})
}
for _, p := range st.pending {
row("pending"+p, "loader-circle", k.TextMuted, filepath.Base(p), msg(c, "file_drop_zone.checking"), nil)
}
for i, r := range st.rejected {
name := filepath.Base(r.path)
if r.path == "" {
name = msg(c, "file_drop_zone.dialog")
}
row(fmt.Sprint("bad", i, r.path), "circle-alert", k.Danger, name, r.reason, func() {
st.rejected = slices.Delete(slices.Clone(st.rejected), i, i+1)
})
}
})
}
// fileDropZoneRules describes the limits, such as "PDF, PNG · up to 3 files · 2 MB each".
func fileDropZoneRules(c *ui.Context, opts FileDropZoneOptions) string {
var rules []string
if len(opts.Extensions) > 0 {
rules = append(rules, strings.ToUpper(strings.Join(opts.Extensions, ", ")))
}
if opts.MaxFiles > 0 {
rules = append(rules, fmt.Sprintf(msg(c, "file_drop_zone.max_files"), opts.MaxFiles))
}
if opts.MaxSize > 0 {
rules = append(rules, fmt.Sprintf(msg(c, "file_drop_zone.max_size"), fileDropZoneSize(opts.MaxSize)))
}
if len(rules) == 0 {
return msg(c, "file_drop_zone.any")
}
return strings.Join(rules, " · ")
}
// fileDropZoneSize formats n bytes in binary units.
func fileDropZoneSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
v, i := float64(n)/unit, 0
for v >= unit && i < 3 {
v /= unit
i++
}
return fmt.Sprintf("%.1f %s", v, []string{"KB", "MB", "GB", "TB"}[i])
}
+120
View File
@@ -0,0 +1,120 @@
package mujica
import (
"os"
"path/filepath"
"testing"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
)
// fileDropZoneFiles writes files of the given sizes into a temp dir.
func fileDropZoneFiles(t *testing.T, sizes map[string]int) string {
t.Helper()
dir := t.TempDir()
for name, n := range sizes {
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func fileDropZoneTester(files *[]DroppedFile, opts FileDropZoneOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if FileDropZone(c, files, opts).Changed() {
*changes++
}
}, 560, 600)
}
// fileDropZoneBrowse answers the Browse dialog with paths.
func fileDropZoneBrowse(t *testing.T, paths ...string) *mygo.OpenDialogOptions {
saved := filePickerOpen
t.Cleanup(func() { filePickerOpen = saved })
got := &mygo.OpenDialogOptions{}
filePickerOpen = func(o mygo.OpenDialogOptions) ([]string, error) {
*got = o
return paths, nil
}
return got
}
func TestFileDropZoneChecksTypeSizeAndCount(t *testing.T) {
dir := fileDropZoneFiles(t, map[string]int{"a.pdf": 10, "b.PDF": 3000, "c.pdf": 20, "d.txt": 5, "e.pdf": 30})
p := func(n string) string { return filepath.Join(dir, n) }
got := fileDropZoneBrowse(t, p("a.pdf"), p("b.PDF"), p("c.pdf"), p("d.txt"), p("e.pdf"), p("missing.pdf"), dir+"/sub.pdf")
if err := os.Mkdir(p("sub.pdf"), 0o755); err != nil {
t.Fatal(err)
}
var files []DroppedFile
n := 0
tt := fileDropZoneTester(&files, FileDropZoneOptions{Extensions: []string{"pdf"}, MaxFiles: 2, MaxSize: 1024}, &n)
if err := tt.Click("Browse…"); err != nil {
t.Fatal(err)
}
filePickerWait(t, tt, func() bool { return len(files) == 2 && tt.HasText("More than 2 files") })
if files[0] != (DroppedFile{p("a.pdf"), 10}) || files[1] != (DroppedFile{p("c.pdf"), 20}) || n != 1 {
t.Fatalf("files %+v changes %d", files, n)
}
for _, want := range []string{"Only pdf files", "Larger than 1.0 KB", "Folders are not accepted", "Cannot read: no such file or directory"} {
if !tt.HasText(want) {
t.Errorf("missing %q in %q", want, tt.Texts())
}
}
if !got.Multiple || got.Filters[0].Extensions[0] != "pdf" {
t.Fatalf("dialog options %+v", got)
}
}
func TestFileDropZoneRemoveAndDedupe(t *testing.T) {
dir := fileDropZoneFiles(t, map[string]int{"score.mid": 2048})
path := filepath.Join(dir, "score.mid")
fileDropZoneBrowse(t, path)
files := []DroppedFile{{path, 2048}}
n := 0
tt := fileDropZoneTester(&files, FileDropZoneOptions{}, &n)
_ = tt.Click("Browse…")
for range 20 {
tt.Frame()
}
if len(files) != 1 || n != 0 || !tt.HasText("2.0 KB") {
t.Fatalf("duplicate added: %+v", files)
}
if err := tt.Click("Remove score.mid"); err != nil {
t.Fatal(err)
}
if len(files) != 0 || n != 1 {
t.Fatalf("remove: %+v changes %d", files, n)
}
}
func TestFileDropZoneDisabled(t *testing.T) {
called := false
saved := filePickerOpen
t.Cleanup(func() { filePickerOpen = saved })
filePickerOpen = func(mygo.OpenDialogOptions) ([]string, error) { called = true; return nil, nil }
files := []DroppedFile{{"/x/kept.pdf", 1}}
n := 0
tt := fileDropZoneTester(&files, FileDropZoneOptions{Disabled: true}, &n)
_ = tt.Click("Browse…")
_ = tt.Click("Remove kept.pdf")
tt.Frame()
if called || len(files) != 1 {
t.Fatal("disabled zone acted")
}
if fileDropZoneSize(1536) != "1.5 KB" || fileDropZoneSize(3<<20) != "3.0 MB" || fileDropZoneSize(12) != "12 B" {
t.Fatal("size format")
}
}
func TestFileDropZoneSnapshot(t *testing.T) {
files := []DroppedFile{{"/scores/Imprisoned XII.pdf", 482133}, {"/scores/cover.png", 1 << 20}}
snapshot(t, "file_drop_zone", 520, 330, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() {
FileDropZone(c, &files, FileDropZoneOptions{Extensions: []string{"pdf", "png"}, MaxFiles: 3, MaxSize: 2 << 20})
})
})
}
+137
View File
@@ -0,0 +1,137 @@
package mujica
import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// FilePickerOptions configure a FilePicker.
type FilePickerOptions struct {
// Directory picks folders instead of files.
Directory bool
Multiple bool
// Filters limit the files offered by extension, without the dot.
Filters []mygo.FileFilter
// Title is the system dialog's title.
Title string
// Label is the button text; empty uses the built-in "Choose file…" or "Choose folder…".
Label string
// Parent attaches the dialog to a window, as a sheet on macOS.
Parent *mygo.Window
Disabled bool
}
// FilePickerParts are the parts of a FilePicker.
type FilePickerParts struct {
Element *ui.Element
changed bool
canceled bool
}
// Changed reports whether the user picked new paths this frame.
func (p FilePickerParts) Changed() bool { return p.changed }
// Canceled reports whether the user closed the dialog without picking, which is not an error.
func (p FilePickerParts) Canceled() bool { return p.canceled }
// filePickerOpen shows the system dialog; tests replace it.
var filePickerOpen = mygo.Dialog.Open
type filePickerResult struct {
paths []string
err error
}
type filePickerState struct {
result chan filePickerResult
err string
}
// FilePicker opens the system file or folder dialog and stores the chosen paths in *paths.
// The dialog runs off the UI thread; the button shows loading until it closes.
func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts {
for _, f := range opts.Filters {
for _, ext := range f.Extensions {
if ext == "" || strings.HasPrefix(ext, ".") {
panic(fmt.Sprintf("mujica: FilePicker extension %q: give it without the dot", ext))
}
}
}
k := Tokens(c)
d := Density(c)
parts := FilePickerParts{}
col := ui.Column(c).Gap(d.Unit() * 2).AlignItems(ui.Start)
parts.Element = col
st := ui.Local(col, "picker", func() filePickerState { return filePickerState{} })
if st.result != nil {
select {
case r := <-st.result:
st.result = nil
switch {
case r.err != nil:
st.err = fmt.Sprintf(msg(c, "file_picker.failed"), r.err)
slog.Debug("mujica: file picker failed", "err", r.err)
case r.paths == nil:
parts.canceled = true
slog.Debug("mujica: file picker canceled")
default:
*paths = r.paths
parts.changed = true
slog.Debug("mujica: file picker picked", "count", len(r.paths))
}
default:
}
}
label := opts.Label
if label == "" {
label = msg(c, "file_picker.choose_file")
if opts.Directory {
label = msg(c, "file_picker.choose_folder")
}
}
icon := icons.Must("file")
if opts.Directory {
icon = icons.Must("folder")
}
col.Children(func() {
b := Button(c, label, ButtonOptions{Variant: Secondary, Icon: icon, Loading: st.result != nil, Disabled: opts.Disabled})
if b.Clicked() {
st.err = ""
ch := make(chan filePickerResult, 1)
st.result = ch
dlg := mygo.OpenDialogOptions{Parent: opts.Parent, Title: opts.Title, Filters: opts.Filters,
Directory: opts.Directory, Multiple: opts.Multiple}
slog.Debug("mujica: file picker opened", "directory", opts.Directory, "multiple", opts.Multiple)
go func() {
p, err := filePickerOpen(dlg)
ch <- filePickerResult{p, err}
c.Invalidate()
}()
}
switch {
case st.err != "":
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
case len(*paths) == 0:
ui.Text(c, msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(theme.CaptionSize)
default:
ui.Column(c).Gap(d.Unit()).Children(func() {
for _, p := range *paths {
ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
ui.Text(c, filepath.Base(p)).SingleLine()
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
})
}
})
}
})
return parts
}
+131
View File
@@ -0,0 +1,131 @@
package mujica
import (
"errors"
"slices"
"testing"
"time"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
)
type filePickerProbe struct {
paths []string
changes, cancels int
got mygo.OpenDialogOptions
reply []string
err error
release chan struct{}
}
func (p *filePickerProbe) stub(t *testing.T) {
saved := filePickerOpen
t.Cleanup(func() { filePickerOpen = saved })
filePickerOpen = func(o mygo.OpenDialogOptions) ([]string, error) {
p.got = o
if p.release != nil {
<-p.release
}
return p.reply, p.err
}
}
func (p *filePickerProbe) tester(opts FilePickerOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := FilePicker(c, &p.paths, opts)
if r.Changed() {
p.changes++
}
if r.Canceled() {
p.cancels++
}
}, 500, 200)
}
// filePickerWait draws frames until done holds.
func filePickerWait(t *testing.T, tt *ui.Tester, done func() bool) {
t.Helper()
for range 200 {
tt.Frame()
if done() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("dialog result never arrived")
}
func TestFilePickerPicksFiles(t *testing.T) {
p := &filePickerProbe{reply: []string{"/court/score.pdf", "/court/cast.png"}, release: make(chan struct{})}
p.stub(t)
filters := []mygo.FileFilter{{Name: "Docs", Extensions: []string{"pdf", "png"}}}
tt := p.tester(FilePickerOptions{Multiple: true, Filters: filters, Title: "Score"})
if err := tt.Click("Choose file…"); err != nil {
t.Fatal(err)
}
_ = tt.Click("Choose file…")
close(p.release)
filePickerWait(t, tt, func() bool { return p.changes > 0 })
if !slices.Equal(p.paths, p.reply) || p.changes != 1 || !tt.HasText("score.pdf") {
t.Fatalf("paths %q changes %d", p.paths, p.changes)
}
if !p.got.Multiple || p.got.Directory || p.got.Title != "Score" || p.got.Filters[0].Extensions[1] != "png" {
t.Fatalf("dialog options %+v", p.got)
}
}
func TestFilePickerCancelIsNotAnError(t *testing.T) {
p := &filePickerProbe{paths: []string{"/kept"}}
p.stub(t)
tt := p.tester(FilePickerOptions{Directory: true})
if err := tt.Click("Choose folder…"); err != nil {
t.Fatal(err)
}
filePickerWait(t, tt, func() bool { return p.cancels > 0 })
if p.changes != 0 || p.paths[0] != "/kept" || !p.got.Directory {
t.Fatalf("cancel changed paths: %q", p.paths)
}
}
func TestFilePickerReportsDialogError(t *testing.T) {
p := &filePickerProbe{err: errors.New("no display")}
p.stub(t)
tt := p.tester(FilePickerOptions{})
_ = tt.Click("Choose file…")
want := "Could not open the file dialog: no display"
filePickerWait(t, tt, func() bool { return tt.HasText(want) })
if p.changes != 0 || p.cancels != 0 {
t.Fatalf("error counted as result")
}
}
func TestFilePickerDisabledAndBadFilter(t *testing.T) {
p := &filePickerProbe{}
p.stub(t)
called := false
filePickerOpen = func(mygo.OpenDialogOptions) ([]string, error) { called = true; return nil, nil }
tt := p.tester(FilePickerOptions{Disabled: true})
_ = tt.Click("Choose file…")
tt.Frame()
if called {
t.Fatal("disabled picker opened the dialog")
}
defer func() {
if recover() == nil {
t.Fatal("dotted extension accepted")
}
}()
p.tester(FilePickerOptions{Filters: []mygo.FileFilter{{Name: "PDF", Extensions: []string{".pdf"}}}})
}
func TestFilePickerSnapshot(t *testing.T) {
a, b := []string{"/Users/sakiko/scores/Imprisoned XII.pdf", "/Users/sakiko/scores/Ave Mujica.mid"}, []string{}
snapshot(t, "file_picker", 460, 190, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(20).Children(func() {
FilePicker(c, &a, FilePickerOptions{Multiple: true})
FilePicker(c, &b, FilePickerOptions{Directory: true, Disabled: true})
})
})
}
+309
View File
@@ -0,0 +1,309 @@
package mujica
import (
"fmt"
"log/slog"
"reflect"
"slices"
"github.com/egoist/mygo/ui"
)
// FormState is a form's validation and submit state; keep it beside the form's values.
// Its methods run on the UI thread: deliver async results through Window.Update.
type FormState struct {
fields map[string]*formFieldState
order []string
resets []func()
seq uint64
tried bool
submitting bool
awaiting bool
requested bool
focus string
formError string
}
type formFieldState struct {
snapshot string
version uint64
touched bool
focused bool
err string
asyncFor uint64
asyncErr string
pending bool
check func() string
startAsync func()
}
// Version returns the version of name's value, which changes whenever the value does.
func (s *FormState) Version(name string) uint64 {
if f := s.fields[name]; f != nil {
return f.version
}
return 0
}
// Resolve delivers the async check of name's value at version; "" means valid.
// It reports false and drops the result when the value has changed since.
func (s *FormState) Resolve(name string, version uint64, err string) bool {
f := s.fields[name]
if f == nil || f.version != version {
slog.Debug("mujica: form async result dropped", "field", name, "version", version)
return false
}
f.pending, f.asyncFor, f.asyncErr = false, version, err
slog.Debug("mujica: form async result", "field", name, "valid", err == "")
return true
}
// Submit asks the form to submit in its next frame, as the submit button does.
func (s *FormState) Submit() { s.requested = true }
// Submitting reports whether a submit is in progress.
func (s *FormState) Submitting() bool { return s.submitting }
// EndSubmit finishes a submit; formError, if any, shows above the buttons and keeps all values.
func (s *FormState) EndSubmit(formError string) {
if !s.submitting {
panic("mujica: FormState.EndSubmit without a submit in progress")
}
s.submitting, s.formError = false, formError
slog.Debug("mujica: form submit ended", "failed", formError != "")
}
// Error returns the error name shows now, "" when valid, untouched or pending.
func (s *FormState) Error(name string) string {
f := s.fields[name]
if f == nil || !(f.touched || s.tried) {
return ""
}
if f.err != "" {
return f.err
}
if f.asyncFor == f.version {
return f.asyncErr
}
return ""
}
// FormOptions configure a Form.
type FormOptions struct {
// Horizontal puts labels in a column beside the controls.
Horizontal bool
// SubmitLabel and ResetLabel replace the built-in "Submit" and "Reset".
SubmitLabel, ResetLabel string
}
// FormScope registers a Form's fields with FormItem.
type FormScope struct {
c *ui.Context
st *FormState
}
// FormParts are the parts of a Form.
type FormParts struct {
Element *ui.Element
submitted, reset bool
}
// Submitted reports a submit that passed every check; call EndSubmit when it finishes.
func (p FormParts) Submitted() bool { return p.submitted }
// Reset reports that the form restored its initial values this frame.
func (p FormParts) Reset() bool { return p.reset }
// FormItemOptions configure one field of a Form.
type FormItemOptions[T any] struct {
Label string
Required bool
Description string
// Initial is the value Reset restores.
Initial T
// Validate returns an error message for v, or ""; cross-field rules read other values here.
Validate func(v T) string
// Async starts a check of v off the UI thread, finished with FormState.Resolve(name, version, msg).
Async func(v T, version uint64)
}
// Form lays out the fields that fields registers, validates them on blur and submit,
// and shows submit and reset buttons. It never stores or sends the values itself.
func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts {
if st.fields == nil {
st.fields = map[string]*formFieldState{}
}
st.order, st.resets = st.order[:0], nil
d := Density(c)
k := Tokens(c)
parts := FormParts{}
col := ui.Column(c).Gap(d.Unit() * 5)
parts.Element = col
scope := &FormScope{c: c, st: st}
col.Children(func() {
if opts.Horizontal {
ui.Form(c, func() { fields(scope) })
} else {
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
}
if st.formError != "" {
ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus)
}
ui.Row(c).Gap(d.Unit() * 3).Children(func() {
submit, reset := opts.SubmitLabel, opts.ResetLabel
if submit == "" {
submit = msg(c, "form.submit")
}
if reset == "" {
reset = msg(c, "form.reset")
}
if Button(c, submit, ButtonOptions{Loading: st.submitting || st.awaiting}).Clicked() {
st.requested = true
}
if Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting}).Clicked() {
formReset(st)
parts.reset = true
}
})
})
if st.requested {
st.requested = false
if !st.submitting && !st.awaiting {
formStartSubmit(st)
}
}
if st.awaiting {
parts.submitted = formSettle(c, st)
}
return parts
}
// formStartSubmit validates every field and starts the async checks still owed.
func formStartSubmit(st *FormState) {
st.tried, st.awaiting, st.formError = true, true, ""
for _, name := range st.order {
f := st.fields[name]
f.err = f.check()
if f.err == "" {
f.startAsync()
}
}
slog.Debug("mujica: form submit requested", "fields", len(st.order))
}
// formSettle finishes a waiting submit once no check is pending; it reports success.
func formSettle(c *ui.Context, st *FormState) bool {
first, pending := "", false
for _, name := range st.order {
if st.Error(name) != "" && first == "" {
first = name
}
pending = pending || st.fields[name].pending
}
switch {
case first != "":
st.awaiting, st.focus = false, first
c.Announce(msg(c, "form.failed"))
c.Invalidate()
slog.Debug("mujica: form submit failed", "first", first)
case pending:
default:
st.awaiting, st.submitting = false, true
slog.Debug("mujica: form submitted")
return true
}
return false
}
// formReset restores the initial values and forgets every check.
func formReset(st *FormState) {
for _, r := range st.resets {
r()
}
st.fields = map[string]*formFieldState{}
st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", ""
slog.Debug("mujica: form reset")
}
// FormItem registers field name, labels the control that control returns, and shows its error.
// control must return the element to focus when the field has the first error.
func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
c, st := f.c, f.st
if name == "" || opts.Label == "" {
panic("mujica: FormItem needs a name and a Label")
}
if slices.Contains(st.order, name) {
panic("mujica: FormItem " + name + " registered twice")
}
st.order = append(st.order, name)
snap := fmt.Sprintf("%#v", *value)
fs := st.fields[name]
if fs == nil {
st.seq++
fs = &formFieldState{snapshot: snap, version: st.seq}
st.fields[name] = fs
}
fs.check = func() string {
if opts.Required && formEmpty(*value) {
return msg(c, "form.required")
}
if opts.Validate != nil {
return opts.Validate(*value)
}
return ""
}
fs.startAsync = func() {
if opts.Async != nil && fs.asyncFor != fs.version && !fs.pending {
fs.pending = true
slog.Debug("mujica: form async check started", "field", name, "version", fs.version)
opts.Async(*value, fs.version)
}
}
if snap != fs.snapshot {
st.seq++
fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, ""
if fs.touched || st.tried {
fs.err = fs.check()
}
}
initial := opts.Initial
st.resets = append(st.resets, func() { *value = initial })
desc := opts.Description
if fs.pending {
desc = msg(c, "form.checking")
}
var ctrl *ui.Element
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, func() {
ctrl = control()
})
if ctrl == nil {
panic("mujica: FormItem " + name + ": control returned nil")
}
focused := ctrl.FocusWithin()
if fs.focused && !focused {
fs.touched = true
fs.err = fs.check()
if fs.err == "" {
fs.startAsync()
}
slog.Debug("mujica: form field blurred", "field", name, "valid", fs.err == "")
}
fs.focused = focused
if st.focus == name {
st.focus = ""
ctrl.Focus().ScrollIntoView()
}
return field
}
// formEmpty reports whether a required value is missing: zero, or an empty string, slice or map.
func formEmpty(v any) bool {
r := reflect.ValueOf(v)
switch r.Kind() {
case reflect.Invalid:
return true
case reflect.String, reflect.Slice, reflect.Map, reflect.Array:
return r.Len() == 0
}
return r.IsZero()
}
+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"github.com/egoist/mygo/ui"
)
// FormFieldOptions configure a FormField.
type FormFieldOptions struct {
// Required marks the label; the check itself belongs to the caller or Form.
Required bool
Description string
// Error shows below the control and marks it invalid for assistive technology.
Error string
// Horizontal puts the label beside the control instead of above it.
Horizontal bool
}
// FormField labels the control that control builds and adds help and error text.
// The label names the control and a click on it focuses the control.
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
if label == "" {
panic("mujica: FormField needs a label")
}
if opts.Horizontal {
var f *ui.Element
ui.Form(c, func() { f = formFieldBuild(c, label, opts, control) })
return f
}
return formFieldBuild(c, label, opts, control)
}
// formFieldBuild builds the field in the layout the caller set up.
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
if opts.Required {
label += " *"
}
return ui.Field(c, label, control).Description(opts.Description).Error(opts.Error)
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestFormFieldLabelFocusesControl(t *testing.T) {
name := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() {
InputGroup(c, &name, InputGroupOptions{})
})
}, 400, 200)
if err := tt.Click("Name *"); err != nil {
t.Fatal(err)
}
tt.Type("Sakiko")
if name != "Sakiko" {
t.Fatalf("label click did not focus the control: %q", name)
}
if !tt.Focused("Name *") || !tt.HasText("As on your seal.") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestFormFieldErrorAndLayout(t *testing.T) {
v := ""
horizontal := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() {
InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"})
})
}, 500, 200)
if !tt.HasText("Enter an email address.") {
t.Fatal("error not shown")
}
label, _ := tt.Find("Email")
input, _ := tt.Find("mailto:")
if !(input.Y > label.Y+label.H/2) {
t.Fatalf("vertical: label %v input %v", label, input)
}
horizontal = true
tt.Frame()
label, _ = tt.Find("Email")
input, _ = tt.Find("mailto:")
if !(input.X > label.X+label.W) || input.Y > label.Y+label.H {
t.Fatalf("horizontal: label %v input %v", label, input)
}
}
func TestFormFieldNeedsLabel(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an empty label")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
FormField(c, "", FormFieldOptions{}, func() {})
}, 100, 100, 1)
}
func TestFormFieldSnapshot(t *testing.T) {
a, b := "Sakiko Togawa", "uika@"
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() {
InputGroup(c, &a, InputGroupOptions{})
})
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() {
InputGroup(c, &b, InputGroupOptions{Invalid: true})
})
})
})
}
+202
View File
@@ -0,0 +1,202 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
type formProbe struct {
st FormState
name, email string
tags []string
asyncCalls []uint64
asyncValues []string
submits, resets int
horizontal bool
useAsync bool
}
func (p *formProbe) run(c *ui.Context) {
Use(c, Settings{})
p.view(c)
}
func (p *formProbe) view(c *ui.Context) {
res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) {
FormItem(f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element {
return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input
})
opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string {
if v != "" && !strings.Contains(v, "@") {
return "Enter an email address."
}
return ""
}}
if p.useAsync {
opts.Async = func(v string, version uint64) {
p.asyncCalls = append(p.asyncCalls, version)
p.asyncValues = append(p.asyncValues, v)
}
}
FormItem(f, "email", &p.email, opts, func() *ui.Element {
return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input
})
FormItem(f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element {
return TagsInput(c, &p.tags, TagsInputOptions{}).Input
})
})
if res.Submitted() {
p.submits++
}
if res.Reset() {
p.resets++
}
}
func TestFormBlurValidationAndUntouchedFields(t *testing.T) {
p := &formProbe{name: "Sakiko", tags: []string{"rose"}}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Email")
tt.Type("mutsumi")
if tt.HasText("Enter an email address.") {
t.Fatal("error before blur")
}
tt.Key(0, ui.KeyTab)
if !tt.HasText("Enter an email address.") {
t.Fatal("no error after blur")
}
if tt.HasText("Required") {
t.Fatal("untouched field shows an error")
}
_ = tt.Click("Email")
tt.Type("@court")
if tt.HasText("Enter an email address.") || p.email != "mutsumi@court" {
t.Fatalf("touched field did not revalidate: %q", p.email)
}
}
func TestFormSubmitFailureFocusesFirstErrorAndKeepsValues(t *testing.T) {
p := &formProbe{email: "bad"}
tt := ui.NewTester(p.run, 600, 500)
if err := tt.Click("Submit"); err != nil {
t.Fatal(err)
}
if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") {
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
}
if !tt.Focused("Name *") || p.email != "bad" {
t.Fatal("first error not focused or value lost")
}
if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Some fields need attention" {
t.Fatalf("announcements %q", got)
}
}
func TestFormSubmitOnceAndEndSubmit(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
if p.submits != 1 || !p.st.Submitting() {
t.Fatalf("submits %d", p.submits)
}
_ = tt.Click("Submit")
p.st.Submit()
tt.Frame()
if p.submits != 1 {
t.Fatalf("re-entered: %d", p.submits)
}
p.st.EndSubmit("The server is away.")
tt.Frame()
if !tt.HasText("The server is away.") || p.name != "Uika" {
t.Fatal("form error not shown")
}
_ = tt.Click("Submit")
if p.submits != 2 || tt.HasText("The server is away.") {
t.Fatalf("second submit: %d", p.submits)
}
}
func TestFormAsyncStaleResultsAndPendingSubmit(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Email")
tt.Type("a@b")
tt.Key(0, ui.KeyTab)
if len(p.asyncCalls) != 1 || p.asyncValues[0] != "a@b" || !tt.HasText("Checking…") {
t.Fatalf("async calls %v", p.asyncCalls)
}
old := p.asyncCalls[0]
_ = tt.Click("Email")
tt.Type("c")
if p.st.Resolve("email", old, "Taken") {
t.Fatal("stale result accepted")
}
tt.Frame()
if tt.HasText("Taken") {
t.Fatal("stale result shown")
}
_ = tt.Click("Submit")
if len(p.asyncCalls) != 2 || p.asyncValues[1] != "a@bc" || p.submits != 0 {
t.Fatalf("submit did not wait for the new check: %v %d", p.asyncValues, p.submits)
}
if !p.st.Resolve("email", p.st.Version("email"), "") {
t.Fatal("current result refused")
}
tt.Frame()
if p.submits != 1 {
t.Fatalf("submit did not finish after the check: %d", p.submits)
}
}
func TestFormAsyncErrorBlocksSubmit(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "a@b", useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
p.st.Resolve("email", p.st.Version("email"), "Already registered")
tt.Frame()
tt.Frame()
if p.submits != 0 || !tt.HasText("Already registered") || !tt.Focused("Email") {
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
}
}
func TestFormResetRestoresInitialValues(t *testing.T) {
p := &formProbe{name: "", email: "bad", tags: nil}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
if err := tt.Click("Reset"); err != nil {
t.Fatal(err)
}
tt.Frame()
if p.name != "Sakiko" || p.email != "" || len(p.tags) != 1 || p.tags[0] != "rose" || p.resets != 1 {
t.Fatalf("reset values %q %q %q", p.name, p.email, p.tags)
}
if tt.HasText("Required") || p.st.Error("email") != "" {
t.Fatal("errors survive reset")
}
}
func TestFormHorizontalLayout(t *testing.T) {
p := &formProbe{horizontal: true}
tt := ui.NewTester(p.run, 600, 500)
name, _ := tt.Find("Name *")
email, _ := tt.Find("Email")
if name.X+name.W != email.X+email.W {
t.Fatalf("labels not right-aligned: %v %v", name, email)
}
}
func TestFormSnapshot(t *testing.T) {
p := &formProbe{name: "Sakiko Togawa", email: "oblivionis", tags: []string{"Ave Mujica"}}
snapshot(t, "form", 560, 420, func(c *ui.Context) {
ui.Column(c).Padding(24).Children(func() {
p.view(c)
if !p.st.tried {
p.st.Submit()
c.Invalidate()
}
})
})
}
+172
View File
@@ -0,0 +1,172 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// InputGroupButton is a button joined to an InputGroup's border.
type InputGroupButton struct {
// Label is shown, or names an icon-only button for assistive technology.
Label string
Icon *ui.SVG
// IconOnly hides Label on screen; Icon is then required.
IconOnly bool
// Leading puts the button before the input.
Leading bool
Disabled bool
}
// InputGroupOptions configure an InputGroup.
type InputGroupOptions struct {
// Prefix and Suffix are fixed text beside the input, such as "https://".
Prefix, Suffix string
Buttons []InputGroupButton
Placeholder string
// Label names the input when it is not inside a FormField.
Label string
Disabled bool
// ReadOnly shows the value as selectable text that cannot change.
ReadOnly bool
// Invalid draws the border in the danger color.
Invalid bool
}
// InputGroupParts are the parts of an InputGroup.
type InputGroupParts struct {
Group *ui.Element
// Input takes the focus; it is selectable text when ReadOnly.
Input *ui.Element
// Clicked is the index in Buttons of the button clicked, or -1.
Clicked int
}
// Changed reports whether the user edited the value this frame.
func (p InputGroupParts) Changed() bool { return p.Input.Changed() }
// Submitted reports whether Enter was pressed in the input.
func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() }
// InputGroup joins a single-line input with text and buttons inside one border.
// Every button keeps its own place in the Tab order.
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
for _, b := range opts.Buttons {
if b.IconOnly && b.Icon == nil {
panic("mujica: InputGroupButton IconOnly needs an Icon")
}
if b.Label == "" {
panic("mujica: InputGroupButton needs a Label")
}
}
k := Tokens(c)
d := Density(c)
parts := InputGroupParts{Clicked: -1}
g := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).Radius(theme.ControlRadius).
Padding(theme.BorderWidth).Background(k.Surface).Disabled(opts.Disabled)
parts.Group = g
r := theme.ControlRadius - theme.BorderWidth
// Index of each segment, for its corners and separators.
n := len(opts.Buttons) + 1
if opts.Prefix != "" {
n++
}
if opts.Suffix != "" {
n++
}
seg := 0
segment := func(e *ui.Element) {
var left, right float32
if seg == 0 {
left = r
}
if seg == n-1 {
right = r
}
e.Radius(left, right, right, left)
if seg > 0 {
e.BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.ControlBorder)
}
seg++
}
button := func(i int) {
b := opts.Buttons[i]
bb := ui.ButtonBase(c).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label)
segment(bb)
internal.Ring(bb, k.Focus, theme.ControlRadius)
if bb.Pressed() {
bb.Background(k.Selection)
} else if bb.Hovered() {
bb.Background(k.SurfaceHover)
}
bb.Children(func() {
if b.Icon != nil {
ui.Icon(c, b.Icon).Size(16, 16)
}
if !b.IconOnly {
ui.Text(c, b.Label).SingleLine()
}
})
if bb.Clicked() {
parts.Clicked = i
}
}
addon := func(s string) {
a := ui.Row(c).Padding(0, d.Unit()*3).Background(k.Background).Shrink(0)
segment(a)
a.Children(func() { ui.Text(c, s).TextColor(k.TextMuted).SingleLine() })
}
g.Children(func() {
for i, b := range opts.Buttons {
if b.Leading {
button(i)
}
}
if opts.Prefix != "" {
addon(opts.Prefix)
}
box := ui.Row(c).Grow(1).MinWidth(d.Unit()*12).Padding(0, d.Unit()*3)
segment(box)
box.Children(func() {
if opts.ReadOnly {
// As wide as MyGo makes a text input.
parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0)
} else {
parts.Input = ui.TextInputBase(c, value).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
}
if opts.Label != "" {
parts.Input.Label(opts.Label)
}
})
if box.Clicked() {
parts.Input.Focus()
}
if opts.Suffix != "" {
addon(opts.Suffix)
}
for i, b := range opts.Buttons {
if !b.Leading {
button(i)
}
}
})
in := parts.Input
border := k.ControlBorder
switch {
case opts.Invalid:
border = k.Danger
case in.Focused():
border = k.Focus
case g.Hovered() && !opts.Disabled:
border = k.ControlBorder.Mix(k.Text, 0.25)
}
g.Border(theme.BorderWidth, border)
g.DrawOver(func(p *ui.Painter, r ui.Rect) {
if in.Focused() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
return parts
}
+110
View File
@@ -0,0 +1,110 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
func TestInputGroupTypingAndButtons(t *testing.T) {
value, clicked, submitted := "", -1, 0
opts := InputGroupOptions{
Prefix: "https://", Suffix: ".com", Label: "Site",
Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}, {Label: "Go"}},
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
p := InputGroup(c, &value, opts)
if p.Clicked >= 0 {
clicked = p.Clicked
if p.Clicked == 0 {
value = ""
}
}
if p.Submitted() {
submitted++
}
}, 500, 120)
if err := tt.Click("Site"); err != nil {
t.Fatal(err)
}
tt.Type("mujica")
tt.Key(0, ui.KeyEnter)
if value != "mujica" || submitted != 1 {
t.Fatalf("value %q submitted %d", value, submitted)
}
if err := tt.Click("Go"); err != nil {
t.Fatal(err)
}
if clicked != 1 {
t.Fatalf("clicked %d, want 1", clicked)
}
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if clicked != 0 || value != "" {
t.Fatalf("clear: clicked %d value %q", clicked, value)
}
if !tt.HasText("https://") || !tt.HasText(".com") {
t.Fatal("addons missing")
}
}
func TestInputGroupTabOrder(t *testing.T) {
value := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Back", Leading: true}, {Label: "Search"}}})
}, 500, 120)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Back") {
t.Fatal("leading button is not first")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Query") {
t.Fatal("input is not second")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Search") {
t.Fatal("trailing button is not third")
}
}
func TestInputGroupBlockedStates(t *testing.T) {
for _, opts := range []InputGroupOptions{{Label: "Code", Disabled: true}, {Label: "Code", ReadOnly: true}} {
value, clicked := "abc", -1
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
opts.Buttons = []InputGroupButton{{Label: "Copy"}}
if p := InputGroup(c, &value, opts); p.Clicked >= 0 {
clicked = p.Clicked
}
}, 400, 100)
_ = tt.Click("Code")
tt.Type("zz")
_ = tt.Click("Copy")
if value != "abc" {
t.Fatalf("%+v: value changed to %q", opts, value)
}
if opts.Disabled && clicked != -1 {
t.Fatal("disabled button clicked")
}
if opts.ReadOnly && clicked != 0 {
t.Fatal("read-only group blocked its button")
}
}
}
func TestInputGroupSnapshot(t *testing.T) {
a, b, e := "mujica", "", "bad value"
snapshot(t, "input_group", 520, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).Children(func() {
InputGroup(c, &a, InputGroupOptions{Prefix: "https://", Suffix: ".com", Label: "Site"})
InputGroup(c, &b, InputGroupOptions{Placeholder: "Search the archive", Label: "Search",
Buttons: []InputGroupButton{{Label: "Search", Icon: icons.Must("search"), IconOnly: true, Leading: true}, {Label: "Go"}}})
InputGroup(c, &e, InputGroupOptions{Invalid: true, Suffix: "kg", Label: "Weight"})
})
})
}
+53
View File
@@ -0,0 +1,53 @@
package mujica
func init() {
addMessages(map[string][2]string{
"rating.label": {"Rating", "评分"},
"pin_input.label": {"Verification code", "验证码"},
"pin_input.filled": {"%d of %d digits entered", "已输入 %d / %d 位"},
"rating.value": {"%s of %d", "%s / %d 分"},
"tags_input.duplicate": {"“%s” is already added", "“%s”已添加"},
"tags_input.max": {"At most %d tags", "最多 %d 个标签"},
"tags_input.remove": {"Remove %s", "移除 %s"},
"color_picker.label": {"Color", "颜色"},
"color_picker.red": {"Red", "红"},
"color_picker.green": {"Green", "绿"},
"color_picker.blue": {"Blue", "蓝"},
"color_picker.percent": {"Opacity percent", "不透明度百分比"},
"color_picker.hue": {"Hue", "色相"},
"color_picker.alpha": {"Opacity", "不透明度"},
"color_picker.square": {"Saturation and brightness", "饱和度与亮度"},
"color_picker.sv": {"%.0f%% saturation, %.0f%% brightness", "饱和度 %.0f%%,亮度 %.0f%%"},
"color_picker.invalid": {"Use HEX RGB, RRGGBB or RRGGBBAA; channels 0–255; opacity 0–100.", "HEX 须为 RGB、RRGGBB 或 RRGGBBAA;通道 0–255;不透明度 0–100。"},
"file_picker.failed": {"Could not open the file dialog: %v", "无法打开文件对话框:%v"},
"file_picker.choose_file": {"Choose file…", "选择文件…"},
"file_picker.choose_folder": {"Choose folder…", "选择文件夹…"},
"file_picker.none": {"Nothing chosen", "未选择"},
"file_drop_zone.hint": {"Drop files here", "将文件拖到此处"},
"file_drop_zone.browse": {"Browse…", "浏览…"},
"file_drop_zone.any": {"Any file", "任意文件"},
"file_drop_zone.max_files": {"up to %d files", "最多 %d 个文件"},
"file_drop_zone.max_size": {"%s each", "每个不超过 %s"},
"file_drop_zone.type": {"Only %s files", "仅接受 %s 文件"},
"file_drop_zone.size": {"Larger than %s", "超过 %s"},
"file_drop_zone.count": {"More than %d files", "超过 %d 个文件"},
"file_drop_zone.folder": {"Folders are not accepted", "不接受文件夹"},
"file_drop_zone.unreadable": {"Cannot read: %v", "无法读取:%v"},
"file_drop_zone.checking": {"Checking…", "检查中…"},
"file_drop_zone.remove": {"Remove %s", "移除 %s"},
"file_drop_zone.dialog": {"File dialog", "文件对话框"},
"transfer.source": {"Available", "可选"},
"transfer.target": {"Chosen", "已选"},
"transfer.to": {"Move to %s", "移到%s"},
"transfer.all": {"Select all in %s", "全选%s"},
"transfer.search": {"Search", "搜索"},
"transfer.search_in": {"Search %s", "搜索%s"},
"transfer.empty": {"No items", "暂无条目"},
"transfer.no_match": {"No matches", "无匹配结果"},
"form.submit": {"Submit", "提交"},
"form.reset": {"Reset", "重置"},
"form.required": {"Required", "必填"},
"form.checking": {"Checking…", "校验中…"},
"form.failed": {"Some fields need attention", "部分字段需要修改"},
})
}
+165
View File
@@ -0,0 +1,165 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// PinInputOptions configure a PinInput. Length is 6 when zero.
type PinInputOptions struct {
Length int
// Label names the input; empty uses the built-in "Verification code".
Label string
Disabled bool
// Invalid draws the cells in the danger color.
Invalid bool
}
// PinInputParts are the parts of a PinInput.
type PinInputParts struct {
Element *ui.Element
changed, completed bool
}
// Changed reports whether the user changed the code this frame.
func (p PinInputParts) Changed() bool { return p.changed }
// Completed reports whether the user filled the last cell this frame.
func (p PinInputParts) Completed() bool { return p.completed }
// pinInputEvent is input taken between frames, applied in the next one.
type pinInputEvent struct {
text string
backspace bool
paste bool
}
type pinInputState struct {
queue []pinInputEvent
compose string
}
// PinInput edits a fixed-length code of digits, one per cell, keeping leading zeros.
// Typing fills the next cell, Backspace clears the last, and paste fills them all.
func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts {
n := opts.Length
if n == 0 {
n = 6
}
if n < 0 || len(*value) > n || strings.Trim(*value, "0123456789") != "" {
panic(fmt.Sprintf("mujica: PinInput value %q does not fit %d digits", *value, n))
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "pin_input.label")
}
parts := PinInputParts{}
e := ui.Row(c).Gap(d.Unit() * 2).Shrink(0).Role(ui.RoleTextField).Label(label).Disabled(opts.Disabled)
parts.Element = e
st := ui.Local(e, "pin", func() pinInputState { return pinInputState{} })
put := func(s string) {
var b strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
next := b.String()
if len(next) > n {
next = next[:n]
}
if next == *value {
return
}
was := len(*value)
*value = next
parts.changed = true
if len(next) == n && was < n {
parts.completed = true
slog.Debug("mujica: pin input completed", "length", n)
}
}
for _, ev := range st.queue {
switch {
case ev.paste:
put(c.ReadClipboard())
case ev.backspace && *value != "":
put((*value)[:len(*value)-1])
case ev.text != "":
put(*value + ev.text)
}
}
st.queue = st.queue[:0]
if opts.Disabled {
st.compose = ""
} else {
e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool {
switch {
case ev.Kind == ui.InputText:
st.queue = append(st.queue, pinInputEvent{text: ev.Text})
st.compose = ""
case ev.Kind == ui.InputCompose:
st.compose = ev.Text
case ev.Kind == ui.InputCommand && ev.Text == "paste",
ev.Kind == ui.InputKeyDown && ev.Mods == ui.Cmd && ev.Key == ui.KeyV:
st.queue = append(st.queue, pinInputEvent{paste: true})
case ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace:
st.queue = append(st.queue, pinInputEvent{backspace: true})
default:
return false
}
return true
})
}
size := d.ControlHeight() + d.Unit()*2
active := min(len(*value), n-1)
focused := e.Focused()
compose := st.compose
e.Description(fmt.Sprintf(msg(c, "pin_input.filled"), len(*value), n))
e.TextCaret(ui.Rect{X: float32(active) * (size + d.Unit()*2), Y: 0, W: size, H: size})
e.Children(func() {
for i := range n {
cell := ui.Box(c).Size(size, size).Center().Radius(theme.ControlRadius).Background(k.Surface).Role(ui.RoleNone)
border := k.ControlBorder
switch {
case opts.Invalid:
border = k.Danger
case focused && i == active:
border = k.Focus
}
cell.Border(theme.BorderWidth, border)
ch := ""
if i < len(*value) {
ch = (*value)[i : i+1]
}
showCompose := focused && i == active && compose != ""
cell.Children(func() {
switch {
case showCompose:
ui.Text(c, compose).FontSize(theme.H3Size).Underline().SingleLine()
case ch != "":
ui.Text(c, ch).FontSize(theme.H3Size).FontFeatures("tnum").SingleLine()
}
})
caret := focused && i == active && len(*value) < n && !showCompose
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if focused && i == active {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
if caret {
h := r.H * 0.4
p.Fill(ui.Rect{X: r.X + r.W/2 - 0.5, Y: r.Y + (r.H-h)/2, W: 1, H: h}, k.Text, 0)
}
})
}
})
return parts
}
+104
View File
@@ -0,0 +1,104 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type pinInputProbe struct {
value string
changes, completes int
}
func (p *pinInputProbe) tester(opts PinInputOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := PinInput(c, &p.value, opts)
if r.Changed() {
p.changes++
}
if r.Completed() {
p.completes++
}
}, 480, 120)
}
func TestPinInputTypingAndBackspace(t *testing.T) {
p := &pinInputProbe{}
tt := p.tester(PinInputOptions{Length: 4, Label: "Code"})
tt.Key(0, ui.KeyTab)
if !tt.Focused("Code") {
t.Fatal("not focusable")
}
tt.TypeKey(0, ui.Key0, "0")
tt.Type("a")
tt.Type("07")
if p.value != "007" || p.completes != 0 {
t.Fatalf("value %q completes %d", p.value, p.completes)
}
tt.Type("9")
tt.Type("5")
if p.value != "0079" || p.completes != 1 {
t.Fatalf("full: value %q completes %d", p.value, p.completes)
}
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
if p.value != "00" {
t.Fatalf("backspace: %q", p.value)
}
tt.Type("12")
if p.value != "0012" || p.completes != 2 {
t.Fatalf("refill: %q completes %d", p.value, p.completes)
}
}
func TestPinInputPasteAndCompose(t *testing.T) {
p := &pinInputProbe{value: "1"}
tt := p.tester(PinInputOptions{Label: "Code"})
tt.Key(0, ui.KeyTab)
tt.SetClipboard(" 012-345 9")
tt.Command("paste")
if p.value != "012345" || p.completes != 1 || p.changes != 1 {
t.Fatalf("paste: %q completes %d changes %d", p.value, p.completes, p.changes)
}
tt.Key(0, ui.KeyBackspace)
tt.Compose("4", 1)
if p.value != "01234" || !tt.HasText("4") {
t.Fatalf("compose committed early: %q", p.value)
}
tt.Type("8")
if p.value != "012348" {
t.Fatalf("commit: %q", p.value)
}
}
func TestPinInputDisabled(t *testing.T) {
p := &pinInputProbe{value: "12"}
tt := p.tester(PinInputOptions{Label: "Code", Disabled: true})
tt.Key(0, ui.KeyTab)
tt.Type("3")
if tt.Focused("Code") || p.value != "12" {
t.Fatalf("disabled took input: %q", p.value)
}
}
func TestPinInputRejectsBadValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("letters accepted")
}
}()
p := &pinInputProbe{value: "12a"}
p.tester(PinInputOptions{})
}
func TestPinInputSnapshot(t *testing.T) {
a, b := "0420", "13"
snapshot(t, "pin_input", 420, 150, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
PinInput(c, &a, PinInputOptions{}).Element.Focus()
PinInput(c, &b, PinInputOptions{Length: 4, Invalid: true})
})
})
}
+144
View File
@@ -0,0 +1,144 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// RatingOptions configure a Rating. Max is 5 and Step 1 when zero.
type RatingOptions struct {
Max int
// Step is the smallest change, such as 0.5 for half stars; it must divide 1.
Step float64
ReadOnly bool
Disabled bool
// Label names the rating; empty uses the built-in "Rating".
Label string
// ShowValue prints "3.5 / 5" beside the stars.
ShowValue bool
}
// RatingParts are the parts of a Rating.
type RatingParts struct {
Element *ui.Element
changed bool
}
// Changed reports whether the user set a new value this frame.
func (p RatingParts) Changed() bool { return p.changed }
// Rating shows *value as stars and lets the pointer and arrows set it in steps.
func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
n, step := opts.Max, opts.Step
if n == 0 {
n = 5
}
if step == 0 {
step = 1
}
if n < 0 || step < 0 || step > 1 || math.Abs(1/step-math.Round(1/step)) > 1e-9 {
panic(fmt.Sprintf("mujica: Rating Max %d, Step %v: Max must be positive and Step divide 1", opts.Max, opts.Step))
}
k := Tokens(c)
size := Density(c).ControlHeight() * 0.6
label := opts.Label
if label == "" {
label = msg(c, "rating.label")
}
parts := RatingParts{}
set := func(v float64) {
v = math.Max(0, math.Min(float64(n), math.Round(v/step)*step))
if v != *value {
slog.Debug("mujica: rating changed", "from", *value, "to", v)
*value = v
parts.changed = true
}
}
e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0)
parts.Element = e
e.Label(label)
interactive := !opts.ReadOnly && !opts.Disabled
if opts.Disabled {
e.Disabled(true)
} else {
e.Focusable()
internal.Ring(e, k.Focus, theme.ControlRadius)
}
if interactive {
e.Role(ui.RoleSlider).Cursor(ui.CursorPointer)
switch {
case e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyUp):
set(*value + step)
case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown):
set(*value - step)
case e.Shortcut(0, ui.KeyHome):
set(0)
case e.Shortcut(0, ui.KeyEnd):
set(float64(n))
}
}
e.Children(func() {
stars := ui.Box(c).Size(size*float32(n), size).Shrink(0)
shown, fill := *value, k.AccentText
if interactive {
at := func(x float32) float64 { return math.Ceil(float64(x/size)/step-1e-9) * step }
if _, _, ok := stars.Dragged(); ok {
x, _, _ := stars.PointerPosition()
set(at(x))
shown = *value
} else if x, _, over := stars.PointerPosition(); over {
shown, fill = math.Max(step, math.Min(float64(n), at(x))), k.AccentText.Alpha(0.6)
}
}
stars.Draw(func(p *ui.Painter, r ui.Rect) {
for i := range n {
cell := ui.Rect{X: r.X + float32(i)*size, Y: r.Y, W: size, H: size}
path := ratingStar(cell)
p.StrokePath(path, 1.25, k.ControlBorder)
part := float32(math.Max(0, math.Min(1, shown-float64(i))))
if part > 0 {
p.Clip(ui.Rect{X: cell.X, Y: cell.Y, W: cell.W * part, H: cell.H}, 0, func() {
p.FillPath(path, fill)
p.StrokePath(path, 1.25, fill)
})
}
}
})
if opts.ShowValue {
ui.Text(c, fmt.Sprintf("%s / %d", strconv.FormatFloat(*value, 'f', -1, 64), n)).
TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
}
})
e.Description(fmt.Sprintf(msg(c, "rating.value"), strconv.FormatFloat(*value, 'f', -1, 64), n))
return parts
}
// ratingStar returns a five-pointed star inset in r.
func ratingStar(r ui.Rect) *ui.Path {
var p ui.Path
cx, cy := r.X+r.W/2, r.Y+r.H*0.54
outer := r.W * 0.42
inner := outer * 0.45
for i := range 10 {
rad := outer
if i%2 == 1 {
rad = inner
}
a := -math.Pi/2 + float64(i)*math.Pi/5
x, y := cx+rad*float32(math.Cos(a)), cy+rad*float32(math.Sin(a))
if i == 0 {
p.MoveTo(x, y)
} else {
p.LineTo(x, y)
}
}
p.Close()
return &p
}
+102
View File
@@ -0,0 +1,102 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
if Rating(c, v, opts).Changed() {
*changes++
}
})
}, 300, 100)
}
func TestRatingKeyboardSteps(t *testing.T) {
v, n := 2.0, 0
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Taste") {
t.Fatal("rating not focusable")
}
tt.Key(0, ui.KeyRight)
if v != 2.5 {
t.Fatalf("right: %v", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != 1.5 {
t.Fatalf("down twice: %v", v)
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyRight)
if v != 5 {
t.Fatalf("end then right: %v", v)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyLeft)
if v != 0 || n != 5 {
t.Fatalf("home then left: %v, %d changes", v, n)
}
}
func TestRatingPointer(t *testing.T) {
v, n := 0.0, 0
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
size := float32(36) * 0.6
// The left half of the third star.
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
if v != 2.5 || n != 1 {
t.Fatalf("click: %v, %d changes", v, n)
}
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
if n != 1 {
t.Fatalf("same value reported a change")
}
}
func TestRatingReadOnlyAndDisabled(t *testing.T) {
for _, opts := range []RatingOptions{{ReadOnly: true, Label: "Score"}, {Disabled: true, Label: "Score"}} {
v, n := 3.0, 0
tt := ratingTester(&v, opts, &n)
tt.ClickAt(12, 20)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if v != 3 || n != 0 {
t.Fatalf("%+v: value %v changes %d", opts, v, n)
}
if opts.ReadOnly != tt.Focused("Score") {
t.Fatalf("%+v: focus %v", opts, tt.Focused("Score"))
}
}
}
func TestRatingShowsValueAndPanics(t *testing.T) {
v, n := 3.5, 0
tt := ratingTester(&v, RatingOptions{Step: 0.5, ShowValue: true}, &n)
if !tt.HasText("3.5 / 5") {
t.Fatalf("texts %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("Step 0.3 accepted")
}
}()
ratingTester(&v, RatingOptions{Step: 0.3}, &n)
}
func TestRatingSnapshot(t *testing.T) {
a, b, d := 3.5, 4.0, 2.0
snapshot(t, "rating", 360, 170, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(14).AlignItems(ui.Start).Children(func() {
Rating(c, &a, RatingOptions{Step: 0.5, ShowValue: true})
Rating(c, &b, RatingOptions{ReadOnly: true, Max: 7, ShowValue: true})
Rating(c, &d, RatingOptions{Disabled: true})
})
})
}
+178
View File
@@ -0,0 +1,178 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TagsInputOptions configure a TagsInput.
type TagsInputOptions struct {
// Max caps the number of tags; zero allows any number.
Max int
// Separators split typed and pasted text into tags; empty uses ",;,;" and line breaks.
Separators string
// Validate checks a tag before it is added and returns an error message, or "".
Validate func(tag string) string
Placeholder string
// Label names the input when it is not inside a FormField.
Label string
Disabled bool
// Invalid draws the border in the danger color.
Invalid bool
}
// TagsInputParts are the parts of a TagsInput.
type TagsInputParts struct {
Element *ui.Element
// Input takes the focus.
Input *ui.Element
changed bool
}
// Changed reports whether tags were added or removed this frame.
func (p TagsInputParts) Changed() bool { return p.changed }
type tagsInputState struct {
query string
err string
backspace bool
}
// TagsInput edits a list of unique tags shown as chips before a text input.
// Enter or a separator adds the text; Backspace in the empty input removes the last tag.
func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputParts {
if opts.Max < 0 {
panic("mujica: TagsInput Max must not be negative")
}
k := Tokens(c)
d := Density(c)
seps := opts.Separators
if seps == "" {
seps = ",;,;\n\r\t"
}
parts := TagsInputParts{}
outer := ui.Column(c).Gap(d.Unit())
parts.Element = outer
st := ui.Local(outer, "tags", func() tagsInputState { return tagsInputState{} })
add := func(s string) bool {
s = strings.TrimSpace(s)
switch {
case s == "":
return true
case slices.Contains(*tags, s):
st.err = fmt.Sprintf(msg(c, "tags_input.duplicate"), s)
return false
case opts.Max > 0 && len(*tags) >= opts.Max:
st.err = fmt.Sprintf(msg(c, "tags_input.max"), opts.Max)
return false
}
if opts.Validate != nil {
if e := opts.Validate(s); e != "" {
st.err = e
return false
}
}
*tags = append(*tags, s)
parts.changed = true
slog.Debug("mujica: tags input added", "count", len(*tags))
return true
}
remove := func(i int) {
*tags = slices.Delete(slices.Clone(*tags), i, i+1)
parts.changed, st.err = true, ""
slog.Debug("mujica: tags input removed", "count", len(*tags))
}
if st.backspace {
st.backspace = false
if len(*tags) > 0 && st.query == "" {
remove(len(*tags) - 1)
}
}
var box *ui.Element
outer.Children(func() {
box = ui.Row(c).Wrap().AlignItems(ui.Center).Gap(d.Unit()*1.5).MinHeight(d.ControlHeight()).
Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled)
box.Children(func() {
for i, tag := range *tags {
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Gap(d.Unit()).Padding(0, d.Unit(), 0, d.Unit()*2).
MinHeight(d.ControlHeight() - d.Unit()*3).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
chip.Children(func() {
ui.Text(c, tag).SingleLine()
x := ui.ButtonBase(c).Size(20, 20).Radius(theme.ControlRadius).TextColor(k.TextMuted).
Label(fmt.Sprintf(msg(c, "tags_input.remove"), tag))
internal.Ring(x, k.Focus, theme.ControlRadius)
if x.Hovered() {
x.Background(k.SurfaceHover).TextColor(k.Text)
}
x.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14) })
if x.Clicked() {
remove(i)
}
})
}
// The input keeps its identity whatever chips come before it.
ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24).Children(func() {
in := ui.TextInputBase(c, &st.query).FocusRing(false).FillWidth().Placeholder(opts.Placeholder)
parts.Input = in
if opts.Label != "" {
in.Label(opts.Label)
}
in.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" {
st.backspace = true
return true
}
return false
})
})
})
if st.err != "" {
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
}
})
in := parts.Input
if in.Changed() {
st.err = ""
}
if strings.ContainsAny(st.query, seps) {
pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) })
last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00")
var kept []string
for _, p := range pieces[:len(pieces)-1] {
if !add(p) {
kept = append(kept, strings.TrimSpace(p))
}
}
st.query = strings.Join(append(kept, last), string([]rune(seps)[0]))
}
if in.Submitted() && add(st.query) {
st.query = ""
}
if box.Clicked() {
in.Focus()
}
border := k.ControlBorder
switch {
case opts.Invalid || st.err != "":
border = k.Danger
case in.Focused():
border = k.Focus
case box.Hovered() && !opts.Disabled:
border = k.ControlBorder.Mix(k.Text, 0.25)
}
box.Border(theme.BorderWidth, border)
box.DrawOver(func(p *ui.Painter, r ui.Rect) {
if in.Focused() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
return parts
}
+117
View File
@@ -0,0 +1,117 @@
package mujica
import (
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func tagsInputTester(tags *[]string, opts TagsInputOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Children(func() {
if TagsInput(c, tags, opts).Changed() {
*changes++
}
})
}, 520, 200)
}
func TestTagsInputEnterSeparatorsAndBackspace(t *testing.T) {
tags, n := []string{"rose"}, 0
tt := tagsInputTester(&tags, TagsInputOptions{Label: "Tags"}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Tags") {
t.Fatal("input not after the chip's remove button")
}
tt.Type("thorn")
tt.Key(0, ui.KeyEnter)
tt.Type("lily, ivy;moss")
if !slices.Equal(tags, []string{"rose", "thorn", "lily", "ivy"}) {
t.Fatalf("tags %q", tags)
}
tt.Key(0, ui.KeyEnter)
if tags[4] != "moss" || n != 3 {
t.Fatalf("tags %q changes %d", tags, n)
}
tt.Key(0, ui.KeyBackspace)
if !slices.Equal(tags, []string{"rose", "thorn", "lily", "ivy"}) {
t.Fatalf("backspace: %q", tags)
}
}
func TestTagsInputPasteDedupeAndMax(t *testing.T) {
tags, n := []string{"a"}, 0
tt := tagsInputTester(&tags, TagsInputOptions{Label: "Tags", Max: 3}, &n)
_ = tt.Click("Tags")
tt.SetClipboard("b,a,c,d,")
tt.Command("paste")
if !slices.Equal(tags, []string{"a", "b", "c"}) {
t.Fatalf("tags %q", tags)
}
if !tt.HasText("“a” is already added") && !tt.HasText("At most 3 tags") {
t.Fatalf("no error shown: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if len(tags) != 3 {
t.Fatalf("max exceeded: %q", tags)
}
}
func TestTagsInputValidateKeepsText(t *testing.T) {
tags, n, seen := []string{}, 0, ""
opts := TagsInputOptions{Label: "Tags", Validate: func(s string) string {
seen = s
if strings.ContainsAny(s, " ") {
return "No spaces"
}
return ""
}}
tt := tagsInputTester(&tags, opts, &n)
_ = tt.Click("Tags")
tt.Type("two words")
tt.Key(0, ui.KeyEnter)
if len(tags) != 0 || !tt.HasText("No spaces") {
t.Fatalf("tags %q texts %q", tags, tt.Texts())
}
tt.Type("x")
if tt.HasText("No spaces") {
t.Fatal("error stays after editing")
}
tt.Key(0, ui.KeyEnter)
if seen != "two wordsx" {
t.Fatalf("typed text lost: %q", seen)
}
}
func TestTagsInputRemoveButtonAndDisabled(t *testing.T) {
tags, n := []string{"rose", "ivy"}, 0
tt := tagsInputTester(&tags, TagsInputOptions{}, &n)
if err := tt.Click("Remove rose"); err != nil {
t.Fatal(err)
}
if !slices.Equal(tags, []string{"ivy"}) || n != 1 {
t.Fatalf("tags %q", tags)
}
tags = []string{"rose"}
tt = tagsInputTester(&tags, TagsInputOptions{Disabled: true}, &n)
_ = tt.Click("Remove rose")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyBackspace)
if len(tags) != 1 {
t.Fatal("disabled input removed a tag")
}
}
func TestTagsInputSnapshot(t *testing.T) {
a, b := []string{"Ave Mujica", "Oblivionis", "Doloris"}, []string{"rose"}
snapshot(t, "tags_input", 520, 190, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
TagsInput(c, &a, TagsInputOptions{Placeholder: "Add a member"})
TagsInput(c, &b, TagsInputOptions{Invalid: true, Placeholder: "Add a flower"})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+225
View File
@@ -0,0 +1,225 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TransferItem is one entry of a Transfer; ID must be unique and stable.
type TransferItem struct {
ID string
Label string
Disabled bool
}
// TransferOptions configure a Transfer.
type TransferOptions struct {
// SourceTitle and TargetTitle replace the built-in "Available" and "Chosen".
SourceTitle, TargetTitle string
// Height of each list in DIPs; 240 when zero.
Height float32
Disabled bool
}
// TransferParts are the parts of a Transfer.
type TransferParts struct {
Element *ui.Element
changed bool
}
// Changed reports whether items moved this frame.
func (p TransferParts) Changed() bool { return p.changed }
type transferState struct {
checked map[string]bool
query [2]string
}
// Transfer moves items between a source list and *target, the IDs chosen, in order.
// Each side has search, check boxes with select-all, and move buttons between them.
func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) TransferParts {
byID := make(map[string]TransferItem, len(items))
for _, it := range items {
if _, dup := byID[it.ID]; dup || it.ID == "" {
panic(fmt.Sprintf("mujica: Transfer item ID %q is empty or repeated", it.ID))
}
byID[it.ID] = it
}
for _, id := range *target {
if _, ok := byID[id]; !ok {
panic(fmt.Sprintf("mujica: Transfer target ID %q is not an item", id))
}
}
d := Density(c)
parts := TransferParts{}
row := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Disabled(opts.Disabled)
parts.Element = row
st := ui.Local(row, "transfer", func() transferState { return transferState{checked: map[string]bool{}} })
for id := range st.checked {
if it, ok := byID[id]; !ok || it.Disabled {
delete(st.checked, id)
}
}
var source, chosen []TransferItem
for _, it := range items {
if !slices.Contains(*target, it.ID) {
source = append(source, it)
}
}
for _, id := range *target {
chosen = append(chosen, byID[id])
}
move := func(from []TransferItem, right bool) {
var ids []string
for _, it := range from {
if st.checked[it.ID] && !it.Disabled {
ids = append(ids, it.ID)
delete(st.checked, it.ID)
}
}
if len(ids) == 0 {
return
}
if right {
*target = append(slices.Clone(*target), ids...)
} else {
*target = slices.DeleteFunc(slices.Clone(*target), func(id string) bool { return slices.Contains(ids, id) })
}
parts.changed = true
slog.Debug("mujica: transfer moved", "count", len(ids), "right", right)
}
height := opts.Height
if height == 0 {
height = 240
}
srcTitle, dstTitle := opts.SourceTitle, opts.TargetTitle
if srcTitle == "" {
srcTitle = msg(c, "transfer.source")
}
if dstTitle == "" {
dstTitle = msg(c, "transfer.target")
}
row.Children(func() {
transferPanel(c, st, 0, srcTitle, source, height)
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
right := transferCount(st, source) > 0
left := transferCount(st, chosen) > 0
if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-right"), Label: fmt.Sprintf(msg(c, "transfer.to"), dstTitle), Disabled: !right}).Clicked() {
move(source, true)
}
if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-left"), Label: fmt.Sprintf(msg(c, "transfer.to"), srcTitle), Disabled: !left}).Clicked() {
move(chosen, false)
}
})
transferPanel(c, st, 1, dstTitle, chosen, height)
})
return parts
}
// transferCount counts the checked items that can move.
func transferCount(st *transferState, items []TransferItem) int {
n := 0
for _, it := range items {
if st.checked[it.ID] && !it.Disabled {
n++
}
}
return n
}
// transferPanel is one side: header with select-all, search, and the list.
func transferPanel(c *ui.Context, st *transferState, side int, title string, items []TransferItem, height float32) {
k := Tokens(c)
d := Density(c)
q := strings.ToLower(strings.TrimSpace(st.query[side]))
var shown []TransferItem
enabled := 0
for _, it := range items {
if q == "" || strings.Contains(strings.ToLower(it.Label), q) {
shown = append(shown, it)
if !it.Disabled {
enabled++
}
}
}
checked := transferCount(st, shown)
ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() {
head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border)
head.Children(func() {
all := checked > 0 && checked == enabled
box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0)
transferMark(c, box, all, checked > 0 && !all)
if box.Changed() {
for _, it := range shown {
if !it.Disabled {
st.checked[it.ID] = all
}
}
}
ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1)
ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum")
})
ui.Box(c).Padding(d.Unit() * 2).Children(func() {
InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: msg(c, "transfer.search"), Label: fmt.Sprintf(msg(c, "transfer.search_in"), title)})
})
list := ui.Scroll(c).Height(height).Padding(0, d.Unit(), d.Unit(), d.Unit()).FocusGroup(ui.Vertical).Role(ui.RoleList).Label(title)
list.Children(func() {
if len(shown) == 0 {
empty := msg(c, "transfer.empty")
if q != "" {
empty = msg(c, "transfer.no_match")
}
ui.Text(c, empty).TextColor(k.TextMuted).Padding(d.Unit() * 3).TextAlign(ui.Center).FillWidth()
return
}
for _, it := range shown {
on := st.checked[it.ID]
ui.Box(c).Key(it.ID).Children(func() {
cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(d.ControlHeight()-d.Unit()).Padding(0, d.Unit()*2).
Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled)
if cb.Changed() {
st.checked[it.ID] = on
}
if on {
cb.Background(k.Selection)
} else if cb.Hovered() {
cb.Background(k.SurfaceHover)
}
transferMark(c, cb, on, false)
cb.Children(func() { ui.Text(c, it.Label).SingleLine().Ellipsis("…").MinWidth(0) })
})
}
})
})
}
// transferMark draws a check box's square inside box, which takes the focus ring.
func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) {
k := Tokens(c)
internal.Ring(box, k.Focus, theme.ControlRadius)
box.Children(func() {
sq := ui.Box(c).Size(16, 16).Shrink(0).Center().Radius(theme.ControlRadius).Role(ui.RoleNone)
if on || mixed {
sq.Background(k.Accent).TextColor(k.OnAccent)
} else {
sq.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
}
sq.Children(func() {
switch {
case on:
ui.Icon(c, icons.Must("check")).Size(12, 12)
case mixed:
ui.Icon(c, icons.Must("minus")).Size(12, 12)
}
})
})
}
+106
View File
@@ -0,0 +1,106 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
var transferCast = []TransferItem{
{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "umiri", Label: "Umiri"},
{ID: "nyamu", Label: "Nyamu", Disabled: true}, {ID: "sakiko", Label: "Sakiko"},
}
func transferTester(target *[]string, items *[]TransferItem, opts TransferOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Transfer(c, target, *items, opts).Changed() {
*changes++
}
}, 640, 420)
}
func TestTransferMoveSelected(t *testing.T) {
target, items, n := []string{"sakiko"}, slices.Clone(transferCast), 0
tt := transferTester(&target, &items, TransferOptions{}, &n)
_ = tt.Click("Mutsumi")
_ = tt.Click("Uika")
if err := tt.Click("Move to Chosen"); err != nil {
t.Fatal(err)
}
if !slices.Equal(target, []string{"sakiko", "uika", "mutsumi"}) || n != 1 {
t.Fatalf("target %q changes %d", target, n)
}
_ = tt.Click("Sakiko")
_ = tt.Click("Move to Available")
if !slices.Equal(target, []string{"uika", "mutsumi"}) || n != 2 {
t.Fatalf("back: %q", target)
}
}
func TestTransferSelectAllSkipsDisabledAndFollowsSearch(t *testing.T) {
target, items, n := []string{}, slices.Clone(transferCast), 0
tt := transferTester(&target, &items, TransferOptions{}, &n)
_ = tt.Click("Search Available")
tt.Type("u")
if tt.HasText("Sakiko") {
t.Fatal("search did not filter")
}
_ = tt.Click("Select all in Available")
_ = tt.Click("Move to Chosen")
if !slices.Equal(target, []string{"uika", "mutsumi", "umiri"}) {
t.Fatalf("select all moved %q", target)
}
_ = tt.Click("Search Available")
tt.Type("zzz")
if !tt.HasText("No matches") {
t.Fatal("no empty search state")
}
}
func TestTransferKeyboardAndStableIDs(t *testing.T) {
target, items, n := []string{}, slices.Clone(transferCast), 0
tt := transferTester(&target, &items, TransferOptions{}, &n)
_ = tt.Click("Umiri")
// Items reorder and relabel; the check stays on the same ID.
items = []TransferItem{{ID: "umiri", Label: "Yahata Umiri"}, {ID: "uika", Label: "Uika"}}
tt.Frame()
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
_ = tt.Click("Move to Chosen")
if !slices.Equal(target, []string{"umiri", "uika"}) {
t.Fatalf("target %q", target)
}
}
func TestTransferDisabledAndBadIDs(t *testing.T) {
target, items, n := []string{}, slices.Clone(transferCast), 0
tt := transferTester(&target, &items, TransferOptions{Disabled: true}, &n)
_ = tt.Click("Uika")
_ = tt.Click("Move to Chosen")
if len(target) != 0 {
t.Fatal("disabled transfer moved")
}
defer func() {
if recover() == nil {
t.Fatal("unknown target ID accepted")
}
}()
bad := []string{"ghost"}
transferTester(&bad, &items, TransferOptions{}, &n)
}
func TestTransferSnapshot(t *testing.T) {
target := []string{"sakiko", "mutsumi"}
snapshot(t, "transfer", 620, 380, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() {
p := Transfer(c, &target, transferCast, TransferOptions{Height: 200})
st := ui.Local(p.Element, "transfer", func() transferState { return transferState{} })
if !st.checked["uika"] {
st.checked["uika"] = true
c.Invalidate()
}
})
})
}