From 03ffddfd0807a5f5e1a0d8dc57c89a37e1ca0c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:19:22 -0400 Subject: [PATCH] WIP essentials 7 --- masked_input.go | 209 ++++++++++++++++++ masked_input_test.go | 125 +++++++++++ .../snapshots/darwin/masked_input-dark.png | Bin 0 -> 4081 bytes .../snapshots/darwin/masked_input-light.png | Bin 0 -> 3758 bytes 4 files changed, 334 insertions(+) create mode 100644 masked_input.go create mode 100644 masked_input_test.go create mode 100644 testdata/snapshots/darwin/masked_input-dark.png create mode 100644 testdata/snapshots/darwin/masked_input-light.png 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 0000000000000000000000000000000000000000..49147451eabe009fd74267dc95f91a76517814cb GIT binary patch literal 4081 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/masked_input-light.png b/testdata/snapshots/darwin/masked_input-light.png new file mode 100644 index 0000000000000000000000000000000000000000..a2a03754275e6ac03a6561a6afc074d703928963 GIT binary patch literal 3758 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