WIP essentials 7
This commit is contained in:
4 files changed
+334
No files matched your search
+209
@@ -0,0 +1,209 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MaskedInputOptions configure a MaskedInput.
|
||||
type MaskedInputOptions struct {
|
||||
// Mask is required: 9 takes a digit, a a letter, * either; anything else shows as is.
|
||||
Mask string
|
||||
Label string
|
||||
Disabled bool
|
||||
// Error marks the value invalid and shows the message below.
|
||||
Error string
|
||||
}
|
||||
|
||||
// maskedInputState is the caret over the typed characters and any input method composition.
|
||||
type maskedInputState struct {
|
||||
caret int
|
||||
compose string
|
||||
changed, submitted bool
|
||||
}
|
||||
|
||||
// maskedInputFits reports whether r may fill the mask slot m.
|
||||
func maskedInputFits(m, r rune) bool {
|
||||
switch m {
|
||||
case '9':
|
||||
return unicode.IsDigit(r)
|
||||
case 'a':
|
||||
return unicode.IsLetter(r)
|
||||
case '*':
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maskedInputSlot(m rune) bool { return m == '9' || m == 'a' || m == '*' }
|
||||
|
||||
// MaskFormat lays raw into mask; unfilled slots show as underscores.
|
||||
func MaskFormat(mask, raw string) string {
|
||||
rs := []rune(raw)
|
||||
var b strings.Builder
|
||||
i := 0
|
||||
for _, m := range mask {
|
||||
switch {
|
||||
case !maskedInputSlot(m):
|
||||
b.WriteRune(m)
|
||||
case i < len(rs):
|
||||
b.WriteRune(rs[i])
|
||||
i++
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// maskedInputAt is the mask index of slot n, or the mask's length past the last.
|
||||
func maskedInputAt(mask []rune, n int) int {
|
||||
for i, m := range mask {
|
||||
if maskedInputSlot(m) {
|
||||
if n == 0 {
|
||||
return i
|
||||
}
|
||||
n--
|
||||
}
|
||||
}
|
||||
return len(mask)
|
||||
}
|
||||
|
||||
// MaskedInput edits *value, the characters typed into Mask's slots; an input method's composition commits only when done.
|
||||
func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputResult {
|
||||
if value == nil {
|
||||
panic("mujica: MaskedInput needs a value")
|
||||
}
|
||||
mask := []rune(opts.Mask)
|
||||
var slots []rune
|
||||
for _, m := range mask {
|
||||
if maskedInputSlot(m) {
|
||||
slots = append(slots, m)
|
||||
}
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
panic("mujica: MaskedInput Mask has no slots")
|
||||
}
|
||||
raw := []rune(*value)
|
||||
if len(raw) > len(slots) {
|
||||
panic("mujica: MaskedInput value is longer than its Mask")
|
||||
}
|
||||
for i, r := range raw {
|
||||
if !maskedInputFits(slots[i], r) {
|
||||
panic("mujica: MaskedInput value does not fit its Mask")
|
||||
}
|
||||
}
|
||||
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 {
|
||||
in := ui.Row(c).Grow(1).Focusable().Role(ui.RoleTextField).Label(opts.Label).Description(MaskFormat(opts.Mask, *value))
|
||||
st := ui.Local(in, "masked_input", func() *maskedInputState { return &maskedInputState{caret: len(raw)} })
|
||||
s := *st
|
||||
s.caret = min(s.caret, len(raw))
|
||||
insert := func(text string) {
|
||||
for _, ch := range text {
|
||||
if s.caret < len(slots) && len(raw) < len(slots) && maskedInputFits(slots[s.caret], ch) {
|
||||
raw = append(raw[:s.caret], append([]rune{ch}, raw[s.caret:]...)...)
|
||||
s.caret++
|
||||
s.changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if in.IsDisabled() {
|
||||
return false
|
||||
}
|
||||
raw = []rune(*value)
|
||||
switch ev.Kind {
|
||||
case ui.InputText:
|
||||
s.compose = ""
|
||||
insert(ev.Text)
|
||||
case ui.InputCompose:
|
||||
s.compose = ev.Text
|
||||
case ui.InputCommand:
|
||||
switch ev.Text {
|
||||
case "paste":
|
||||
insert(c.ReadClipboard())
|
||||
case "copy":
|
||||
c.WriteClipboard(*value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case ui.InputKeyDown:
|
||||
if s.compose != "" {
|
||||
return false // the input method owns the keys while composing
|
||||
}
|
||||
switch ev.Key {
|
||||
case ui.KeyBackspace:
|
||||
if s.caret > 0 {
|
||||
raw = append(raw[:s.caret-1], raw[s.caret:]...)
|
||||
s.caret--
|
||||
s.changed = true
|
||||
}
|
||||
case ui.KeyDelete:
|
||||
if s.caret < len(raw) {
|
||||
raw = append(raw[:s.caret], raw[s.caret+1:]...)
|
||||
s.changed = true
|
||||
}
|
||||
case ui.KeyLeft:
|
||||
s.caret = max(0, s.caret-1)
|
||||
case ui.KeyRight:
|
||||
s.caret = min(len(raw), s.caret+1)
|
||||
case ui.KeyHome:
|
||||
s.caret = 0
|
||||
case ui.KeyEnd:
|
||||
s.caret = len(raw)
|
||||
case ui.KeyEnter:
|
||||
s.submitted = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
*value = string(raw)
|
||||
return true
|
||||
})
|
||||
if s.changed {
|
||||
r.changed, s.changed = true, false
|
||||
}
|
||||
if s.submitted {
|
||||
r.submitted, s.submitted = true, false
|
||||
}
|
||||
shown := []rune(MaskFormat(opts.Mask, *value))
|
||||
at := maskedInputAt(mask, s.caret)
|
||||
size := FontSize(c, theme.BodySize)
|
||||
w, h := c.MeasureText(0, ui.Span{Text: string(shown[:at]), Size: size})
|
||||
if h == 0 {
|
||||
_, h = c.MeasureText(0, ui.Span{Text: "0", Size: size})
|
||||
}
|
||||
in.MinHeight(h).TextCaret(ui.Rect{X: w, Y: 0, W: 1, H: h}).Cursor(ui.CursorText)
|
||||
filled := maskedInputAt(mask, len([]rune(*value)))
|
||||
in.Children(func() {
|
||||
ui.Text(c, string(shown[:at])).TextColor(k.Text).FontSize(size).SingleLine()
|
||||
if s.compose != "" {
|
||||
ui.Text(c, s.compose).TextColor(k.Text).FontSize(size).Underline().SingleLine()
|
||||
}
|
||||
if at < filled {
|
||||
ui.Text(c, string(shown[at:filled])).TextColor(k.Text).FontSize(size).SingleLine()
|
||||
}
|
||||
ui.Text(c, string(shown[max(at, filled):])).TextColor(k.TextMuted).FontSize(size).SingleLine()
|
||||
})
|
||||
in.DrawOver(func(p *ui.Painter, rr ui.Rect) {
|
||||
if in.Focused() && !in.IsDisabled() && s.compose == "" {
|
||||
p.Fill(ui.Rect{X: rr.X + w, Y: rr.Y + (rr.H-h)/2, W: 1, H: h}, k.Text, 0)
|
||||
}
|
||||
})
|
||||
return in
|
||||
}, nil)
|
||||
if r.changed {
|
||||
slog.Debug("mujica: masked_input change", "filled", len([]rune(*value)), "slots", len(slots))
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMaskFormat(t *testing.T) {
|
||||
for _, tc := range [][3]string{
|
||||
{"(999) 999-9999", "555123", "(555) 123-____"},
|
||||
{"9999 9999 9999 9999", "", "____ ____ ____ ____"},
|
||||
{"aa-99", "AB12", "AB-12"},
|
||||
} {
|
||||
if got := MaskFormat(tc[0], tc[1]); got != tc[2] {
|
||||
t.Errorf("MaskFormat(%q, %q) = %q, want %q", tc[0], tc[1], got, tc[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maskedTester(v *string, dis *bool, n, submits *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := MaskedInput(c, v, MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone", Disabled: *dis})
|
||||
if r.Changed() {
|
||||
*n++
|
||||
}
|
||||
if r.Submitted() {
|
||||
*submits++
|
||||
}
|
||||
}, 400, 120)
|
||||
}
|
||||
|
||||
func TestMaskedInputTypingEditingAndPaste(t *testing.T) {
|
||||
v := ""
|
||||
dis := false
|
||||
n, submits := 0, 0
|
||||
tt := maskedTester(&v, &dis, &n, &submits)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("55x5-12")
|
||||
if v != "55512" {
|
||||
t.Fatalf("typed: %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Type("9")
|
||||
if v != "55951" {
|
||||
t.Fatalf("insert at caret: %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if v != "5951" {
|
||||
t.Fatalf("Delete at start: %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.SetClipboard("123-456-789")
|
||||
tt.Command("paste")
|
||||
if v != "5951123456" {
|
||||
t.Fatalf("paste fills to the end: %q", v)
|
||||
}
|
||||
tt.Type("7")
|
||||
if v != "5951123456" {
|
||||
t.Fatalf("typed past the mask: %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if submits != 1 || n == 0 {
|
||||
t.Fatalf("submits %d changes %d", submits, n)
|
||||
}
|
||||
if _, ok := tt.Find("Phone"); !ok {
|
||||
t.Fatal("no field name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedInputCompositionAndDisabled(t *testing.T) {
|
||||
v := "12"
|
||||
dis := false
|
||||
n, submits := 0, 0
|
||||
tt := maskedTester(&v, &dis, &n, &submits)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Compose("3", 1)
|
||||
if v != "12" || !tt.HasText("3") {
|
||||
t.Fatalf("composition committed early: %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyBackspace) // the input method's, while composing
|
||||
if v != "12" {
|
||||
t.Fatalf("Backspace during composition edited: %q", v)
|
||||
}
|
||||
tt.Type("3")
|
||||
if v != "123" {
|
||||
t.Fatalf("commit: %q", v)
|
||||
}
|
||||
dis = true
|
||||
tt.Frame()
|
||||
tt.Type("4")
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
if v != "123" {
|
||||
t.Fatalf("disabled edit: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedInputRejects(t *testing.T) {
|
||||
for name, tc := range map[string][2]string{"no slots": {"---", ""}, "too long": {"99", "123"}, "wrong kind": {"99", "1a"}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
v := tc[1]
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); MaskedInput(c, &v, MaskedInputOptions{Mask: tc[0]}) }, 200, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedInputSnapshot(t *testing.T) {
|
||||
a, b, d := "5551234", "", "4111111111111111"
|
||||
snapshot(t, "masked_input", 520, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(12).Children(func() {
|
||||
MaskedInput(c, &a, MaskedInputOptions{Mask: "(999) 999-9999"}).Element.Width(160)
|
||||
MaskedInput(c, &b, MaskedInputOptions{Mask: "99/99/9999"}).Element.Width(130)
|
||||
MaskedInput(c, &d, MaskedInputOptions{Mask: "9999 9999 9999 9999", Disabled: true}).Element.Width(190)
|
||||
})
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Reference in new issue
Block a user