diff --git a/bulk_action_bar.go b/bulk_action_bar.go index ad4e0d7..0ca0e62 100644 --- a/bulk_action_bar.go +++ b/bulk_action_bar.go @@ -26,7 +26,7 @@ func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actio k := Tokens(c) u := Density(c).Unit() count := fmt.Sprintf(msg(c, "bulk_action_bar.selected"), selected) - bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight()+4*u). + bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight() + 4*u). Role(ui.RoleToolbar).Label(count).Disabled(opts.Disabled) overlayStyle(c, bar, overlayEnter(c, bar)) said := ui.Local(bar, "bulk_action_bar.said", func() int { return 0 }) diff --git a/choice_chips.go b/choice_chips.go index c979143..ac94e76 100644 --- a/choice_chips.go +++ b/choice_chips.go @@ -30,7 +30,7 @@ func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } k := Tokens(c) d := Density(c) - g := ui.Row(c).Wrap().Gap(d.Unit()*2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal) + g := ui.Row(c).Wrap().Gap(d.Unit() * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal) if opts.Label != "" { g.Label(opts.Label) } @@ -54,7 +54,7 @@ func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) { - b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit()*1.5). + b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 1.5). Radius(theme.PopupRadius) internal.Ring(b, k.Focus, theme.PopupRadius) bg, fg, line := k.Surface, k.Text, k.ControlBorder diff --git a/currency_input.go b/currency_input.go new file mode 100644 index 0000000..9b8f1cf --- /dev/null +++ b/currency_input.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +// CurrencyInputOptions configure a CurrencyInput. +type CurrencyInputOptions struct { + // Symbol goes before the amount, as "$" or "¥"; empty shows none. + Symbol string + // Decimals is the minor unit digits; zero is 2. NoFraction makes it 0, as for JPY. + Decimals int + NoFraction bool + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// currencyInputDigits resolves the decimals of opts. +func currencyInputDigits(opts CurrencyInputOptions) int { + switch { + case opts.Decimals < 0 || opts.Decimals > 6: + panic(fmt.Sprintf("mujica: CurrencyInput Decimals %d outside 0..6", opts.Decimals)) + case opts.NoFraction && opts.Decimals != 0: + panic("mujica: CurrencyInput NoFraction with Decimals") + case opts.NoFraction: + return 0 + case opts.Decimals == 0: + return 2 + } + return opts.Decimals +} + +// CurrencyFormat writes minor units as an amount with thousands separators. +func CurrencyFormat(minor int64, decimals int, group bool) string { + neg := minor < 0 + u := uint64(minor) + if neg { + u = -u + } + p := uint64(math.Pow10(decimals)) + whole := strconv.FormatUint(u/p, 10) + if group { + var b strings.Builder + for i, r := range whole { + if i > 0 && (len(whole)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(r) + } + whole = b.String() + } + s := whole + if decimals > 0 { + s += "." + fmt.Sprintf("%0*d", decimals, u%p) + } + if neg { + s = "-" + s + } + return s +} + +// currencyInputParse reads an amount into minor units; reason is a message key when it fails. +func currencyInputParse(text string, decimals int) (minor int64, reason string) { + t := strings.NewReplacer(",", "", " ", "", " ", "").Replace(strings.TrimSpace(text)) + neg := strings.HasPrefix(t, "-") + t = strings.TrimPrefix(t, "-") + whole, frac, dot := strings.Cut(t, ".") + if whole == "" && frac == "" || strings.Trim(whole+frac, "0123456789") != "" || dot && strings.Contains(frac, ".") { + return 0, "currency_input.invalid" + } + if len(frac) > decimals { + return 0, "currency_input.precision" + } + frac += strings.Repeat("0", decimals-len(frac)) + v, err := strconv.ParseInt(whole+frac, 10, 64) + if err != nil { + return 0, "currency_input.invalid" + } + if neg { + v = -v + } + return v, "" +} + +// CurrencyInput edits *value, an amount in minor units (cents); it groups thousands while not focused. +func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult { + if value == nil { + panic("mujica: CurrencyInput needs a value") + } + dec := currencyInputDigits(opts) + k := Tokens(c) + var r InputResult + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + if opts.Symbol != "" { + ui.Text(c, opts.Symbol).TextColor(k.TextMuted).SingleLine() + } + text := ui.Local(frame, "text", func() string { return CurrencyFormat(*value, dec, false) }) + bad := ui.Local(frame, "bad", func() string { return "" }) + seen := ui.Local(frame, "seen", func() int64 { return *value }) + was := ui.Local(frame, "was", func() bool { return false }) + if *seen != *value { + *bad = "" + } + // Grouped while resting; plain digits once focused, for editing. + focused := frame.FocusWithin() + switch { + case *bad != "": + case !focused: + *text = CurrencyFormat(*value, dec, true) + case !*was || *seen != *value: + *text = CurrencyFormat(*value, dec, false) + } + *was = focused + in := textInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 16).Label(opts.Label) + if in.Changed() && !look.disabled { + v, why := currencyInputParse(*text, dec) + *bad = "" + switch { + case why == "currency_input.precision": + *bad = fmt.Sprintf(msg(c, why), dec) + case why != "": + *bad = msg(c, why) + case v != *value: + *value, r.changed = v, true + } + } + *seen = *value + if look.err == "" { + look.err = *bad + } + return in + }, nil) + if r.changed { + slog.Debug("mujica: currency_input change", "minor", *value) + } + return r +} diff --git a/currency_input_test.go b/currency_input_test.go new file mode 100644 index 0000000..cef2209 --- /dev/null +++ b/currency_input_test.go @@ -0,0 +1,120 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCurrencyFormatAndParse(t *testing.T) { + for _, tc := range []struct { + minor int64 + dec int + group bool + want string + }{ + {123450, 2, true, "1,234.50"}, {123450, 2, false, "1234.50"}, {-5, 2, true, "-0.05"}, + {1234567, 0, true, "1,234,567"}, {0, 3, true, "0.000"}, {100000000, 2, true, "1,000,000.00"}, + } { + if got := CurrencyFormat(tc.minor, tc.dec, tc.group); got != tc.want { + t.Errorf("CurrencyFormat(%d, %d) = %q, want %q", tc.minor, tc.dec, got, tc.want) + } + } + for text, want := range map[string]int64{"1,234.5": 123450, "0.07": 7, "-12": -1200, " 3 ": 300, "1.": 100} { + if v, why := currencyInputParse(text, 2); v != want || why != "" { + t.Errorf("parse %q = %d %q, want %d", text, v, why, want) + } + } + for text, want := range map[string]string{"1.234": "currency_input.precision", "abc": "currency_input.invalid", "": "currency_input.invalid", "1.2.3": "currency_input.invalid", "99999999999999999999": "currency_input.invalid"} { + if _, why := currencyInputParse(text, 2); why != want { + t.Errorf("parse %q: %q, want %q", text, why, want) + } + } +} + +// fieldText copies the focused field's text through the clipboard. +func fieldText(tt *ui.Tester) string { + tt.Command("selectAll") + tt.Command("copy") + return tt.Clipboard() +} + +func TestCurrencyInputTypingGroupingAndErrors(t *testing.T) { + v := int64(123450) + dis := false + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "$", Label: "Price", Disabled: dis}).Changed() { + n++ + } + Button(c, "Elsewhere", ButtonOptions{}) + }, 500, 200) + if !tt.HasText("$") { + t.Fatalf("no symbol: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + if got := fieldText(tt); got != "1234.50" { + t.Fatalf("focused text %q, want ungrouped", got) + } + tt.Command("selectAll") + tt.Type("2500.255") + if v != 123450 || !tt.HasText("Use at most 2 decimal places") { + t.Fatalf("too precise: %d %q", v, tt.Texts()) + } + tt.Command("selectAll") + tt.Type("2500.25") + if v != 250025 || n != 1 || tt.HasText("decimal places") { + t.Fatalf("typed: %d (%d)", v, n) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + if got := fieldText(tt); got != "2500.25" { + t.Fatalf("refocused text %q", got) + } + dis = true + tt.Frame() + _ = tt.Click("Price") + tt.Type("9") + if v != 250025 { + t.Fatalf("disabled edit: %d", v) + } +} + +func TestCurrencyInputNoFractionAndRejects(t *testing.T) { + v := int64(1500) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "¥", NoFraction: true, Label: "Amount"}) + }, 300, 100) + tt.Key(0, ui.KeyTab) + if got := fieldText(tt); got != "1500" { + t.Fatalf("yen %q", got) + } + tt.Command("selectAll") + tt.Type("12.5") + if v != 1500 || !tt.HasText("Use at most 0 decimal places") { + t.Fatalf("fraction accepted: %d", v) + } + for _, o := range []CurrencyInputOptions{{Decimals: -1}, {Decimals: 7}, {Decimals: 2, NoFraction: true}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", o) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); CurrencyInput(c, &v, o) }, 200, 100) + }() + } +} + +func TestCurrencyInputSnapshot(t *testing.T) { + a, b, d := int64(1234550), int64(98000), int64(0) + snapshot(t, "currency_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + CurrencyInput(c, &a, CurrencyInputOptions{Symbol: "$"}).Element.Width(150) + CurrencyInput(c, &b, CurrencyInputOptions{Symbol: "¥", NoFraction: true}).Element.Width(150) + CurrencyInput(c, &d, CurrencyInputOptions{Symbol: "€", Disabled: true}).Element.Width(150) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index f4a4f68..fcb62b3 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -18,6 +18,9 @@ func init() { "knob.label": {"Knob", "旋钮"}, + "currency_input.invalid": {"Enter an amount, such as 1,234.50", "请输入金额,例如 1,234.50"}, + "currency_input.precision": {"Use at most %d decimal places", "最多保留 %d 位小数"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/scrub_input.go b/scrub_input.go new file mode 100644 index 0000000..387bc67 --- /dev/null +++ b/scrub_input.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ScrubInputOptions configure a ScrubInput; the range and step rules are NumberInput's. +type ScrubInputOptions struct { + Min, Max float64 + Step float64 + Precision int + Disabled bool +} + +// scrubInputPixels is how far a drag moves for one step. +const scrubInputPixels float32 = 4 + +// ScrubInput edits *value by dragging its label sideways, one Step per 4 DIPs, or by typing in its field. +func ScrubInput(c *ui.Context, label string, value *float64, opts ScrubInputOptions) InputResult { + if label == "" || value == nil { + panic("mujica: ScrubInput needs a label and a value") + } + nopts := NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: opts.Step, Precision: opts.Precision, Label: label, Disabled: opts.Disabled} + s := numberInputResolve(nopts) + k := Tokens(c) + var r InputResult + row := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Disabled(opts.Disabled) + row.Children(func() { + h := ui.Box(c).Shrink(0).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Center().Cursor(ui.CursorResizeEW).Role(ui.RoleNone) + drag := ui.Local(h, "scrub_input.drag", func() float64 { return math.NaN() }) + dx, _, held := h.Dragged() + scrubbed := false + switch { + case !held || h.IsDisabled(): + *drag = math.NaN() + case math.IsNaN(*drag): + *drag = 0 + fallthrough + default: + start := *value + *drag += float64(dx) + if n := int(*drag / float64(scrubInputPixels)); n != 0 { + *drag -= float64(n) * float64(scrubInputPixels) + // One grid step at a time, so a long drag stops at the bound. + v := start + for range max(n, -n) { + v = s.stepBy(v, max(-1, min(1, n))) + } + if v != *value { + *value, scrubbed = v, true + } + } + } + fg := k.TextMuted + if held || h.Hovered() { + fg = k.AccentText + } + h.Children(func() { + ui.Text(c, label).TextColor(fg).Underline().DecorationColor(k.Border).SingleLine() + }) + r = NumberInput(c, value, nopts) + r.Element.Width(Density(c).Unit() * 36) + r.changed = r.changed || scrubbed + if scrubbed { + slog.Debug("mujica: scrub_input scrubbed", "label", label, "value", *value) + } + }) + r.Element = row + return r +} diff --git a/scrub_input_test.go b/scrub_input_test.go new file mode 100644 index 0000000..2e9502b --- /dev/null +++ b/scrub_input_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func scrubTester(v *float64, opts *ScrubInputOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if ScrubInput(c, "Opacity", v, *opts).Changed() { + *n++ + } + }) + }, 500, 200) +} + +func TestScrubInputDragAndRange(t *testing.T) { + v := 0.5 + opts := ScrubInputOptions{Min: 0, Max: 1, Step: 0.1} + n := 0 + tt := scrubTester(&v, &opts, &n) + r, _ := tt.Find("Opacity") + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + tt.Move(x+6, y) // one step, 2 DIPs carried + tt.Move(x+10, y) + tt.Release(x+10, y) + if v != 0.7 { + t.Fatalf("10 DIPs right: %v", v) + } + tt.Press(x, y) + tt.Move(x+400, y) + tt.Release(x+400, y) + if v != 1 { + t.Fatalf("past the max: %v", v) + } + tt.Press(x, y) + tt.Move(x-8, y) + tt.Release(x-8, y) + if v != 0.8 || n != 4 { + t.Fatalf("8 DIPs left: %v (%d)", v, n) + } +} + +func TestScrubInputTypingAndDisabled(t *testing.T) { + v := 2.0 + opts := ScrubInputOptions{Step: 1} + n := 0 + tt := scrubTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if v != 3 { + t.Fatalf("Up in the field: %v", v) + } + tt.Command("selectAll") + tt.Type("12") + if v != 12 { + t.Fatalf("typed: %v", v) + } + opts.Disabled = true + tt.Frame() + r, _ := tt.Find("Opacity") + tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W/2+40, r.Y+r.H/2) + tt.Release(r.X+r.W/2+40, r.Y+r.H/2) + if v != 12 { + t.Fatalf("disabled scrub: %v", v) + } +} + +func TestScrubInputSnapshot(t *testing.T) { + a, b := 0.6, 24.0 + snapshot(t, "scrub_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ScrubInput(c, "Opacity", &a, ScrubInputOptions{Max: 1, Step: 0.1}) + ScrubInput(c, "Size", &b, ScrubInputOptions{Disabled: true}) + }) + }) +} diff --git a/testdata/snapshots/darwin/currency_input-dark.png b/testdata/snapshots/darwin/currency_input-dark.png new file mode 100644 index 0000000..07aaac8 Binary files /dev/null and b/testdata/snapshots/darwin/currency_input-dark.png differ diff --git a/testdata/snapshots/darwin/currency_input-light.png b/testdata/snapshots/darwin/currency_input-light.png new file mode 100644 index 0000000..b4a5df3 Binary files /dev/null and b/testdata/snapshots/darwin/currency_input-light.png differ diff --git a/testdata/snapshots/darwin/scrub_input-dark.png b/testdata/snapshots/darwin/scrub_input-dark.png new file mode 100644 index 0000000..f5a4d87 Binary files /dev/null and b/testdata/snapshots/darwin/scrub_input-dark.png differ diff --git a/testdata/snapshots/darwin/scrub_input-light.png b/testdata/snapshots/darwin/scrub_input-light.png new file mode 100644 index 0000000..4d7f243 Binary files /dev/null and b/testdata/snapshots/darwin/scrub_input-light.png differ