diff --git a/mention_input.go b/mention_input.go index 88125c2..e286fb0 100644 --- a/mention_input.go +++ b/mention_input.go @@ -57,7 +57,7 @@ func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts Mentio 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.Box(c).Grow(1).MinWidth(Density(c).Unit()*20).Focusable().Clip().Role(ui.RoleTextField).Label(opts.Label).Cursor(ui.CursorText) + in := ui.Box(c).Grow(1).MinWidth(Density(c).Unit() * 20).Focusable().Clip().Role(ui.RoleTextField).Label(opts.Label).Cursor(ui.CursorText) s := *ui.Local(in, "mention_input", func() *mentionInputState { return &mentionInputState{at: -1} }) if s.doc.text == nil || s.synced != *value || !slices.Equal(s.syncedMentions, *mentions) { // Set from outside: start over at the end. @@ -314,7 +314,9 @@ func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, fram Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer) if i == s.pick { it.Selected(true).Background(k.Selection).TextColor(k.AccentText) - it.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 6, W: 2, H: r.H - 12}, k.AccentText, 0) }) + it.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 6, W: 2, H: r.H - 12}, k.AccentText, 0) + }) } else if it.Hovered() { it.Background(k.SurfaceHover) } diff --git a/messages_essentials.go b/messages_essentials.go index fcb62b3..ad95e04 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -21,6 +21,11 @@ func init() { "currency_input.invalid": {"Enter an amount, such as 1,234.50", "请输入金额,例如 1,234.50"}, "currency_input.precision": {"Use at most %d decimal places", "最多保留 %d 位小数"}, + "signature_pad.label": {"Signature", "签名"}, + "signature_pad.hint": {"Draw with the pointer; Delete clears", "用鼠标书写;Delete 键清除"}, + "signature_pad.empty": {"Sign here", "请在此签名"}, + "signature_pad.clear": {"Clear", "清除"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/signature_pad.go b/signature_pad.go new file mode 100644 index 0000000..53352b8 --- /dev/null +++ b/signature_pad.go @@ -0,0 +1,153 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SignaturePoint is a point of a stroke, in DIPs from the pad's top left. +type SignaturePoint struct{ X, Y float32 } + +// SignaturePadOptions configure a SignaturePad. +type SignaturePadOptions struct { + // Height is the drawing area's height; zero is 160. The pad fills its parent's width. + Height float32 + // Label names the pad; empty uses "Signature". + Label string + Disabled bool +} + +// SignaturePath writes strokes as SVG path data: "M x y L x y …" per stroke. +func SignaturePath(strokes [][]SignaturePoint) string { + var b strings.Builder + num := func(v float32) string { return strconv.FormatFloat(float64(v), 'f', -1, 32) } + for _, st := range strokes { + for i, p := range st { + if b.Len() > 0 { + b.WriteByte(' ') + } + if i == 0 { + b.WriteString("M ") + } else { + b.WriteString("L ") + } + b.WriteString(num(p.X) + " " + num(p.Y)) + } + } + return b.String() +} + +// signaturePadDrawing is the stroke in progress. +type signaturePadDrawing struct { + active, ended bool +} + +// SignaturePad records pointer strokes into *strokes; Changed reports a finished stroke or a clear. +func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadOptions) ChoiceResult { + if strokes == nil { + panic("mujica: SignaturePad needs strokes") + } + if !(opts.Height >= 0) || math.IsInf(float64(opts.Height), 1) { + panic("mujica: SignaturePad Height must be finite and >= 0") + } + k := Tokens(c) + u := Density(c).Unit() + name := cmp.Or(opts.Label, msg(c, "signature_pad.label")) + col := ui.Column(c).FillWidth().Gap(u * 2).Disabled(opts.Disabled) + disabled := col.IsDisabled() + changed := false + col.Children(func() { + pad := ui.Box(c).FillWidth().Height(cmp.Or(opts.Height, 160)).Focusable().Radius(theme.CardRadius).Clip(). + Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Cursor(ui.CursorCrosshair). + Role(ui.RoleImage).Label(name).Description(msg(c, "signature_pad.hint")) + internal.Ring(pad, k.Focus, theme.CardRadius) + d := ui.Local(pad, "signature_pad", func() *signaturePadDrawing { return &signaturePadDrawing{} }) + s := *d + pad.HandleInput(func(ev ui.InputEvent) bool { + if pad.IsDisabled() { + return false + } + pt := SignaturePoint{ev.X, ev.Y} + switch { + case ev.Kind == ui.InputPointerDown && ev.Button == 0: + *strokes = append(*strokes, []SignaturePoint{pt}) + s.active = true + pad.Focus() + case ev.Kind == ui.InputPointerMove && s.active && len(*strokes) > 0: + last := &(*strokes)[len(*strokes)-1] + if q := (*last)[len(*last)-1]; math.Hypot(float64(pt.X-q.X), float64(pt.Y-q.Y)) >= 1 { + *last = append(*last, pt) + } + case ev.Kind == ui.InputPointerUp && s.active: + s.active, s.ended = false, true + default: + return false + } + return true + }) + if s.ended { + s.ended, changed = false, true + slog.Debug("mujica: signature_pad stroke", "strokes", len(*strokes)) + } + clear := func() { + if len(*strokes) > 0 { + *strokes, changed = nil, true + slog.Debug("mujica: signature_pad clear") + } + } + if !disabled && (pad.Shortcut(0, ui.KeyDelete) || pad.Shortcut(0, ui.KeyBackspace)) { + clear() + } + ink := signaturePadClone(*strokes) + empty := len(ink) == 0 + size := FontSize(c, theme.CaptionSize) + pad.Draw(func(p *ui.Painter, r ui.Rect) { + line := r.Y + r.H - 28 + internal.Rule(p, r.X+16, r.X+r.W-16, line, k.Border) + p.Text(r.X+16, line+4, "×", size, k.TextMuted) + if empty { + w, _ := p.MeasureText(0, ui.Span{Text: msg(c, "signature_pad.empty"), Size: size}) + p.Text(r.X+(r.W-w)/2, r.Y+r.H/2-size, msg(c, "signature_pad.empty"), size, k.TextMuted) + } + for _, st := range ink { + var path ui.Path + for i, q := range st { + x, y := internal.Snap(p, r.X+q.X), internal.Snap(p, r.Y+q.Y) + if i == 0 { + path.MoveTo(x, y) + } else { + path.LineTo(x, y) + } + } + if len(st) == 1 { + path.Circle(internal.Snap(p, r.X+st[0].X), internal.Snap(p, r.Y+st[0].Y), 1) + } + p.StrokePath(&path, 2, k.Text) + } + }) + ui.Row(c).Children(func() { + if Button(c, msg(c, "signature_pad.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("trash-2"), Disabled: empty}).Clicked() && !disabled { + clear() + } + }) + }) + return ChoiceResult{col, changed} +} + +// signaturePadClone copies strokes so painting never sees later edits. +func signaturePadClone(s [][]SignaturePoint) [][]SignaturePoint { + out := make([][]SignaturePoint, len(s)) + for i, st := range s { + out[i] = append([]SignaturePoint(nil), st...) + } + return out +} diff --git a/signature_pad_test.go b/signature_pad_test.go new file mode 100644 index 0000000..5aaf1c8 --- /dev/null +++ b/signature_pad_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSignaturePath(t *testing.T) { + got := SignaturePath([][]SignaturePoint{{{1, 2}, {3.5, 4}}, {{10, 10}}}) + if want := "M 1 2 L 3.5 4 M 10 10"; got != want { + t.Fatalf("path %q, want %q", got, want) + } + if SignaturePath(nil) != "" { + t.Fatal("empty strokes made a path") + } +} + +func TestSignaturePadStrokesAndClear(t *testing.T) { + var strokes [][]SignaturePoint + dis := false + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(400).Children(func() { + if SignaturePad(c, &strokes, SignaturePadOptions{Disabled: dis}).Changed() { + n++ + } + }) + }, 500, 300) + tt.Press(50, 60) + tt.Move(80, 70) + tt.Move(80.5, 70) // under a DIP: not kept + tt.Move(120, 90) + tt.Release(120, 90) + if len(strokes) != 1 || len(strokes[0]) != 3 || strokes[0][0] != (SignaturePoint{30, 40}) || n != 1 { + t.Fatalf("stroke %+v, changes %d", strokes, n) + } + tt.Press(200, 60) + tt.Release(200, 60) + if len(strokes) != 2 || n != 2 { + t.Fatalf("dot: %+v", strokes) + } + tt.Key(0, ui.KeyDelete) // drawing focused the pad + if len(strokes) != 0 || n != 3 { + t.Fatalf("Delete kept %d strokes", len(strokes)) + } + tt.Press(50, 60) + tt.Release(50, 60) + if err := tt.Click("Clear"); err != nil || len(strokes) != 0 { + t.Fatalf("Clear button: %v %d", err, len(strokes)) + } + dis = true + tt.Frame() + tt.Press(50, 60) + tt.Move(90, 60) + tt.Release(90, 60) + if len(strokes) != 0 { + t.Fatal("disabled pad drew") + } +} + +func TestSignaturePadSnapshot(t *testing.T) { + strokes := [][]SignaturePoint{{{30, 90}, {50, 60}, {60, 95}, {80, 55}, {95, 100}, {120, 70}, {150, 85}}, {{170, 60}, {175, 100}}} + var empty [][]SignaturePoint + snapshot(t, "signature_pad", 560, 260, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ui.Column(c).Width(250).Children(func() { SignaturePad(c, &strokes, SignaturePadOptions{Height: 180}) }) + ui.Column(c).Width(250).Children(func() { SignaturePad(c, &empty, SignaturePadOptions{Height: 180}) }) + }) + }) +} diff --git a/testdata/snapshots/darwin/signature_pad-dark.png b/testdata/snapshots/darwin/signature_pad-dark.png new file mode 100644 index 0000000..aec2569 Binary files /dev/null and b/testdata/snapshots/darwin/signature_pad-dark.png differ diff --git a/testdata/snapshots/darwin/signature_pad-light.png b/testdata/snapshots/darwin/signature_pad-light.png new file mode 100644 index 0000000..ac20d18 Binary files /dev/null and b/testdata/snapshots/darwin/signature_pad-light.png differ