diff --git a/masked_input.go b/masked_input.go new file mode 100644 index 0000000..8e7fc8a --- /dev/null +++ b/masked_input.go @@ -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 +} diff --git a/masked_input_test.go b/masked_input_test.go new file mode 100644 index 0000000..8b1b8d0 --- /dev/null +++ b/masked_input_test.go @@ -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) + }) + }) +} diff --git a/testdata/snapshots/darwin/masked_input-dark.png b/testdata/snapshots/darwin/masked_input-dark.png new file mode 100644 index 0000000..4914745 Binary files /dev/null and b/testdata/snapshots/darwin/masked_input-dark.png differ diff --git a/testdata/snapshots/darwin/masked_input-light.png b/testdata/snapshots/darwin/masked_input-light.png new file mode 100644 index 0000000..a2a0375 Binary files /dev/null and b/testdata/snapshots/darwin/masked_input-light.png differ