WIP essentials 9
This commit is contained in:
6 files changed
+234
-2
No files matched your search
+4
-2
@@ -57,7 +57,7 @@ func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts Mentio
|
|||||||
var r InputResult
|
var r InputResult
|
||||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
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} })
|
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) {
|
if s.doc.text == nil || s.synced != *value || !slices.Equal(s.syncedMentions, *mentions) {
|
||||||
// Set from outside: start over at the end.
|
// 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)
|
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer)
|
||||||
if i == s.pick {
|
if i == s.pick {
|
||||||
it.Selected(true).Background(k.Selection).TextColor(k.AccentText)
|
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() {
|
} else if it.Hovered() {
|
||||||
it.Background(k.SurfaceHover)
|
it.Background(k.SurfaceHover)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ func init() {
|
|||||||
"currency_input.invalid": {"Enter an amount, such as 1,234.50", "请输入金额,例如 1,234.50"},
|
"currency_input.invalid": {"Enter an amount, such as 1,234.50", "请输入金额,例如 1,234.50"},
|
||||||
"currency_input.precision": {"Use at most %d decimal places", "最多保留 %d 位小数"},
|
"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.label": {"QR code", "二维码"},
|
||||||
"qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"},
|
"qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
Reference in new issue
Block a user