WIP essentials 6
This commit is contained in:
11 files changed
+429
-3
No files matched your search
+1
-1
@@ -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 })
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "文字过长,无法生成二维码"},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Reference in new issue
Block a user