Forms: components 041-050
No files matched your search
@@ -0,0 +1,396 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ColorPickerOptions configure a ColorPicker.
|
||||
type ColorPickerOptions struct {
|
||||
// Presets are offered as swatches; nil uses the built-in court palette.
|
||||
Presets []ui.Color
|
||||
// Label names the trigger; empty uses the built-in "Color".
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ColorPickerParts are the parts of a ColorPicker.
|
||||
type ColorPickerParts struct {
|
||||
Trigger *ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
// Changed reports whether the user picked a new color this frame.
|
||||
func (p ColorPickerParts) Changed() bool { return p.changed }
|
||||
|
||||
// colorPickerPresets are the default swatches: court tones and neutrals.
|
||||
var colorPickerPresets = []ui.Color{
|
||||
ui.Hex("#7D2034"), ui.Hex("#A12635"), ui.Hex("#AD9164"), ui.Hex("#805714"),
|
||||
ui.Hex("#326247"), ui.Hex("#355E80"), ui.Hex("#4B3A5A"), ui.Hex("#211C1D"),
|
||||
ui.Hex("#6A5B55"), ui.Hex("#D2C3AF"), ui.Hex("#F2EBDD"), ui.Hex("#FFFFFF"),
|
||||
}
|
||||
|
||||
// colorPickerHSVA keeps hue for grays, which RGB loses.
|
||||
type colorPickerHSVA struct{ h, s, v, a float64 }
|
||||
|
||||
type colorPickerState struct {
|
||||
open, wasOpen bool
|
||||
hsv colorPickerHSVA
|
||||
last ui.Color
|
||||
hex, r, g, b string
|
||||
alpha string
|
||||
bad string
|
||||
}
|
||||
|
||||
// ColorPicker is a swatch button opening a panel to pick *color: a saturation and
|
||||
// brightness square, hue and opacity sliders, HEX, RGB and opacity fields, and presets.
|
||||
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "color_picker.label")
|
||||
}
|
||||
presets := opts.Presets
|
||||
if presets == nil {
|
||||
presets = colorPickerPresets
|
||||
}
|
||||
parts := ColorPickerParts{}
|
||||
trigger := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*1.5).Gap(d.Unit() * 2).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled).Label(label)
|
||||
parts.Trigger = trigger
|
||||
internal.Ring(trigger, k.Focus, theme.ControlRadius)
|
||||
st := ui.Local(trigger, "picker", func() colorPickerState {
|
||||
s := colorPickerState{last: *color, hsv: colorPickerToHSVA(*color)}
|
||||
s.sync(*color)
|
||||
return s
|
||||
})
|
||||
if *color != st.last {
|
||||
st.hsv, st.last = colorPickerToHSVA(*color), *color
|
||||
st.sync(*color)
|
||||
}
|
||||
set := func(x colorPickerHSVA, fields bool) {
|
||||
x.s, x.v, x.a = colorPickerClamp(x.s), colorPickerClamp(x.v), colorPickerClamp(x.a)
|
||||
st.hsv = x
|
||||
next := x.color()
|
||||
if next != *color {
|
||||
slog.Debug("mujica: color picker changed", "color", colorPickerHex(next))
|
||||
*color = next
|
||||
parts.changed = true
|
||||
}
|
||||
st.last = *color
|
||||
if fields {
|
||||
st.sync(*color)
|
||||
}
|
||||
}
|
||||
if trigger.Clicked() {
|
||||
st.open = !st.open
|
||||
}
|
||||
border := k.ControlBorder
|
||||
if trigger.Hovered() && !opts.Disabled {
|
||||
border = k.ControlBorder.Mix(k.Text, 0.25)
|
||||
trigger.Background(k.SurfaceHover)
|
||||
}
|
||||
trigger.Border(theme.BorderWidth, border).Description(colorPickerHex(*color))
|
||||
now := *color
|
||||
trigger.Children(func() {
|
||||
ui.Box(c).Size(d.Unit()*6, d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Clip().Shrink(0).
|
||||
Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
colorPickerCheckers(p, r, 4)
|
||||
p.Fill(r, now, 0)
|
||||
})
|
||||
ui.Text(c, strings.ToUpper(colorPickerHex(now))).Font(theme.MonoFont).SingleLine()
|
||||
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted)
|
||||
})
|
||||
opened := st.open && !st.wasOpen
|
||||
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
|
||||
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()*3).Gap(d.Unit()*3).Width(260).Radius(theme.PopupRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.18))
|
||||
colorPickerSquare(c, st, set, opened)
|
||||
x := st.hsv
|
||||
h := x.h
|
||||
if colorPickerSlider(c, &h, 360, msg(c, "color_picker.hue"), func(p *ui.Painter, r ui.Rect) {
|
||||
hues := []ui.Color{ui.RGB(255, 0, 0), ui.RGB(255, 255, 0), ui.RGB(0, 255, 0), ui.RGB(0, 255, 255), ui.RGB(0, 0, 255), ui.RGB(255, 0, 255), ui.RGB(255, 0, 0)}
|
||||
w := r.W / 6
|
||||
for i := range 6 {
|
||||
p.FillGradient(ui.Rect{X: r.X + w*float32(i), Y: r.Y, W: w + 0.5, H: r.H}, ui.LinearGradient{From: hues[i], To: hues[i+1], Angle: 90}, 0)
|
||||
}
|
||||
}).Changed() {
|
||||
set(colorPickerHSVA{h, x.s, x.v, x.a}, true)
|
||||
}
|
||||
a := x.a
|
||||
opaque := colorPickerHSVA{x.h, x.s, x.v, 1}.color()
|
||||
if colorPickerSlider(c, &a, 1, msg(c, "color_picker.alpha"), func(p *ui.Painter, r ui.Rect) {
|
||||
colorPickerCheckers(p, r, 4)
|
||||
p.FillGradient(r, ui.LinearGradient{From: opaque.Alpha(0), To: opaque, Angle: 90}, 0)
|
||||
}).Changed() {
|
||||
set(colorPickerHSVA{x.h, x.s, x.v, a}, true)
|
||||
}
|
||||
colorPickerFields(c, st, color, set)
|
||||
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
|
||||
for _, sw := range presets {
|
||||
hex := strings.ToUpper(colorPickerHex(sw))
|
||||
b := ui.ButtonBase(c).Height(d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
chosen := *color == sw
|
||||
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
colorPickerCheckers(p, r, 4)
|
||||
p.Fill(r, sw, 0)
|
||||
})
|
||||
if chosen {
|
||||
mark := ui.RGB(255, 255, 255)
|
||||
if colorPickerLuma(sw) > 0.55 {
|
||||
mark = ui.RGB(0, 0, 0)
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(mark) })
|
||||
}
|
||||
if b.Clicked() {
|
||||
set(colorPickerToHSVA(sw), true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
if st.wasOpen && !st.open {
|
||||
trigger.Focus()
|
||||
}
|
||||
st.wasOpen = st.open
|
||||
return parts
|
||||
}
|
||||
|
||||
// colorPickerSquare picks saturation across and brightness up.
|
||||
func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPickerHSVA, bool), opened bool) {
|
||||
k := Tokens(c)
|
||||
x := st.hsv
|
||||
sq := ui.Box(c).Height(150).Radius(theme.ControlRadius).Focusable().Role(ui.RoleSlider).Cursor(ui.CursorCrosshair).
|
||||
Label(msg(c, "color_picker.square"))
|
||||
sq.FocusRing(false)
|
||||
if opened {
|
||||
sq.Focus()
|
||||
}
|
||||
if _, _, ok := sq.Dragged(); ok {
|
||||
px, py, _ := sq.PointerPosition()
|
||||
if b := sq.Bounds(); b.W > 0 && b.H > 0 {
|
||||
set(colorPickerHSVA{x.h, float64(px / b.W), 1 - float64(py/b.H), x.a}, true)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case sq.Shortcut(0, ui.KeyRight):
|
||||
set(colorPickerHSVA{x.h, x.s + 0.01, x.v, x.a}, true)
|
||||
case sq.Shortcut(0, ui.KeyLeft):
|
||||
set(colorPickerHSVA{x.h, x.s - 0.01, x.v, x.a}, true)
|
||||
case sq.Shortcut(0, ui.KeyUp):
|
||||
set(colorPickerHSVA{x.h, x.s, x.v + 0.01, x.a}, true)
|
||||
case sq.Shortcut(0, ui.KeyDown):
|
||||
set(colorPickerHSVA{x.h, x.s, x.v - 0.01, x.a}, true)
|
||||
}
|
||||
x = st.hsv
|
||||
sq.Description(fmt.Sprintf(msg(c, "color_picker.sv"), x.s*100, x.v*100))
|
||||
hue := colorPickerHSVA{x.h, 1, 1, 1}.color()
|
||||
sq.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillGradient(r, ui.LinearGradient{From: ui.RGB(255, 255, 255), To: hue, Angle: 90}, theme.ControlRadius)
|
||||
p.FillGradient(r, ui.LinearGradient{From: ui.RGBA(0, 0, 0, 0), To: ui.RGB(0, 0, 0), Angle: 180}, theme.ControlRadius)
|
||||
cx, cy := internal.Snap(p, r.X+r.W*float32(x.s)), internal.Snap(p, r.Y+r.H*float32(1-x.v))
|
||||
p.FillPath(internal.Diamond(cx, cy, 14), ui.RGB(0, 0, 0).Alpha(0.5))
|
||||
p.FillPath(internal.Diamond(cx, cy, 11), ui.RGB(255, 255, 255))
|
||||
p.FillPath(internal.Diamond(cx, cy, 6), x.color().Alpha(1))
|
||||
if sq.FocusVisible() {
|
||||
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
|
||||
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
|
||||
k := Tokens(c)
|
||||
const knob = 10
|
||||
s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name)
|
||||
frac := float32(*v / hi)
|
||||
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
|
||||
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
|
||||
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
|
||||
x := r.X + knob/2 + (r.W-knob)*frac
|
||||
thumb := ui.Rect{X: x - knob/2, Y: r.Y, W: knob, H: r.H}
|
||||
p.Fill(thumb, k.Surface, theme.ControlRadius)
|
||||
p.Stroke(thumb, k.Text, theme.ControlRadius, 1.5)
|
||||
if s.FocusVisible() {
|
||||
internal.FocusRing(p, thumb, theme.ControlRadius, k.Focus)
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
// sync shows col in every field.
|
||||
func (st *colorPickerState) sync(col ui.Color) {
|
||||
st.hex = strings.TrimPrefix(strings.ToUpper(colorPickerHex(col)), "#")
|
||||
st.r, st.g, st.b = strconv.Itoa(int(col.R)), strconv.Itoa(int(col.G)), strconv.Itoa(int(col.B))
|
||||
st.alpha = strconv.Itoa(int(math.Round(float64(col.A) / 255 * 100)))
|
||||
st.bad = ""
|
||||
}
|
||||
|
||||
// colorPickerFields edits the color as HEX, RGB and opacity text.
|
||||
func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set func(colorPickerHSVA, bool)) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
apply := func(field string, text *string, col ui.Color, ok bool) {
|
||||
if !ok {
|
||||
st.bad = field
|
||||
return
|
||||
}
|
||||
kept := *text
|
||||
set(colorPickerToHSVA(col), true)
|
||||
*text = kept
|
||||
}
|
||||
editing := false
|
||||
channel := func(s string) (uint8, bool) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
return uint8(n), err == nil && n >= 0 && n <= 255
|
||||
}
|
||||
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
|
||||
hex := InputGroup(c, &st.hex, InputGroupOptions{Prefix: "#", Label: "HEX", Invalid: st.bad == "hex"})
|
||||
editing = hex.Input.Focused()
|
||||
if hex.Changed() {
|
||||
col, err := colorPickerParseHex(st.hex)
|
||||
apply("hex", &st.hex, col, err == nil)
|
||||
}
|
||||
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
|
||||
for _, f := range []struct {
|
||||
name, label string
|
||||
text *string
|
||||
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
|
||||
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
|
||||
ui.Column(c).Gap(d.Unit()).Children(func() {
|
||||
ui.Text(c, f.name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
|
||||
editing = editing || in.Input.Focused()
|
||||
if !in.Changed() {
|
||||
return
|
||||
}
|
||||
col := *color
|
||||
v, ok := channel(*f.text)
|
||||
switch f.name {
|
||||
case "R":
|
||||
col.R = v
|
||||
case "G":
|
||||
col.G = v
|
||||
case "B":
|
||||
col.B = v
|
||||
default:
|
||||
ok = ok && v <= 100
|
||||
col.A = uint8(math.Round(float64(v) / 100 * 255))
|
||||
}
|
||||
apply(f.name, f.text, col, ok)
|
||||
})
|
||||
}
|
||||
})
|
||||
if !editing {
|
||||
st.sync(*color)
|
||||
}
|
||||
if st.bad != "" {
|
||||
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(theme.CaptionSize).TextColor(k.Danger).Role(ui.RoleStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// colorPickerHex returns col as #rrggbb, or #rrggbbaa when translucent.
|
||||
func colorPickerHex(col ui.Color) string {
|
||||
if col.A == 255 {
|
||||
return fmt.Sprintf("#%02x%02x%02x", col.R, col.G, col.B)
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x%02x", col.R, col.G, col.B, col.A)
|
||||
}
|
||||
|
||||
// colorPickerParseHex parses rgb, rrggbb or rrggbbaa, with or without "#".
|
||||
func colorPickerParseHex(s string) (ui.Color, error) {
|
||||
h := strings.TrimPrefix(strings.TrimSpace(s), "#")
|
||||
if len(h) == 3 {
|
||||
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
|
||||
}
|
||||
if len(h) == 6 {
|
||||
h += "ff"
|
||||
}
|
||||
if len(h) != 8 {
|
||||
return ui.Color{}, fmt.Errorf("mujica: color %q is not hex", s)
|
||||
}
|
||||
v, err := strconv.ParseUint(h, 16, 32)
|
||||
if err != nil {
|
||||
return ui.Color{}, fmt.Errorf("mujica: color %q: %w", s, err)
|
||||
}
|
||||
return ui.Color{R: uint8(v >> 24), G: uint8(v >> 16), B: uint8(v >> 8), A: uint8(v)}, nil
|
||||
}
|
||||
|
||||
func colorPickerToHSVA(col ui.Color) colorPickerHSVA {
|
||||
r, g, b := float64(col.R)/255, float64(col.G)/255, float64(col.B)/255
|
||||
hi, lo := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
|
||||
d := hi - lo
|
||||
var h float64
|
||||
switch {
|
||||
case d == 0:
|
||||
case hi == r:
|
||||
h = 60 * math.Mod((g-b)/d, 6)
|
||||
case hi == g:
|
||||
h = 60 * ((b-r)/d + 2)
|
||||
default:
|
||||
h = 60 * ((r-g)/d + 4)
|
||||
}
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
s := 0.0
|
||||
if hi > 0 {
|
||||
s = d / hi
|
||||
}
|
||||
return colorPickerHSVA{h, s, hi, float64(col.A) / 255}
|
||||
}
|
||||
|
||||
func (x colorPickerHSVA) color() ui.Color {
|
||||
ch := x.v * x.s
|
||||
h := math.Mod(x.h, 360) / 60
|
||||
y := ch * (1 - math.Abs(math.Mod(h, 2)-1))
|
||||
var r, g, b float64
|
||||
switch {
|
||||
case h < 1:
|
||||
r, g = ch, y
|
||||
case h < 2:
|
||||
r, g = y, ch
|
||||
case h < 3:
|
||||
g, b = ch, y
|
||||
case h < 4:
|
||||
g, b = y, ch
|
||||
case h < 5:
|
||||
r, b = y, ch
|
||||
default:
|
||||
r, b = ch, y
|
||||
}
|
||||
m := x.v - ch
|
||||
byte8 := func(v float64) uint8 { return uint8(math.Round(colorPickerClamp(v) * 255)) }
|
||||
return ui.Color{R: byte8(r + m), G: byte8(g + m), B: byte8(b + m), A: byte8(x.a)}
|
||||
}
|
||||
|
||||
func colorPickerClamp(v float64) float64 { return math.Max(0, math.Min(1, v)) }
|
||||
|
||||
// colorPickerLuma is the relative brightness of col, 0 to 1.
|
||||
func colorPickerLuma(col ui.Color) float64 {
|
||||
return (0.2126*float64(col.R) + 0.7152*float64(col.G) + 0.0722*float64(col.B)) / 255
|
||||
}
|
||||
|
||||
// colorPickerCheckers paints the grid that shows transparency.
|
||||
func colorPickerCheckers(p *ui.Painter, r ui.Rect, size float32) {
|
||||
p.Fill(r, ui.RGB(255, 255, 255), 0)
|
||||
for y, row := r.Y, 0; y < r.Y+r.H; y, row = y+size, row+1 {
|
||||
for x, col := r.X, 0; x < r.X+r.W; x, col = x+size, col+1 {
|
||||
if (row+col)%2 == 0 {
|
||||
p.Fill(ui.Rect{X: x, Y: y, W: min(size, r.X+r.W-x), H: min(size, r.Y+r.H-y)}, ui.RGB(204, 204, 204), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
|
||||
if ColorPicker(c, col, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
})
|
||||
}, 400, 560)
|
||||
}
|
||||
|
||||
func TestColorPickerPresetAndHex(t *testing.T) {
|
||||
col, n := ui.Hex("#7D2034"), 0
|
||||
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
|
||||
if err := tt.Click("Accent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("#355E80"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if col != ui.Hex("#355E80") || n != 1 {
|
||||
t.Fatalf("preset: %v changes %d", col, n)
|
||||
}
|
||||
if err := tt.Click("HEX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("ff000080")
|
||||
if col != (ui.Color{R: 255, A: 128}) {
|
||||
t.Fatalf("hex: %v", col)
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("zz")
|
||||
if col != (ui.Color{R: 255, A: 128}) || !tt.HasText(colorPickerInvalidEN) {
|
||||
t.Fatalf("invalid hex changed the color or showed no error: %v", col)
|
||||
}
|
||||
}
|
||||
|
||||
const colorPickerInvalidEN = "Use HEX RGB, RRGGBB or RRGGBBAA; channels 0–255; opacity 0–100."
|
||||
|
||||
func TestColorPickerRGBAndAlphaFields(t *testing.T) {
|
||||
col, n := ui.RGB(0, 0, 0), 0
|
||||
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Ink"}, &n)
|
||||
_ = tt.Click("Ink")
|
||||
_ = tt.Click("Green")
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("200")
|
||||
_ = tt.Click("Opacity percent")
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("50")
|
||||
if col != (ui.Color{G: 200, A: 128}) {
|
||||
t.Fatalf("rgb: %v", col)
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("300")
|
||||
if col.A != 128 || !tt.HasText(colorPickerInvalidEN) {
|
||||
t.Fatalf("opacity 300 accepted: %v", col)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorPickerKeyboardAndEscape(t *testing.T) {
|
||||
col, n := ui.Hex("#808080"), 0
|
||||
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Shade"}, &n)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.Focused("Saturation and brightness") {
|
||||
t.Fatal("square not focused on open")
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if col == ui.Hex("#808080") || col.R != col.G {
|
||||
t.Fatalf("up arrow: %v", col)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Hue") {
|
||||
t.Fatal("hue slider not next")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("HEX") || !tt.Focused("Shade") {
|
||||
t.Fatal("escape did not close and return focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorPickerDisabledAndParse(t *testing.T) {
|
||||
col, n := ui.Hex("#7D2034"), 0
|
||||
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent", Disabled: true}, &n)
|
||||
_ = tt.Click("Accent")
|
||||
if tt.HasText("HEX") {
|
||||
t.Fatal("disabled picker opened")
|
||||
}
|
||||
for in, want := range map[string]ui.Color{"#abc": ui.Hex("#aabbcc"), "7D2034": ui.Hex("#7D2034"), "#00000000": {}} {
|
||||
got, err := colorPickerParseHex(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("%q: %v %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "#12", "#12345g", "#1234567"} {
|
||||
if _, err := colorPickerParseHex(in); err == nil {
|
||||
t.Errorf("%q parsed", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorPickerSnapshot(t *testing.T) {
|
||||
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
|
||||
snapshot(t, "color_picker", 340, 520, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
|
||||
p := ColorPicker(c, &a, ColorPickerOptions{})
|
||||
st := ui.Local(p.Trigger, "snap", func() bool { return false })
|
||||
if !*st {
|
||||
*st = true
|
||||
ui.Local(p.Trigger, "picker", func() colorPickerState { return colorPickerState{} }).open = true
|
||||
c.Invalidate()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# 041 Form
|
||||
|
||||
表单。
|
||||
|
||||
## 用途
|
||||
|
||||
组合多个字段:注册字段、同步与调用方提供的异步校验、提交与重置、提交中防重入、提交失败时定位第一个错误字段。Form 不保存业务模型,不调用远程接口,也不做持久化;值始终由应用持有。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var state mujica.FormState
|
||||
name := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
|
||||
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
|
||||
})
|
||||
})
|
||||
if res.Submitted() {
|
||||
state.EndSubmit("") // after saving name
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐(MyGo `ui.Form` 布局)。 |
|
||||
| `FormOptions.SubmitLabel` | `string` | 内置“Submit/提交” | 提交按钮文字。 |
|
||||
| `FormOptions.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 |
|
||||
| `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 |
|
||||
| `FormItemOptions[T].Required` | `bool` | false | 标记必填;零值、空字符串、空切片或空映射视为缺失。 |
|
||||
| `FormItemOptions[T].Description` | `string` | "" | 说明文字;异步校验进行中显示“Checking…”。 |
|
||||
| `FormItemOptions[T].Initial` | `T` | 零值 | 重置时写回的初始值。 |
|
||||
| `FormItemOptions[T].Validate` | `func(T) string` | nil | 同步校验,返回错误文字或 "";跨字段规则在此读取其他值。 |
|
||||
| `FormItemOptions[T].Async` | `func(v T, version uint64)` | nil | 启动异步校验;完成后在界面线程调用 `FormState.Resolve(name, version, msg)`。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认;字段错误(触碰后或首次提交后才显示);异步校验中(说明显示 Checking…);提交中(提交按钮显示加载并阻止重复提交,重置禁用);提交失败(表单级错误显示在按钮上方,保留全部值)。
|
||||
|
||||
## 事件
|
||||
|
||||
`FormParts.Submitted()`:全部校验通过的一次提交,处理完毕后调用 `FormState.EndSubmit(err)`。`FormParts.Reset()`:已恢复初始值并清除校验与提交状态。`FormState.Submit()` 可在 Enter 等处请求提交。`FormState.Error(name)` 返回当前应显示的错误,用于给控件设置 Invalid。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab / Shift+Tab 在字段间移动,离开字段即校验。提交失败后焦点移到第一个错误字段并滚动到可见处,同时向屏幕阅读器播报。点击标签聚焦控件。
|
||||
|
||||
## 限制
|
||||
|
||||
- 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。
|
||||
- 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。
|
||||
- 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。
|
||||
- 必填标记以“ *”附在标签文字后,屏幕阅读器会一起读出;MyGo 的 Field 标签不支持富文本,无法单独着色。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 042 FormField
|
||||
|
||||
表单字段。
|
||||
|
||||
## 用途
|
||||
|
||||
为单个控件提供标签、必填标记、说明与错误文字。标签通过 MyGo `ui.Field` 关联控件:屏幕阅读器以标签命名控件,并读出说明与错误;点击标签聚焦控件。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
email := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
|
||||
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `label` | `string` | 必填 | 标签文字;空值 panic。 |
|
||||
| `FormFieldOptions.Required` | `bool` | false | 在标签后加“ *”。 |
|
||||
| `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 |
|
||||
| `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 |
|
||||
| `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 |
|
||||
| `control` | `func()` | 必填 | 构建控件;其中第一个可获取焦点的元素即被关联的控件。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、必填、带说明、错误;禁用由控件自身决定,标签随之变淡。
|
||||
|
||||
## 事件
|
||||
|
||||
无自身事件;事件来自内部控件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 直接到达控件;点击标签聚焦控件,或切换复选框、开关。
|
||||
|
||||
## 限制
|
||||
|
||||
- 标签、说明与错误文字由 MyGo `ui.Field` 绘制,使用主题颜色(文字、TextMuted、Danger);字号与间距跟随 MyGo。
|
||||
- 错误只把边框染红的效果对 MyGo 自带输入框生效;MujicaUI 的 InputGroup 等需同时传入 `Invalid: true`。
|
||||
- 左右布局时多个字段需放在同一个 `ui.Form` 或 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 043 InputGroup
|
||||
|
||||
输入组合。
|
||||
|
||||
## 用途
|
||||
|
||||
把单行输入框与前后缀文字、按钮放进同一个边框,例如网址前缀、单位、清空或搜索按钮。每个按钮保留独立焦点。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
site := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
g := mujica.InputGroup(c, &site, mujica.InputGroupOptions{
|
||||
Prefix: "https://", Label: "Site",
|
||||
Buttons: []mujica.InputGroupButton{{Label: "Open"}},
|
||||
})
|
||||
if g.Clicked == 0 {
|
||||
c.OpenURL("https://" + site)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `*string` | 必填 | 输入框的值。 |
|
||||
| `Prefix / Suffix` | `string` | "" | 输入框前后的固定文字。 |
|
||||
| `Buttons` | `[]InputGroupButton` | nil | 按钮:Label(必填,文字或无障碍名称)、Icon、IconOnly、Leading(放在输入框前)、Disabled。 |
|
||||
| `Placeholder` | `string` | "" | 占位文字。 |
|
||||
| `Label` | `string` | "" | 不在 FormField 内时为输入框命名。 |
|
||||
| `Disabled` | `bool` | false | 禁用整个组合。 |
|
||||
| `ReadOnly` | `bool` | false | 值显示为可选择、可复制、可聚焦的文字,不能修改;按钮仍可用。 |
|
||||
| `Invalid` | `bool` | false | 边框显示危险色。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停(边框加深)、聚焦(强调色边框与焦点环)、禁用、只读、错误。
|
||||
|
||||
## 事件
|
||||
|
||||
`InputGroupParts.Changed()` 值被编辑;`Submitted()` 在输入框中按 Enter;`Clicked` 为被点击按钮在 Buttons 中的下标,未点击为 -1。`Input` 为可聚焦的输入元素。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
输入框与每个按钮都是独立的 Tab 停靠点;按钮响应 Enter 和 Space;输入框保留选择、剪贴板、撤销与输入法。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 没有公开的只读输入框,ReadOnly 改用可选择文字实现,宽度与输入框一致。
|
||||
- IconOnly 按钮必须提供 Icon,Label 作为无障碍名称;缺失时 panic。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 044 PinInput
|
||||
|
||||
验证码输入。
|
||||
|
||||
## 用途
|
||||
|
||||
输入固定位数的数字验证码。值是字符串,保留前导零。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
code := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.PinInput(c, &code, mujica.PinInputOptions{Length: 6}).Completed() {
|
||||
fmt.Println("verify", code)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `*string` | 必填 | 当前数字串;含非数字或超出位数时 panic。 |
|
||||
| `Length` | `int` | 6 | 位数。 |
|
||||
| `Label` | `string` | 内置“Verification code/验证码” | 无障碍名称。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
| `Invalid` | `bool` | false | 格子边框显示危险色。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、聚焦(当前格强调边框、焦点环与光标)、输入法组合中(当前格显示带下划线的组合文字)、禁用、错误。
|
||||
|
||||
## 事件
|
||||
|
||||
`PinInputParts.Changed()` 值改变;`Completed()` 在用户填满最后一格的那一帧为 true。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
数字键依次填格;非数字被忽略;Backspace 清除最后一位;Cmd/Ctrl+V 或编辑菜单粘贴整段,取其中的数字填满各格。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只支持数字。编辑按顺序进行:只能在末尾追加或删除,不能单独改中间某一格。
|
||||
- 输入法组合期间不提交、不跳格;组合结束后才写入值。
|
||||
- 整个组件是一个焦点停靠点,屏幕阅读器以文本框读出名称与“已输入 n / N 位”的说明。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 045 TagsInput
|
||||
|
||||
标签输入。
|
||||
|
||||
## 用途
|
||||
|
||||
编辑一组不重复的标签,对应 MyGo 的 Token field。支持 Enter 或分隔符添加、整段粘贴拆分、去重、数量上限与添加前校验。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
tags := []string{"gothic"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TagsInput(c, &tags, mujica.TagsInputOptions{Max: 5, Placeholder: "Add a tag", Label: "Tags"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tags` | `*[]string` | 必填 | 标签列表。 |
|
||||
| `Max` | `int` | 0(不限) | 最多标签数;负数 panic。 |
|
||||
| `Separators` | `string` | ",;,;" 与换行、制表符 | 输入或粘贴时用于拆分的字符。 |
|
||||
| `Validate` | `func(string) string` | nil | 添加前校验,返回错误文字或 ""。 |
|
||||
| `Placeholder` | `string` | "" | 占位文字。 |
|
||||
| `Label` | `string` | "" | 不在 FormField 内时为输入框命名。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
| `Invalid` | `bool` | false | 边框显示危险色。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、聚焦、错误(重复、超出上限、校验失败时显示具体原因并保留输入文字)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`TagsInputParts.Changed()` 添加或移除了标签;`Input` 为输入框元素。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Enter 或分隔符添加;输入框为空时 Backspace 移除最后一个标签;Tab 可到达每个标签的移除按钮,Enter/Space 移除。
|
||||
|
||||
## 限制
|
||||
|
||||
- 粘贴含分隔符的文本时,未通过检查的片段保留在输入框中,供用户修改。
|
||||
- MyGo 单行输入框会处理粘贴文本中的换行,换行能否作为分隔符取决于平台粘贴行为;逗号、分号总是有效。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 046 Rating
|
||||
|
||||
评分。
|
||||
|
||||
## 用途
|
||||
|
||||
以星形显示和设置评分,支持半星等步长、只读展示和数值说明。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
stars := 3.5
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Rating(c, &stars, mujica.RatingOptions{Step: 0.5, ShowValue: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `*float64` | 必填 | 评分,0 到 Max。 |
|
||||
| `Max` | `int` | 5 | 星数;负数 panic。 |
|
||||
| `Step` | `float64` | 1 | 最小变化量,须整除 1(如 0.5、0.25);否则 panic。 |
|
||||
| `ReadOnly` | `bool` | false | 只显示,可聚焦以便读出数值。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
| `Label` | `string` | 内置“Rating/评分” | 无障碍名称。 |
|
||||
| `ShowValue` | `bool` | false | 在星形右侧显示“3.5 / 5”。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停(预览指针处的数值)、按下拖动、聚焦(焦点环)、只读、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`RatingParts.Changed()` 用户设置了新值;同值点击不触发。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
→/↑ 增加一个步长,←/↓ 减少,Home 清零,End 满分。
|
||||
|
||||
## 限制
|
||||
|
||||
- 屏幕阅读器以滑块角色读出名称,数值通过说明“3.5 of 5”提供;MyGo 未公开为自定义元素设置数值范围的接口。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 047 ColorPicker
|
||||
|
||||
颜色选择器。
|
||||
|
||||
## 用途
|
||||
|
||||
选择带透明度的颜色:色板按钮弹出面板,包含饱和度/亮度方块、色相与透明度滑块、HEX 与 RGB、透明度输入以及预设色。透明部分显示棋盘格。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
tint := ui.Hex("#7D2034")
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ColorPicker(c, &tint, mujica.ColorPickerOptions{Label: "Tint"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `color` | `*ui.Color` | 必填 | 当前颜色。 |
|
||||
| `Presets` | `[]ui.Color` | 内置 12 色宫廷色板 | 预设色。 |
|
||||
| `Label` | `string` | 内置“Color/颜色” | 按钮的无障碍名称。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦、展开、输入无效(对应输入框边框变红并显示格式说明,颜色不变)、预设选中(边框加勾选标记)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`ColorPickerParts.Changed()` 用户选择了新颜色;`Trigger` 为按钮元素。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Enter/Space 打开;打开后焦点在方块上,方向键调整饱和度与亮度;Tab 依次到达色相、透明度滑块(方向键、Home、End)、HEX、R、G、B、A 输入框与预设色;Escape 或点击外部关闭并把焦点还给按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- HEX 接受 RGB、RRGGBB、RRGGBBAA,可带或不带 #;RGB 为 0–255 整数,透明度为 0–100。无效输入保留在输入框中直到失焦,不修改颜色。
|
||||
- 色谱渐变是 PRD 允许的例外。
|
||||
- 弹层位置由 MyGo PopoverBase 决定,空间不足时显示在按钮上方。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 048 FilePicker
|
||||
|
||||
文件选择。
|
||||
|
||||
## 用途
|
||||
|
||||
调用 MyGo 的系统文件或文件夹选择对话框(`mygo.Dialog.Open`),支持单选、多选与扩展名筛选,只返回所选路径。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var paths []string
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.FilePicker(c, &paths, mujica.FilePickerOptions{
|
||||
Multiple: true,
|
||||
Filters: []mygo.FileFilter{{Name: "Scores", Extensions: []string{"pdf", "mid"}}},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `paths` | `*[]string` | 必填 | 所选路径;取消时不变。 |
|
||||
| `Directory` | `bool` | false | 选择文件夹。 |
|
||||
| `Multiple` | `bool` | false | 允许多选。 |
|
||||
| `Filters` | `[]mygo.FileFilter` | nil | 扩展名筛选,扩展名不带点;带点时 panic。 |
|
||||
| `Title` | `string` | "" | 对话框标题。 |
|
||||
| `Label` | `string` | 内置“Choose file…/选择文件…” | 按钮文字。 |
|
||||
| `Parent` | `*mygo.Window` | nil | 附着的窗口(macOS 显示为表单)。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、对话框打开中(按钮显示加载并阻止重复打开)、已选择(列出文件名与所在目录)、对话框失败(危险色说明)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`FilePickerParts.Changed()` 选中了新路径;`Canceled()` 用户取消,不视为错误。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Enter/Space 打开系统对话框;对话框内的键盘操作由系统提供。
|
||||
|
||||
## 限制
|
||||
|
||||
- 对话框为系统样式,不换肤。
|
||||
- 对话框在后台 goroutine 中等待,结果经 `Context.Invalidate` 回到界面线程;需要应用已通过 `mygo.App.Run` 启动,无窗口测试中无法打开真实对话框。
|
||||
- 不读取文件内容。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 049 FileDropZone
|
||||
|
||||
文件拖放区。
|
||||
|
||||
## 用途
|
||||
|
||||
接收从系统拖入或通过“浏览…”选择的文件,检查类型、数量和大小,显示文件列表与错误,可逐项移除。不上传,不读取内容。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var files []mujica.DroppedFile
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.FileDropZone(c, &files, mujica.FileDropZoneOptions{
|
||||
Extensions: []string{"png", "jpg"}, MaxFiles: 3, MaxSize: 5 << 20,
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `files` | `*[]DroppedFile` | 必填 | 已接受的文件(Path、Size)。 |
|
||||
| `Extensions` | `[]string` | nil(不限) | 允许的扩展名,不带点,不区分大小写。 |
|
||||
| `MaxFiles` | `int` | 0(不限) | 列表最多文件数。 |
|
||||
| `MaxSize` | `int64` | 0(不限) | 单个文件的最大字节数。 |
|
||||
| `Hint` | `string` | 内置“Drop files here/将文件拖到此处” | 提示文字。 |
|
||||
| `Disabled` | `bool` | false | 禁用拖放、浏览与移除。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、文件拖到上方(选中底色与强调色虚线边框)、检查中(列表显示“Checking…”)、已接受、被拒绝(危险色图标与具体原因)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`FileDropZoneParts.Changed()` 添加或移除了文件。被拒绝的条目只显示在组件内,可单独关闭。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 到达“浏览…”与每个移除按钮;拖放需要指针,键盘用户通过“浏览…”添加文件。
|
||||
|
||||
## 限制
|
||||
|
||||
- 文件大小通过后台 goroutine 中的 `os.Stat` 获取,不阻塞界面线程;文件夹、无法读取的路径会被拒绝。
|
||||
- 重复路径静默忽略。
|
||||
- MyGo 的无窗口测试器不能模拟系统拖放,自动测试通过“浏览…”路径覆盖同一检查流程。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 050 Transfer
|
||||
|
||||
穿梭框。
|
||||
|
||||
## 用途
|
||||
|
||||
在左右两个列表间移动条目,选出一个子集。两侧各有搜索、复选与全选,中间按钮批量移动。以稳定 ID 保存状态。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
chosen := []string{"uika"}
|
||||
items := []mujica.TransferItem{{ID: "uika", Label: "Uika"}, {ID: "mutsumi", Label: "Mutsumi"}, {ID: "nyamu", Label: "Nyamu", Disabled: true}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Transfer(c, &chosen, items, mujica.TransferOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `target` | `*[]string` | 必填 | 右侧已选条目的 ID,按加入顺序;含未知 ID 时 panic。 |
|
||||
| `items` | `[]TransferItem` | 必填 | 全部条目:ID(唯一、非空)、Label、Disabled。 |
|
||||
| `SourceTitle / TargetTitle` | `string` | 内置“Available/可选”“Chosen/已选” | 两侧标题。 |
|
||||
| `Height` | `float32` | 240 | 列表高度(DIP)。 |
|
||||
| `Disabled` | `bool` | false | 禁用整个组件。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、勾选(底色加勾选标记)、半选(全选框显示减号)、禁用条目(不可勾选、不随全选移动)、空列表、无搜索结果、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`TransferParts.Changed()` 有条目移动。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入列表,↑/↓ 在条目间移动,Space 勾选;Tab 到达全选框、搜索框与移动按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 条目逐项创建,适合数百条以内;大数据量请使用虚拟化列表组合。
|
||||
- 全选只作用于当前搜索结果中的可用条目。
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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)`,
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "部分字段需要修改"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||