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