From f7096b13f9855431c4d0aae8f9e5b6942f78c20e 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:06:14 -0400 Subject: [PATCH 01/20] WIP essentials: QR --- messages_essentials.go | 8 + qr_code.go | 79 ++++++ qr_code_encoder.go | 262 ++++++++++++++++++++ qr_code_encoder_test.go | 81 ++++++ qr_code_grid.go | 242 ++++++++++++++++++ qr_code_test.go | 74 ++++++ testdata/qr/hello-l.txt | 22 ++ testdata/qr/long-h.txt | 90 +++++++ testdata/qr/text-q.txt | 50 ++++ testdata/qr/url-m.txt | 26 ++ testdata/qr/v8-l.txt | 50 ++++ testdata/qr/zh-m.txt | 26 ++ testdata/snapshots/darwin/qr_code-dark.png | Bin 0 -> 6280 bytes testdata/snapshots/darwin/qr_code-light.png | Bin 0 -> 6170 bytes 14 files changed, 1010 insertions(+) create mode 100644 messages_essentials.go create mode 100644 qr_code.go create mode 100644 qr_code_encoder.go create mode 100644 qr_code_encoder_test.go create mode 100644 qr_code_grid.go create mode 100644 qr_code_test.go create mode 100644 testdata/qr/hello-l.txt create mode 100644 testdata/qr/long-h.txt create mode 100644 testdata/qr/text-q.txt create mode 100644 testdata/qr/url-m.txt create mode 100644 testdata/qr/v8-l.txt create mode 100644 testdata/qr/zh-m.txt create mode 100644 testdata/snapshots/darwin/qr_code-dark.png create mode 100644 testdata/snapshots/darwin/qr_code-light.png diff --git a/messages_essentials.go b/messages_essentials.go new file mode 100644 index 0000000..88cfc55 --- /dev/null +++ b/messages_essentials.go @@ -0,0 +1,8 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "qr_code.label": {"QR code", "二维码"}, + "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, + }) +} diff --git a/qr_code.go b/qr_code.go new file mode 100644 index 0000000..09a90b6 --- /dev/null +++ b/qr_code.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// QRCodeOptions configure a QRCode. +type QRCodeOptions struct { + Level QRLevel + // Size is the side in DIPs with the quiet zone; zero is 160. + Size float32 + // Label names the code for assistive technology; empty uses "QR code". + Label string +} + +// qrCodeCache keeps the last encoding so it runs only when the text or level changes. +type qrCodeCache struct { + text string + level QRLevel + m QRMatrix + err error + done bool +} + +// QRCode draws text as a QR code, encoded offline; too long a text shows an error. +func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element { + _ = opts.Level.String() + if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { + panic("mujica: QRCode Size must be finite and >= 0") + } + size := opts.Size + if size == 0 { + size = 160 + } + k := Tokens(c) + e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(cmp.Or(opts.Label, msg(c, "qr_code.label"))) + cache := ui.Local(e, "qr_code", func() qrCodeCache { return qrCodeCache{} }) + if !cache.done || cache.text != text || cache.level != opts.Level { + m, err := EncodeQR([]byte(text), opts.Level) + *cache = qrCodeCache{text: text, level: opts.Level, m: m, err: err, done: true} + slog.Debug("mujica: qr_code encoded", "version", m.Version, "level", opts.Level, "err", err) + } + if cache.err != nil { + e.Center().Padding(Density(c).Unit()*2).Background(k.Surface).Border(theme.BorderWidth, k.Danger).Radius(theme.CardRadius). + Description(cache.err.Error()) + e.Children(func() { + ui.Text(c, msg(c, "qr_code.too_long")).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).TextAlign(ui.Center) + }) + return e + } + m := cache.m + // Scanners need dark modules on a light field, in either theme. + light, dark := theme.Light().Surface, theme.Light().Text + e.Draw(func(p *ui.Painter, r ui.Rect) { + n := m.Size() + 8 + s := p.Scale() + mod := float32(math.Floor(float64(r.W*s)/float64(n))) / s + if mod <= 0 { + return + } + ox := float32(math.Round(float64((r.X+(r.W-mod*float32(n))/2)*s))) / s + oy := float32(math.Round(float64((r.Y+(r.H-mod*float32(n))/2)*s))) / s + p.Fill(r, light, theme.ControlRadius) + for y, row := range m.Modules { + for x, on := range row { + if on { + p.Fill(ui.Rect{X: ox + float32(x+4)*mod, Y: oy + float32(y+4)*mod, W: mod, H: mod}, dark, 0) + } + } + } + }) + return e +} diff --git a/qr_code_encoder.go b/qr_code_encoder.go new file mode 100644 index 0000000..4408273 --- /dev/null +++ b/qr_code_encoder.go @@ -0,0 +1,262 @@ +package mujica + +import ( + "fmt" +) + +// QRLevel is a QR code's error correction level. The zero value is QRMedium. +type QRLevel int + +const ( + QRMedium QRLevel = iota // M: about 15% recoverable + QRLow // L: about 7% + QRQuartile // Q: about 25% + QRHigh // H: about 30% +) + +func (l QRLevel) String() string { + switch l { + case QRLow: + return "L" + case QRMedium: + return "M" + case QRQuartile: + return "Q" + case QRHigh: + return "H" + } + panic(fmt.Sprintf("mujica: unknown QRLevel %d", int(l))) +} + +// index is the level's column in qrCodeTable. +func (l QRLevel) index() int { return [4]int{1, 0, 2, 3}[l] } + +// formatBits are the level's two format information bits. +func (l QRLevel) formatBits() int { return [4]int{0, 1, 3, 2}[l] } + +// qrCodeBlocks is one version and level: EC codewords per block, then two block groups. +type qrCodeBlocks struct{ ec, n1, d1, n2, d2 int } + +func (b qrCodeBlocks) data() int { return b.n1*b.d1 + b.n2*b.d2 } + +// qrCodeTable is ISO/IEC 18004 Table 9, indexed [version-1][L, M, Q, H]. +var qrCodeTable = [40][4]qrCodeBlocks{ + {{7, 1, 19, 0, 0}, {10, 1, 16, 0, 0}, {13, 1, 13, 0, 0}, {17, 1, 9, 0, 0}}, + {{10, 1, 34, 0, 0}, {16, 1, 28, 0, 0}, {22, 1, 22, 0, 0}, {28, 1, 16, 0, 0}}, + {{15, 1, 55, 0, 0}, {26, 1, 44, 0, 0}, {18, 2, 17, 0, 0}, {22, 2, 13, 0, 0}}, + {{20, 1, 80, 0, 0}, {18, 2, 32, 0, 0}, {26, 2, 24, 0, 0}, {16, 4, 9, 0, 0}}, + {{26, 1, 108, 0, 0}, {24, 2, 43, 0, 0}, {18, 2, 15, 2, 16}, {22, 2, 11, 2, 12}}, + {{18, 2, 68, 0, 0}, {16, 4, 27, 0, 0}, {24, 4, 19, 0, 0}, {28, 4, 15, 0, 0}}, + {{20, 2, 78, 0, 0}, {18, 4, 31, 0, 0}, {18, 2, 14, 4, 15}, {26, 4, 13, 1, 14}}, + {{24, 2, 97, 0, 0}, {22, 2, 38, 2, 39}, {22, 4, 18, 2, 19}, {26, 4, 14, 2, 15}}, + {{30, 2, 116, 0, 0}, {22, 3, 36, 2, 37}, {20, 4, 16, 4, 17}, {24, 4, 12, 4, 13}}, + {{18, 2, 68, 2, 69}, {26, 4, 43, 1, 44}, {24, 6, 19, 2, 20}, {28, 6, 15, 2, 16}}, + {{20, 4, 81, 0, 0}, {30, 1, 50, 4, 51}, {28, 4, 22, 4, 23}, {24, 3, 12, 8, 13}}, + {{24, 2, 92, 2, 93}, {22, 6, 36, 2, 37}, {26, 4, 20, 6, 21}, {28, 7, 14, 4, 15}}, + {{26, 4, 107, 0, 0}, {22, 8, 37, 1, 38}, {24, 8, 20, 4, 21}, {22, 12, 11, 4, 12}}, + {{30, 3, 115, 1, 116}, {24, 4, 40, 5, 41}, {20, 11, 16, 5, 17}, {24, 11, 12, 5, 13}}, + {{22, 5, 87, 1, 88}, {24, 5, 41, 5, 42}, {30, 5, 24, 7, 25}, {24, 11, 12, 7, 13}}, + {{24, 5, 98, 1, 99}, {28, 7, 45, 3, 46}, {24, 15, 19, 2, 20}, {30, 3, 15, 13, 16}}, + {{28, 1, 107, 5, 108}, {28, 10, 46, 1, 47}, {28, 1, 22, 15, 23}, {28, 2, 14, 17, 15}}, + {{30, 5, 120, 1, 121}, {26, 9, 43, 4, 44}, {28, 17, 22, 1, 23}, {28, 2, 14, 19, 15}}, + {{28, 3, 113, 4, 114}, {26, 3, 44, 11, 45}, {26, 17, 21, 4, 22}, {26, 9, 13, 16, 14}}, + {{28, 3, 107, 5, 108}, {26, 3, 41, 13, 42}, {30, 15, 24, 5, 25}, {28, 15, 15, 10, 16}}, + {{28, 4, 116, 4, 117}, {26, 17, 42, 0, 0}, {28, 17, 22, 6, 23}, {30, 19, 16, 6, 17}}, + {{28, 2, 111, 7, 112}, {28, 17, 46, 0, 0}, {30, 7, 24, 16, 25}, {24, 34, 13, 0, 0}}, + {{30, 4, 121, 5, 122}, {28, 4, 47, 14, 48}, {30, 11, 24, 14, 25}, {30, 16, 15, 14, 16}}, + {{30, 6, 117, 4, 118}, {28, 6, 45, 14, 46}, {30, 11, 24, 16, 25}, {30, 30, 16, 2, 17}}, + {{26, 8, 106, 4, 107}, {28, 8, 47, 13, 48}, {30, 7, 24, 22, 25}, {30, 22, 15, 13, 16}}, + {{28, 10, 114, 2, 115}, {28, 19, 46, 4, 47}, {28, 28, 22, 6, 23}, {30, 33, 16, 4, 17}}, + {{30, 8, 122, 4, 123}, {28, 22, 45, 3, 46}, {30, 8, 23, 26, 24}, {30, 12, 15, 28, 16}}, + {{30, 3, 117, 10, 118}, {28, 3, 45, 23, 46}, {30, 4, 24, 31, 25}, {30, 11, 15, 31, 16}}, + {{30, 7, 116, 7, 117}, {28, 21, 45, 7, 46}, {30, 1, 23, 37, 24}, {30, 19, 15, 26, 16}}, + {{30, 5, 115, 10, 116}, {28, 19, 47, 10, 48}, {30, 15, 24, 25, 25}, {30, 23, 15, 25, 16}}, + {{30, 13, 115, 3, 116}, {28, 2, 46, 29, 47}, {30, 42, 24, 1, 25}, {30, 23, 15, 28, 16}}, + {{30, 17, 115, 0, 0}, {28, 10, 46, 23, 47}, {30, 10, 24, 35, 25}, {30, 19, 15, 35, 16}}, + {{30, 17, 115, 1, 116}, {28, 14, 46, 21, 47}, {30, 29, 24, 19, 25}, {30, 11, 15, 46, 16}}, + {{30, 13, 115, 6, 116}, {28, 14, 46, 23, 47}, {30, 44, 24, 7, 25}, {30, 59, 16, 1, 17}}, + {{30, 12, 121, 7, 122}, {28, 12, 47, 26, 48}, {30, 39, 24, 14, 25}, {30, 22, 15, 41, 16}}, + {{30, 6, 121, 14, 122}, {28, 6, 47, 34, 48}, {30, 46, 24, 10, 25}, {30, 2, 15, 64, 16}}, + {{30, 17, 122, 4, 123}, {28, 29, 46, 14, 47}, {30, 49, 24, 10, 25}, {30, 24, 15, 46, 16}}, + {{30, 4, 122, 18, 123}, {28, 13, 46, 32, 47}, {30, 48, 24, 14, 25}, {30, 42, 15, 32, 16}}, + {{30, 20, 117, 4, 118}, {28, 40, 47, 7, 48}, {30, 43, 24, 22, 25}, {30, 10, 15, 67, 16}}, + {{30, 19, 118, 6, 119}, {28, 18, 47, 31, 48}, {30, 34, 24, 34, 25}, {30, 20, 15, 61, 16}}, +} + +// QRMatrix is an encoded QR code: Modules[y][x] is true for dark. +type QRMatrix struct { + Version int + Level QRLevel + Mask int + Modules [][]bool +} + +// Size is the side of the symbol in modules, without the quiet zone. +func (m QRMatrix) Size() int { return len(m.Modules) } + +// EncodeQR encodes data in byte mode at the smallest version that fits, choosing the mask by penalty. +func EncodeQR(data []byte, level QRLevel) (QRMatrix, error) { + _ = level.String() + for v := 1; v <= 40; v++ { + cc := 8 + if v >= 10 { + cc = 16 + } + if len(data) >= 1<= 10 { + w.put(len(data), 16) + } else { + w.put(len(data), 8) + } + for _, d := range data { + w.put(int(d), 8) + } + capBits := 8 * b.data() + w.put(0, min(4, capBits-w.n)) + w.put(0, (8-w.n%8)%8) + for pad := 0xEC; w.n < capBits; pad ^= 0xEC ^ 0x11 { + w.put(pad, 8) + } + words := qrCodeInterleave(w.bytes, b) + g := newQRCodeGrid(v) + g.place(words) + best, bestPenalty := 0, -1 + for m := range 8 { + t := g.clone() + t.apply(m) + if p := t.penalty(); bestPenalty < 0 || p < bestPenalty { + best, bestPenalty = m, p + } + } + g.apply(best) + g.format(level.formatBits(), best) + g.version(true) + return QRMatrix{Version: v, Level: level, Mask: best, Modules: g.dark} +} + +// qrCodeBits appends bits most significant first. +type qrCodeBits struct { + bytes []byte + n int +} + +func (w *qrCodeBits) put(v, count int) { + for i := count - 1; i >= 0; i-- { + if w.n%8 == 0 { + w.bytes = append(w.bytes, 0) + } + if v>>i&1 == 1 { + w.bytes[w.n/8] |= 0x80 >> (w.n % 8) + } + w.n++ + } +} + +// qrCodeInterleave splits data into blocks, adds Reed-Solomon codewords and interleaves both. +func qrCodeInterleave(data []byte, b qrCodeBlocks) []byte { + var blocks, ecs [][]byte + gen := qrCodeGenerator(b.ec) + at := 0 + for i := range b.n1 + b.n2 { + n := b.d1 + if i >= b.n1 { + n = b.d2 + } + blk := data[at : at+n] + at += n + blocks = append(blocks, blk) + ecs = append(ecs, qrCodeRemainder(blk, gen)) + } + var out []byte + for i := range max(b.d1, b.d2) { + for _, blk := range blocks { + if i < len(blk) { + out = append(out, blk[i]) + } + } + } + for i := range b.ec { + for _, e := range ecs { + out = append(out, e[i]) + } + } + return out +} + +// qrCodeMul multiplies in GF(256) modulo x^8+x^4+x^3+x^2+1. +func qrCodeMul(x, y byte) byte { + var z byte + for i := 7; i >= 0; i-- { + hi := z & 0x80 + z <<= 1 + if hi != 0 { + z ^= 0x1D + } + if y>>i&1 == 1 { + z ^= x + } + } + return z +} + +// qrCodeGenerator returns the degree-n generator's coefficients, highest first, leading 1 dropped. +func qrCodeGenerator(n int) []byte { + g := make([]byte, n) + g[n-1] = 1 + root := byte(1) + for range n { + for j := range n { + g[j] = qrCodeMul(g[j], root) + if j+1 < n { + g[j] ^= g[j+1] + } + } + root = qrCodeMul(root, 2) + } + return g +} + +func qrCodeRemainder(data, gen []byte) []byte { + r := make([]byte, len(gen)) + for _, d := range data { + f := d ^ r[0] + copy(r, r[1:]) + r[len(r)-1] = 0 + for i := range r { + r[i] ^= qrCodeMul(gen[i], f) + } + } + return r +} + +// qrCodeAlignment returns the alignment pattern centers of version v. +func qrCodeAlignment(v int) []int { + if v == 1 { + return nil + } + n := v/7 + 2 + step := 26 + if v != 32 { + step = (v*4 + n*2 + 1) / (n*2 - 2) * 2 + } + pos := make([]int, n) + pos[0] = 6 + for i, p := n-1, v*4+10; i >= 1; i, p = i-1, p-step { + pos[i] = p + } + return pos +} diff --git a/qr_code_encoder_test.go b/qr_code_encoder_test.go new file mode 100644 index 0000000..49f0a26 --- /dev/null +++ b/qr_code_encoder_test.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "encoding/hex" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// testdata/qr: segno 1.6.6 byte-mode symbols, padding fixed to ISO 7.4.10. +func TestEncodeQRMatchesReference(t *testing.T) { + files, err := filepath.Glob("testdata/qr/*.txt") + if err != nil || len(files) < 6 { + t.Fatalf("reference files: %v %v", files, err) + } + levels := map[string]QRLevel{"L": QRLow, "M": QRMedium, "Q": QRQuartile, "H": QRHigh} + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + head := strings.Fields(lines[0]) + version, _ := strconv.Atoi(head[0]) + mask, _ := strconv.Atoi(head[2]) + data, err := hex.DecodeString(head[3]) + if err != nil { + t.Fatal(err) + } + m, err := EncodeQR(data, levels[head[1]]) + if err != nil { + t.Fatalf("%s: %v", f, err) + } + if m.Version != version || m.Mask != mask { + t.Errorf("%s: version %d mask %d, want %d %d", f, m.Version, m.Mask, version, mask) + continue + } + for y, row := range lines[1:] { + for x, ch := range row { + if m.Modules[y][x] != (ch == '1') { + t.Fatalf("%s: module (%d,%d) differs", f, x, y) + } + } + } + } +} + +func TestQRCodeAlignmentPositions(t *testing.T) { + for v, want := range map[int]string{1: "[]", 2: "[6 18]", 7: "[6 22 38]", 32: "[6 34 60 86 112 138]", 40: "[6 30 58 86 114 142 170]"} { + if got := fmtInts(qrCodeAlignment(v)); got != want { + t.Errorf("version %d: %s, want %s", v, got, want) + } + } +} + +func fmtInts(v []int) string { + s := make([]string, len(v)) + for i, n := range v { + s[i] = strconv.Itoa(n) + } + return "[" + strings.Join(s, " ") + "]" +} + +func TestEncodeQRLimits(t *testing.T) { + m, err := EncodeQR(make([]byte, 2953), QRLow) + if err != nil || m.Version != 40 || m.Size() != 177 { + t.Fatalf("largest L symbol: %v %v", m.Version, err) + } + if _, err := EncodeQR(make([]byte, 2954), QRLow); err == nil { + t.Fatal("2954 bytes fit at L") + } + if _, err := EncodeQR(make([]byte, 1274), QRHigh); err == nil { + t.Fatal("1274 bytes fit at H") + } + m, err = EncodeQR(nil, QRHigh) + if err != nil || m.Version != 1 { + t.Fatalf("empty data: %v %v", m.Version, err) + } +} diff --git a/qr_code_grid.go b/qr_code_grid.go new file mode 100644 index 0000000..39dbade --- /dev/null +++ b/qr_code_grid.go @@ -0,0 +1,242 @@ +package mujica + +// qrCodeGrid is a symbol under construction; fixed marks function modules. +type qrCodeGrid struct { + v int + dark, fixed [][]bool +} + +func newQRCodeGrid(v int) *qrCodeGrid { + n := 4*v + 17 + g := &qrCodeGrid{v: v, dark: make([][]bool, n), fixed: make([][]bool, n)} + for i := range n { + g.dark[i], g.fixed[i] = make([]bool, n), make([]bool, n) + } + for i := range n { + g.set(6, i, i%2 == 0) + g.set(i, 6, i%2 == 0) + } + g.finder(3, 3) + g.finder(n-4, 3) + g.finder(3, n-4) + al := qrCodeAlignment(v) + for i, x := range al { + for j, y := range al { + if i == 0 && j == 0 || i == 0 && j == len(al)-1 || i == len(al)-1 && j == 0 { + continue + } + for dy := -2; dy <= 2; dy++ { + for dx := -2; dx <= 2; dx++ { + g.set(x+dx, y+dy, max(qrCodeAbs(dx), qrCodeAbs(dy)) != 1) + } + } + } + } + // Reserve format and version areas; apply fills them. + g.format(-1, 0) + g.version(false) + return g +} + +func qrCodeAbs(v int) int { return max(v, -v) } + +func (g *qrCodeGrid) set(x, y int, dark bool) { + g.dark[y][x], g.fixed[y][x] = dark, true +} + +// finder draws a finder pattern and its separator centered at (cx, cy). +func (g *qrCodeGrid) finder(cx, cy int) { + n := len(g.dark) + for dy := -4; dy <= 4; dy++ { + for dx := -4; dx <= 4; dx++ { + x, y := cx+dx, cy+dy + if x < 0 || y < 0 || x >= n || y >= n { + continue + } + d := max(qrCodeAbs(dx), qrCodeAbs(dy)) + g.set(x, y, d != 2 && d != 4) + } + } +} + +// format writes the 15 format bits and the dark module; levelBits -1 leaves them light. +func (g *qrCodeGrid) format(levelBits, mask int) { + data := levelBits<<3 | mask + rem := data + for range 10 { + rem = rem<<1 ^ (rem>>9)*0x537 + } + b := (data<<10 | rem) ^ 0x5412 + bit := func(i int) bool { return levelBits >= 0 && b>>i&1 == 1 } + n := len(g.dark) + for i := 0; i <= 5; i++ { + g.set(8, i, bit(i)) + } + g.set(8, 7, bit(6)) + g.set(8, 8, bit(7)) + g.set(7, 8, bit(8)) + for i := 9; i < 15; i++ { + g.set(14-i, 8, bit(i)) + } + for i := range 8 { + g.set(n-1-i, 8, bit(i)) + } + for i := 8; i < 15; i++ { + g.set(8, n-15+i, bit(i)) + } + g.set(8, n-8, levelBits >= 0) +} + +// version writes the 18 version bits from version 7 on, or light ones. +func (g *qrCodeGrid) version(on bool) { + if g.v < 7 { + return + } + rem := g.v + for range 12 { + rem = rem<<1 ^ (rem>>11)*0x1F25 + } + b := g.v<<12 | rem + n := len(g.dark) + for i := range 18 { + dark := on && b>>i&1 == 1 + a, c := n-11+i%3, i/3 + g.set(a, c, dark) + g.set(c, a, dark) + } +} + +// place lays the codewords in the zigzag from the bottom right; remainder bits stay light. +func (g *qrCodeGrid) place(words []byte) { + n := len(g.dark) + i := 0 + for right := n - 1; right >= 1; right -= 2 { + if right == 6 { + right = 5 + } + for vert := range n { + for j := range 2 { + x := right - j + up := (right+1)&2 == 0 + y := vert + if up { + y = n - 1 - vert + } + if g.fixed[y][x] || i >= len(words)*8 { + continue + } + g.dark[y][x] = words[i/8]>>(7-i%8)&1 == 1 + i++ + } + } + } +} + +func (g *qrCodeGrid) clone() *qrCodeGrid { + t := &qrCodeGrid{v: g.v, dark: make([][]bool, len(g.dark)), fixed: g.fixed} + for i, row := range g.dark { + t.dark[i] = append([]bool(nil), row...) + } + return t +} + +// apply XORs mask over the data modules; the information areas stay light, as 7.8.3 evaluates them. +func (g *qrCodeGrid) apply(mask int) { + for y, row := range g.dark { + for x := range row { + if g.fixed[y][x] { + continue + } + var flip bool + switch mask { + case 0: + flip = (x+y)%2 == 0 + case 1: + flip = y%2 == 0 + case 2: + flip = x%3 == 0 + case 3: + flip = (x+y)%3 == 0 + case 4: + flip = (x/3+y/2)%2 == 0 + case 5: + flip = x*y%2+x*y%3 == 0 + case 6: + flip = (x*y%2+x*y%3)%2 == 0 + case 7: + flip = ((x+y)%2+x*y%3)%2 == 0 + } + row[x] = row[x] != flip + } + } +} + +// penalty scores the symbol by the four rules of ISO/IEC 18004 7.8.3. +func (g *qrCodeGrid) penalty() int { + n := len(g.dark) + p, dark := 0, 0 + row, col := make([]bool, n), make([]bool, n) + for i := range n { + for j := range n { + row[j], col[j] = g.dark[i][j], g.dark[j][i] + if row[j] { + dark++ + } + if i > 0 && j > 0 && row[j] == row[j-1] && row[j] == g.dark[i-1][j] && row[j] == g.dark[i-1][j-1] { + p += 3 + } + } + p += qrCodeRuns(row) + qrCodeFinders(row) + qrCodeRuns(col) + qrCodeFinders(col) + } + return p + 10*(qrCodeAbs(dark*100-50*n*n)/(5*n*n)) +} + +// qrCodeRuns scores runs of five or more equal modules. +func qrCodeRuns(seq []bool) int { + p, run := 0, 1 + for j := 1; j <= len(seq); j++ { + if j < len(seq) && seq[j] == seq[j-1] { + run++ + continue + } + if run >= 5 { + p += run - 2 + } + run = 1 + } + return p +} + +// qrCodeFinders scores 1:1:3:1:1 patterns with four light modules on a side. +func qrCodeFinders(seq []bool) int { + core := [7]bool{true, false, true, true, true, false, true} + n, p := len(seq), 0 + light := func(a, b int) bool { + for i := max(a, 0); i < min(b, n); i++ { + if seq[i] { + return false + } + } + return true + } + for idx := 0; idx+7 <= n; { + match := true + for i, d := range core { + if seq[idx+i] != d { + match = false + break + } + } + if !match { + idx++ + continue + } + if idx == 0 || idx == n-7 || light(idx-4, idx) || light(idx+7, idx+11) { + p += 40 + idx += 7 + } else { + idx += 4 + } + } + return p +} diff --git a/qr_code_test.go b/qr_code_test.go new file mode 100644 index 0000000..19bf83c --- /dev/null +++ b/qr_code_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestQRCodeDrawsModulesAndCaches(t *testing.T) { + text := "HELLO" + var box ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Dark}) + ui.Row(c).Children(func() { box = QRCode(c, text, QRCodeOptions{Level: QRLow, Size: 116}).Bounds() }) + }, 200, 200) + tt.Frame() + if box.W != 116 { + t.Fatalf("size %v", box) + } + m, _ := EncodeQR([]byte(text), QRLow) + // 21 modules plus 8 of quiet zone in 116 DIPs: 4 DIPs each. + px := func(x, y int) ui.Color { + r, g, b, _ := tt.Image().At(int(box.X)+(x+4)*4+2, int(box.Y)+(y+4)*4+2).RGBA() + return ui.RGB(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + } + for _, at := range [][2]int{{0, 0}, {3, 3}, {7, 7}, {10, 6}, {12, 12}} { + want := theme.Light().Surface + if m.Modules[at[1]][at[0]] { + want = theme.Light().Text + } + if got := px(at[0], at[1]); got != want { + t.Errorf("module %v is %v, want %v", at, got, want) + } + } + if _, ok := tt.Find("QR code"); !ok { + t.Fatal("no accessible name") + } +} + +func TestQRCodeTooLong(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + QRCode(c, strings.Repeat("x", 1300), QRCodeOptions{Level: QRHigh}) + }, 300, 300) + if !tt.HasText("Text too long for a QR code") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestQRCodeRejectsBadOptions(t *testing.T) { + for name, o := range map[string]QRCodeOptions{"level": {Level: 9}, "size": {Size: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); QRCode(c, "x", o) }, 200, 200) + }() + } +} + +func TestQRCodeSnapshot(t *testing.T) { + snapshot(t, "qr_code", 420, 200, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + QRCode(c, "https://example.com/mujica", QRCodeOptions{}) + QRCode(c, "MujicaUI", QRCodeOptions{Level: QRHigh, Size: 100}) + QRCode(c, strings.Repeat("x", 1300), QRCodeOptions{Level: QRHigh, Size: 100}) + }) + }) +} diff --git a/testdata/qr/hello-l.txt b/testdata/qr/hello-l.txt new file mode 100644 index 0000000..2e671dd --- /dev/null +++ b/testdata/qr/hello-l.txt @@ -0,0 +1,22 @@ +1 L 4 48454c4c4f +111111101111101111111 +100000101010101000001 +101110101000001011101 +101110101111001011101 +101110100000101011101 +100000101101001000001 +111111101010101111111 +000000000111100000000 +110011100101000101111 +101001000101100101111 +000101101111001110010 +100000011000011010000 +111011110100111000110 +000000001000111001011 +111111100100110001010 +100000101101100100010 +101110101011001110101 +101110100111100001011 +101110100011001111000 +100000101110011000000 +111111101110111110101 diff --git a/testdata/qr/long-h.txt b/testdata/qr/long-h.txt new file mode 100644 index 0000000..3b7ee86 --- /dev/null +++ b/testdata/qr/long-h.txt @@ -0,0 +1,90 @@ +18 H 7 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff4d756a69636155494d756a69636155494d756a69636155494d756a69636155494d756a69636155494d756a6963615549 +11111110100011001001111011010000010111101000100000010111000011001100110010001011101111111 +10000010101011101101011100110101101000101010010110111010110010101001011001101101001000001 +10111010001001001111010000100101011110100001011011110100001001100001010111110100001011101 +10111010100100010101001110100110011011010101000010101110111111000100010001010110101011101 +10111010101010111100111110011111101001100110111101011011111000110110110110110101001011101 +10000010100000010011011110101000101011101010001101001110001110001010000111101101001000001 +11111110101010101010101010101010101010101010101010101010101010101010101010101010101111111 +00000000000100111011011011011000110111111010011111101010001010001100011010010101100000000 +00010010010011001101111101111111111101110010000000011011111001110001110101100000100111011 +10101101110010101001001010110100000011101100111000001100111100100010001000100111101011101 +11001010010011001011011100010001110110101010100100000101101110010000110111110101111010110 +11110000111100111011111111011011111000000010010001001001010111000011111111011010100110100 +00011111010010111001101001111011001001101010011011011010101110011101110011001100010010110 +01010100101111110011101011110110000011001001011100111101010000110000101111010000010100010 +01100111001111011000101010110001101010011010000001010011101000110001100001010111010001011 +00110100101010000011010010100001100101100111010011111011111111010100110111000100100111000 +11011011110100011110000001001001101110100100010111110100001101111001001001001000111110010 +00001100000011111111011101111010100110111111011001010010111101011111001010011111000000001 +10100111011010111101110011011000101010111111110010101010001110011101100111111011110000110 +10100100110000110010110111110010110101000001001111000001111110111111110110101111010110010 +10111010001101001111110100110101001000001010110010111101110011011010000110101101010100000 +11110101111110111001001000100111101000100001111110010100100111101110100010001001010101111 +10111011110011100011110101100100000100100101001011011011100100010100111111010111011111011 +00011101001011101011111010111010110101001001011011100101101101110100010011100000001011101 +01001111001100011011100000101110000000111101111011010101100100110010011001100110100101100 +11111100110100101110101001000110011010110001000101011011001110100111100011100010101100101 +10111010100111110001100011100100001100111000010000110111010010010001110000110010111111010 +10111000100010011100000000100111110110011010000110101101111000101001101001110110111111010 +10101111110101100000000101001111111100101100111100010111111110100111110010101111111111011 +10101000111001101001101111101000110111110111001000100010001111110001110000110101100011010 +11111010101011010010001001011010101111101110010101010110101110101100110011001000101010110 +11001000110100110111101011001000110000111000001000111010001001000100111000011101100010001 +00111111101010100101011100011111111110110101110010110011111000000110100100000111111111110 +01110100010100001010101100111001010010001000010110100110000011010111011100100010111100001 +11110110001111001101001101001010010111001000110111010011010101101011011000000001101110001 +00000001010110101011111100100111010111011000110011011110100001011110100000001011100110111 +10001111111111010000000001001000010010010011000100101001010111001010100000000011100110110 +01110101010101111111001101111110011111001011110010010100010100001101010000101000001001000 +01110110111011101011001011010001010010000111111010100011101111001110010111100000111010100 +01111000101111011100100000111101100111010010100001111100011101011100001100111000000110001 +11010110010110100001001100010011001010110011111110101101111101001101000001101101001110111 +01000100000000100000011110001100101110100110000000011000001001000000111011010111010010111 +01110010001101101110101110100100100011000011100001110101011001100010001010001100001111100 +00100001110101101010001100100110001001111010010000111100100010101110011110010011011001011 +01101011010011011011010111100011000001100010011100011001010110101100110100100001001010101 +11111001101110000110011010000001000010100111010011110011111001011101110111111001101010000 +00010011101111111111011101111010101100100011100000001110100011000111100011000101001111101 +11101000011100000110100100101011111100001100000111000110111111100001110011111010110000100 +11111010100111100010000011110011101010001010011001000000110001100110000000100011001111001 +00100000100100111111110000110100011101001000111111100010010000110111111100100110000100011 +01001011001011100101100110001011100000010100000000110111011100100010001000110101101101100 +01111001101101011101001100001111010101100101010000110010011111110111111011101110110011111 +00010011000010000100010100100110011001000101011101001010101010101110110001010110110011100 +00101100110100111101000011010110110011100000110100110010001011101000010011110010011101101 +01111111110111000100011111101111100010101010010000110111111011001001111110101100111110011 +11111000101000010111101000001000110010001111100011000010001110011101111010000001100010100 +01111010110010101100110011001010100110001001001010001110101100000110101101010111101011001 +10111000111100000111101000001000110101000111101110000110001111110111111111010110100011011 +00001111101000100111001010111111101101111100011011010111111000010001110001101101111110110 +10111100001110011110110000000110001111001011001000011011001101011010011111011000001011010 +10001010001110011001101000000010001011111111011100000011001010111010101110111111101000011 +00001100001000010000010101111001111110111011001101111100111011000101011000001101110100001 +01001010111011000100111111011101011101101011010010101100011110011001000111001111100100011 +01100101010011110111101001010100001111110111011001100000100000001000100010011100111111100 +11001010010110110101001000111110001111000011101001100010010000001101011100011010000110010 +11111101110001011011011010100011010100111111100001101110000110010011111101000001001000010 +01101110111110000010110111001000101010000010011111101000111010111101000001100011100101011 +10101101100011110111111001100111111111011101100110101000011111100011100111101010001111110 +00101110100111110111010110110100110111011001000101101011011011100001010011111000011110011 +11110101001100110111010001110100000111001001000001000111111010000101110111011101110100101 +10111111000011010010111111011010101101110111010010011111010101101101101010100010001111000 +00100100001100000000110101000001000101110100100010100001000010011110001010000111110110001 +10000011001111010110111100101101011110001000001101110001110000000001000100010000011010111 +01111000111000001101011100100000001001001010100100101010111101111010110110110111001101110 +01110110010010000011101111101110110110011000110001011011011111000110110100100011011111001 +11111000001000010011001111011111010010110010101101101111111110001001100000010000001100110 +11101110100111111001110001010110111000011000100001100110001001010000101111010001110100101 +01100100001010000001110010110010001101100111000000011010100010101100010111101000111100000 +10010010110001100010101010000100111011100101110010110111001011101010110010001111011110101 +11001101101000010100111000110111011001110010100001011110011111100010100001111011010011101 +10010010110011101100111101111111100101001010101100000111111101010011111001010010111110100 +00000000100111100111001010011000111111010100011101010010001100101010101011101110100011110 +11111110010000000110100100001010110101000111000011110010101101001111000000001001101010000 +10000010001100100100001000101000101111110111101100011110001110111100100000101101100010001 +10111010010110011100100111011111111101010101001011011111111111111111111111101111111111000 +10111010100111001001001111001011011111111000010101000010010101011000001100011100001110111 +10111010011110001100111110000001010110010101100011110110001010111011001011111101010010101 +10000010010100100000000110110110011001110010010011111000011010111010001100101011000000010 +11111110010101101000100101001010110001111100100010001101100101111010000101101011010111010 diff --git a/testdata/qr/text-q.txt b/testdata/qr/text-q.txt new file mode 100644 index 0000000..ac74755 --- /dev/null +++ b/testdata/qr/text-q.txt @@ -0,0 +1,50 @@ +8 Q 7 436f75727420676f7468696320636f6d706f6e656e747320666f72204d79476f2e20436f75727420676f7468696320636f6d706f6e656e747320666f72204d79476f2e20436f75727420676f7468696320636f6d706f6e656e747320666f72204d79476f2e20 +1111111010000000011000011010001101101100101111111 +1000001001110010010000011101111111110111101000001 +1011101011011000111001101101000010110001101011101 +1011101010000101101110110110100000100001001011101 +1011101001110100001101111110111000001000001011101 +1000001010110100011000100010111111101010001000001 +1111111010101010101010101010101010101010101111111 +0000000010011100110000100010110101011110000000000 +0101011111100110100111111110111001111110011101101 +1110000001011111100110101101000000001000010011011 +0010101010110111110001110101001110010000000110111 +1111100010101110100010010101011101001101010100010 +0100011010111010111010100000010111011010010011011 +0011110101011111010100001100000000110100100001101 +1001101100011110010011010110110000011010110101001 +0011110000100101001011000100101110101011001011001 +1000111010110011111011101000100110111010010010110 +1100100110001000010010011000111101010010010101001 +1010101010000100010001101010101100010100110101011 +1001000100000000010001111110101011011101110110101 +1001101000011010111000111011100101101110010100000 +1000110000100011001110111101000010000000000000110 +1000111111110000110000111110000000100100111110111 +0001100010111001101001100010011100101011100010001 +0100101011100111010110101010100111101001101010011 +1100100010011010100001100010110110111101100010101 +1011111111111000100110111110110101011010111111101 +0000000000101101101110111010110111011000101000010 +0110001000011100110101101111001010011110011101111 +1011110100110010010001101101110110000010100010111 +1100101000111000111101011101000010011000111000011 +1111010100011101111100110011110000111000001100100 +0001001010010000010001100101000111001010011011001 +1100010101101101011000100111100110001100110000011 +0100011111111010101101011101100000111100011110011 +1000100110011000000010111001111000101100001111001 +0001111011111011100011110001100111001110001100010 +0110100001010111011011010110100100101100110101111 +0100011010111000011100101111110110011010101000101 +0111000110000100010100010100110111010110011000001 +1110001100001010111101111111011110011010111110101 +0000000011011100100111100010100001000011100010101 +1111111011100101010110101011011100101010101011011 +1000001011111010001011100011001011111110100010110 +1011101000010000100010111111101101001110111111011 +1011101011100000000001100000100011010001101010011 +1011101000001111011101101011110110100000010011011 +1000001010111010110010001001000111111001110100000 +1111111001000101010110100001101111111100110010011 diff --git a/testdata/qr/url-m.txt b/testdata/qr/url-m.txt new file mode 100644 index 0000000..02002b2 --- /dev/null +++ b/testdata/qr/url-m.txt @@ -0,0 +1,26 @@ +2 M 1 68747470733a2f2f6578616d706c652e636f6d2f6d756a696361 +1111111011100010001111111 +1000001000110110101000001 +1011101010101100101011101 +1011101001100110001011101 +1011101000011010001011101 +1000001011100010001000001 +1111111010101010101111111 +0000000001110011100000000 +1010001100110010100100101 +1110110100110101011101011 +0100101101010001010011101 +1110100110110010100101000 +0001101111101001101100001 +0011110111000101101100011 +1110101011010101111001101 +0011010110000010110111000 +1100001100111000111110010 +0000000010111010100010001 +1111111011010100101010001 +1000001000110011100010001 +1011101000111001111110010 +1011101001011100010010110 +1011101011010011110111011 +1000001001100001111110000 +1111111011101000101001001 diff --git a/testdata/qr/v8-l.txt b/testdata/qr/v8-l.txt new file mode 100644 index 0000000..52d312b --- /dev/null +++ b/testdata/qr/v8-l.txt @@ -0,0 +1,50 @@ +8 L 2 78787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878 +1111111001110110010100001101011000001000101111111 +1000001010011111010000100111110010100011101000001 +1011101000010011100011001010100111110001101011101 +1011101011110110010100010000001101011101001011101 +1011101000011001001001111111011000001100001011101 +1000001011100010000101100011110010100110001000001 +1111111010101010101010101010101010101010101111111 +0000000000010001001001100010100111110011100000000 +1111101110010100111110111110001101011000110101010 +1110100111010110010101100101011000001100011001110 +1001111000111001001001010111110010100111010011011 +0001010000110101111010010010100111110011100110001 +0010111111110001001000111000001101011000101100100 +0101100101101100111110101101011000001100011001110 +1111101000100011100011100111110010100111010011011 +0010010010001000101110101010100111110011100110001 +1010101000110100111111001000001101011000101100100 +1101000111111110010101101101011000001100011001110 +0011001000000111010001000111110010100111010011011 +0101110000001011100010010010100111110011100110001 +0011011000100110010100111000001101011000101100100 +1100010101001001001001001101011000001100011001110 +1111111110111010000101111111110010100111111111011 +0001100011111001001000100010100111110011100010001 +0101101010100100111111101010001101011001101010100 +1011100010100110010101100011011000001100100011110 +0001111111100001001001111111110010100110111111011 +0100000011111101111010100110100111110010110010001 +1011001101110001001001010010001101011001000110100 +1000010010110100111111011001011000001101001101110 +1111111010101011100010101101110010100110111001011 +1000110011000000101110100110100111110010110010001 +1010101100010100111110110010001101011001000110100 +0111100001011110010100011001011000001101001101110 +1111101111101111010001101101110010100110111001011 +1000000111101011100010000110100111110010110010001 +1100001010111110010101010010001101011001000110100 +1110100110001001001001011001011000001101001101110 +0100011101111010000100101101110010100110111001011 +0111000000101001001001100110100111110010110010001 +1110001011110100111111111110001101011001111110101 +0000000010011110010101100011011000001101100011110 +1111111011101001001000101011110010100111101011011 +1000001000100101111011100010100111110011100010001 +1011101011101001001001111110001101011000111110100 +1011101010110100111110010011011000001100100111100 +1011101011100011100011111001110010100110001101000 +1000001011110000101111001100100111110011011000001 +1111111010110100111110000110001101011001110010111 diff --git a/testdata/qr/zh-m.txt b/testdata/qr/zh-m.txt new file mode 100644 index 0000000..563da6d --- /dev/null +++ b/testdata/qr/zh-m.txt @@ -0,0 +1,26 @@ +2 M 2 e5aeabe5bbb7e593a5e789b9e7bb84e4bbb6e5ba93 +1111111001000010001111111 +1000001000001110101000001 +1011101010111100001011101 +1011101010010110101011101 +1011101011011000001011101 +1000001010100010001000001 +1111111010101010101111111 +0000000011111111100000000 +1011111000001101001111100 +0010110001110110100111100 +0001011110011100011100100 +0101010111001101110100100 +0000101110001001000011110 +1000010101000011001010000 +1011011110111100100110000 +1001110100110010011001111 +1010001111010000111110111 +0000000011001100100011100 +1111111000100110101010110 +1000001011101101100011111 +1011101011001010111110111 +1011101010000000100100011 +1011101010111000010010101 +1000001001110011100100101 +1111111011010000111101111 diff --git a/testdata/snapshots/darwin/qr_code-dark.png b/testdata/snapshots/darwin/qr_code-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a6481aa1142dbeccce5b1e9ee78cb591e5ecc44d GIT binary patch literal 6280 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 Date: Mon, 5 Oct 2026 14:08:29 -0400 Subject: [PATCH 02/20] WIP essentials 2 --- banner.go | 56 ++++++++++ banner_test.go | 75 +++++++++++++ copy_button.go | 64 +++++++++++ copy_button_test.go | 77 ++++++++++++++ messages_essentials.go | 5 + split_button.go | 86 +++++++++++++++ split_button_test.go | 99 ++++++++++++++++++ testdata/snapshots/darwin/banner-dark.png | Bin 0 -> 17577 bytes testdata/snapshots/darwin/banner-light.png | Bin 0 -> 17027 bytes .../snapshots/darwin/copy_button-dark.png | Bin 0 -> 3687 bytes .../snapshots/darwin/copy_button-light.png | Bin 0 -> 3651 bytes .../snapshots/darwin/split_button-dark.png | Bin 0 -> 4424 bytes .../snapshots/darwin/split_button-light.png | Bin 0 -> 4333 bytes 13 files changed, 462 insertions(+) create mode 100644 banner.go create mode 100644 banner_test.go create mode 100644 copy_button.go create mode 100644 copy_button_test.go create mode 100644 split_button.go create mode 100644 split_button_test.go create mode 100644 testdata/snapshots/darwin/banner-dark.png create mode 100644 testdata/snapshots/darwin/banner-light.png create mode 100644 testdata/snapshots/darwin/copy_button-dark.png create mode 100644 testdata/snapshots/darwin/copy_button-light.png create mode 100644 testdata/snapshots/darwin/split_button-dark.png create mode 100644 testdata/snapshots/darwin/split_button-light.png diff --git a/banner.go b/banner.go new file mode 100644 index 0000000..db5f1e1 --- /dev/null +++ b/banner.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// BannerOptions configure a Banner. +type BannerOptions struct { + Severity Severity + // Action labels a button after the message; empty shows none. + Action string +} + +// Banner is a full-width notice for the top of a page; a non-nil open adds a close button. +func Banner(c *ui.Context, open *bool, message string, opts BannerOptions) AlertResult { + if message == "" { + panic("mujica: Banner needs a message") + } + k := Tokens(c) + tone, icon := severityLook(k, opts.Severity) + if open != nil && !*open { + return AlertResult{} + } + u := Density(c).Unit() + name := msg(c, "alert."+opts.Severity.String()) + var res AlertResult + bar := ui.Row(c).FillWidth().MinHeight(Density(c).ControlHeight()+2*u).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3, u*2, u*4). + Background(k.Surface.Mix(tone, 0.08)).Role(ui.RoleGroup).Label(name) + bar.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, tone, 0) + }) + said := ui.Local(bar, "banner.said", func() string { return "" }) + if news := name + ": " + message; opts.Severity >= SeverityWarning && *said != news { + *said = news + c.Announce(news) + } + bar.Children(func() { + ui.Icon(c, icon).Size(20, 20).TextColor(tone) + ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight) + if opts.Action != "" && Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() { + slog.Debug("mujica: banner action", "severity", opts.Severity) + res.Action = true + } + if open != nil && iconAction(c, icons.Must("x"), msg(c, "alert.close")).Clicked() { + slog.Debug("mujica: banner closed", "severity", opts.Severity) + *open, res.Closed = false, true + } + }) + res.Element = bar + return res +} diff --git a/banner_test.go b/banner_test.go new file mode 100644 index 0000000..bba858e --- /dev/null +++ b/banner_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBannerActionCloseAndAnnounce(t *testing.T) { + open := true + var last AlertResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if r := Banner(c, &open, "Maintenance tonight at 22:00", BannerOptions{Severity: SeverityWarning, Action: "Details"}); r.Action || r.Closed { + last = r + } + }, 700, 200) + if a := tt.Announcements(); !slices.Equal(a, []string{"Warning: Maintenance tonight at 22:00"}) { + t.Fatalf("announcements %q", a) + } + if err := tt.Click("Details"); err != nil { + t.Fatal(err) + } + if !last.Action || !open { + t.Fatalf("action: %+v open %v", last, open) + } + tt.Key(0, ui.KeyTab) // from Details to Dismiss + tt.Key(0, ui.KeyEnter) + if open || !last.Closed || tt.HasText("Maintenance") { + t.Fatalf("keyboard close: open %v %+v", open, last) + } +} + +func TestBannerDisabledAndFixed(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + Banner(c, &open, "Read only", BannerOptions{Severity: SeverityError}) + }) + Banner(c, nil, "Always here", BannerOptions{}) + }, 700, 200) + _ = tt.Click("Dismiss") + if !open { + t.Fatal("disabled banner closed") + } + if !tt.HasText("Always here") { + t.Fatal("banner without open missing") + } + for _, f := range []func(c *ui.Context){ + func(c *ui.Context) { Banner(c, nil, "", BannerOptions{}) }, + func(c *ui.Context) { Banner(c, nil, "x", BannerOptions{Severity: 7}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 100) + }() + } +} + +func TestBannerSnapshot(t *testing.T) { + open := true + snapshot(t, "banner", 640, 200, func(c *ui.Context) { + ui.Column(c).Gap(8).Children(func() { + Banner(c, &open, "A new version is available.", BannerOptions{Action: "Update"}) + Banner(c, &open, "Your trial ends in 3 days.", BannerOptions{Severity: SeverityWarning}) + Banner(c, nil, "Sync is failing; changes stay on this device.", BannerOptions{Severity: SeverityError, Action: "Retry"}) + }) + }) +} diff --git a/copy_button.go b/copy_button.go new file mode 100644 index 0000000..2dd07c9 --- /dev/null +++ b/copy_button.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// copyButtonShown is how long the button says Copied. +const copyButtonShown = 2 * time.Second + +// CopyButtonOptions configure a CopyButton. +type CopyButtonOptions struct { + // Label shows beside the icon; empty makes a square icon button. + Label string + Disabled bool +} + +// CopyButton puts text on the clipboard, then shows and announces Copied for two seconds. +func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) ChoiceResult { + copied := msg(c, "copy_button.copied") + name := msg(c, "copy_button.copy") + label, icon := opts.Label, icons.Must("copy") + var b *ui.Element + box := ui.Box(c) + at := ui.Local(box, "copy_button.at", func() time.Time { return time.Time{} }) + left := copyButtonShown - c.Now().Sub(*at) + done := !at.IsZero() && left > 0 + if done { + icon, name = icons.Must("check"), copied + if label != "" { + label = copied + } + c.After(left) + } + box.Children(func() { + bo := ButtonOptions{Variant: Secondary, Icon: icon, Disabled: opts.Disabled} + if opts.Label == "" { + bo.Label = name + } + b = Button(c, label, bo) + if opts.Label == "" { + b.Padding(0).Size(Density(c).ControlHeight(), Density(c).ControlHeight()).Tooltip(name) + } else { + // Room for the longer word, so the width holds. + w1, _ := c.MeasureText(0, ui.Span{Text: copied}) + w2, _ := c.MeasureText(0, ui.Span{Text: opts.Label}) + b.MinWidth(max(w1, w2) + buttonSpinnerSize + Density(c).Unit()*10 + 2*theme.BorderWidth) + } + }) + if b.Clicked() && !b.IsDisabled() { + c.WriteClipboard(text) + *at = c.Now() + c.Announce(copied) + c.Invalidate() + slog.Debug("mujica: copy_button copied", "bytes", len(text)) + return ChoiceResult{box, true} + } + return ChoiceResult{box, false} +} diff --git a/copy_button_test.go b/copy_button_test.go new file mode 100644 index 0000000..4e6d07b --- /dev/null +++ b/copy_button_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestCopyButtonCopiesAndAnnounces(t *testing.T) { + n := 0 + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + r := CopyButton(c, "sk-live-123", CopyButtonOptions{Label: "Copy key"}) + if r.Changed() { + n++ + } + w = r.Element.Bounds().W + }) + }, 400, 200) + tt.Frame() + before := w + if err := tt.Click("Copy key"); err != nil { + t.Fatal(err) + } + if tt.Clipboard() != "sk-live-123" || n != 1 { + t.Fatalf("clipboard %q, copies %d", tt.Clipboard(), n) + } + if !tt.HasText("Copied") || !slices.Contains(tt.Announcements(), "Copied") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Frame() + if w != before { + t.Fatalf("width %v changed from %v", w, before) + } +} + +func TestCopyButtonKeyboardRevertAndDisabled(t *testing.T) { + dis := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + CopyButton(c, "abc", CopyButtonOptions{Disabled: dis}) + }, 300, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if tt.Clipboard() != "abc" { + t.Fatalf("Space copied %q", tt.Clipboard()) + } + if _, ok := tt.Find("已复制"); !ok { + t.Fatal("icon button lacks the copied name") + } + time.Sleep(copyButtonShown + 50*time.Millisecond) + tt.Frame() + if _, ok := tt.Find("复制"); !ok { + t.Fatal("did not revert to Copy") + } + tt.SetClipboard("") + dis = true + tt.Frame() + _ = tt.Click("复制") + if tt.Clipboard() != "" { + t.Fatal("disabled button copied") + } +} + +func TestCopyButtonSnapshot(t *testing.T) { + snapshot(t, "copy_button", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + CopyButton(c, "x", CopyButtonOptions{}) + CopyButton(c, "x", CopyButtonOptions{Label: "Copy link"}) + CopyButton(c, "x", CopyButtonOptions{Label: "Copy", Disabled: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 88cfc55..ca8dd7e 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -2,6 +2,11 @@ package mujica func init() { addMessages(map[string][2]string{ + "copy_button.copy": {"Copy", "复制"}, + "copy_button.copied": {"Copied", "已复制"}, + + "split_button.more": {"More options", "更多选项"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/split_button.go b/split_button.go new file mode 100644 index 0000000..200d60a --- /dev/null +++ b/split_button.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SplitButtonOptions configure a SplitButton. +type SplitButtonOptions struct { + // Variant is Primary (the zero value) or Secondary. + Variant ButtonVariant + Icon *ui.SVG + // MenuLabel names the menu half; empty uses "More options". + MenuLabel string + Disabled bool +} + +// SplitButton runs its main action from the left half and opens build's menu from the right; each half takes focus. +func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build func(m *PopupMenu)) ChoiceResult { + if label == "" || build == nil { + panic("mujica: SplitButton needs a label and a menu") + } + if opts.Variant != Primary && opts.Variant != Secondary { + panic(fmt.Sprintf("mujica: SplitButton Variant %d must be Primary or Secondary", opts.Variant)) + } + k := Tokens(c) + d := Density(c) + menuName := opts.MenuLabel + if menuName == "" { + menuName = msg(c, "split_button.more") + } + group := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) + disabled := group.IsDisabled() + open := ui.Local(group, "split_button.open", func() bool { return false }) + if disabled { + *open = false + } + var clicked bool + group.Children(func() { + main := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*4).Gap(d.Unit()*2). + Radius(theme.ControlRadius, 0, 0, theme.ControlRadius) + splitButtonLook(c, k, main, opts.Variant, false) + main.Children(func() { + if opts.Icon != nil { + ui.Icon(c, opts.Icon).Size(16, 16) + } + ui.Text(c, label).SingleLine() + }) + clicked = main.Clicked() && !disabled + if opts.Variant == Primary { + ui.Box(c).Width(theme.BorderWidth).Background(k.OnAccent.Alpha(0.45)).Role(ui.RoleNone) + } + more := ui.ButtonBase(c).Role(ui.RoleMenuButton).Label(menuName).MinHeight(d.ControlHeight()).Width(theme.MinHitSize+d.Unit()). + Radius(0, theme.ControlRadius, theme.ControlRadius, 0).Tooltip(menuName) + splitButtonLook(c, k, more, opts.Variant, *open) + if opts.Variant == Secondary { + more.BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 0) + } + more.Children(func() { ui.Icon(c, icons.Must("chevron-down")).Size(14, 14) }) + if !disabled && (more.Clicked() || more.Shortcut(0, ui.KeyDown)) { + *open = true + slog.Debug("mujica: split_button open") + } + popupMenuOpen(c, group, open, build) + }) + if clicked { + slog.Debug("mujica: split_button action", "label", label) + } + return ChoiceResult{group, clicked} +} + +// splitButtonLook colors one half as a Button of variant v would be. +func splitButtonLook(c *ui.Context, k theme.Tokens, b *ui.Element, v ButtonVariant, open bool) { + internal.Ring(b, k.Focus, theme.ControlRadius) + bg, fg, border := buttonColors(k, v, b.Hovered() && !b.IsDisabled(), b.Pressed() || open) + b.Background(bg).TextColor(fg).Transition(colorFade) + if border.A > 0 { + b.Border(theme.BorderWidth, border) + } +} diff --git a/split_button_test.go b/split_button_test.go new file mode 100644 index 0000000..166d2c1 --- /dev/null +++ b/split_button_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func splitButtonTester(dis *bool, runs *int, chosen *string) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + r := SplitButton(c, "Save", SplitButtonOptions{Disabled: *dis}, func(m *PopupMenu) { + for _, s := range []string{"Save as…", "Save all"} { + if m.Item(s, PopupMenuItemOptions{}) { + *chosen = s + } + } + }) + if r.Changed() { + *runs++ + } + }) + }, 500, 300) +} + +func TestSplitButtonMainAndMenu(t *testing.T) { + dis, runs, chosen := false, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + if err := tt.Click("Save"); err != nil { + t.Fatal(err) + } + if runs != 1 || tt.HasText("Save all") { + t.Fatalf("main click: runs %d, menu open %v", runs, tt.HasText("Save all")) + } + if err := tt.Click("More options"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Save all"); err != nil { + t.Fatal(err) + } + if chosen != "Save all" || runs != 1 || tt.HasText("Save as…") { + t.Fatalf("menu choice %q runs %d", chosen, runs) + } +} + +func TestSplitButtonKeyboardHalves(t *testing.T) { + dis, runs, chosen := false, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Save") { + t.Fatal("first Tab misses the main half") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + if !tt.Focused("More options") { + t.Fatal("second Tab misses the menu half") + } + tt.Key(0, ui.KeyDown) + if runs != 1 || !tt.HasText("Save as…") { + t.Fatalf("runs %d, menu %v", runs, tt.HasText("Save as…")) + } + tt.Key(0, ui.KeyEnter) + if chosen != "Save as…" { + t.Fatalf("Enter chose %q", chosen) + } +} + +func TestSplitButtonDisabled(t *testing.T) { + dis, runs, chosen := true, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + _ = tt.Click("Save") + _ = tt.Click("More options") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if runs != 0 || tt.HasText("Save all") { + t.Fatalf("disabled: runs %d menu %v", runs, tt.HasText("Save all")) + } + defer func() { + if recover() == nil { + t.Fatal("Danger variant accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SplitButton(c, "x", SplitButtonOptions{Variant: Danger}, func(*PopupMenu) {}) + }, 200, 100) +} + +func TestSplitButtonSnapshot(t *testing.T) { + snapshot(t, "split_button", 460, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + menu := func(m *PopupMenu) { m.Item("Other", PopupMenuItemOptions{}) } + SplitButton(c, "Publish", SplitButtonOptions{}, menu) + SplitButton(c, "Export", SplitButtonOptions{Variant: Secondary}, menu) + SplitButton(c, "Merge", SplitButtonOptions{Disabled: true}, menu) + }) + }) +} diff --git a/testdata/snapshots/darwin/banner-dark.png b/testdata/snapshots/darwin/banner-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..9ec220a9b60e0bd9bfcc6c04e512562f32f7eecf GIT binary patch literal 17577 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/banner-light.png b/testdata/snapshots/darwin/banner-light.png new file mode 100644 index 0000000000000000000000000000000000000000..c465661c98354ba36203221a6a175d56b8737c17 GIT binary patch literal 17027 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/copy_button-dark.png b/testdata/snapshots/darwin/copy_button-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b059394ebc77b5d5b7a0fe6bb3bcd1450774431b GIT binary patch literal 3687 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 A0RR91 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/copy_button-light.png b/testdata/snapshots/darwin/copy_button-light.png new file mode 100644 index 0000000000000000000000000000000000000000..f5668c35644b09b5bd2e049b4dafc92e5547d10a GIT binary patch literal 3651 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 C0bct6 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/split_button-light.png b/testdata/snapshots/darwin/split_button-light.png new file mode 100644 index 0000000000000000000000000000000000000000..892ce9ef1f8f5bc8bd2e3fe45146d4aa57c61429 GIT binary patch literal 4333 zcmd6qXEYlQ_s6vqrD{~|5q@S9)Cv{5R;ol%D`HiZ*4`r$BdS)bW@A>3QbbE*#Hxmt zsJ&NI)GTVx;_?5#`M>(Vd(L^DH}~B8=AL`L_j~R=pEwg^eI^DT1}Z8lCa8gqDHYYF z9~Zd^{pE|u`*aXNMaA+Ns-tBdoU@k8XvMxj{xUl;aS{_Vvrt*O zB0e4~nI76Q9Qtyx`2MLer*c{P>uuopk^9+LImA;Yb&hh1ih3S&i4ep(|gj}`*AOznEqmjWge^GOCpRZSGuB{wWEZY5zk$-{GHoqj-3NFQ`5##)tGw>m?gS0TBDD10Ks@}b5#y^Ikgm8S|H@ydx?u?wheld> zyDA&+g2_iG@#$5q35I!?l9lc9Oc*Ns<3A~tB$-dEN%b`TR9S6sbr$sLq?ys9 z3ld9Y^AAk>xe*AMX?#vkkarlv*Zj5$nz&9CDyYB&`EVD)5QumZ$p60!>qxm0Ax-sf z*<7a-Wzp0?M<>&IsPeab3X_DjEJ!NCTTRO$!;1KG zgDG}iNYQVR?44&D{o_JPLCkJhy*&+F5O)dV-D`tXvjpHmJx0e@FlESS7Su;;FI>x& z$PbNj{sZaC3OL@L7RH#Y^xbh6_|xYnXvq|3iupj{5c7iBIC-Eju&=ASAA|-P^K8h| zmvxPJ(my-m+hKOt1GI+wEP5TOPUgC?1?0Ske2nd@61Qp}@9?A30Bcdiy-Mp*$HX7+ zZfM9o1BftQw@q%i1GrW3$tK)oDYA(;J2#q#=~?)J@(Q1;JMT4~+SmqZvBW*&{4SC> zA?K4vbomb4r#d@3BgQflMa2|&)|=jQ-VX=)tPGxO)2~nB4@^eqVEcb2GFphP?xu$% zjq^3b0uwGl4WES%k3k&r&h|E;3(b`)oYKEKZ*Kd{e*@W{MFBNL=i-XEVL7TkALb6kbc-2aA`Y)<54Qk#$#~BTBMAJ@9`*ah_I0Um-70~ zoT8gT;WVaKjfO(;dLpUd5v5T}^SVhsuklnCkTBp~{)4Hv4K|T}2CN_0y2DAn3>&G} znG(Bd)y2Yi>!}T2IFqX#M7!qdRX*CCF@zYf*fA#ADZnisn!P!VerhnBvpeR=`!4;~ zDbhTWUY2<~E_ZeRc z%MTB6pp3WU6XIOh#-s(_C z4bkE=0!<@d&pE}svi4F~*}b?Qs;`rK7Cw*gc}=DEvm+4Z!WNzE1yYu(iH2VC6T`N+RpC|hmo*y}p_`qH+$Yo8v8+l5IP ziLmSG=@tFlqKVRk1eMUy+!mK_oXNu;RkcdNL$`mG5mb84Uv^}MYu+~%eL z%Hng`0KnONdCJP{d(Ig((v4?jgeU0Lg}rB8YAtU5HGYt0SNVYHWr~C|;r9<@w6#~o zEs&N%Pv(nY;mD29%5qLO&*75;W{>v`wM`7@7V%G5&`3-N=?#4pc0*G*1{UBbdkr`{ z|It?o3ikc_!m{rPUUhm18h&Ov+4KI`qU*b#h3V%uf(U(b0^nxooDs)2Ap*|wHWuk zt|qIB`3=9JT;;}iQ^Hr5YTFZh#1X~Hqt2Wv=t^Vry*}OWkdcYR3b-l#M0RU=Em7xu0DbG|49#tJO|X5@ceumSr$_4Wc`N|aV$&J}cu^nIm1+s17gSvP z`b6I+yw3N086SRjHd}Uj95cDTzxSIw-n^7wicLu|bg7vro@r`AMn+Vb?#0E&clN4# z%3(3uB?oEw=e*ErVPV?OBVpA+-A8L@1J4X&zs%i!$|J(S4ZC>Sm14Pe6ri8wL?xI6 z9m&X$Z8pJR)CV<4-A?Y>^hc#>F3qn?Bti`GjTH}+XEs>@*35>rBic|pJ;7rf7 zbf2n~S4XiWbnrktcSkD)akigax>8z4hf9?_CgmI{!Rq<3N+HyblT(Ou$gK{c2HR03 zgw?GTK4M$}HMgVvh}oTm^nWP}h5|ag^f-qwp2Z z<4uRGci5>LZiL*$J;XFNX4Anx)>$$()%Wu@yb$=ruTM~!Cp&GklOUQj_8Z5E-~b}0 zGmSZGUY<)Hv0l%AUvB!OGDIJNjB#L18(Q(dO{7nond9*~-8c;&)NE~4Rr6^7yTWmI zsH>;38!e^N!|94s#HMGD`OiA;M9^rlh{JMJLMZIUZxx?ei1h?hlE!AUAVJ3d{vz70 z7QTbe>&V<1D3G>md#6S!$ij4xPdbyAQ;CwKG}h=@X#|_i+J9vv@1iQoLQkRSwVWDf zg`ETw?;D6vt}vRZLpF6$sJ7RI_7s1w*pLwA9d!ApE+++_7-YEAj_>_3|EAxqlhyY) zy4Zlu!q_xVfiE7V6+#9aj~=+2E^ZL2@A(zl0ruXx5uj5n3b)_gnF;lqraxqk=pRd` zEL`8(-<${@kpI}g_cFq3fOqD-XHYU9*iynljBbM_w(rqSJ->C0Sw#;d4IIoP9dNb0 zSMW=MN6J~C4QI3L6hF`|w8P={#`saefU5{}d-2m`MuBGZ0}1wIkr2au=6;2Bf0YUM z{qXI3-Us`cIGY4RkL{h8eOpeMn!e*{#G~)?Ib}{Y)P+P3F~gagE~H^NH-OpVDefUz zm1@Lz2l8#X!>`xX4B1-h!erS|uJ zk|J`l;i-lG;-fXB01aH0&YD6IX$Q6dn$-en6_ycRVI!JPyfy5bPB$~UNL6;ocm?cdQCa23CbKlz*6TJ{ku0MIgc>%{%{ILO<>ze;nSp1)+b2ZS!xgeE|EWl*${4VN$ OM1txX>)^B Date: Mon, 5 Oct 2026 14:10:22 -0400 Subject: [PATCH 03/20] WIP essentials 3 --- bulk_action_bar.go | 53 ++++++++ bulk_action_bar_test.go | 75 +++++++++++ hold_to_confirm.go | 113 ++++++++++++++++ hold_to_confirm_test.go | 124 ++++++++++++++++++ messages_essentials.go | 7 + .../snapshots/darwin/bulk_action_bar-dark.png | Bin 0 -> 7721 bytes .../darwin/bulk_action_bar-light.png | Bin 0 -> 9109 bytes .../snapshots/darwin/hold_to_confirm-dark.png | Bin 0 -> 3631 bytes .../darwin/hold_to_confirm-light.png | Bin 0 -> 3533 bytes 9 files changed, 372 insertions(+) create mode 100644 bulk_action_bar.go create mode 100644 bulk_action_bar_test.go create mode 100644 hold_to_confirm.go create mode 100644 hold_to_confirm_test.go create mode 100644 testdata/snapshots/darwin/bulk_action_bar-dark.png create mode 100644 testdata/snapshots/darwin/bulk_action_bar-light.png create mode 100644 testdata/snapshots/darwin/hold_to_confirm-dark.png create mode 100644 testdata/snapshots/darwin/hold_to_confirm-light.png diff --git a/bulk_action_bar.go b/bulk_action_bar.go new file mode 100644 index 0000000..ad4e0d7 --- /dev/null +++ b/bulk_action_bar.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// BulkActionBarOptions configure a BulkActionBar. +type BulkActionBarOptions struct { + Disabled bool +} + +// BulkActionBar shows while selected > 0 with the count, actions and a clear button; Changed reports a clear. +func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actions func()) ChoiceResult { + if selected < 0 { + panic(fmt.Sprintf("mujica: BulkActionBar selected %d is negative", selected)) + } + if selected == 0 { + return ChoiceResult{} + } + k := Tokens(c) + u := Density(c).Unit() + count := fmt.Sprintf(msg(c, "bulk_action_bar.selected"), selected) + bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight()+4*u). + Role(ui.RoleToolbar).Label(count).Disabled(opts.Disabled) + overlayStyle(c, bar, overlayEnter(c, bar)) + said := ui.Local(bar, "bulk_action_bar.said", func() int { return 0 }) + if *said != selected { + *said = selected + c.Announce(count) + } + var clear bool + bar.Children(func() { + ui.Text(c, count).Bold().TextColor(k.Text).SingleLine() + ui.Box(c).Size(theme.BorderWidth, u*5).Background(k.Border).Role(ui.RoleNone) + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Grow(1).Children(func() { + if actions != nil { + actions() + } + }) + b := Button(c, msg(c, "bulk_action_bar.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("x")}) + clear = b.Clicked() || bar.Shortcut(0, ui.KeyEscape) && !bar.IsDisabled() + }) + if clear { + slog.Debug("mujica: bulk_action_bar clear", "selected", selected) + } + return ChoiceResult{bar, clear} +} diff --git a/bulk_action_bar_test.go b/bulk_action_bar_test.go new file mode 100644 index 0000000..b1c14c2 --- /dev/null +++ b/bulk_action_bar_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBulkActionBarCountActionsClear(t *testing.T) { + selected, deletes := 0, 0 + dis := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Select", ButtonOptions{}).Clicked() { + selected += 3 + } + r := BulkActionBar(c, selected, BulkActionBarOptions{Disabled: dis}, func() { + if Button(c, "Delete", ButtonOptions{Variant: Danger}).Clicked() { + deletes++ + } + }) + if r.Changed() { + selected = 0 + } + }, 600, 300) + if tt.HasText("selected") { + t.Fatal("bar shown with nothing selected") + } + _ = tt.Click("Select") + if !tt.HasText("3 selected") || !slices.Contains(tt.Announcements(), "3 selected") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Delete") + if deletes != 1 { + t.Fatal("action slot did not run") + } + dis = true + tt.Frame() + _ = tt.Click("Clear selection") + if selected != 3 { + t.Fatal("disabled bar cleared") + } + dis = false + tt.Frame() + _ = tt.Click("Delete") // focus inside the bar + tt.Key(0, ui.KeyEscape) + if selected != 0 || tt.HasText("selected") { + t.Fatalf("Escape left %d selected", selected) + } + _ = tt.Click("Select") + if err := tt.Click("Clear selection"); err != nil || selected != 0 { + t.Fatalf("clear button: %v %d", err, selected) + } +} + +func TestBulkActionBarRejectsNegative(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); BulkActionBar(c, -1, BulkActionBarOptions{}, nil) }, 200, 100) +} + +func TestBulkActionBarSnapshot(t *testing.T) { + snapshot(t, "bulk_action_bar", 600, 90, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + BulkActionBar(c, 12, BulkActionBarOptions{}, func() { + Button(c, "Archive", ButtonOptions{Variant: Secondary}) + Button(c, "Delete", ButtonOptions{Variant: Danger}) + }) + }) + }) +} diff --git a/hold_to_confirm.go b/hold_to_confirm.go new file mode 100644 index 0000000..76f1440 --- /dev/null +++ b/hold_to_confirm.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// HoldToConfirmOptions configure a HoldToConfirm. +type HoldToConfirmOptions struct { + // Duration is how long to hold; zero is 1.5 s. + Duration time.Duration + Disabled bool +} + +// holdToConfirmState tracks one hold: by key or pointer, since start. +type holdToConfirmState struct { + key, done bool + start time.Time +} + +// step advances the hold to now; it reports completion once per hold. +func (s *holdToConfirmState) step(held bool, now time.Time, d time.Duration) (progress float64, confirmed bool) { + switch { + case !held: + s.start, s.done = time.Time{}, false + return 0, false + case s.done: + return 1, false + case s.start.IsZero(): + s.start = now + } + progress = min(1, float64(now.Sub(s.start))/float64(d)) + if progress >= 1 { + s.done = true + return 1, true + } + return progress, false +} + +// HoldToConfirm runs a dangerous action only after the pointer or Space is held for Duration. +func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) ChoiceResult { + if label == "" { + panic("mujica: HoldToConfirm needs a label") + } + if opts.Duration < 0 { + panic("mujica: negative HoldToConfirm Duration") + } + d := opts.Duration + if d == 0 { + d = 1500 * time.Millisecond + } + k := Tokens(c) + den := Density(c) + b := ui.ButtonBase(c).MinHeight(den.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, den.Unit()*4). + Radius(theme.ControlRadius).Disabled(opts.Disabled).Description(msg(c, "hold_to_confirm.hint")).Clip() + internal.Ring(b, k.Focus, theme.ControlRadius) + st := ui.Local(b, "hold_to_confirm", func() *holdToConfirmState { return &holdToConfirmState{} }) + s := *st + // Space holds; Enter and Space never click, so no single key press confirms. + b.HandleInput(func(ev ui.InputEvent) bool { + if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || b.IsDisabled() { + return false + } + if ev.Key == ui.KeySpace && (ev.Kind == ui.InputKeyDown || ev.Kind == ui.InputKeyUp) { + s.key = ev.Kind == ui.InputKeyDown + } + return true + }) + _ = b.Clicked() // a click alone does nothing; it keeps the base pressable + disabled := b.IsDisabled() + held := !disabled && (s.key || b.Pressed()) + if !b.Focused() { + s.key = false + held = !disabled && b.Pressed() + } + progress, confirmed := s.step(held, c.Now(), d) + still := c.Preferences().ReduceMotion + if held && !confirmed && progress < 1 { + if still { + c.After(100 * time.Millisecond) + } else { + c.AnimationFrame() + } + } + bg, fg, _ := buttonColors(k, Danger, b.Hovered() && !disabled, false) + b.Background(bg).TextColor(fg) + text := label + if held && still && progress < 1 { + left := d - time.Duration(progress*float64(d)) + text = fmt.Sprintf(msg(c, "hold_to_confirm.left"), left.Seconds()) + } + b.Draw(func(p *ui.Painter, r ui.Rect) { + if progress > 0 && !still { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(progress), H: r.H}, k.OnDanger.Alpha(0.28), 0) + } + }) + // Room for the countdown, so the width holds. + w1, _ := c.MeasureText(0, ui.Span{Text: label}) + w2, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf(msg(c, "hold_to_confirm.left"), d.Seconds())}) + b.MinWidth(max(w1, w2) + den.Unit()*8) + b.Children(func() { ui.Text(c, text).SingleLine() }) + if confirmed { + c.Announce(msg(c, "hold_to_confirm.done")) + slog.Debug("mujica: hold_to_confirm confirmed", "label", label) + } + return ChoiceResult{b, confirmed} +} diff --git a/hold_to_confirm_test.go b/hold_to_confirm_test.go new file mode 100644 index 0000000..062449e --- /dev/null +++ b/hold_to_confirm_test.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestHoldToConfirmStep(t *testing.T) { + var s holdToConfirmState + t0 := time.Unix(0, 0) + d := time.Second + if p, ok := s.step(true, t0, d); p != 0 || ok { + t.Fatalf("start: %v %v", p, ok) + } + if p, ok := s.step(true, t0.Add(d/2), d); p != 0.5 || ok { + t.Fatalf("half: %v %v", p, ok) + } + if _, ok := s.step(true, t0.Add(d), d); !ok { + t.Fatal("full hold did not confirm") + } + if _, ok := s.step(true, t0.Add(2*d), d); ok { + t.Fatal("one hold confirmed twice") + } + s.step(false, t0.Add(2*d), d) + if p, _ := s.step(true, t0.Add(3*d), d); p != 0 { + t.Fatalf("release did not restart: %v", p) + } +} + +func holdTester(dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if HoldToConfirm(c, "Delete repo", HoldToConfirmOptions{Duration: 60 * time.Millisecond, Disabled: *dis}).Changed() { + *n++ + } + }) + }, 400, 200) +} + +func TestHoldToConfirmPointerHold(t *testing.T) { + dis, n := false, 0 + tt := holdTester(&dis, &n) + r, _ := tt.Find("Delete repo") + cx, cy := r.X+r.W/2, r.Y+r.H/2 + _ = tt.Click("Delete repo") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 0 { + t.Fatalf("a click or key tap confirmed: %d", n) + } + tt.Press(cx, cy) + time.Sleep(20 * time.Millisecond) + tt.Release(cx, cy) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if n != 0 { + t.Fatal("an early release confirmed") + } + tt.Press(cx, cy) + time.Sleep(80 * time.Millisecond) + tt.Frame() + tt.Frame() + tt.Release(cx, cy) + if n != 1 { + t.Fatalf("full hold confirmed %d times", n) + } + dis = true + tt.Frame() + tt.Press(cx, cy) + time.Sleep(80 * time.Millisecond) + tt.Frame() + tt.Release(cx, cy) + if n != 1 { + t.Fatal("disabled hold confirmed") + } +} + +func TestHoldToConfirmSpaceHold(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + // A nanosecond hold completes in the frames between Space down and up. + if HoldToConfirm(c, "Drop table", HoldToConfirmOptions{Duration: time.Nanosecond}).Changed() { + n++ + } + }, 300, 100) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("Enter confirmed") + } + tt.Key(0, ui.KeySpace) + if n != 1 { + t.Fatalf("Space hold confirmed %d times", n) + } +} + +func TestHoldToConfirmReducedMotionCountdown(t *testing.T) { + dis, n := false, 0 + tt := holdTester(&dis, &n) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + r, _ := tt.Find("Delete repo") + tt.Press(r.X+r.W/2, r.Y+r.H/2) + found := false + for _, s := range tt.Texts() { + found = found || strings.HasPrefix(s, "Keep holding… 0.") + } + if !found { + t.Fatalf("no countdown text: %q", tt.Texts()) + } +} + +func TestHoldToConfirmSnapshot(t *testing.T) { + snapshot(t, "hold_to_confirm", 400, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + HoldToConfirm(c, "Delete project", HoldToConfirmOptions{}) + HoldToConfirm(c, "Wipe", HoldToConfirmOptions{Disabled: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index ca8dd7e..3943040 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -7,6 +7,13 @@ func init() { "split_button.more": {"More options", "更多选项"}, + "hold_to_confirm.hint": {"Press and hold, or hold Space, to confirm", "按住鼠标或空格键以确认"}, + "hold_to_confirm.left": {"Keep holding… %.1f s", "继续按住… %.1f 秒"}, + "hold_to_confirm.done": {"Confirmed", "已确认"}, + + "bulk_action_bar.selected": {"%d selected", "已选 %d 项"}, + "bulk_action_bar.clear": {"Clear selection", "清除选择"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/bulk_action_bar-dark.png b/testdata/snapshots/darwin/bulk_action_bar-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..adb314654ee04086ef4803f6492d4b12f9b3a29c GIT binary patch literal 7721 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 RA+itzXsYU|ltOGH{tN9F!mI!Q literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/hold_to_confirm-light.png b/testdata/snapshots/darwin/hold_to_confirm-light.png new file mode 100644 index 0000000000000000000000000000000000000000..30625a2e5d59ec5dd708b777479be4d521b9a798 GIT binary patch literal 3533 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 K>r_A-(EkE*WQ0lp literal 0 HcmV?d00001 From e682ed4d399ed6169e82d3a27f3e8a44f87d0bc2 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:11:52 -0400 Subject: [PATCH 04/20] WIP essentials 4 --- choice_chips.go | 84 ++++++++++++++ choice_chips_test.go | 85 ++++++++++++++ color_palette.go | 109 ++++++++++++++++++ color_palette_test.go | 78 +++++++++++++ messages_essentials.go | 2 + .../snapshots/darwin/choice_chips-dark.png | Bin 0 -> 6174 bytes .../snapshots/darwin/choice_chips-light.png | Bin 0 -> 6140 bytes .../snapshots/darwin/color_palette-dark.png | Bin 0 -> 4352 bytes .../snapshots/darwin/color_palette-light.png | Bin 0 -> 4264 bytes 9 files changed, 358 insertions(+) create mode 100644 choice_chips.go create mode 100644 choice_chips_test.go create mode 100644 color_palette.go create mode 100644 color_palette_test.go create mode 100644 testdata/snapshots/darwin/choice_chips-dark.png create mode 100644 testdata/snapshots/darwin/choice_chips-light.png create mode 100644 testdata/snapshots/darwin/color_palette-dark.png create mode 100644 testdata/snapshots/darwin/color_palette-light.png diff --git a/choice_chips.go b/choice_chips.go new file mode 100644 index 0000000..c979143 --- /dev/null +++ b/choice_chips.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ChoiceChipsOptions configure ChoiceChips. +type ChoiceChipsOptions struct { + // Multiple lets several chips be on; otherwise one or none. + Multiple bool + Disabled bool + // Label names the chip set for assistive technology. + Label string +} + +// ChoiceChips are wrapping filter chips toggling values in *value; arrows move between them. +func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ChoiceChipsOptions) ChoiceResult { + if value == nil { + panic("mujica: ChoiceChips needs a value") + } + if !opts.Multiple && len(*value) > 1 { + panic("mujica: single ChoiceChips holds more than one value") + } + k := Tokens(c) + d := Density(c) + g := ui.Row(c).Wrap().Gap(d.Unit()*2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal) + if opts.Label != "" { + g.Label(opts.Label) + } + changed := false + g.Children(func() { + for _, it := range items { + on := slices.Contains(*value, it.Value) + pressed := on + b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) + // The base took input before Disabled applied: drop a disabled chip's press. + if b.Changed() && !b.IsDisabled() && toggleGroupApply(value, it.Value, pressed, ToggleGroupOptions{Multiple: opts.Multiple, AllowEmpty: true}) { + changed, on = true, pressed + } + choiceChipLook(c, k, d, b, on, it) + } + }) + if changed { + slog.Debug("mujica: choice_chips changed", "label", opts.Label, "value", *value) + } + return ChoiceResult{g, changed} +} + +func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) { + b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit()*1.5). + Radius(theme.PopupRadius) + internal.Ring(b, k.Focus, theme.PopupRadius) + bg, fg, line := k.Surface, k.Text, k.ControlBorder + switch { + case on: + bg, fg, line = k.Selection, k.AccentText, k.AccentText + case b.Pressed(): + bg = k.Selection + case b.Hovered(): + bg = k.SurfaceHover + } + b.Background(bg).TextColor(fg).Border(theme.BorderWidth, line).Transition(colorFade) + if it.Name != "" { + b.Label(it.Name) + } + b.Children(func() { + switch { + case on: + ui.Icon(c, icons.Must("check")).Size(14, 14) + case it.Icon != nil: + ui.Icon(c, it.Icon).Size(14, 14) + } + if it.Label != "" { + ui.Text(c, it.Label).SingleLine() + } + }) +} diff --git a/choice_chips_test.go b/choice_chips_test.go new file mode 100644 index 0000000..531a86a --- /dev/null +++ b/choice_chips_test.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +var chipItems = []ToggleItem[string]{{Value: "open", Label: "Open"}, {Value: "draft", Label: "Draft"}, {Value: "closed", Label: "Closed", Disabled: true}, {Value: "merged", Label: "Merged"}} + +func chipTester(v *[]string, opts *ChoiceChipsOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ChoiceChips(c, v, chipItems, *opts).Changed() { + *n++ + } + }, 600, 200) +} + +func TestChoiceChipsSingleAndMultiple(t *testing.T) { + var v []string + opts := ChoiceChipsOptions{} + n := 0 + tt := chipTester(&v, &opts, &n) + _ = tt.Click("Open") + _ = tt.Click("Draft") + if !slices.Equal(v, []string{"draft"}) || n != 2 { + t.Fatalf("single: %v %d", v, n) + } + _ = tt.Click("Draft") + if len(v) != 0 { + t.Fatalf("single chip did not turn off: %v", v) + } + _ = tt.Click("Closed") + if len(v) != 0 || n != 3 { + t.Fatalf("disabled chip: %v %d", v, n) + } + opts.Multiple = true + _ = tt.Click("Open") + _ = tt.Click("Merged") + if !slices.Equal(v, []string{"open", "merged"}) { + t.Fatalf("multiple: %v", v) + } +} + +func TestChoiceChipsKeyboard(t *testing.T) { + var v []string + opts := ChoiceChipsOptions{Multiple: true} + n := 0 + tt := chipTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) // skips the disabled chip + tt.Key(0, ui.KeySpace) + if !slices.Equal(v, []string{"draft", "merged"}) { + t.Fatalf("keyboard: %v", v) + } + opts.Disabled = true + tt.Frame() + _ = tt.Click("Open") + if len(v) != 2 { + t.Fatalf("disabled set changed: %v", v) + } +} + +func TestChoiceChipsRejectsTwoInSingle(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + v := []string{"open", "draft"} + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{}) }, 300, 100) +} + +func TestChoiceChipsSnapshot(t *testing.T) { + v := []string{"open", "merged"} + snapshot(t, "choice_chips", 480, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Children(func() { + ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{Multiple: true}) + }) + }) +} diff --git a/color_palette.go b/color_palette.go new file mode 100644 index 0000000..89b0df0 --- /dev/null +++ b/color_palette.go @@ -0,0 +1,109 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ColorSwatch is one preset of a ColorPalette; Name is read out. +type ColorSwatch struct { + Color ui.Color + Name string +} + +// ColorPaletteOptions configure a ColorPalette. +type ColorPaletteOptions struct { + // Columns is the grid width in swatches; zero is 8. + Columns int + // Custom adds a ColorPicker after the swatches for any other color. + Custom bool + Disabled bool + // Label names the palette for assistive technology. + Label string +} + +// ColorPalette picks *value from preset swatches in a grid; arrows move in two dimensions. +func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts ColorPaletteOptions) ChoiceResult { + if value == nil || len(swatches) == 0 { + panic("mujica: ColorPalette needs a value and swatches") + } + if opts.Columns < 0 { + panic(fmt.Sprintf("mujica: ColorPalette Columns %d is negative", opts.Columns)) + } + cols := opts.Columns + if cols == 0 { + cols = 8 + } + for _, s := range swatches { + if s.Name == "" { + panic("mujica: ColorPalette swatch needs a Name") + } + } + k := Tokens(c) + u := Density(c).Unit() + g := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleRadioGroup).Label(opts.Label) + disabled := g.IsDisabled() + changed := false + g.Children(func() { + grid := ui.Box(c).Grid().Columns(cols).Gap(u * 1.5).FocusGroup(ui.Horizontal) + var cells []*ui.Element + grid.Children(func() { + for i, s := range swatches { + b := colorPaletteSwatch(c, k, s, *value == s.Color).Key(i) + cells = append(cells, b) + if b.Clicked() && !disabled && *value != s.Color { + *value, changed = s.Color, true + } + } + }) + // Up and Down move a whole row; FocusGroup moves by one. + for i, b := range cells { + switch { + case b.Shortcut(0, ui.KeyDown) && i+cols < len(cells): + cells[i+cols].Focus() + case b.Shortcut(0, ui.KeyUp) && i-cols >= 0: + cells[i-cols].Focus() + } + } + if opts.Custom { + ui.Row(c).Children(func() { + if ColorPicker(c, value, ColorPickerOptions{Label: msg(c, "color_palette.custom"), Disabled: disabled}).Changed() { + changed = true + } + }) + } + }) + if changed { + slog.Debug("mujica: color_palette changed", "color", colorPickerHex(*value)) + } + return ChoiceResult{g, changed} +} + +// colorPaletteSwatch is one square swatch; the chosen one has an inset frame and a check. +func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) *ui.Element { + b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio). + Label(s.Name).Tooltip(s.Name).Selected(on) + internal.Ring(b, k.Focus, theme.ControlRadius) + edge := k.ControlBorder + if b.Hovered() { + edge = k.Text + } + b.Background(s.Color).Border(theme.BorderWidth, edge) + mark := theme.Light().Text + if theme.Contrast(s.Color, mark) < theme.Contrast(s.Color, theme.Light().OnAccent) { + mark = theme.Light().OnAccent + } + b.DrawOver(func(p *ui.Painter, r ui.Rect) { + if !on { + return + } + p.Stroke(ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, mark, 1, 1.5) + internal.Mark(p, ui.Rect{X: r.X + 7, Y: r.Y + 7, W: r.W - 14, H: r.H - 14}, "check", mark) + }) + return b +} diff --git a/color_palette_test.go b/color_palette_test.go new file mode 100644 index 0000000..93eb283 --- /dev/null +++ b/color_palette_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func paletteSwatches() []ColorSwatch { + var s []ColorSwatch + for i, c := range colorPickerPresets { + s = append(s, ColorSwatch{Color: c, Name: "Tone " + string(rune('A'+i))}) + } + return s +} + +func TestColorPaletteChooseAndKeyboard(t *testing.T) { + v := colorPickerPresets[0] + dis := false + n := 0 + sw := paletteSwatches() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ColorPalette(c, &v, sw, ColorPaletteOptions{Columns: 4, Custom: true, Disabled: dis, Label: "Tag color"}).Changed() { + n++ + } + }, 500, 400) + if err := tt.Click("Tone C"); err != nil || v != sw[2].Color || n != 1 { + t.Fatalf("click: %v %v %d", err, v, n) + } + tt.Key(0, ui.KeyDown) // focus stays on C: down a row is G + tt.Key(0, ui.KeyEnter) + if v != sw[6].Color { + t.Fatalf("Down then Enter chose %v, want %v", v, sw[6].Color) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeySpace) + if v != sw[3].Color { + t.Fatalf("Right, Up, Space chose %v", v) + } + if _, ok := tt.Find("Custom color"); !ok { + t.Fatal("no custom color entry") + } + dis = true + tt.Frame() + _ = tt.Click("Tone A") + if v != sw[3].Color || n != 3 { + t.Fatalf("disabled palette changed: %v %d", v, n) + } +} + +func TestColorPaletteRejects(t *testing.T) { + v := ui.Hex("#000000") + for name, f := range map[string]func(c *ui.Context){ + "no name": func(c *ui.Context) { ColorPalette(c, &v, []ColorSwatch{{Color: v}}, ColorPaletteOptions{}) }, + "columns": func(c *ui.Context) { ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: -1}) }, + "no items": func(c *ui.Context) { ColorPalette(c, &v, nil, ColorPaletteOptions{}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestColorPaletteSnapshot(t *testing.T) { + v := colorPickerPresets[2] + snapshot(t, "color_palette", 300, 150, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: 6, Custom: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 3943040..fa7fc68 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -14,6 +14,8 @@ func init() { "bulk_action_bar.selected": {"%d selected", "已选 %d 项"}, "bulk_action_bar.clear": {"Clear selection", "清除选择"}, + "color_palette.custom": {"Custom color", "自定义颜色"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/choice_chips-dark.png b/testdata/snapshots/darwin/choice_chips-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3a10fcf85fab681b092aa0edfc680c3550c41670 GIT binary patch literal 6174 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 ahX7F(UpW8Gq7Rn$2WYA3s#XJSBL4#}C6^Zf literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/choice_chips-light.png b/testdata/snapshots/darwin/choice_chips-light.png new file mode 100644 index 0000000000000000000000000000000000000000..473b5a3fb2dbded889f322fb5cb527e83d95da22 GIT binary patch literal 6140 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/color_palette-dark.png b/testdata/snapshots/darwin/color_palette-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8c4106ed32f972b0a8df2b9f0f5b9a2b2a7881f6 GIT binary patch literal 4352 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/color_palette-light.png b/testdata/snapshots/darwin/color_palette-light.png new file mode 100644 index 0000000000000000000000000000000000000000..65fe9adf757a41bf714937bb70ae403575735092 GIT binary patch literal 4264 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 Date: Mon, 5 Oct 2026 14:13:45 -0400 Subject: [PATCH 05/20] WIP essentials 5 --- knob.go | 118 ++++++++++++++++++ knob_test.go | 98 +++++++++++++++ messages_essentials.go | 2 + testdata/snapshots/darwin/knob-dark.png | Bin 0 -> 10250 bytes testdata/snapshots/darwin/knob-light.png | Bin 0 -> 9974 bytes .../snapshots/darwin/vertical_slider-dark.png | Bin 0 -> 3107 bytes .../darwin/vertical_slider-light.png | Bin 0 -> 3058 bytes vertical_slider.go | 98 +++++++++++++++ vertical_slider_test.go | 83 ++++++++++++ 9 files changed, 399 insertions(+) create mode 100644 knob.go create mode 100644 knob_test.go create mode 100644 testdata/snapshots/darwin/knob-dark.png create mode 100644 testdata/snapshots/darwin/knob-light.png create mode 100644 testdata/snapshots/darwin/vertical_slider-dark.png create mode 100644 testdata/snapshots/darwin/vertical_slider-light.png create mode 100644 vertical_slider.go create mode 100644 vertical_slider_test.go diff --git a/knob.go b/knob.go new file mode 100644 index 0000000..b2d5cf7 --- /dev/null +++ b/knob.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// KnobOptions configure a Knob. Min and Max both zero mean 0 to 100. +type KnobOptions struct { + Min, Max float64 + // Step snaps the value; zero is continuous, moving 1% per arrow press. + Step float64 + // Ticks draws that many evenly spaced marks around the dial; zero draws none. + Ticks int + // Size is the dial diameter; zero is 56. + Size float32 + // Format writes the value under the dial; nil prints as many decimals as Min and Step need. + Format func(float64) string + Disabled bool + // Label names the knob and shows above it. + Label string +} + +// knobSweep is the dial's travel, centered on twelve o'clock, in turns. +const knobSweep = 0.75 + +// knobDragPixels is how far a vertical drag travels the whole range. +const knobDragPixels float32 = 160 + +// Knob sets *value by dragging up and down, the arrows, Home and End; the dial shows the value and ticks. +func Knob(c *ui.Context, value *float64, opts KnobOptions) ChoiceResult { + if value == nil { + panic("mujica: Knob needs a value") + } + if opts.Ticks < 0 || !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { + panic(fmt.Sprintf("mujica: Knob Ticks %d or Size %v invalid", opts.Ticks, opts.Size)) + } + g, _ := sliderSetup(opts.Min, opts.Max, opts.Step, nil, false, c) + format := sliderFormat(opts.Format, g) + size := cmp.Or(opts.Size, 56) + k := Tokens(c) + old := g.snap(*value) + *value = old + col := ui.Column(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Disabled(opts.Disabled) + disabled := col.IsDisabled() + var s *ui.Element + col.Children(func() { + if opts.Label != "" { + ui.Text(c, opts.Label).TextColor(k.Text).FontSize(FontSize(c, theme.CaptionSize)) + } + var next float64 + var dragged bool + s, next, dragged = sliderControl(c, old, g, g.lo, g.hi) + want := ui.Local(s, "knob.want", func() float64 { return math.NaN() }) + if dragged { + if math.IsNaN(*want) { + *want = old + } + _, dy, _ := s.Dragged() + *want = max(g.lo, min(g.hi, *want-float64(dy)*(g.hi-g.lo)/float64(knobDragPixels))) + next = g.snap(*want) + } else { + *want = math.NaN() + } + if !disabled { + *value = next + } + s.Size(size, size).FocusRing(false).Cursor(ui.CursorResizeNS).Label(cmp.Or(opts.Label, msg(c, "knob.label"))) + hot := s.Hovered() || dragged + v := *value + s.Draw(func(p *ui.Painter, r ui.Rect) { knobPaint(p, r, k, g, v, opts.Ticks, hot, s.FocusVisible()) }) + ui.Text(c, format(*value)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + changed := *value != old + if changed { + slog.Debug("mujica: knob changed", "label", opts.Label, "value", *value) + } + return ChoiceResult{col, changed} +} + +// knobPaint draws the track arc, the value arc, ticks and the diamond pointer. +func knobPaint(p *ui.Painter, r ui.Rect, k theme.Tokens, g sliderGeom, v float64, ticks int, hot, focus bool) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + rad := r.W/2 - 6 + start := float32(-knobSweep / 2) + frac := float32((v - g.lo) / (g.hi - g.lo)) + p.StrokePath(progressArc(p, cx, cy, rad, start, start+knobSweep), 3, k.Border) + if frac > 0 { + p.StrokePath(progressArc(p, cx, cy, rad, start, start+knobSweep*frac), 3, k.AccentText) + } + for i := range ticks { + t := float64(start) + knobSweep*float64(i)/float64(max(ticks-1, 1)) + sin, cos := float32(math.Sin(2*math.Pi*t)), float32(math.Cos(2*math.Pi*t)) + p.Line(cx+sin*(rad+3), cy-cos*(rad+3), cx+sin*(rad+6), cy-cos*(rad+6), 1, k.TextMuted) + } + face := k.Surface + if hot { + face = k.SurfaceHover + } + var dial ui.Path + dial.Circle(cx, cy, rad-6) + p.FillPath(&dial, face) + p.StrokePath(&dial, 1, k.ControlBorder) + t := float64(start + knobSweep*frac) + px := internal.Snap(p, cx+float32(math.Sin(2*math.Pi*t))*(rad-12)) + py := internal.Snap(p, cy-float32(math.Cos(2*math.Pi*t))*(rad-12)) + p.FillPath(internal.Diamond(px, py, 7), k.AccentText) + if focus { + internal.FocusRing(p, r, r.W/2, k.Focus) + } +} diff --git a/knob_test.go b/knob_test.go new file mode 100644 index 0000000..79c3136 --- /dev/null +++ b/knob_test.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func knobTester(v *float64, opts *KnobOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(40).Children(func() { + if Knob(c, v, *opts).Changed() { + *n++ + } + }) + }, 300, 300) +} + +func TestKnobKeysAndStepGrid(t *testing.T) { + v := 0.0 + opts := KnobOptions{Max: 1, Step: 0.1, Label: "Gain"} + n := 0 + tt := knobTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + for range 3 { + tt.Key(0, ui.KeyUp) + } + if v != 0.3 || n != 3 || !tt.HasText("0.3") { + t.Fatalf("three Ups: %v (%d) %q", v, n, tt.Texts()) + } + tt.Key(0, ui.KeyLeft) + if v != 0.2 { + t.Fatalf("Left: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 1 { + t.Fatalf("End: %v", v) + } + tt.Key(0, ui.KeyUp) + if v != 1 || n != 5 { + t.Fatalf("past the end: %v %d", v, n) + } + tt.Key(0, ui.KeyHome) + if v != 0 { + t.Fatalf("Home: %v", v) + } +} + +func TestKnobDragAndDisabled(t *testing.T) { + v := 50.0 + opts := KnobOptions{Step: 1, Label: "Volume"} + n := 0 + tt := knobTester(&v, &opts, &n) + // The dial's center: padding 40, caption and gap above it. + x, y := float32(68), float32(40+21+30) + tt.Press(x, y) + tt.Move(x, y-32) // a fifth of 160 DIPs: 20 of 100 + tt.Release(x, y-32) + if v != 70 { + t.Fatalf("drag up 32 DIPs: %v", v) + } + opts.Disabled = true + tt.Frame() + tt.Press(x, y) + tt.Move(x, y-32) + tt.Release(x, y-32) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if v != 70 { + t.Fatalf("disabled knob moved: %v", v) + } +} + +func TestKnobRejects(t *testing.T) { + v := 0.0 + for name, o := range map[string]KnobOptions{"ticks": {Ticks: -1}, "size": {Size: -1}, "range": {Min: 5, Max: 1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Knob(c, &v, o) }, 200, 200) + }() + } +} + +func TestKnobSnapshot(t *testing.T) { + a, b, d := 35.0, 0.8, 10.0 + snapshot(t, "knob", 320, 130, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + Knob(c, &a, KnobOptions{Label: "Gain", Ticks: 11}) + Knob(c, &b, KnobOptions{Max: 1, Step: 0.1, Label: "Mix", Size: 72}) + Knob(c, &d, KnobOptions{Label: "Off", Disabled: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index fa7fc68..f4a4f68 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -16,6 +16,8 @@ func init() { "color_palette.custom": {"Custom color", "自定义颜色"}, + "knob.label": {"Knob", "旋钮"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/knob-dark.png b/testdata/snapshots/darwin/knob-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6ab48fb94a321520c2455fdc2dfd2a63603e3ce7 GIT binary patch literal 10250 zcmc(E^;29y)9#|d-GW2V;O-Dyg1bA5`x4wOK!D&5!C72_Ymne95L^~_cZbXSe%}xG z54csg`qZhOu2XZK>7IFdsyj+mSr#3I7zF?Tpv%iisRIBoU*7c)B-r z>g5Vqa92#Q(@kZ4OlyhHWLQCx5r@Z@)(%cZBqsK8-fuhMYXe7CiHXYG}>$sKEe`kq=(+Fi4P0;n`t{V158(0H1=ed;Wh6*VX_UEe;qCpU^vZ z$8*9r(k1f4F#d~5*a5J6<(;OW5+8;3wlSkJ`JteYdg2w7Ji3FImK@m47e9VG%*GHx zlJL`s{<8xmdV~vUq3XFn0|kB*JW4pq!j0uAEDA!YDg!}`mDA%t{VI&pI5*Q|xI$8| z`<)(c;*%(Zcp*1ax}=009!w_~ikF>}AJ>aXNp#ta{QQAXX22KVV|Lyprc5kyWIN@5 z{IgDlaIbH5qtU8I6d{mkOhKXTc!K^!uzq$nxaT@L!enRX)X^BAr26aakx$2{Mb$X4 z5V?!^zy8H!?D4%N$3R8R$;?IqczxzOnmOp5U`1HjpJ_kyAG74VJsXDuIJ~+rf&Lqc z^?byZrc;@R;^?`rYOkeW7h%O`DH-E>(%&67LhAj;Lt-rDL5`Onv041m9`!VVy$nBlNEC_W(x z&t9FZnKRANHYS3INW|E+fbT6Cl{`+{s*n4s0zp#U_F`I?#GkfCHp#moygW#uDnqR` z7g}GhOl1F{v=9)*Z=j%Mfq^_8wYiXU+B7AB0dK_jtc%0*R#7xh7OQ`to+;4y?QPkT z%;ky22Pi*Lxh8VCb-*>rF)f9~t6intYr{<@4D!49hl)x17{BVwWzeUfTh)K>dZEHP zvUET3VZOpn?CCPkx#M;^*FUnt{%~XY2i6AvYrAZzv`hq723p1J(%f9Y=Wf-~e{g`3 zVr6+n(lzLmp27V39+Po$QvU40CF+D7xBk5yO;&AifZ#^S-P+jr@bmRmM?nKE2S>uu zTo-eir zioJemUtimsTSwL4kjVUr1q zcd|)l4m3MR96$|+4bbvfjcDfM+Nh4s^yRey9&ysgs z1}3xJg21sNhH~(V?wT*+58{3CZ8xl#wPH)BASM=eGF)L06RVT^eZ08x{ws|Vx@nu4 zH8%fCkhqz!KLXYJ`m$BGb+?-IRB@X`J0Z%R>NCa?cXdS?2V+k|^kE8aO zH2diV92|b)YPf!MGO4rK>j=OV$AWnc(EI5Ai^MkU`(X399N2l> zwo#uYelR69`6HT4io)uG7cO9OwipQmr6oAE{<Bd(PS*QM3MSp^s=9*4(nNn zr~%U4!VH>k*$?g+tD?3x#1P6{U3Ln9s-9kh)zK{~A)CP8ZF={HM4Z7I!%nK3gu(8I zZG<$0~IDa{I%hLlWTfQ72uJ0HdqKZO8*L2zdhT8*<#RWqD;~2DfYWqJynN+rb`F z?yYLQA=LW{_%(117DjmT{m?^~uh(egtG#bjYPy?X0jk=@bsJUV+DsQO!4~d&&#caa zYd&ZZhlf51KbT@=$pQyB$U{0MpM0S=eR^4d&Ob97G^ z*B@gBHotev^?nRVpppOOQW@{!&~wyN3;cqKcCGVy)_BxMfV64KhBIq?GWfa&xsRPZ zzGB`}Xi~cE6FN@M&`&B7q`nVZl34$DR`G>VBrAf5KZX-&F16Ht=YaXc{rP5Nq0XGV zyG@#yg4C_B^Tgc0YQRpw#Jy9pN=nS+OpIbASPet?UZD2-=+`X%oyKbi;$o9h<-!5Q z#O<{!6>?#dvXg^||0Tii01Kj9-OZV9;TPZimaqN>>2Cv3lG>V@BvH~l@aHb@>B@!O z{Cw_Ri^{R_mdRp^U2m)jTb*Jx!{VRTU|?a)sD?U>$2of0T*W?h0o1}16m)ECNW6J zpL=!D)s#qxPi!ss`M-#mcLJ+^CiAz zh_-RC$bU)wc^UAR-Qftef;>7d6eDZLr2Tn#>V>oTuG8X7(*e=_=!%IxLRKPjyj(x? z?S65PpaTCr*>nv8)|fcY2zwD6oS-8fg#%raFh$W5)~`*i%a&nr22!O{=AR_xaVBkN zCMg4#tx~E^=o%3Ez9>mHfaCE?K%X7=$~`!OsV2=DuE(A8GylQqx)Wy#);c~gWgW3y z4gh2d7wd%@78POZji$g;_GkZ4kwXUpBB6d1w+>AX%d%SP$j1WVTV7If>xq?%3bCnM zeFbXZDl(%S1)lgmL_ip#S(ik>BtzYz`IN^UlK0A4T((`ZAY1G=<3S4!v4$PC1Q>DO zxvmqp*S)Io3eN!7>O*4Cib_501Giw-4jkauI^MGX819J*!7@OM zSIE@spE2W_32On*+84N>KQa@}cf;JF91xip|v%d+v)h z!tFmPiuE)7&Kqk4xGHD?#Y+o(s4$t>3ZKgP_lw!aQcSV9wU}2Vx6;_+%)K zgpdjj-bngS7pk;CLVDz4ZGXun?lIhsh4snR3cen4eiMV6Lov~Ck*ru8R1I8Q^Ipjm z;??0^^_1NS)&PQY{wp92s(1EM$?oRY6f&dcu#rVD7gYbA`#5+*3}cIkbrUuHy1jKY z`j4e%nHIeKiW5Ejc_%DUU+w)l19>~-$+ZGRJcztt-iX96ynmZ2Q&VMEPLZ3B2Z{6t zZ3RB`^tryAc05OOwoCz|EDE`RBFrhn@dS8xL_R7JPoD^q5(JodXnM|c|3K2J=f#`H z)h%#E{a;fy@-Qukc1H<0_CfpSsedf{IhG&<H>MTc#`zHJQlICzr7GF)4e@yVwP} zVQ*LL92~W6Gvun2x$UE4>61bfYQM-kAy&ryRtU z{zW)=q}CkiA<$mFaG6=heQ}k2il6k?kaYG%9fsSE1c{1{PJQ*Y6=?a{!70;4Lyix! z#M(cW6C3*@he5#Z!%e>*Rs48DMSwlWF1g*q3PRGS98ZtO>;8lqROKF)FGG8xXy$WkyUAw-8=OTk{Qa zMSHUSm-?e<8*EVknbXFmVN7V8k#j2Z$P|k9mP^z;YGiTCNTD>G;t8ENCwyq~z=)CM zvn*9MNyP6c`d{-7EDI)B8Xy?OA1yH#QqIq%wp zC1p%SVdH6G*X_mYxm^1|$y>;8O7-*Pe4dO4L=)_hshrYlo6vQPE9Xa>ipc z&5AiU{r-3Gdt?dG-#HXNX(Amj3E<1i)TIqQA|&Ega~*lW%;C~nrQ6EBdvgf0GN8(9 zce7PKn9VGhu5h`7nY!M-7~ZlFTfXU3Kj~=aL>P<_dq#8m5bt0pvTe^IDTB~kGHz+) z7Sot!us3Nlqh_8zYq#U>$dRUB@qT?JGg4@1P0In}*#+IFHf~PG@h(UK){uL0dpV95 zmMQLCa|*4RhxxB^8jZK&g9g1ni#R6h+BE6fXxdpB;SRnYD;jyKNPt@U7N?3=uJZh9 z!1`!?CH*Qcc*u?(7BQ`rKBgBfov+-^e=hahbN1pKDYW{0*y8(M`w9lxG-ot5`Fi|6 zsN!x?mnwf>ZGCHDqPVTZ%xm-U-l`^ZS))?@RU?Mr3FQu<8P;wcVhs~iRvQUK55KHw}GNbE>c6Lufsbn~|tHKU05q zpOF~>WMdXlDxR93d9Z467@WHsk1Ji0cuIbB~TATu0=)^t;aYRwe+qcu{ z^d3`L*SDP$gK>5`MN*RSJvUD>(U(yR*L6^F8x=K85G7K^ia@95vH8W7?G@QS-;4Z> z!BK_vu2A}STH&#mPPB|ML~LGg@N=+~viT`dRZ(estXbAkd(9m>HB+qF>;d8+`Y|*m z4$HdSfL1+q)FR5;bmGc0VBVPNY$P0d8Z}$vYtCVFJwXMp1}~n?{CPb6=A5C9>zPAuOeZtjx-+>C-L7(3<9Zw^Ba5rqLqwB&7Y7i0+*W5->vwvh=PffD zK!@ny%XQBfCe-!CBwwA8M4Q=)Bg3G()#{_Em{QfO^UE$EW_YF692{eoNW(U--D~v4 zujOfxG_>$W0MbLJ+kW+R0UjKMte){~JnMTVXQU9TNvEO&f?QQixT{!o3a^Q1iSl3W z#F*j^wQX11zz<#=4C5YfFFjn^H-Gea;tZVvbv+a3$YkXTPYtl!<`Es;6k_+hz^0F0 z4$EcQIfuLqSX(K0k|(|_T+@M%*TH#Y@p?#DNMhcn1Autp2c2;{T8!MsR{77P0SmdqX2%=37c=y=kE(7bEl#>W;8v&lVO z#uH&@&V7(J^|1J9y)$f1p=IZz9D!B%(vS>*( z-u!Qdewz22V~dj?ER9ap&)+W#3#2Y}L}>HB4>NO4Ll&7JteSS9ZmZ&LXU+IOWi$K6 z-ruM|K{WyVuvtO&-ILZXiv6A+Ao}qrC-)0-fpukV(5K7Fc2$qgeiLS53X;X658eSV z@aHvvv7)I$xR7I|+V=fGqfd!JgUGjcgv|T?MNR|_q-u@h=wRPRMFPFfL`-+} zt@2O4sc3P21d*p{e5=~69yC_@Bv*f|j~+j2#<|k4QXTK^{~t7P9h5uR3-BhSVt_2u zcY=yl&tN(;%6loIuY-Zzcs!;zt1(fxzll{}EVqjJ1^0Vs)hf!Ea1kR+`f;tE`OgI* zHV%VMnKM_TD1XWH{x*t-)b}TnHoCsIWM0fiMD1!+z=?SunlyAd?c)!+ z6q!6u#{BvCt7l@Q^<|i6nbf4R-oqnci;r>7mTu+Tv->;y?1>5g)+up=ypC#)U}Bzx zcfNjpTcM4aJtPhjp=XzU5{eOob3`pET4KA0IXhcL@Hds8bI1v>v8y7bL*;c06!eEA zGJ6)(o!nE+|7O2Nue>KcWmBP0M9iTXMvUSzZPl5hw5?;Qu$=4^oj9_m40?iJxT@v% z*r9d^DV_dzNL!VQ|0zSEb@!--6wSBkOb!@RY&`v_=|lWpMLZ8YHbQp6)yX`aKTM@P zvT3Frt!K_#xZkWtCuiS?a6uE0gC%iusPCi8Ds*#D&lp#3vP)W8QgU=W0ev#_vR|EJV)^o~j|%|n05u7Yj!8+(&J;*)=Og^fpZg2G(sUH9Al ztEvU=6?Rm_Jk6h0YXPG{s|IFX-+>g8vBG_E64_S>7n@jXs)fV5g@D9JTb?$I3Gy$W z3~e5(xz;+PzHap)^c#~q2IJ5Ky8+oV+sb!Seyn#5VbO4qJ&UdC+KZx`!=bAu^EbMxvcmTU{JB+li& zJ403@xUO(;?{2gxQ56E$4c2AU&?V!g@)|Xl#^~I#J2|tMpc2Snq&HRQ&R zg|tqTnv~(zn9I8Db+n$x$7VRaw8qCzb8kryP(#rU-Q*`gf=58XLR%sY{(?1F_dai6 zLj0eBt#4Cb2s7RD)AqLijV8n_!w`%?`Z*MTv2~lWf49*6^Q_iZo}bt6b+eC~&^-Hq z3J$~y6XT^Kk`nYSGz#(6IfV^S#S;I?@-;Q$wlAj>?)k23&FSfR<)8t5{(@ur9Gh>^ zYxr|)G+vZcuRpmIHMT*>w||6i$imS*#Iz^XwTqwZ7;hi#NMgqdhYFjf_HW!!&whp8 z8o~5FIK%!K`aPdpxx?FIPI}br^3=J%{^cR#CuD3bmtQ86;E_d}V?sc?lARLLiNA9H zb?yAz}D8;%i{ z{V3$}65y}fq|~?IMMAE2bW|}{rV<@+9|o{_fXN2Bdw9KN7$+zysI}|T4uQ7t?)+N+ zp{oYeHj~kg%?Gy?M+to3kp8tn?=-NWN7bplq=IT|&mRG=7)`v~&UWd>>j18D4iP}a z{#`!`D(YP7@xN5e5%F_vW!QROJ^O7_dRuw5+gW_y4%$_5c%MtqH>-#G8twMJQ4I)k zT^i2O*wAP5?fw$DnAy3-z5%?&QlJSM&3PC+kckiF+K|D7`7dfc0<$vfkTPM6fOAI5 zC<=8*nEXA^b&^s0^(RLSq=xQ5x97yvF7f>F*}{G%EQ)%yC^ON`3QVctN923JEJMRz zsWx!+M7yU+wgWYbqvz+fE_sm&lo?iuY=}5sc)^g%@&q{`EvRjUKcYBZ zZdQ6_I*HL=Qu{tGkGM{t6@@0)`IQ-E)p}6Y>Qsnt&4G3K|B1o9`PaMTWX?#r zvmjcw82kAjl$Z6>_a@EHL*-vGWV-ZnR*XGe{|f&NzZHluv`c(_&D7IKQW*RhLuNl@ zaz_Ua=OjZ#45h|1O3ZDg5lO0nb@arbWe4l0_F*HN)0WA#yW6agM=9%R~Tf*Y3nHFbyDNS(r-7$BJRTX z&Z%8$!BYnJAwM5LJtf67Q8()P#%#k(yFectF%)T*RP~1pqtJMh3C!P5W%A%%+LDi8 zuW|YpetpS12TQxwwtfG|k!?;GjkP4cR>ih6ES>dys)KQFHWfN)Rd=qqHhuBhWt8SY zRKSqpmf+vE5|N|qN8BP>IKTpdbXU8X)m(}mCiinc&GCh`G1?0yl3UBA>@N4^p?}~2 zDRuY5_Lm6b(a#-70Fgp5cd}``nxAmTOf=!!>I7<^X2r^McCh~H8 zi3*scopj}lSrvS(K@QTGZ=thJNrc`Z!ouJMu~gWCM>6VJD$?=+wT}3HO1s7}>r_6s zRr?k=6Jnzied%rt&4cz0LeYfVzW|EY?MWX{zFdTmX085V?M`-3WmZ}j&{Fyc^R4q0 zx%B0YE&kXHBSDOewH<2N;Vj9x)|~O)#Jh83=!9B6|1+Bqm?`HTqnch}H9hc1pY=@F zRZAxfm|#5Ux?4;uQfEyW)5TH$97UCKc_+FhrBwIbk=g@ z=j}Zm^aXZ2bZI2sm5l27fSc8iyQI|EgGIIn=qcm2@t36IpJwr|iJ-k!$Gi|bPhKLW zyGj$$!#Re{+?!F7A9UJ%NC2nWLdPMF2{t64Q(kj%>x0j9wNg=2Jk2;LdI8@a zljqIwf}W2-5>)L|V^LdX)IUt?)5&Z21UK52;rD+BWc{h2aJ?LQGJeG;3A}xZT!TJ= zM^6F{qCS+=QqbYmr{#K?4xI$}o|OiOjlZ`-11A?fwm_j7&CzS*B15Fq%h3XVQ|D(6 z8o*VYMxZB3klSDjFe1PnzSqLt>9+zq(Q)o9CYg;D3m)>-_AgKoXr>f!Y4 z^xFEeFG@I7WSz$bZ@3W_3#*>^HO5V~jfHyt} z1eqI<>nbCv(5C5L#!`?=sV?^5Q5vx3jJq*2}|7Z1`bFI)v`XQQ4Q|(;2zdOol#%)y(v2Ute0r zVZo{BRZ}C=U|NF0~70^!W##QGp<@_IpT#JII~q005eNIi)#Z@qwkf{7%YIj!0pFD{@iv z3H^;%XJv;_wlaUpK^OzT%K0_YO)AyQWvKOFd_J8Cn}|(8r*DrH&*J`s-T1m8ZEa|3 z>gjy-?Abs==gE=NI9crL;x1_BBXPQKAv&O^!Qd>O?10t9XQlh?wn8nfl+T#k!HBITb<0OCOvpa=63Ytq1lP3s`RF^yCOB{P^AYklUbMGNia` ziI0{Ovd=ErzKYg1so|M#i`^a8&u08&y^CFc7@M|IIN*Ulap_shY|w!gLKX7Vueg6bN9j&Yzq2|_`0*6VDZqn{1O3OCi$JerBP;$5AZ z0{f)+VrQpZYc@_=2_xuC(e=}b%k58t=w+{A1B^R&&ibw9^2WZNXY*Fp_YcN0f910K zXHw_5zuZpX-}d73!!ogVjsbyU^*$C=!}n8B$ez1l61UG5nDAu^h!uSYpgYkgx0P*y3VXJ+k+yMvcPRx#93mY4MEG1`RlM;@dJ(rX zx|ng+_>@hexkaH#=emggVn>Y9Od1ez3gpZatyQkrd@Y|-(s$l+WS(-Dk}|kZ$yBtT zKQ}1@2aVdC^y`!wjHQNk{QTYr0N9zMZiIjGmy+er9xi!Pr0=${;b-RXP-!{S|2LA4 zj0RBsjHi(fp^S=BV=!&r`rSr$MroSkn`n*AJzfZ!QBCt1NG(!a++%k&l6{2L~hasZyzNn zZDtM&22ex(tY0LFvG4udq>WJr$pfT6Adc-ZRs5X@X+1#y*^@x;qF%6DXN2kZnifh} zUd~t9bu^VDG@+xtc>GNwf1iT=?KTuMBmZ?{+pyVgXC#ls_wGe3lO;6pZABygbmig? z{Zo1jmM1%koBOV{m0*tKu_m$IguA|-UHz$qR_y4BaaX06nz-@oj#@}AUnfl4WJt>- zAPDm2cD@jG#u7R=k~s+o&28NV)C9tGwzH)kiJiguOB37Ztjt>huxg66t2Qk%B zGPziLs=Plk`sK=5EmnO?bfI;fNyNUznhZ$Am-Eiu(l}B{e_HPPRGuDX4<7A!GRwbS z>s019ve3yW{>4uHvmc8cj)k;CSOGQ*`B;c(HEdO1?WCW&YTjs0ouEnW+Pa$54O86{ zQrUC8vL1VMD|}mPTq4PO{@VRqHy7bTf?;^(fgQHy3M05%LE7RyM2n3c07%$JKm=s(5UBI17dUELE!i>2M z7n!K`w|iYZ9n50e!kAq4mfJ6P=A`Tm#dvHW4i2)Pxv)Dm6^pb zJ0rKv@n3{He#%&&z*m=lc%BU26T+E0>>eOGdRq^9ea^U{+_c3x@q=9)g@I_R0d=rt z0m&~^mKIe11N{@e!ERr_`A`myb|a^|;F@l5MJe8Jx2TV>R^Ib_=_O8)ilh5V$gWGg zfX>V;fK~o4x-N4mBxMf>@eP0^QDf_`&0Kbykek1b3X$_ExxxX+JMQ)j=4xW2urXT= zO-KbV1K09^tP?o!jL42oD~c6hqpD~&MtxWS5qnmRQBCRJwPDLBWV_G0J8tp5aCnU| ziDgDM9CeE)W}iZmQ4r$H;0RFNVa3&wYx0Ommp?-bpFRH(t}pAuMJ>(F(DXd6xADL?2vG81d_V(bzGA#->Y)~d1+;-Y6;Vj{{vAd7a#xt literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/knob-light.png b/testdata/snapshots/darwin/knob-light.png new file mode 100644 index 0000000000000000000000000000000000000000..c8a69520300188a27099f2e6ffb3eaf17d3864fc GIT binary patch literal 9974 zcmc(F1ydYR)9xa{-Q9x)4;I|rJy;;PYjBrMa29vB;O?$L7Kh-jSzH!}kN5qmZrvYn zt8R7GOn1*YbGrI;KRrElBGgo5(NRcH0002GyquH<001NQ&W9kuzF%95flvT|zDr(8 zT+1u#BpX>zOPh3LYHc|!Jl&K4D2)d*D*{5*t?O|4n4g*O98#tVai!A1Zs-?iQOA{{GMxM3XuiG3=IoL&1v4f z>dXQym97B?|DQoP&M&cJ&2 z%_9r~U?Lhx5Z@E(?x*qg+ST#sCD|egFHZKwXKYT>Z)sY1*@RJs-` z`U`&k)o|+9w_2Re4>dfxId3`7CdNka=W*XVVzT-AkRRGXmk@%>fScFhrckxG#gAdf zE6lef6~Fb9dJV$rW8(*gQcgb2whTkKE*#S>K=?>!f_oKc_r>1BYgBNxMk}E$MR4 z5mp7`s*4P#Mn~r$x8_{SgVqd4=WPd z7lbKYA349`$*|UKcE0^h#c`w2LvA`qNWL$FhjiI~@r<$?c-j=c%a8Jq1ByTY*F^gD z>m9u+31oj?ok`cQ(~EeZ_LyCzkAsEt7&dnbm4W~s@MmTuQ=W+Io49Huswczr;ZBtTe$Kv+tU1a^Xr-<@BE*q#C zG%sJI#t61lOl32khS6i8j~ijFi4PA$!;T8?^1V&t2xxTT%MFHma%sz&o{n%7q7Wh& z>UF?@BP76Y&|`62pb5K)UY(gitXMOVp2rCc+^o>WfdL?dn(l-hvcI}~5cyM*Bm8nW z6-W%gq7XUNGTiY!??yI=7YI#Kz)x2A9qV=KYSEyrfIpI~TsWJi^l1tJ;LViJSA);y z{4hP^YvSn%k|~^l+yVf>L6kULbo^cy`=5R@H?3H6S3S8x(p)}E$S5thK^S0bgf(dh zqD=b8izy)`l(`<4UWSRIm2=1W1COO@%x&m&{GbK9DpY4(N+&|X)N8^@NPvI47dZD$ zL+%2LY~(#}as}_6YI^9MHk)59S03=y6}M>PbMRgGtF@Sm^YX9+f(-Mu7?UJmC^G2|Xf>C~W8 z2yM;o&%f^)3xT4y_t5)FzP`SZQm{9s3)iMiifCv6y=Hfiv6GkGs6q(UgyppxZRp?X z2P6Q8%}!ct>tNmP+mL00L`dk^*w{9$ZIl!Z2!Om8FAxWWN9Q0yBTQgFzD3|7zJ@}x z__w_w%QCi&`u-HHk5lGc`1q;6!eDtbEj(cXTFg9&qg08b)4?|^3i)dF%PcoM*291E zVgNEgw6AtViv;W;|2Oit67(kV`j8h1aFHs1^mHA&Mq#yd+sNhP!FP2L?U8M{3VRhX z`guFV;d-|nYPkMzl<0895cS;z53WT6trGO9KCmaH)23(08rVG&u_HkXGyBJ)0YN2K z&Wbai^uI->g}N7Be61tR^>M%{zc3xenA`s*6GSw&$SZ6R^==9uqDj=KJJX}wzc`m^ z^|*F4NHH@AoBs@dUelE`dzZB_ZK8hdXKhz=d(6>W%`N40nzfaEOm-d8mIs`d_I&y6 zX64T&6`H6~KiAuX&ds+FjGvEFWDHl36{nS@k+{ehftT#CD8ilyYJlk?Y5r=7aXxKH zKz)`(g+Gr_(x8MqOVXgvES-+{V-;=G8>$`eLmWCu7oEo5Cc40@I5%*7E1exJ=K&Uw zcVoO!EG&D)CJ=yeMqjk1EElo#S{LYyb@RW4i~%l<>X+GhO{$^7PYlSnt)gwS&Fra5 zED71fj~F+ZZQjZ1g;7Rz-S;&e)9A76n_Dw~om6fJ%~1rBS^b!EFR2@UAgybd?Uq_Z zS80}i-?(qo(D+VicI`Kais4wqX_Qw8RFWy&3*e;!Dan}0*ZuewTiSw|ECUBWBb-;- zq?S7&qd5V2W9zW%*fO}?oNee>l4vw*&LM9oLv(zgNTWjP(y2-q2Z-xUEF$CEAxhA+ zUEQ-OK{ea6jEptftsXmSEt?(?d#;dfLU}Ts$=I3WvtorOe(c&U*d~r1Ef;6XotSdy z6&ik(4sX0smsf{LRxqnyj#OBVYM|+Ghn)FkbD+euZ}kGNp8Io+^^yHe4(C69?lIWs z`_ADmWJEI|lPF$O_3Br-YFP2w<4$O)9j__@@I(q~VwNtx0gYM*#jebjI;F>3bT@S~M1IhM72Uf_ti!yK!q8+SYOG|II34xxrhys}5fbA47K zm0znQ^ix17%%k9K)OAT(%jsqlr41>xP;y@9bs$Gdr@*YZhB*O{~kps_XPMPAF^lCC3WJy5FhxdF*B2c)|=W(AW z2;J9zfr|!>3dEqa+l_~h50e7OaTN}G*rX9>>fDeClJV-ZLUepwchcF` zpDm@>%sK2%O6(Jk8#6S2-r?i##bk0-slJI%J2X8vP82Hs6-K}0$k+J;rZ;0kXo&M>A4SSAxHbP;OP4?P6SD$7{;a-Ho$TKgn|zP?eg8V$X38q&ki)_Q;6yft zL6C&8_rFx4Xr+LcY;9Ij*e!zb4F`xzl*RBTw>F^`pOIM#|CfXFCzR&Kp83czeRB5f zK3CIv64CN<1wUIf0p;xKRFu|sp{_{iHLT-qOydn`35o!ljq*-R0d1RihV=`TA`?0I5XYgx#?3Vl{ll8 z<%zjid9Z3H*5#B3Mr+wJ08TZtrhji^QC*RIhd~GYtFcgD5BNBeyXT_X5#b@+Rm#I1 zP4=!aM$JzIt;_T+z$y(!Up!~$%CliXzSj4OWR5Y;sZ_uEvslPl+~Wi8$ENC1Tb2=~ zYKHwjp4pHtRcMdx{tTcHvDARvc1@1SDYp4s8V+XZo?g~oKReeeO=}LXbhICMFo&9QS?u^Dpcv@3}sPsPbo1=x)GY!EtZLOqb zr_RR-=m0D71?J}s9TE4i%1<@q^rjd-$tVxO>#Iz73Su<@{)p(;x99TZxR|*7Ik}ns z3@?u_9W7wX>u0{yzy17gj+U~&ek%LC@D&m+RmYG#^bcYZ^byqh&k0nM2D;2MOwasV zxdsbJ7u!GljMZHeeKUI!sJdf2v#aq}Y}(n-&n^RBJkF>N3-a|QHf6xZXvd?^m;Z;I zs+Kbs-?46Qy+`*en?-$^@Y9W58D#*%^{n0;hEuN+LSeQspx2<(W9gVD6D;q-fI`=H zy%CKPlQu`>Q*i*X$;Wm(=pxpp8dZ)IH02en{LWS~kFmY5zSw-FG0fu*L;7(B!~+on8I zi`35-2p>g7%i5&K*OQWQ?vN06Mq$+cU{O>oftc2`kg^H~cy4>*U}*-;qqMm#q7YO; zvYZUNl(354OW6@yGiCQM>3TRemo1riyrW^)$wck7B7YAzY^yqebUtHsB=9H9ws~yF z?wnAr^9mo8^Fim(LKMp0P_Zbx9us>tD8;W6#FUhNP4I*OpQGx$W}2i>c)jk>g1X38 zo7Q~G0-5-@T6H7zK`YH$hbcK>EsGe0x~=X|^?v)<_Jz6Fp^G_Y=z`SDK<(ik65QB| z1l#hn8!e^6jq+1l?kTWsp#BWEhbe}%vs-e+GvAf4bY`~D4%)HR9D<_NE$Ugi}Bn+s9ZleF#o$Tj=PcqIpDGzrJfZq! z|C$C=kfk7Vh3G46r&!rWe99{S*419b!sqZxVPuiSf_W_Mygu?Dbm&mj9LNB8045Qbusvo~ETNLGMjs9ikqtin7VziV<6`)lK=tiPQqQjPU0u+dEC3bWTnJ3o zSbgkUY&NJPtb7A-++JHuBdUy09Pn4kJ1Wjq*q+?%T&$@GejqU`I9S=4Z5{*v%ks!s zKKz01Bb=^uiRxTLVIUO%6Ku{kw1F(BN-Nld4(~hw5LDiVkM(2`Qs8&t&eIh=B&N-2 zxnFoOC@1z{%%OTuQXFAgE$es+mp!s zk;pVh4jZ9HcYsyam+%PAT0hC*KIZj>#$`2r>BtOADp?06l^{9qw)7d!NVN!!sRGsc1yUYzw7Xfv>_32C&hF^iUm>$*zj>&|=~?<( zZ(#mmY5eBdAJ}VEkVe;viPKBTaqq0Q@&qhV}(Ug`{lPOhBzr6 zE?$u~a!B3*P#{K!PbF^VKAauPy0QITwheliCR*hwRSQc*4mhiFDg@^D@9)o3&s((! zUoGkX!;j!_5`Bw%i_nWXmyaZ15vg~=yhTa)Sbj7pNjBmwL{XYQ6RYFeqPq4;;lu4o z(2KFtNtbLR)Arg^E>_g(B&%s4j(Q0qa6nbn1r~Q9sJ1yO3;9}%Fd{t;;~owuM88WD zIbBk;!m0%00Ma7~Iub(kn_aB;qT|RUD#`HqhlQgiehAZ}N{4eugj)6)4aoo8GhY!w zHKIp_69E_SZE0g%#hd)b7k4kh@FGR#X+Iv=TNUH9P&!}w8ZI^3kP zNV>&QL354+&e2`sF*II66hJeT4u`!qb#??SFQVYCMG;r>(lvZ3q+mUb#=ImSe1AMv zM*M~=TpLUgU&%>q%l!m{vk=+z7g*Qa5fbXZb=g4M&5n z6+Xh=^HY9!*9~%*L{)5b`?f@5J9je;FRspd88*MTa)NC3(1{JWgpEBl=O#8~c!gA( zwu+up&lG{oyrgJz)T_$JxBctJ@zUs3wn0BxEC)(6&%$PTw+uLB3($u3(2D7lXEeRt zuG6Rp3*7#rBzDbccQ813>ZZ`kMj zwc9O9>o=oen|r@Z9}{S@qRB|RrC#CCf}O2o7(o9^zN%M2m>4xVZH$ij=yUzio+}$2 zLrtw0bz#BWB5gCp5Voc0BUL(fQ`+b+1;wdoIsMq;B&TJpzKykzFV_SdY;2tPW{j#@ zt^uFbftUh>F|G1i^50YRNiEqy1o}TJu5DR)gj~wzNsLI^noI+^SM%^k(&pKjus~VI zUCRBYY!*;t$Cja#P#@+cY({MN>CJB_G|WJzhivn>og+f4;P!JwdvJc*XR|4^^t@K6LPag!R-dFb9GzE~?r4MLfl% zw3{+xK%MjZvO@}dISmSE0MJkf{@!AdRt-;IzH2A{1a32W`ps#*@K~x(;#k)OQip*Z zU`21ysS1pIM-l!pA{KV+DI`T$EzvVR-HV%GXaMY^kW} zY74k-CZ7lFotb?{9=Mb_LES~V$=1Dl@SG<~0y)$gfL|s}i-*nFUsQgs+fM%BRfCKF zt1O};MKjC>W|}?x{3Wz1mDu3rSvtxH!`GQZ3|)z!uP~zbbE?Aj|a3NtCuHexZC)8yhJ)!WpGTZSx;4eD?XalqtMNA%%szFKHDz( z=tS%`a&KsEh?qMy!g(?t89JZ-yDt?V(FLvWCHEv_g3}<_+b^IXMJeX2QzI%|uIVdH z^4MNIM3=4pwgee&p!S^`+vD;`w{CaPu!m}1N|+$|T!B-6OCH?^o|a(bFOc%><4+3v zII|nHqywDEM17o{jJFLvf~_&_+7wmBi!i?Ytfu-oId>1eLo72rO0Qb|PpjU9G^hs~ zkUMAUYYBmZiu;RP!^L{rFe6f=NhwP)@*n32%OuN8&V-s|=JY(>X4RLy%+{5gWCqx< z$ct!XA`k`cs6V+#o4AB<-(lBB=!~Z`lYX-C*e~ZHXN{4Co~B<2L<1$>nPY1k!!}!V z9t3LVct=nAWTx--b&Ng$IAM{*yft|3rxmDfGmi-V8py_QBPqhf?sx~m6k$j34FTzUe z>HO%q9I{E1c`1fR%+0e=(H$4Ic&my!-MWJaoWOceBH(lNk@?7p24y^%no6 zqkGA?d9z<#-@w~M(rfz*PXjI+6YJ+6&B_s?qoxgM0;>uN<1#;L>lAZG5U&4n+{&{B z`XvQSTs}M_rm*Z^UW{E^(brO9U?4D0rBHUB(uTt0gg*TYj`1-n&w{UWh_9CCS?97$wuZ!)bm$i9k|v z6DMwDZbAX-vsN-0E7qG^v>1KZ(XZCn)Ry1S^c7V0Cs#Q26vyq3GeY@FN?U1d-9^>} zDMSO+ZIx~FVg;#zfk485-RRTT5qN!qiEJyDtm_u`IHL~tNz;a3g9%0InO{xyWEFSA zonC#emQ3xOrs8robvmu}Z0m|ElGy@3CNmvfopGi#ws~tmsmALAB@)Ebjhou%KQpMV zb$g!%f5gP}g-gA^;OjYSSk2L!(h8J=%P+2)6VPa1u{ zGU9w5-Og)U?&9SmEuef|9cjjZ;%;og|Ja$V5sl8`jDQyGHFOqJpi|#T`p(1!US-_! zrfc1=<-aYGG@K_w3r>v3G~PSoaiWKuH00R8mP3vc1~OcnHuq)JOd1BI>Mq8aszn=2 zr&4j>8OBD!kFVxLucNAg+nj;~&lfWm27yH0J_YFAHeOj6Epu!*QKeViMa)`f`or^s zUi)Lz0_3(Rq<*sQCSW!einhujJbvTb<8O%TITPX1%f+6kUG1d^n~m^ruO_`Cgh*B< zX6httQBArS9KwUq;chwprce8B zrO*?f#El|pMGn061_*fkP(}Eu--Fi|@iCj*7oQp3#A&U8F9SX&UGEyf3|8pw>^0?D z2_uDBzH58LV2$nYRrp+bre&}8EA-rZ>eSWo{*s{iK1q~-cnWk^@*lY`p$$JL%yN0iaFavrZ9iToevWPkuTwArz$3uNBEm{y znYS;i{kZVbS2uoWfRxqAp?L{Ci9VCZP5u3MGe-C0p&oyf>9=Pj+>qM5X@geI+wk0Z zy#bgXDBSQvkeum@2EyZ02uzPgHrdl30x=V@EgRLXOH`m~$t09rM6gK1?w5!RQdSD6 zYN2@c47X!p*6tr(#4ia-$5c1)url#7f(Hx9`xywaEj`>cba?tlB5@%K*nuu#sVg;o zA9{5x#1VAw4<|bb06-xumKQTJ8h}IoqHi7MELL!XJgE2o1>NBX`*%k)vWQ^~p5MvF z!bWKJsiBF-|LXJ2;D8IijwRJj)@FEhg%!ujS8Mfo`8VrYpj7#L_2yOZTvIbILHc7R zuWSm-^Yfp>_ksM_i%v_=b6`()M+AGOXz6;-$>Vf%{Xzd6e;Jqp`KqIX;M3)#Nv z^%F|5v6`s>;A2ICg&kFgwj&R}`HA#!^gQa{mLiH|_n36+(Lgc0KG$>Q_-=o>qLBin zs=IX^tSs2EzdR{&wVlNEyzP$eGU(PEHuuwt*-y_b&TOnyYgBAe&G~|7?Km^R7MLj! zQU#>CPL146coU`qrO{UX(H{=u>cFC_S>NQeZYFun3mHk!a4eR);Ec5s6sqIL_qf_5 z*g#Uvi5=d%2MNWBjRIAM)ZZ?bA~Q&N=81!jW3X_TpNzliuhd39FB;ofT9z2H5k?IG zg+hmLzDn3nAe($RF#n)ao}`dGYg;F95{r7smc+s=uZ7ce-37N1BziGfv*`l;;&$zp z8*pIL_Fb=z@#la#ckW4NPDYBr7IBcN9@>>zX<&f8u_=Z6=ISZ2@!6;N1cgOp4Msd? zEE)Br^$@H7E@S%vNOkmYzd{z1vPr-4nsAa~mC&mTqr>dBeSy9E=6f$8f8#k8PwHTh zZ%|325*w|I5Zr(zU=TZ`kIG>eDFGxeWT5HM#lORb1gmu4 z3G>1(iUy5hGMgh<96p-FQEPO&-p^DZ1sdC+m@lToC@q_?KvP)^%42)Ve}l_QCug3e zR#+cK9CQqZwo!7Yeij@p&sp6b`$TB%xcJ6W3R95LRT`oy5*l~8FId*S|IPVaI>o|l z#C>w85*U5FGwiZ1Y!3DI=Rosp{84m_jH}B*WMA&|{+;ZZ0f^SiBCT_E zb<=q^8QOSTB}EAz{CV&ZJYjUY9KYspu?#mbIg{^%ZsB?nh=^+9gGM`#mmRU*zzB z`(YUx&>*qz$nFmG!B68i_o^5-8OF$GcvzT!=0cKGcvA77cXEioz?4m|l}`g`IZL7h z`PliseDvPTh2l@LRko>r_S{%AXFd8%^yZInzKKs16vJt>CPcyhS@7E9^mL9vwWX!m z%F;^13IL#i(b>|g={0L_tugG>Ffe)=gVb1C+Dr%^h&~@+{(hWacC*qv>_b7*F{y=B zT}KfriR$R6SWUBXDPhJEB4sz0sD`-;(XC{cl4b0|STh=f z#0*9z%*ejfkY$XmJM+!`{`}7Q{s z@6@Fb4oK|95#{yNN1UtT9iR@S#VQ5Wk^vLw;nI>5#KIx*!E?T^3w)0C@Krg z4byPKnl?*7YXdp0ks;mz9Q0@ZduDh6F80)PrDRXoi)0#w>~3}2i!Im z{MKP=%n5a0>5Ho0_wVoW-7@&Q+-BqFS49K`F#Q$nhVM?}yLx*s9{A<0uWw_PUJ!SM#Q1!RpESKSy)ux$occ1p25b5Y~PBVoILQt=hyK z|5PdLvrk@kxprhwj+9G`w^;*8bS8-B~0wLG}Kx3%I z`Wblx@xH;i^HkZ>f6gzi9~0$uvWon(ilR^ejEd&hhWm);qY=AaH(O0Nu}KZ+?hn+H zRX*DAOb#;N3R3_8n|To+)v9F$rE5lj=0!-z! z=!1OX>!*OA16`WQ0t&M?_JM^`r@TdTOAD!KWCwA<$aDh!TGHIjve6CJ=;az*kh@qD zy8ifIeX{KlBVyX`%{`s=YHG#K&flRD&(eGZnIKZFj@SE-KPrS0Y7@Rc1cDSFyRXWz z7<2d~ZI~A9<#2|XB%G(0|MaIoBdqagQ_I()Uj3&tTy|YCY#=YaTFVnFF?`rf8$4qj zpV+ux7nY`Uw4{(ycMC2b|XvNC7@q&Ww+JDUDHU z1p@3RlSnf2irp-qDYnVKdsgO-$TD|rvUSw)d$=;+%Yr}^%v8AX&5c&E;_(1@ zP4qW>sROROjupDm#HzEX)}0MrI$I_7IK1qF1HZCqha>;1%Mnei#!U22On~um!*!PZ z<#hE}y7zl2NW?uPa$a)j)9DyDQ?)nRwo>a&+A-Jgj=EIZ=ZAV!ZeoVF$IQHFRb5@@ z%zXY)^|RMGgyh@yu{)dB6~+S4bUacS4iRe#(!pKB06{y+?u$#Z;gKQ~X0KEMJ8nA2 z-pr$>k!E4Mn@b};m&Ntap`urCiKJ-;0^uh%uFzna&}iKpRu;GBx#5Ip|G3(yQhX)% zrtXiA?><9^(Cb@Gjyh#NjyAZAZL9vFuII(WLcAf!QB;U=uHMj^fQ8C1&MQf^3?qGg zbZzMlp@Rqwu3PJTVGe8m&{cJto%E_|WnwGWnRgC(ixInR7_Gj_#tyB`v_wbqh)Jg} z=f|$?Ox!=zKS+(<699Jru&tc~zcm$P$)9|ADJ+3VT%MF=H z!;@r{G%{Zsh3Sg&$OvGK6btvDXaa_W_!=S)6h&8m5Qru#OaetEw@{RK`{9!vy%45B z`U3&@^$}u;`@re`rC~i)q`{kbIZ>JPs!+o)YbutXN7#grW(AWF46!HXk$ufl7%I7g zJw;f=P~*cUL*(hh^Qkz<(wQhqi}LlGVF6R>$lJ;P~ z@FnnlkbKm+ed6V(hkzI?u}&v3MV>x#cLDYGyX!;;e5U5B{_@I- zDv4*ebjpkkS5vZpeBdV_OyYKaiIg8Nd|XJGvJs8HZon?*ipI}c{<9qNcZ~g?yiGF( z$6%)d^&L{vj**-x^zERT5rGj0E7erej6mu!2DKX5Z!1*-x^PX(&9$v}DzJ59rK{@0 zUG&`>)^n^aKZ6*LN`9x;#GGUUP;*J8YF0 zVohVx%GtI!SftNnZb8O`RI8h*S=8R{P_7lx2TWW!wM|EA+vp}Ka%+7ADpuozjuGXI0Ib>h(xpO1+-H%y`?)gA7$+x#VF#4TeK4bFWRvYisd@BbWol zb{vW0rY^w|zaO5@PC1Jmae2##v`T`SQn*7O;AwDfV_OT-^+v33gyd1@3hx=#FKx9a zAOyDv2D8^Np4r?bkL)u0LX+3N@6OLMp%nyxDM^!6RkOP|9u4tS&HD-)g*p0XI}~QO z6-npl==+pR8(_5N4uLcY0KWf+Lafq?IRyAaE{SwSu5*DG{sER3Y|JW6P>=ovS|?*4 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/vertical_slider-light.png b/testdata/snapshots/darwin/vertical_slider-light.png new file mode 100644 index 0000000000000000000000000000000000000000..98cab767886e069eeb74b8609c3e001ad3aa86cd GIT binary patch literal 3058 zcmdT`S5%YP7N#jGKSMx-fDFx0MT&3%g@^)Dq^cmD1d%2t6cK_QiiL%R z+t^6|77Gh22h$%t!N#;gzD7kXEL^q5`q!*b=__Lvp8WG-t>4x^F*>?WBGqr2hI*zI zNEs#RLDn3s(9LR<@vG@n#)wKE5P- z4E&DqVE>p3R-Y_pWj`Y_A4ByHbUl3anGY6i;PHVd6xM~Hi$LdQW&W{WRmi*kH zRd|P44C@G&fx4o7^=+^0H`v@;Z`?XNPUWf9s1bzvs#ozIs-??~<6-KDrX|dx6|zxB zTTKZD4U^WAPo7?vyz6eN>WY1zH8p!pAD}9$zP6_vOXI41rt}#WwpS}ZyU}Bze{X>* zF|d(|%Qr8qQ0uYnzgR+cHxx4ItMpV`>P%fphkD1Hb9olyWpFlIic8QlG!b?y@4MRrnH}_ya;Y^r<5E#J=fnS*HKa0QifdEZj>dJ29>PG!xFn0 zDhC@BYKZF&Nh6@>j0jFsdT;mT@9Gxwq^|MC@jh5aU^4sXw{@&bEbRl!Xg z>nfkjazWd4dFzKh?v?GHj~G4j60^X+BI7@!GuLT$^4Db|Y;~Zr_a|_ZB?hc!cJS)$ zJKF+L<_ZT+ORcf9@Q9CIieLq*VkAI-7Jm33$Om;qE()pI+3&p*HpA9=jO4EPIRdVE zh+68|)*tuVOx#*{EP05M2LTvnSag=G%lFNydYqCPWPPSp2}ae!Ll=xT2prm0?U+Ok zu^UZ#*R`2IK8JP^jS0}003NDrU()1h-4|fSqb(F>Dd|EF>OfB>+(wL~*yh|1*;$mC z<`K4cP%{e}3*IPKEmu{@&7eHm>N2Twg}WA4UEdy0vh_A;Z8Dh8ONcr$X9LS)uIZ1~ zPm{XRG6?hAAa8^n@gZ^fGIIPTR5?5=UirHokC7~aL)nUtS}*$C()=X6KpCrnx~hwp z3eVaEJMX|1ykylr*!G4;T{MAL+T(kcMp3ZGP7K)YH8_iuCH8F7? zKE`Kuoq=YYj^l+S*|BM1V1d{v9Nrw-u;cyK^2eRhDzP7*;O7e=vp=;$$RaqSX7<0% zBfTouU0g#Qm)G*sX-x)XZe}(2bq)!#-BK{tT#U1qsdhc)?WL#?iMi%u1#aMrLfn$g zYxpV~EeViR42B|Wc?~!v>ugQ5ysiy)lG1Lpel5CbR6GznCj!j8%;c?UZChA6E}aRX z!H}7$+LeN#yGLzpmstOHAa3{rSeN3`(sm(<5ixJH_xAq6_TsDjS<`f3O>1(Q^9dEk z>h9sIHNtBlXXtK1B9b}4Vi|t z(9IDXTvm2)+Q05jij6;+Zc`=0YaznV84Zd3e?WEW zQ^P%J!!P1V`b>>0o`r=EC2=eE z)w8Q9)l`ITY|HDGLHb|^HPy@oyG+e^Vg5%}Hv!#PtcGwG+8-%bTUq2kG}rxvL|aY_ zxDHS^_I~l_W@fGn41^rDu8)m8sD@gH%&e@x8$9O$XVK%mt2xT+_cBw5(?0=pUYZQ% z&!6u^clIzOB=l(?%cp(^<10rYAMD$v^Nr*Ca+Gqj5A`~}?al7~QW<6hGmnj6!g z>!%5w_E18|xlpOQ!zMrFw>ndx>7&%z!yVr>2v*R}^#X3K(f4tX+11hp`8sd5F+uY( zw_c?RtRp)+_y!vW^!{TmdLS8nj_UCEi___avqE%h8-MOZZ{NY%<24yzMh+)LoD#`b zEk;cdeICx?ed8k+)&_NzjZfjc1PtuF#Cp=Gydp~b?rzMFgHwE)j5Ek{xWt69m9s)! zP_WO6yF*y&D6?DI0SsuWYrZFemC5x%uzHC2hxWJghcNM6BeuWx^xt;Z|3;7evoGLJ z^@E+F{I>j9osg`mdx}3s#8#42YDi9$o;ul_C9sPF|#uNu`SeF%oO3etG(YN*R zTkUz67r zr(8u!>MQe`DnkAYx>RLsf8%y~e70-AP}|$iOT|C@ZhIg^)%@N zDPpt07Qc@!SaA(Gwf|x~SgDk-2;0^u18v$jI+xgVSi6<+>Aviq55DjWCiQ~IaC3Co zeFAQr+oJoJp~;zUb}DjdF`Xb?6**zf1XO|Q0wDJYtI%O@STeclyGIhyd1|Cd23!Z4}0Tel$)M|u{$1g?V>wHvq!UIhmpp9q0`YO vB^t}XWon=YXcz$xXFU%7zc7snlL!{0r`e)Q-zKO`Eze?n-CVy^7asXv`@cyr literal 0 HcmV?d00001 diff --git a/vertical_slider.go b/vertical_slider.go new file mode 100644 index 0000000..7e25cb9 --- /dev/null +++ b/vertical_slider.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// VerticalSliderOptions configure a VerticalSlider. Min and Max both zero mean 0 to 100. +type VerticalSliderOptions struct { + Min, Max float64 + // Step snaps the value; zero is continuous. + Step float64 + Marks []SliderMark + // Height is the track length; zero is 160. + Height float32 + // ShowValue shows the value beside the thumb while hovered, dragged or focused. + ShowValue bool + Format func(float64) string + Disabled bool + Label string +} + +// VerticalSlider is a Slider standing up: max at the top, Up and Right raise it. +func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) ChoiceResult { + if value == nil { + panic("mujica: VerticalSlider needs a value") + } + if !(opts.Height >= 0) || math.IsInf(float64(opts.Height), 1) { + panic("mujica: VerticalSlider Height must be finite and >= 0") + } + g, _ := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, false, c) + format := sliderFormat(opts.Format, g) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + h := cmp.Or(opts.Height, 160) + old := g.snap(*value) + *value = old + var s *ui.Element + var next float64 + var held bool + wrap := ui.Column(c).Disabled(opts.Disabled) + wrap.Children(func() { s, next, held = sliderControl(c, old, g, g.lo, g.hi) }) + // The base maps the pointer across; here the height decides. + y := func(r ui.Rect, v float64) float32 { + return r.Y + r.H - sliderThumb/2 - (r.H-sliderThumb)*float32((v-g.lo)/(g.hi-g.lo)) + } + if held { + if _, py, _ := s.PointerPosition(); s.Bounds().H > sliderThumb { + f := 1 - float64((py-sliderThumb/2)/(s.Bounds().H-sliderThumb)) + next = g.snap(g.lo + max(0, min(1, f))*(g.hi-g.lo)) + } + } + w := Density(c).ControlHeight() + for _, m := range opts.Marks { + if m.Label != "" { + w += 40 + break + } + } + s.Size(w, h).PaddingY(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer) + if opts.Label != "" { + s.Label(opts.Label) + } + if !wrap.IsDisabled() { + *value = next + } + changed := *value != old + if changed { + slog.Debug("mujica: vertical_slider changed", "label", opts.Label, "value", *value) + } + hot := s.Hovered() || held + v := *value + s.Draw(func(p *ui.Painter, r ui.Rect) { + x := r.X + Density(c).ControlHeight()/2 + top, bot := y(r, g.hi), y(r, g.lo) + p.Fill(ui.Rect{X: x - sliderTrack/2, Y: top, W: sliderTrack, H: bot - top}, k.Border, 0) + p.Stroke(ui.Rect{X: x - sliderTrack/2, Y: top, W: sliderTrack, H: bot - top}, k.ControlBorder, 0, 1) + p.Fill(ui.Rect{X: x - sliderTrack/2, Y: y(r, v), W: sliderTrack, H: bot - y(r, v)}, k.AccentText, 0) + for _, m := range opts.Marks { + my := y(r, m.Value) + p.Fill(ui.Rect{X: x + sliderTrack/2 + 2, Y: my - 0.5, W: 5, H: 1}, k.TextMuted, 0) + if m.Label != "" { + _, th := p.MeasureText(1000, ui.Span{Text: m.Label, Size: size}) + p.Text(x+sliderTrack/2+10, my-th/2, m.Label, size, k.TextMuted) + } + } + sliderThumbPaint(p, k, x, y(r, v), hot, s.FocusVisible()) + if opts.ShowValue && (hot || s.Focused()) { + sliderBubblePaint(p, k, x, y(r, v), format(v), size) + } + }) + return ChoiceResult{wrap, changed} +} diff --git a/vertical_slider_test.go b/vertical_slider_test.go new file mode 100644 index 0000000..45d0347 --- /dev/null +++ b/vertical_slider_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func verticalTester(v *float64, opts *VerticalSliderOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if VerticalSlider(c, v, *opts).Changed() { + *n++ + } + }) + }, 300, 300) +} + +func TestVerticalSliderKeysAndPointer(t *testing.T) { + v := 0.0 + opts := VerticalSliderOptions{Step: 10, Label: "Level", Height: 216} + n := 0 + tt := verticalTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyRight) + if v != 20 || n != 2 { + t.Fatalf("Up, Right: %v %d", v, n) + } + tt.Key(0, ui.KeyDown) + if v != 10 { + t.Fatalf("Down: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 100 { + t.Fatalf("End: %v", v) + } + r, _ := tt.Find("Level") + // Track runs from y+8 (100) to y+208 (0): a quarter up is 25, snapped to 30 or 20. + tt.Press(r.X+r.W/2, r.Y+8+150) + tt.Release(r.X+r.W/2, r.Y+8+150) + if v != 30 && v != 20 { + t.Fatalf("press at a quarter height: %v", v) + } + tt.Press(r.X+r.W/2, r.Y+8+150) + tt.Move(r.X+r.W/2, r.Y) + tt.Release(r.X+r.W/2, r.Y) + if v != 100 { + t.Fatalf("drag to the top: %v", v) + } + opts.Disabled = true + tt.Frame() + tt.Press(r.X+r.W/2, r.Y+200) + tt.Release(r.X+r.W/2, r.Y+200) + if v != 100 { + t.Fatalf("disabled slider moved: %v", v) + } +} + +func TestVerticalSliderRejects(t *testing.T) { + v := 0.0 + for name, o := range map[string]VerticalSliderOptions{"height": {Height: -1}, "step": {Step: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); VerticalSlider(c, &v, o) }, 200, 200) + }() + } +} + +func TestVerticalSliderSnapshot(t *testing.T) { + a, b := 40.0, 70.0 + snapshot(t, "vertical_slider", 260, 220, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + VerticalSlider(c, &a, VerticalSliderOptions{Marks: []SliderMark{{Value: 0, Label: "0"}, {Value: 50, Label: "50"}, {Value: 100, Label: "100"}}}) + VerticalSlider(c, &b, VerticalSliderOptions{Disabled: true}) + }) + }) +} From 5ee3ed2f16bae27038bf6038b3eadc3e45987d2b 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:18:02 -0400 Subject: [PATCH 06/20] WIP essentials 6 --- bulk_action_bar.go | 2 +- choice_chips.go | 4 +- currency_input.go | 146 ++++++++++++++++++ currency_input_test.go | 120 ++++++++++++++ messages_essentials.go | 3 + scrub_input.go | 75 +++++++++ scrub_input_test.go | 82 ++++++++++ .../snapshots/darwin/currency_input-dark.png | Bin 0 -> 5372 bytes .../snapshots/darwin/currency_input-light.png | Bin 0 -> 5100 bytes .../snapshots/darwin/scrub_input-dark.png | Bin 0 -> 4370 bytes .../snapshots/darwin/scrub_input-light.png | Bin 0 -> 4192 bytes 11 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 currency_input.go create mode 100644 currency_input_test.go create mode 100644 scrub_input.go create mode 100644 scrub_input_test.go create mode 100644 testdata/snapshots/darwin/currency_input-dark.png create mode 100644 testdata/snapshots/darwin/currency_input-light.png create mode 100644 testdata/snapshots/darwin/scrub_input-dark.png create mode 100644 testdata/snapshots/darwin/scrub_input-light.png diff --git a/bulk_action_bar.go b/bulk_action_bar.go index ad4e0d7..0ca0e62 100644 --- a/bulk_action_bar.go +++ b/bulk_action_bar.go @@ -26,7 +26,7 @@ func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actio k := Tokens(c) u := Density(c).Unit() count := fmt.Sprintf(msg(c, "bulk_action_bar.selected"), selected) - bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight()+4*u). + bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight() + 4*u). Role(ui.RoleToolbar).Label(count).Disabled(opts.Disabled) overlayStyle(c, bar, overlayEnter(c, bar)) said := ui.Local(bar, "bulk_action_bar.said", func() int { return 0 }) diff --git a/choice_chips.go b/choice_chips.go index c979143..ac94e76 100644 --- a/choice_chips.go +++ b/choice_chips.go @@ -30,7 +30,7 @@ func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } k := Tokens(c) d := Density(c) - g := ui.Row(c).Wrap().Gap(d.Unit()*2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal) + g := ui.Row(c).Wrap().Gap(d.Unit() * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal) if opts.Label != "" { g.Label(opts.Label) } @@ -54,7 +54,7 @@ func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) { - b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit()*1.5). + b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 1.5). Radius(theme.PopupRadius) internal.Ring(b, k.Focus, theme.PopupRadius) bg, fg, line := k.Surface, k.Text, k.ControlBorder diff --git a/currency_input.go b/currency_input.go new file mode 100644 index 0000000..9b8f1cf --- /dev/null +++ b/currency_input.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +// CurrencyInputOptions configure a CurrencyInput. +type CurrencyInputOptions struct { + // Symbol goes before the amount, as "$" or "¥"; empty shows none. + Symbol string + // Decimals is the minor unit digits; zero is 2. NoFraction makes it 0, as for JPY. + Decimals int + NoFraction bool + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// currencyInputDigits resolves the decimals of opts. +func currencyInputDigits(opts CurrencyInputOptions) int { + switch { + case opts.Decimals < 0 || opts.Decimals > 6: + panic(fmt.Sprintf("mujica: CurrencyInput Decimals %d outside 0..6", opts.Decimals)) + case opts.NoFraction && opts.Decimals != 0: + panic("mujica: CurrencyInput NoFraction with Decimals") + case opts.NoFraction: + return 0 + case opts.Decimals == 0: + return 2 + } + return opts.Decimals +} + +// CurrencyFormat writes minor units as an amount with thousands separators. +func CurrencyFormat(minor int64, decimals int, group bool) string { + neg := minor < 0 + u := uint64(minor) + if neg { + u = -u + } + p := uint64(math.Pow10(decimals)) + whole := strconv.FormatUint(u/p, 10) + if group { + var b strings.Builder + for i, r := range whole { + if i > 0 && (len(whole)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(r) + } + whole = b.String() + } + s := whole + if decimals > 0 { + s += "." + fmt.Sprintf("%0*d", decimals, u%p) + } + if neg { + s = "-" + s + } + return s +} + +// currencyInputParse reads an amount into minor units; reason is a message key when it fails. +func currencyInputParse(text string, decimals int) (minor int64, reason string) { + t := strings.NewReplacer(",", "", " ", "", " ", "").Replace(strings.TrimSpace(text)) + neg := strings.HasPrefix(t, "-") + t = strings.TrimPrefix(t, "-") + whole, frac, dot := strings.Cut(t, ".") + if whole == "" && frac == "" || strings.Trim(whole+frac, "0123456789") != "" || dot && strings.Contains(frac, ".") { + return 0, "currency_input.invalid" + } + if len(frac) > decimals { + return 0, "currency_input.precision" + } + frac += strings.Repeat("0", decimals-len(frac)) + v, err := strconv.ParseInt(whole+frac, 10, 64) + if err != nil { + return 0, "currency_input.invalid" + } + if neg { + v = -v + } + return v, "" +} + +// CurrencyInput edits *value, an amount in minor units (cents); it groups thousands while not focused. +func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult { + if value == nil { + panic("mujica: CurrencyInput needs a value") + } + dec := currencyInputDigits(opts) + 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 { + if opts.Symbol != "" { + ui.Text(c, opts.Symbol).TextColor(k.TextMuted).SingleLine() + } + text := ui.Local(frame, "text", func() string { return CurrencyFormat(*value, dec, false) }) + bad := ui.Local(frame, "bad", func() string { return "" }) + seen := ui.Local(frame, "seen", func() int64 { return *value }) + was := ui.Local(frame, "was", func() bool { return false }) + if *seen != *value { + *bad = "" + } + // Grouped while resting; plain digits once focused, for editing. + focused := frame.FocusWithin() + switch { + case *bad != "": + case !focused: + *text = CurrencyFormat(*value, dec, true) + case !*was || *seen != *value: + *text = CurrencyFormat(*value, dec, false) + } + *was = focused + in := textInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 16).Label(opts.Label) + if in.Changed() && !look.disabled { + v, why := currencyInputParse(*text, dec) + *bad = "" + switch { + case why == "currency_input.precision": + *bad = fmt.Sprintf(msg(c, why), dec) + case why != "": + *bad = msg(c, why) + case v != *value: + *value, r.changed = v, true + } + } + *seen = *value + if look.err == "" { + look.err = *bad + } + return in + }, nil) + if r.changed { + slog.Debug("mujica: currency_input change", "minor", *value) + } + return r +} diff --git a/currency_input_test.go b/currency_input_test.go new file mode 100644 index 0000000..cef2209 --- /dev/null +++ b/currency_input_test.go @@ -0,0 +1,120 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCurrencyFormatAndParse(t *testing.T) { + for _, tc := range []struct { + minor int64 + dec int + group bool + want string + }{ + {123450, 2, true, "1,234.50"}, {123450, 2, false, "1234.50"}, {-5, 2, true, "-0.05"}, + {1234567, 0, true, "1,234,567"}, {0, 3, true, "0.000"}, {100000000, 2, true, "1,000,000.00"}, + } { + if got := CurrencyFormat(tc.minor, tc.dec, tc.group); got != tc.want { + t.Errorf("CurrencyFormat(%d, %d) = %q, want %q", tc.minor, tc.dec, got, tc.want) + } + } + for text, want := range map[string]int64{"1,234.5": 123450, "0.07": 7, "-12": -1200, " 3 ": 300, "1.": 100} { + if v, why := currencyInputParse(text, 2); v != want || why != "" { + t.Errorf("parse %q = %d %q, want %d", text, v, why, want) + } + } + for text, want := range map[string]string{"1.234": "currency_input.precision", "abc": "currency_input.invalid", "": "currency_input.invalid", "1.2.3": "currency_input.invalid", "99999999999999999999": "currency_input.invalid"} { + if _, why := currencyInputParse(text, 2); why != want { + t.Errorf("parse %q: %q, want %q", text, why, want) + } + } +} + +// fieldText copies the focused field's text through the clipboard. +func fieldText(tt *ui.Tester) string { + tt.Command("selectAll") + tt.Command("copy") + return tt.Clipboard() +} + +func TestCurrencyInputTypingGroupingAndErrors(t *testing.T) { + v := int64(123450) + dis := false + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "$", Label: "Price", Disabled: dis}).Changed() { + n++ + } + Button(c, "Elsewhere", ButtonOptions{}) + }, 500, 200) + if !tt.HasText("$") { + t.Fatalf("no symbol: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + if got := fieldText(tt); got != "1234.50" { + t.Fatalf("focused text %q, want ungrouped", got) + } + tt.Command("selectAll") + tt.Type("2500.255") + if v != 123450 || !tt.HasText("Use at most 2 decimal places") { + t.Fatalf("too precise: %d %q", v, tt.Texts()) + } + tt.Command("selectAll") + tt.Type("2500.25") + if v != 250025 || n != 1 || tt.HasText("decimal places") { + t.Fatalf("typed: %d (%d)", v, n) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + if got := fieldText(tt); got != "2500.25" { + t.Fatalf("refocused text %q", got) + } + dis = true + tt.Frame() + _ = tt.Click("Price") + tt.Type("9") + if v != 250025 { + t.Fatalf("disabled edit: %d", v) + } +} + +func TestCurrencyInputNoFractionAndRejects(t *testing.T) { + v := int64(1500) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "¥", NoFraction: true, Label: "Amount"}) + }, 300, 100) + tt.Key(0, ui.KeyTab) + if got := fieldText(tt); got != "1500" { + t.Fatalf("yen %q", got) + } + tt.Command("selectAll") + tt.Type("12.5") + if v != 1500 || !tt.HasText("Use at most 0 decimal places") { + t.Fatalf("fraction accepted: %d", v) + } + for _, o := range []CurrencyInputOptions{{Decimals: -1}, {Decimals: 7}, {Decimals: 2, NoFraction: true}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", o) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); CurrencyInput(c, &v, o) }, 200, 100) + }() + } +} + +func TestCurrencyInputSnapshot(t *testing.T) { + a, b, d := int64(1234550), int64(98000), int64(0) + snapshot(t, "currency_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + CurrencyInput(c, &a, CurrencyInputOptions{Symbol: "$"}).Element.Width(150) + CurrencyInput(c, &b, CurrencyInputOptions{Symbol: "¥", NoFraction: true}).Element.Width(150) + CurrencyInput(c, &d, CurrencyInputOptions{Symbol: "€", Disabled: true}).Element.Width(150) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index f4a4f68..fcb62b3 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -18,6 +18,9 @@ func init() { "knob.label": {"Knob", "旋钮"}, + "currency_input.invalid": {"Enter an amount, such as 1,234.50", "请输入金额,例如 1,234.50"}, + "currency_input.precision": {"Use at most %d decimal places", "最多保留 %d 位小数"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/scrub_input.go b/scrub_input.go new file mode 100644 index 0000000..387bc67 --- /dev/null +++ b/scrub_input.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ScrubInputOptions configure a ScrubInput; the range and step rules are NumberInput's. +type ScrubInputOptions struct { + Min, Max float64 + Step float64 + Precision int + Disabled bool +} + +// scrubInputPixels is how far a drag moves for one step. +const scrubInputPixels float32 = 4 + +// ScrubInput edits *value by dragging its label sideways, one Step per 4 DIPs, or by typing in its field. +func ScrubInput(c *ui.Context, label string, value *float64, opts ScrubInputOptions) InputResult { + if label == "" || value == nil { + panic("mujica: ScrubInput needs a label and a value") + } + nopts := NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: opts.Step, Precision: opts.Precision, Label: label, Disabled: opts.Disabled} + s := numberInputResolve(nopts) + k := Tokens(c) + var r InputResult + row := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Disabled(opts.Disabled) + row.Children(func() { + h := ui.Box(c).Shrink(0).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Center().Cursor(ui.CursorResizeEW).Role(ui.RoleNone) + drag := ui.Local(h, "scrub_input.drag", func() float64 { return math.NaN() }) + dx, _, held := h.Dragged() + scrubbed := false + switch { + case !held || h.IsDisabled(): + *drag = math.NaN() + case math.IsNaN(*drag): + *drag = 0 + fallthrough + default: + start := *value + *drag += float64(dx) + if n := int(*drag / float64(scrubInputPixels)); n != 0 { + *drag -= float64(n) * float64(scrubInputPixels) + // One grid step at a time, so a long drag stops at the bound. + v := start + for range max(n, -n) { + v = s.stepBy(v, max(-1, min(1, n))) + } + if v != *value { + *value, scrubbed = v, true + } + } + } + fg := k.TextMuted + if held || h.Hovered() { + fg = k.AccentText + } + h.Children(func() { + ui.Text(c, label).TextColor(fg).Underline().DecorationColor(k.Border).SingleLine() + }) + r = NumberInput(c, value, nopts) + r.Element.Width(Density(c).Unit() * 36) + r.changed = r.changed || scrubbed + if scrubbed { + slog.Debug("mujica: scrub_input scrubbed", "label", label, "value", *value) + } + }) + r.Element = row + return r +} diff --git a/scrub_input_test.go b/scrub_input_test.go new file mode 100644 index 0000000..2e9502b --- /dev/null +++ b/scrub_input_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func scrubTester(v *float64, opts *ScrubInputOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if ScrubInput(c, "Opacity", v, *opts).Changed() { + *n++ + } + }) + }, 500, 200) +} + +func TestScrubInputDragAndRange(t *testing.T) { + v := 0.5 + opts := ScrubInputOptions{Min: 0, Max: 1, Step: 0.1} + n := 0 + tt := scrubTester(&v, &opts, &n) + r, _ := tt.Find("Opacity") + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + tt.Move(x+6, y) // one step, 2 DIPs carried + tt.Move(x+10, y) + tt.Release(x+10, y) + if v != 0.7 { + t.Fatalf("10 DIPs right: %v", v) + } + tt.Press(x, y) + tt.Move(x+400, y) + tt.Release(x+400, y) + if v != 1 { + t.Fatalf("past the max: %v", v) + } + tt.Press(x, y) + tt.Move(x-8, y) + tt.Release(x-8, y) + if v != 0.8 || n != 4 { + t.Fatalf("8 DIPs left: %v (%d)", v, n) + } +} + +func TestScrubInputTypingAndDisabled(t *testing.T) { + v := 2.0 + opts := ScrubInputOptions{Step: 1} + n := 0 + tt := scrubTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if v != 3 { + t.Fatalf("Up in the field: %v", v) + } + tt.Command("selectAll") + tt.Type("12") + if v != 12 { + t.Fatalf("typed: %v", v) + } + opts.Disabled = true + tt.Frame() + r, _ := tt.Find("Opacity") + tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W/2+40, r.Y+r.H/2) + tt.Release(r.X+r.W/2+40, r.Y+r.H/2) + if v != 12 { + t.Fatalf("disabled scrub: %v", v) + } +} + +func TestScrubInputSnapshot(t *testing.T) { + a, b := 0.6, 24.0 + snapshot(t, "scrub_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ScrubInput(c, "Opacity", &a, ScrubInputOptions{Max: 1, Step: 0.1}) + ScrubInput(c, "Size", &b, ScrubInputOptions{Disabled: true}) + }) + }) +} diff --git a/testdata/snapshots/darwin/currency_input-dark.png b/testdata/snapshots/darwin/currency_input-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..07aaac8ff586ba209b4211d5d53da9a534768c60 GIT binary patch literal 5372 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 XeJ$fkKWZ79Y63t?T8b6&7Qz1k;>_5o literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/currency_input-light.png b/testdata/snapshots/darwin/currency_input-light.png new file mode 100644 index 0000000000000000000000000000000000000000..b4a5df3afa0bd1e983cbfa697405c734a8ee768d GIT binary patch literal 5100 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 literal 0 HcmV?d00001 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 07/20] 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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 Date: Mon, 5 Oct 2026 14:23:10 -0400 Subject: [PATCH 08/20] WIP essentials 8 --- mention_input.go | 333 ++++++++++++++++++ mention_input_model.go | 142 ++++++++ mention_input_test.go | 149 ++++++++ .../snapshots/darwin/mention_input-dark.png | Bin 0 -> 7082 bytes .../snapshots/darwin/mention_input-light.png | Bin 0 -> 6909 bytes 5 files changed, 624 insertions(+) create mode 100644 mention_input.go create mode 100644 mention_input_model.go create mode 100644 mention_input_test.go create mode 100644 testdata/snapshots/darwin/mention_input-dark.png create mode 100644 testdata/snapshots/darwin/mention_input-light.png diff --git a/mention_input.go b/mention_input.go new file mode 100644 index 0000000..88125c2 --- /dev/null +++ b/mention_input.go @@ -0,0 +1,333 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MentionCandidate is someone a MentionInput may mention. +type MentionCandidate struct { + ID, Name string +} + +// MentionInputOptions configure a MentionInput. +type MentionInputOptions struct { + // Candidates is required: it returns matches for the text after "@", called when that text changes. + Candidates func(query string) []MentionCandidate + Placeholder string + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// mentionInputState is one field's document, history and popup. +type mentionInputState struct { + doc mentionDoc + synced string // the value as last read or written + syncedMentions []Mention + undo, redo []mentionDoc + compose string + query string + at, pick int + found []MentionCandidate + dismissed bool + panel ui.Rect // the popup's box last frame, which keeps it through a click + offset float32 + changed, submitted bool +} + +// MentionInput edits *value with "@" mentions kept in *mentions; a mention is deleted and stepped over whole. +func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts MentionInputOptions) InputResult { + if value == nil || mentions == nil || opts.Candidates == nil { + panic("mujica: MentionInput needs a value, mentions and Candidates") + } + text := []rune(*value) + for _, m := range *mentions { + if m.Start < 0 || m.End > len(text) || m.Start >= m.End || string(text[m.Start:m.End]) != "@"+m.Name { + panic(fmt.Sprintf("mujica: MentionInput mention %+v does not match its text", m)) + } + } + 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.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. + s.doc = mentionDoc{text: text, mentions: slices.Clone(*mentions), caret: len(text), anchor: len(text)} + s.undo, s.redo = nil, nil + s.synced, s.syncedMentions = *value, slices.Clone(*mentions) + } + mentionInputQuery(s, opts) + px, py, _ := frame.PointerPosition() + fb := frame.Bounds() + onPanel := s.panel.Contains(fb.X+px, fb.Y+py) + popup := (in.Focused() || onPanel) && s.at >= 0 && !s.dismissed && len(s.found) > 0 + in.HandleInput(func(ev ui.InputEvent) bool { + if in.IsDisabled() { + return false + } + return mentionInputEvent(c, s, ev, popup, in) + }) + if s.changed && !look.disabled { + *value, *mentions = string(s.doc.text), slices.Clone(s.doc.mentions) + s.synced, s.syncedMentions = *value, slices.Clone(*mentions) + r.changed = true + } + r.submitted = s.submitted && !look.disabled + s.changed, s.submitted = false, false + mentionInputView(c, k, s, in, opts.Placeholder) + s.panel = ui.Rect{} + if popup { + mentionInputPopup(c, k, s, frame, in) + } + return in + }, nil) + if r.changed { + slog.Debug("mujica: mention_input change", "mentions", len(*mentions)) + } + return r +} + +// mentionInputQuery refreshes the candidates when the "@" text changes. +func mentionInputQuery(s *mentionInputState, opts MentionInputOptions) { + at, q := s.doc.query() + if at != s.at || q != s.query { + s.at, s.query, s.pick, s.dismissed = at, q, 0, false + s.found = nil + if at >= 0 { + s.found = opts.Candidates(q) + } + } +} + +// mentionInputEvent applies one input event to the document and reports whether it took it. +func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, popup bool, in *ui.Element) bool { + edit := func(f func()) { + s.undo = append(s.undo, s.doc.clone()) + s.redo = nil + f() + s.changed = true + } + extend := ev.Mods&ui.Shift != 0 + switch ev.Kind { + case ui.InputText: + s.compose = "" + edit(func() { s.doc.insert(ev.Text) }) + case ui.InputCompose: + s.compose = ev.Text + case ui.InputPointerDown: + in.Focus() + s.doc.moveTo(mentionInputHit(c, s, ev.X), extend) + case ui.InputPointerMove: + if ev.Button != 0 { + return false + } + s.doc.moveTo(mentionInputHit(c, s, ev.X), true) + case ui.InputCommand: + a, b := s.doc.span() + switch ev.Text { + case "copy", "cut": + c.WriteClipboard(string(s.doc.text[a:b])) + if ev.Text == "cut" && a != b { + edit(func() { s.doc.erase(false) }) + } + case "paste": + edit(func() { s.doc.insert(c.ReadClipboard()) }) + case "selectAll": + s.doc.anchor, s.doc.caret = 0, len(s.doc.text) + case "delete": + edit(func() { s.doc.erase(true) }) + case "undo", "redo": + from, to := &s.undo, &s.redo + if ev.Text == "redo" { + from, to = to, from + } + if n := len(*from); n > 0 { + *to = append(*to, s.doc.clone()) + s.doc, *from = (*from)[n-1], (*from)[:n-1] + s.changed = true + } + default: + return false + } + case ui.InputKeyDown: + return s.compose == "" && mentionInputKey(s, ev.Key, extend, popup, edit) + default: + return false + } + return true +} + +// mentionInputKey handles editing and popup keys; others go on to Tab and shortcuts. +func mentionInputKey(s *mentionInputState, key ui.Key, extend, popup bool, edit func(func())) bool { + if popup { + switch key { + case ui.KeyDown, ui.KeyUp: + n := len(s.found) + if key == ui.KeyDown { + s.pick = (s.pick + 1) % n + } else { + s.pick = (s.pick + n - 1) % n + } + return true + case ui.KeyEnter, ui.KeyTab: + m := s.found[s.pick] + edit(func() { s.doc.choose(s.at, m.ID, m.Name) }) + slog.Debug("mujica: mention_input mention", "id", m.ID) + return true + case ui.KeyEscape: + s.dismissed = true + return true + } + } + switch key { + case ui.KeyBackspace, ui.KeyDelete: + if s.doc.caret == s.doc.anchor && (key == ui.KeyBackspace && s.doc.caret == 0 || key == ui.KeyDelete && s.doc.caret == len(s.doc.text)) { + return true + } + edit(func() { s.doc.erase(key == ui.KeyDelete) }) + case ui.KeyLeft: + s.doc.move(-1, extend) + case ui.KeyRight: + s.doc.move(1, extend) + case ui.KeyHome: + s.doc.moveTo(0, extend) + case ui.KeyEnd: + s.doc.moveTo(len(s.doc.text), extend) + case ui.KeyEnter: + s.submitted = true + default: + return false + } + return true +} + +// mentionInputHit is the rune boundary nearest x in the field, outside any mention. +func mentionInputHit(c *ui.Context, s *mentionInputState, x float32) int { + x += s.offset + best, bestD := 0, float32(-1) + for i := 0; i <= len(s.doc.text); i++ { + w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: FontSize(c, theme.BodySize)}) + if d := max(w-x, x-w); bestD < 0 || d < bestD { + best, bestD = i, d + } + } + for _, m := range s.doc.mentions { + if best > m.Start && best < m.End { + best = m.End + } + } + return best +} + +// mentionInputView draws the text with mention chips, the selection and the caret, scrolled to keep the caret in view. +func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in *ui.Element, placeholder string) { + size := FontSize(c, theme.BodySize) + measure := func(i int) float32 { + w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: size}) + return w + } + _, h := c.MeasureText(0, ui.Span{Text: "@", Size: size}) + x := measure(s.doc.caret) + if box := in.Bounds().W; box > 0 { + s.offset = max(0, min(s.offset, x), x-box+2) + } + in.MinHeight(h).TextCaret(ui.Rect{X: x - s.offset, Y: 0, W: 1, H: h}) + a, b := min(s.doc.caret, s.doc.anchor), max(s.doc.caret, s.doc.anchor) + sel := [2]float32{measure(a), measure(b)} + chips := make([][2]float32, len(s.doc.mentions)) + for i, m := range s.doc.mentions { + chips[i] = [2]float32{measure(m.Start), measure(m.End)} + } + spans := mentionInputSpans(k, s, size, placeholder) + in.Draw(func(p *ui.Painter, r ui.Rect) { + y := r.Y + (r.H-h)/2 + for _, ch := range chips { + p.Fill(ui.Rect{X: r.X + ch[0] - s.offset - 1, Y: y, W: ch[1] - ch[0] + 2, H: h}, k.Selection, theme.ControlRadius) + } + if a != b && in.Focused() { + p.Fill(ui.Rect{X: r.X + sel[0] - s.offset, Y: y, W: sel[1] - sel[0], H: h}, k.Selection.Mix(k.AccentText, 0.25), 0) + } + p.RichText(r.X-s.offset, y, 0, spans...) + }) + in.DrawOver(func(p *ui.Painter, r ui.Rect) { + if in.Focused() && s.compose == "" { + p.Fill(ui.Rect{X: r.X + x - s.offset, Y: r.Y + (r.H-h)/2, W: 1, H: h}, k.Text, 0) + } + }) +} + +// mentionInputSpans is the field's text: mentions in AccentText, a composition underlined. +func mentionInputSpans(k theme.Tokens, s *mentionInputState, size float32, placeholder string) []ui.Span { + if len(s.doc.text) == 0 && s.compose == "" { + return []ui.Span{{Text: placeholder, Size: size, Color: k.TextMuted}} + } + var spans []ui.Span + at := 0 + seg := func(to int, fg ui.Color) { + if to > at { + spans = append(spans, ui.Span{Text: string(s.doc.text[at:to]), Size: size, Color: fg}) + at = to + } + } + composed := s.compose == "" + for _, m := range s.doc.mentions { + if !composed && s.doc.caret <= m.Start { + seg(s.doc.caret, k.Text) + spans = append(spans, ui.Span{Text: s.compose, Size: size, Color: k.Text, Underline: true}) + composed = true + } + seg(m.Start, k.Text) + seg(m.End, k.AccentText) + } + if !composed { + seg(s.doc.caret, k.Text) + spans = append(spans, ui.Span{Text: s.compose, Size: size, Color: k.Text, Underline: true}) + } + seg(len(s.doc.text), k.Text) + return spans +} + +// mentionInputPopup lists the candidates under the field; a click or Enter picks one. +func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in *ui.Element) { + b := frame.Bounds() + u := Density(c).Unit() + ui.Overlay(c, func() { + ww, wh := c.Size() + h := float32(len(s.found)) * Density(c).ControlHeight() + x, y := overlayPlace(c, b, min(b.W, 320), min(h+2*u, wh/2), 4) + panel := ui.Column(c).Absolute().Left(x).Top(y).Width(min(max(b.W, 200), ww-8)).MaxHeight(wh / 2).Padding(u).Role(ui.RoleList) + overlayStyle(c, panel, overlayEnter(c, panel)) + s.panel = panel.Bounds() + panel.Children(func() { + ui.Scroll(c).Shrink(1).Children(func() { + for i, m := range s.found { + it := ui.Row(c).Key(m.ID).MinHeight(Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2). + 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) }) + } else if it.Hovered() { + it.Background(k.SurfaceHover) + } + it.Children(func() { ui.Text(c, "@"+m.Name).SingleLine() }) + if it.Clicked() { + s.undo = append(s.undo, s.doc.clone()) + s.doc.choose(s.at, m.ID, m.Name) + s.changed = true + in.Focus() + c.Invalidate() + } + } + }) + }) + }) +} diff --git a/mention_input_model.go b/mention_input_model.go new file mode 100644 index 0000000..2384235 --- /dev/null +++ b/mention_input_model.go @@ -0,0 +1,142 @@ +package mujica + +import ( + "slices" + "unicode" +) + +// Mention is a whole "@Name" token in a MentionInput's text, at rune offsets [Start, End). +type Mention struct { + ID string + Name string + Start, End int +} + +// mentionDoc is a MentionInput's text, mentions, caret and selection anchor, in runes. +type mentionDoc struct { + text []rune + mentions []Mention + caret, anchor int +} + +func (d mentionDoc) clone() mentionDoc { + d.text = slices.Clone(d.text) + d.mentions = slices.Clone(d.mentions) + return d +} + +// span returns the selection, widened to whole mentions it touches. +func (d *mentionDoc) span() (int, int) { + a, b := min(d.caret, d.anchor), max(d.caret, d.anchor) + for _, m := range d.mentions { + if m.Start < b && m.End > a { + a, b = min(a, m.Start), max(b, m.End) + } + } + return a, b +} + +// replace puts ins in place of [a, b), dropping mentions inside and shifting later ones; the caret lands after ins. +func (d *mentionDoc) replace(a, b int, ins []rune) { + d.text = slices.Concat(d.text[:a], ins, d.text[b:]) + shift := len(ins) - (b - a) + kept := d.mentions[:0] + for _, m := range d.mentions { + switch { + case m.End <= a: + kept = append(kept, m) + case m.Start >= b: + m.Start, m.End = m.Start+shift, m.End+shift + kept = append(kept, m) + } + } + d.mentions = kept + d.caret = a + len(ins) + d.anchor = d.caret +} + +// insert types s at the caret, over any selection. +func (d *mentionDoc) insert(s string) { + a, b := d.span() + d.replace(a, b, []rune(s)) +} + +// erase deletes the selection, else one rune or one whole mention back (or forward). +func (d *mentionDoc) erase(forward bool) bool { + if d.caret != d.anchor { + a, b := d.span() + d.replace(a, b, nil) + return true + } + for _, m := range d.mentions { + if !forward && m.End == d.caret || forward && m.Start == d.caret { + d.replace(m.Start, m.End, nil) + return true + } + } + switch { + case !forward && d.caret > 0: + d.replace(d.caret-1, d.caret, nil) + case forward && d.caret < len(d.text): + d.replace(d.caret, d.caret+1, nil) + default: + return false + } + return true +} + +// move steps the caret by dir runes, over whole mentions; extend keeps the anchor. +func (d *mentionDoc) move(dir int, extend bool) { + at := max(0, min(len(d.text), d.caret+dir)) + for _, m := range d.mentions { + if at > m.Start && at < m.End { + if dir < 0 { + at = m.Start + } else { + at = m.End + } + } + } + d.moveTo(at, extend) +} + +func (d *mentionDoc) moveTo(at int, extend bool) { + d.caret = at + if !extend { + d.anchor = at + } +} + +// query returns where an "@" being typed starts and what follows it, or -1. +func (d *mentionDoc) query() (int, string) { + if d.caret != d.anchor { + return -1, "" + } + for i := d.caret - 1; i >= 0; i-- { + r := d.text[i] + if unicode.IsSpace(r) { + return -1, "" + } + if r != '@' { + continue + } + for _, m := range d.mentions { + if i >= m.Start && i < m.End { + return -1, "" + } + } + if i > 0 && !unicode.IsSpace(d.text[i-1]) { + return -1, "" + } + return i, string(d.text[i+1 : d.caret]) + } + return -1, "" +} + +// choose turns the "@query" before the caret into a mention of id, followed by a space. +func (d *mentionDoc) choose(at int, id, name string) { + tok := []rune("@" + name) + d.replace(at, d.caret, append(slices.Clone(tok), ' ')) + d.mentions = append(d.mentions, Mention{ID: id, Name: name, Start: at, End: at + len(tok)}) + slices.SortFunc(d.mentions, func(a, b Mention) int { return a.Start - b.Start }) +} diff --git a/mention_input_test.go b/mention_input_test.go new file mode 100644 index 0000000..d9b87eb --- /dev/null +++ b/mention_input_test.go @@ -0,0 +1,149 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +var mentionPeople = []MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Albert"}, {ID: "u3", Name: "Bob"}} + +func mentionFind(q string) []MentionCandidate { + var out []MentionCandidate + for _, p := range mentionPeople { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out +} + +func TestMentionDocWholeTokens(t *testing.T) { + d := mentionDoc{text: []rune("hi @al"), caret: 6, anchor: 6} + at, q := d.query() + if at != 3 || q != "al" { + t.Fatalf("query %d %q", at, q) + } + d.choose(at, "u1", "Alice") + d.insert("there") + if string(d.text) != "hi @Alice there" || d.mentions[0] != (Mention{ID: "u1", Name: "Alice", Start: 3, End: 9}) { + t.Fatalf("after choose: %q %+v", string(d.text), d.mentions) + } + d.moveTo(10, false) + d.move(-1, false) // to the mention's end + d.move(-1, false) // over the whole mention + if d.caret != 3 { + t.Fatalf("Left over a mention lands at %d", d.caret) + } + d.moveTo(9, false) + d.erase(false) + if string(d.text) != "hi there" || len(d.mentions) != 0 || d.caret != 3 { + t.Fatalf("Backspace after a mention: %q %+v %d", string(d.text), d.mentions, d.caret) + } + if at, _ := (&mentionDoc{text: []rune("a@b"), caret: 3, anchor: 3}).query(); at != -1 { + t.Fatal("an email-like @ started a query") + } +} + +func mentionTester(v *string, ms *[]Mention, dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(400).Children(func() { + if MentionInput(c, v, ms, MentionInputOptions{Candidates: mentionFind, Label: "Comment", Disabled: *dis}).Changed() { + *n++ + } + }) + }, 500, 400) +} + +func TestMentionInputCandidatesKeyboard(t *testing.T) { + v, ms := "", []Mention(nil) + dis, n := false, 0 + tt := mentionTester(&v, &ms, &dis, &n) + tt.Key(0, ui.KeyTab) + tt.Type("ping @al") + if !tt.HasText("@Alice") || !tt.HasText("@Albert") || tt.HasText("@Bob") { + t.Fatalf("candidates %q", tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if v != "ping @Albert " || len(ms) != 1 || ms[0].ID != "u2" || ms[0].Start != 5 || ms[0].End != 12 { + t.Fatalf("chose: %q %+v", v, ms) + } + if tt.HasText("@Alice") { + t.Fatal("popup stayed open") + } + tt.Type("ok") + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) // the space + tt.Key(0, ui.KeyBackspace) // the whole mention + if v != "ping " || len(ms) != 0 { + t.Fatalf("whole delete: %q %+v", v, ms) + } + tt.Command("undo") + if v != "ping @Albert" || len(ms) != 1 { + t.Fatalf("undo: %q %+v", v, ms) + } + tt.Type(" @b") + tt.Key(0, ui.KeyEscape) + if tt.HasText("@Bob") { + t.Fatal("Escape did not close the candidates") + } +} + +func TestMentionInputClickCompositionDisabled(t *testing.T) { + v, ms := "", []Mention(nil) + dis, n := false, 0 + tt := mentionTester(&v, &ms, &dis, &n) + tt.Key(0, ui.KeyTab) + tt.Type("@b") + if err := tt.Click("Bob"); err != nil { + t.Fatal(err) + } + if v != "@Bob " || !slices.Equal(ms, []Mention{{ID: "u3", Name: "Bob", Start: 0, End: 4}}) { + t.Fatalf("click: %q %+v", v, ms) + } + tt.Compose("你", 1) + if v != "@Bob " { + t.Fatalf("composition committed early: %q", v) + } + tt.Type("你好") + if v != "@Bob 你好" { + t.Fatalf("commit: %q", v) + } + before := n + dis = true + tt.Frame() + tt.Type("x") + tt.Key(0, ui.KeyBackspace) + if v != "@Bob 你好" || n != before { + t.Fatalf("disabled edit: %q", v) + } +} + +func TestMentionInputRejectsBadMentions(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + v, ms := "hi Bob", []Mention{{ID: "u3", Name: "Bob", Start: 3, End: 6}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind}) + }, 300, 100) +} + +func TestMentionInputSnapshot(t *testing.T) { + v, ms := "thanks @Alice, see @Bob", []Mention{{ID: "u1", Name: "Alice", Start: 7, End: 13}, {ID: "u3", Name: "Bob", Start: 19, End: 23}} + e, none := "", []Mention(nil) + snapshot(t, "mention_input", 420, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind}) + MentionInput(c, &e, &none, MentionInputOptions{Candidates: mentionFind, Placeholder: "Write a comment…"}) + }) + }) +} diff --git a/testdata/snapshots/darwin/mention_input-dark.png b/testdata/snapshots/darwin/mention_input-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1588c52048c107292597cb68244528082d1c4e70 GIT binary patch literal 7082 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�Qf<)2x*g~#<5y&asJO=F@}sg~`DSZX>ByG#;C4wFGh zy66s&EC2A2tEZ8lUzs913!S(lqufPrziJXSa5QH`)F?A`$qv!Cr!z%ICx2RFg7z@t za(?|9FxD)0Fpq~llK^!jd2KM_TBqmSoR|Fo%gO8%6#T$mr2YLiaFkJu6W$>&cbFUo zEui!X3z7;S^7n$Drj8GS>c*P=+pz25l2MB6u7g--3dY>*{E1Dn(><853Sf3#{0&l0 zgxA~;%h3=;`5RdhcCaQ+$nrYADJWPuL1fD#kCoSqk&eNC7|wslDKC*Fqvx|)Bz?CX zt@(qu0+GNX1?K%LaRIR4ZSH3-2wbA)pQHrK?9?g4lySYVr&TG-EzvGAWbYgDyI{#f zb)A~asPy-lL3jtpWaVX1B~zs2PuTcYekwh)$Cb>2g_G>Kt^5&r$kSMwG#7 z!J%F!jAGlYco^MR&ofkOiF9nvFL#gq2w4nt7exq+!T0L}6u?6#*h@^iA{CY2-^rGQ zFDqBfeDadm^H^^`u>WLxA^Pjo(J0E9&kSA9lX-KtYdU~=UN{~=Wd;~zt11c8;!MR+RZo}o7rPY`hxK5F&%m4k5B-R ztDUu-b+aDxr~8j~7fHrh&6z(%;yT|zbvgS4{ zi{I*G|0=d`Awn$ot841OA;cEBe|!)iIDCFM9@5MDa=cNGD?9hX)9A0dmlyv=!uWYa zOi&g#zts7I&GUK&gn%ZAn_;iV_iRPhrlMsR*P z%FuY%DfI<}FIv!IS9_PceYrkV{K?(Bnx04RnEp{;V_W-p|5b2RbKG$^;wSlklKB4z h!_(g+;Fb;tRq=vx(BEm}?hXz|U0Fw|8fg3BKLDm!+l~ML literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/mention_input-light.png b/testdata/snapshots/darwin/mention_input-light.png new file mode 100644 index 0000000000000000000000000000000000000000..cba77f7292026525b6b9e81f8bb5dd33e1df2959 GIT binary patch literal 6909 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 literal 0 HcmV?d00001 From d9e210ffa454bc6a552dbce72ebe9427f3dbd725 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:24:18 -0400 Subject: [PATCH 09/20] WIP essentials 9 --- mention_input.go | 6 +- messages_essentials.go | 5 + signature_pad.go | 153 ++++++++++++++++++ signature_pad_test.go | 72 +++++++++ .../snapshots/darwin/signature_pad-dark.png | Bin 0 -> 8129 bytes .../snapshots/darwin/signature_pad-light.png | Bin 0 -> 8001 bytes 6 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 signature_pad.go create mode 100644 signature_pad_test.go create mode 100644 testdata/snapshots/darwin/signature_pad-dark.png create mode 100644 testdata/snapshots/darwin/signature_pad-light.png 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 0000000000000000000000000000000000000000..aec256994e61a2a3910a0f0a94dc8c4d794970bd GIT binary patch literal 8129 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 PPo$!xu2?4jJn&xt6^sZC literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/signature_pad-light.png b/testdata/snapshots/darwin/signature_pad-light.png new file mode 100644 index 0000000000000000000000000000000000000000..ac20d182e9f308acc9f3e200ccb9b203db4a635d GIT binary patch literal 8001 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 Date: Mon, 5 Oct 2026 14:25:28 -0400 Subject: [PATCH 10/20] WIP essentials 10 --- messages_essentials.go | 2 + testdata/snapshots/darwin/undo_toast-dark.png | Bin 0 -> 7684 bytes .../snapshots/darwin/undo_toast-light.png | Bin 0 -> 8528 bytes undo_toast.go | 136 ++++++++++++++++++ undo_toast_test.go | 91 ++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 testdata/snapshots/darwin/undo_toast-dark.png create mode 100644 testdata/snapshots/darwin/undo_toast-light.png create mode 100644 undo_toast.go create mode 100644 undo_toast_test.go diff --git a/messages_essentials.go b/messages_essentials.go index ad95e04..7955b6e 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -26,6 +26,8 @@ func init() { "signature_pad.empty": {"Sign here", "请在此签名"}, "signature_pad.clear": {"Clear", "清除"}, + "undo_toast.undo": {"Undo", "撤销"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/undo_toast-dark.png b/testdata/snapshots/darwin/undo_toast-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..61e51a2ce5d338e785fabe56df8e80cdb1317245 GIT binary patch literal 7684 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 O2&gD%%Gb(TNB#%KEe}!v literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/undo_toast-light.png b/testdata/snapshots/darwin/undo_toast-light.png new file mode 100644 index 0000000000000000000000000000000000000000..26b855e06925d30e2eb68ba64488719e19604539 GIT binary patch literal 8528 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 literal 0 HcmV?d00001 diff --git a/undo_toast.go b/undo_toast.go new file mode 100644 index 0000000..bbbe138 --- /dev/null +++ b/undo_toast.go @@ -0,0 +1,136 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// UndoToastOptions configure an UndoToast. +type UndoToastOptions struct { + // OnUndo is required; it runs when the user undoes. + OnUndo func() + // Duration is the countdown; zero is ToastDuration. + Duration time.Duration + // Action labels the button; empty uses "Undo". + Action string +} + +// UndoToastResult reports how an UndoToast ended this frame, if it did. +type UndoToastResult struct { + Element *ui.Element + Undone bool + // Expired: the countdown ran out, or the user closed it; the action stands. + Expired bool +} + +// undoToastClock counts the time shown, stopped while hovered or focused. +type undoToastClock struct { + message string + spent time.Duration + runFrom time.Time +} + +// UndoToast shows message at the bottom of the window with a countdown while *open; it never takes the focus. +func UndoToast(c *ui.Context, open *bool, message string, opts UndoToastOptions) UndoToastResult { + if open == nil || message == "" || opts.OnUndo == nil { + panic("mujica: UndoToast needs open, a message and OnUndo") + } + if opts.Duration < 0 { + panic("mujica: negative UndoToast Duration") + } + clock := ui.Local(c.Root(), "undo_toast", func() *undoToastClock { return &undoToastClock{} }) + s := *clock + var res UndoToastResult + if !*open { + *s = undoToastClock{} + return res + } + now := c.Now() + life := cmp.Or(opts.Duration, ToastDuration) + if s.message != message { + *s = undoToastClock{message: message, runFrom: now} + c.Announce(message) + } + spent := s.spent + if !s.runFrom.IsZero() { + spent += now.Sub(s.runFrom) + } + if spent >= life { + *open, res.Expired = false, true + *s = undoToastClock{} + slog.Debug("mujica: undo_toast expired") + return res + } + k := Tokens(c) + u := Density(c).Unit() + ww, _ := c.Size() + left := life - spent + ui.Overlay(c, func() { + dock := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 4).Justify(ui.Center).PassThrough() + dock.Children(func() { + card := ui.Column(c).Width(min(420, ww-8*u)).Radius(theme.PopupRadius).Background(k.Surface). + Border(theme.BorderWidth, k.ControlBorder).Role(ui.RoleStatus).Label(message).Clip() + card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14)) + card.Transition(ui.ElementTransition{Duration: theme.PopupDuration, Enter: &ui.Motion{Y: 8}, Exit: &ui.Motion{}}) + res.Element = card + card.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).Children(func() { + ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1) + secs := int(math.Ceil(left.Seconds())) + label := fmt.Sprintf("%s (%d)", cmp.Or(opts.Action, msg(c, "undo_toast.undo")), secs) + if Button(c, label, ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw")}).Clicked() { + res.Undone = true + } + if iconAction(c, icons.Must("x"), msg(c, "toast.close")).Clicked() { + res.Expired = true + } + }) + still := c.Preferences().ReduceMotion + ui.Box(c).FillWidth().Height(2).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + if still { + return + } + rest := left + if !s.runFrom.IsZero() { + rest = life - s.spent - p.Now().Sub(s.runFrom) + p.AnimationFrame() + } + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * max(0, float32(rest)/float32(life)), H: r.H}, k.AccentText, 0) + }) + }) + if card.Shortcut(0, ui.KeyEscape) { + res.Expired = true + } + // Hover or focus holds the countdown. + switch held := card.Hovered() || card.FocusWithin(); { + case held && !s.runFrom.IsZero(): + s.spent, s.runFrom = spent, time.Time{} + case !held && s.runFrom.IsZero(): + s.runFrom = now + } + }) + }) + switch { + case res.Undone: + *open, res.Expired = false, false + *s = undoToastClock{} + slog.Debug("mujica: undo_toast undo") + opts.OnUndo() + case res.Expired: + *open = false + *s = undoToastClock{} + slog.Debug("mujica: undo_toast closed") + case !s.runFrom.IsZero(): + // Wake for the next whole second of the count, and for the end. + c.After(min(left, left-left.Truncate(time.Second)+time.Millisecond)) + } + return res +} diff --git a/undo_toast_test.go b/undo_toast_test.go new file mode 100644 index 0000000..4c687d7 --- /dev/null +++ b/undo_toast_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func undoTester(open *bool, life time.Duration, undos *int, last *UndoToastResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Delete", ButtonOptions{}).Clicked() { + *open = true + } + if r := UndoToast(c, open, "File deleted", UndoToastOptions{Duration: life, OnUndo: func() { *undos++ }}); r.Undone || r.Expired { + *last = r + } + }, 600, 400) +} + +func TestUndoToastUndoAndFocus(t *testing.T) { + open, undos := false, 0 + var last UndoToastResult + tt := undoTester(&open, 0, &undos, &last) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("File deleted") || !tt.HasText("Undo (5)") || !tt.Focused("Delete") { + t.Fatalf("shown %q, focus kept %v", tt.Texts(), tt.Focused("Delete")) + } + if !slices.Contains(tt.Announcements(), "File deleted") { + t.Fatal("not announced") + } + if err := tt.Click("Undo (5)"); err != nil { + t.Fatal(err) + } + if undos != 1 || open || !last.Undone || tt.HasText("File deleted") { + t.Fatalf("undo: %d %v %+v", undos, open, last) + } +} + +func TestUndoToastCountdownPauseAndClose(t *testing.T) { + open, undos := false, 0 + var last UndoToastResult + tt := undoTester(&open, 60*time.Millisecond, &undos, &last) + _ = tt.Click("Delete") + r, _ := tt.Find("File deleted") + tt.Move(r.X+r.W/2, r.Y+r.H/2) + time.Sleep(90 * time.Millisecond) + tt.Frame() + if !open { + t.Fatal("hovered countdown ran out") + } + tt.Move(5, 5) + time.Sleep(90 * time.Millisecond) + tt.Frame() + if open || !last.Expired || undos != 0 { + t.Fatalf("expiry: %v %+v %d", open, last, undos) + } + last = UndoToastResult{} + _ = tt.Click("Delete") + _ = tt.Click("Dismiss notification") + if open || !last.Expired || undos != 0 { + t.Fatalf("close: %v %+v", open, last) + } +} + +func TestUndoToastRejects(t *testing.T) { + open := true + for name, f := range map[string]func(c *ui.Context){ + "no undo": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{}) }, + "negative": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{OnUndo: func() {}, Duration: -1}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestUndoToastSnapshot(t *testing.T) { + open := true + snapshot(t, "undo_toast", 520, 160, func(c *ui.Context) { + UndoToast(c, &open, "3 messages archived", UndoToastOptions{OnUndo: func() {}, Duration: time.Hour}) + }) +} From beb4c02c61b6631b50f3b8bc5164b0f0874f54f1 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:28:55 -0400 Subject: [PATCH 11/20] WIP essentials 11 --- masonry.go | 139 ++++++++++++++++++++ masonry_test.go | 78 +++++++++++ testdata/snapshots/darwin/masonry-dark.png | Bin 0 -> 14270 bytes testdata/snapshots/darwin/masonry-light.png | Bin 0 -> 13801 bytes 4 files changed, 217 insertions(+) create mode 100644 masonry.go create mode 100644 masonry_test.go create mode 100644 testdata/snapshots/darwin/masonry-dark.png create mode 100644 testdata/snapshots/darwin/masonry-light.png diff --git a/masonry.go b/masonry.go new file mode 100644 index 0000000..3c1d7ad --- /dev/null +++ b/masonry.go @@ -0,0 +1,139 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + "sort" + + "github.com/egoist/mygo/ui" +) + +// MasonryOptions configure a Masonry. +type MasonryOptions struct { + // Key is required: a stable, unique key per item. + Key func(i int) string + // ColumnWidth is the narrowest column; zero is 220. Columns fill the width. + ColumnWidth float32 + // Gap is between items; zero is 3 spacing units. + Gap float32 + // Estimate is the height assumed until an item is measured; zero is 160. + Estimate float32 +} + +// masonryState is the scroll, the measured heights and the cached placement. +type masonryState struct { + scroll ui.ScrollState + heights map[string]float32 + version int + laidOut masonryLayout + // built are the items built last frame; their keys are checked for changed data. + built []int + // sized: a frame was asked for once the box had a width. + sized bool +} + +// masonryLayout places n items in cols columns; byCol lists each column's items top to bottom. +type masonryLayout struct { + n, cols, version int + colW, gap float32 + keys []string + x, y, h []float32 + byCol [][]int + total float32 +} + +// Masonry lays n items in as many columns as fit, each into the shortest column, building only those in view. +func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element { + if opts.Key == nil || item == nil { + panic("mujica: Masonry needs Key and item") + } + if n < 0 || opts.ColumnWidth < 0 || opts.Gap < 0 || opts.Estimate < 0 { + panic("mujica: Masonry count and sizes must not be negative") + } + u := Density(c).Unit() + colMin := cmp.Or(opts.ColumnWidth, 220) + gap := cmp.Or(opts.Gap, 3*u) + est := cmp.Or(opts.Estimate, 160) + sc := ui.Scroll(c) + st := ui.Local(sc, "masonry", func() *masonryState { return &masonryState{heights: map[string]float32{}} }) + s := *st + sc.TrackScroll(&s.scroll) + view := sc.Bounds() + if view.W == 0 && !s.sized { + // Unmeasured: lay out again once the box has its width. + s.sized = true + c.Invalidate() + } + cols := max(1, int((view.W+gap)/(colMin+gap))) + colW := (view.W - gap*float32(cols-1)) / float32(cols) + l := &s.laidOut + stale := l.n != n || l.cols != cols || l.colW != colW || l.gap != gap || l.version != s.version + for _, i := range s.built { + stale = stale || i >= n || l.keys[i] != opts.Key(i) + } + if stale { + *l = masonryPlace(n, cols, colW, gap, est, s.heights, opts.Key) + l.version = s.version + slog.Debug("mujica: masonry layout", "items", n, "columns", cols) + } + top, bottom := s.scroll.Y-view.H/2, s.scroll.Y+view.H*1.5 + var seen []int + sc.Children(func() { + ui.Box(c).FillWidth().Height(l.total).Children(func() { masonryBuild(c, s, l, colW, top, bottom, item, &seen) }) + }) + s.built = seen + return sc +} + +// masonryBuild builds the items between top and bottom and records measured heights. +func masonryBuild(c *ui.Context, s *masonryState, l *masonryLayout, colW, top, bottom float32, item func(int), seen *[]int) { + for _, list := range l.byCol { + from := sort.Search(len(list), func(j int) bool { i := list[j]; return l.y[i]+l.h[i] >= top }) + for _, i := range list[from:] { + if l.y[i] > bottom { + break + } + *seen = append(*seen, i) + key := l.keys[i] + box := ui.Column(c).Key(key).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW) + box.Children(func() { item(i) }) + if h := box.Bounds().H; h > 0 && math.Abs(float64(h-l.h[i])) > 0.5 { + s.heights[key] = h + s.version++ + c.Invalidate() + } + } + } +} + +// masonryPlace puts each item into the shortest column, using measured heights or est. +func masonryPlace(n, cols int, colW, gap, est float32, heights map[string]float32, key func(int) string) masonryLayout { + l := masonryLayout{n: n, cols: cols, colW: colW, gap: gap, keys: make([]string, n), x: make([]float32, n), y: make([]float32, n), h: make([]float32, n), byCol: make([][]int, cols)} + colH := make([]float32, cols) + seen := make(map[string]bool, n) + for i := range n { + k := key(i) + if seen[k] { + panic("mujica: Masonry key repeated: " + k) + } + seen[k] = true + col := 0 + for j := range cols { + if colH[j] < colH[col] { + col = j + } + } + h, ok := heights[k] + if !ok { + h = est + } + l.keys[i], l.x[i], l.y[i], l.h[i] = k, float32(col)*(colW+gap), colH[col], h + l.byCol[col] = append(l.byCol[col], i) + colH[col] += h + gap + } + for _, h := range colH { + l.total = max(l.total, h-gap) + } + return l +} diff --git a/masonry_test.go b/masonry_test.go new file mode 100644 index 0000000..71349e8 --- /dev/null +++ b/masonry_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "strconv" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMasonryPlaceShortestColumn(t *testing.T) { + hs := map[string]float32{"0": 100, "1": 40, "2": 40, "3": 50} + l := masonryPlace(4, 2, 100, 10, 160, hs, strconv.Itoa) + // 0 left; 1 right; 2 right again (50 < 110); 3 right (100 = 110? no: left 110, right 100). + want := [][2]float32{{0, 0}, {110, 0}, {110, 50}, {110, 100}} + for i, w := range want { + if l.x[i] != w[0] || l.y[i] != w[1] { + t.Errorf("item %d at %v,%v, want %v", i, l.x[i], l.y[i], w) + } + } + if l.total != 150 { + t.Errorf("total %v", l.total) + } + defer func() { + if recover() == nil { + t.Error("repeated key accepted") + } + }() + masonryPlace(2, 1, 100, 0, 10, nil, func(int) string { return "same" }) +} + +func TestMasonryVirtualizesAndMeasures(t *testing.T) { + built := map[int]bool{} + n := 100000 + width := 700 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Masonry(c, n, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 200, Gap: 10}, func(i int) { + built[i] = true + ui.Box(c).Height(float32(40 + i%3*30)).Label("tile " + strconv.Itoa(i)) + }).Size(float32(width), 500) + }, 800, 600) + tt.Frame() + clear(built) + tt.Frame() + if len(built) == 0 || len(built) > 120 { + t.Fatalf("built %d of %d", len(built), n) + } + if !built[0] || !built[2] || built[5000] { + t.Fatalf("wrong items built: %v", built[5000]) + } + a, _ := tt.Find("tile 0") + b, _ := tt.Find("tile 1") + d, _ := tt.Find("tile 3") + if a.W < 226 || a.W > 227 || b.X < 236 || b.X > 237 { + t.Fatalf("three columns in 700: tile 0 %v, tile 1 %v", a, b) + } + // Measured: tile 0 is 40 high, so tile 3 goes under it at 40 + 10. + if d.X != 0 || d.Y != 50 { + t.Fatalf("tile 3 at %v; texts %q", d, tt.Texts()) + } + tt.Scroll(100, 100, 0, 600000) + clear(built) + tt.Frame() + if len(built) == 0 || len(built) > 120 || built[0] { + t.Fatalf("after scrolling built %d, includes first %v", len(built), built[0]) + } +} + +func TestMasonrySnapshot(t *testing.T) { + snapshot(t, "masonry", 520, 300, func(c *ui.Context) { + k := Tokens(c) + Masonry(c, 9, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 150}, func(i int) { + ui.Column(c).Height(float32(50+i%4*25)).Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).Children(func() { + ui.Text(c, "Card "+strconv.Itoa(i+1)) + }) + }).Size(500, 280).Margin(10) + }) +} diff --git a/testdata/snapshots/darwin/masonry-dark.png b/testdata/snapshots/darwin/masonry-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4c4ee52d495a9bf0d3cc9b1f0b94609ad8f6a6a5 GIT binary patch literal 14270 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 OU-Hr_Qq>ZsA^!&@w->Mg literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/masonry-light.png b/testdata/snapshots/darwin/masonry-light.png new file mode 100644 index 0000000000000000000000000000000000000000..44e6eb2dd0726094ae3894fa8944c5b9e4e46f56 GIT binary patch literal 13801 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 Date: Mon, 5 Oct 2026 14:30:44 -0400 Subject: [PATCH 12/20] WIP essentials 12 --- messages_essentials.go | 2 + resizable_panel_group.go | 256 ++++++++++++++++++ resizable_panel_group_test.go | 135 +++++++++ .../darwin/resizable_panel_group-dark.png | Bin 0 -> 6459 bytes .../darwin/resizable_panel_group-light.png | Bin 0 -> 6121 bytes 5 files changed, 393 insertions(+) create mode 100644 resizable_panel_group.go create mode 100644 resizable_panel_group_test.go create mode 100644 testdata/snapshots/darwin/resizable_panel_group-dark.png create mode 100644 testdata/snapshots/darwin/resizable_panel_group-light.png diff --git a/messages_essentials.go b/messages_essentials.go index 7955b6e..618816a 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -28,6 +28,8 @@ func init() { "undo_toast.undo": {"Undo", "撤销"}, + "resizable_panel_group.divider": {"Resize %s and %s", "调整 %s 与 %s 的大小"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/resizable_panel_group.go b/resizable_panel_group.go new file mode 100644 index 0000000..8e273b6 --- /dev/null +++ b/resizable_panel_group.go @@ -0,0 +1,256 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ResizablePanel is one panel of a ResizablePanelGroup. +type ResizablePanel struct { + // Label names the panel; dividers read it out. + Label string + // Min is the smallest size in DIPs; zero is 10 spacing units. + Min float32 + Collapsible bool + Build func() +} + +// ResizablePanelGroupOptions configure a ResizablePanelGroup. +type ResizablePanelGroupOptions struct { + // Vertical stacks the panels top to bottom. + Vertical bool + Disabled bool +} + +// ResizablePanelGroupResult reports a group's changes: Resized while moving, Committed when a change is done, to save. +type ResizablePanelGroupResult struct { + Element *ui.Element + Resized bool + Committed bool +} + +// resizablePanelState keeps drags and the sizes collapsed panels return to. +type resizablePanelState struct { + held int // divider being dragged, -1 for none + restored map[int]float32 +} + +// ResizablePanelGroup lays panels in a row with dividers; *sizes holds their fractions, summing to 1, for the caller to save. +func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePanel, opts ResizablePanelGroupOptions) ResizablePanelGroupResult { + if sizes == nil || len(panels) < 2 { + panic("mujica: ResizablePanelGroup needs sizes and two panels or more") + } + n := len(panels) + if len(*sizes) == 0 { + *sizes = make([]float32, n) + for i := range *sizes { + (*sizes)[i] = 1 / float32(n) + } + } + if len(*sizes) != n { + panic(fmt.Sprintf("mujica: ResizablePanelGroup has %d sizes for %d panels", len(*sizes), n)) + } + var sum float32 + for i, f := range *sizes { + if !(f >= 0) || f == 0 && !panels[i].Collapsible || panels[i].Label == "" || panels[i].Build == nil || panels[i].Min < 0 { + panic(fmt.Sprintf("mujica: ResizablePanelGroup panel %d is invalid", i)) + } + sum += f + } + if math.Abs(float64(sum-1)) > 1e-3 { + panic(fmt.Sprintf("mujica: ResizablePanelGroup sizes sum to %v, not 1", sum)) + } + u := Density(c).Unit() + k := Tokens(c) + var res ResizablePanelGroupResult + box := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) + if opts.Vertical { + box.Column() + } + disabled := box.IsDisabled() + st := *ui.Local(box, "resizable_panel_group", func() *resizablePanelState { return &resizablePanelState{held: -1, restored: map[int]float32{}} }) + was := box.Bounds() + internal.Remeasure(c, box, was.W, was.H) + total := was.W + if opts.Vertical { + total = was.H + } + avail := max(0, total-float32(n-1)*theme.BorderWidth) + mins := make([]float32, n) + for i, p := range panels { + mins[i] = cmp.Or(p.Min, 10*u) + } + along := func(e *ui.Element, v float32) *ui.Element { + if opts.Vertical { + return e.Height(v).FillWidth() + } + return e.Width(v).FillHeight() + } + box.Children(func() { + var grips []func() + defer func() { + // Grips go last, above the panels they overlap. + for _, g := range grips { + g() + } + }() + for i, p := range panels { + if i > 0 { + grips = append(grips, resizablePanelDivider(c, k, u, st, sizes, panels, mins, avail, i-1, opts.Vertical, disabled, &res)) + } + if (*sizes)[i] == 0 { + continue + } + pane := ui.Box(c).Key(i).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label) + if avail > 0 { + along(pane, (*sizes)[i]*avail) + } else { + pane.Grow((*sizes)[i]) + } + pane.Children(p.Build) + } + }) + if res.Resized || res.Committed { + c.Invalidate() + } + res.Element = box + return res +} + +// resizablePanelDivider builds the divider after panel i, stepped with arrows, Enter collapsing; it returns the grip's builder. +func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizablePanelState, sizes *[]float32, panels []ResizablePanel, mins []float32, avail float32, i int, vertical, disabled bool, res *ResizablePanelGroupResult) func() { + s := *sizes + div := ui.Box(c).Key(fmt.Sprint("div", i)).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter). + Label(fmt.Sprintf(msg(c, "resizable_panel_group.divider"), panels[i].Label, panels[i+1].Label)) + if vertical { + div.Height(theme.BorderWidth).FillWidth() + } else { + div.Width(theme.BorderWidth).FillHeight() + } + // Moving the boundary by d DIPs trades size between panels i and i+1 within their minimums. + move := func(d float32) bool { + if avail <= 0 { + return false + } + a, b := s[i]*avail, s[i+1]*avail + pair := a + b + na := max(0, min(pair, a+d)) + collapse := func(size, min float32, ok bool) bool { return ok && size < min/2 } + switch { + case collapse(na, mins[i], panels[i].Collapsible): + na = 0 + case collapse(pair-na, mins[i+1], panels[i+1].Collapsible): + na = pair + default: + na = max(min(mins[i], pair-mins[i+1]), min(na, pair-mins[i+1])) + } + if math.Abs(float64(na-a)) < 0.01 { + return false + } + s[i], s[i+1] = na/avail, (pair-na)/avail + return true + } + toggle := func() { + j := i + 1 + if !panels[j].Collapsible { + j = i + } + if !panels[j].Collapsible { + return + } + other := i + i + 1 - j + if s[j] > 0 { + st.restored[j] = s[j] + s[other] += s[j] + s[j] = 0 + } else { + back := min(cmp.Or(st.restored[j], mins[j]/max(avail, 1)), s[other]-mins[other]/max(avail, 1)) + s[j], s[other] = back, s[other]-back + } + res.Committed = true + slog.Debug("mujica: resizable_panel_group toggled", "panel", panels[j].Label, "size", s[j]) + } + if !disabled { + back, forth := ui.KeyLeft, ui.KeyRight + if vertical { + back, forth = ui.KeyUp, ui.KeyDown + } + switch { + case div.Shortcut(0, back): + res.Committed = move(-4 * u) + case div.Shortcut(0, forth): + res.Committed = move(4 * u) + case div.Shortcut(0, ui.KeyHome): + res.Committed = move(-s[i] * avail) + case div.Shortcut(0, ui.KeyEnd): + res.Committed = move(s[i+1] * avail) + case div.Shortcut(0, ui.KeyEnter): + toggle() + } + } + if s[i] > 0 && s[i+1] > 0 { + div.Description(fmt.Sprintf("%.0f%%", 100*s[i]/(s[i]+s[i+1]))) + } else { + div.Description(msg(c, "split_pane.collapsed")) + } + return func() { + grip := ui.Box(c).Key(fmt.Sprint("grip", i)).Absolute().Cursor(ui.CursorResizeEW) + if vertical { + grip.Cursor(ui.CursorResizeNS) + } + dx, dy, held := grip.Dragged() + held = held && !disabled + switch { + case held: + if st.held != i { + st.held = i + div.Focus() + } + d := dx + if vertical { + d = dy + } + if d != 0 && move(d) { + res.Resized = true + } + case st.held == i: + st.held = -1 + res.Committed = true + slog.Debug("mujica: resizable_panel_group resized", "divider", i, "sizes", s) + } + resizablePanelGrip(c, k, u, s, avail, i, vertical, grip, div, st.held == i) + } +} + +// resizablePanelGrip places the grip over divider i and draws the divider. +func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div *ui.Element, held bool) { + // The grip is wider than the 1 DIP line, centered on the boundary. + var at float32 + for _, f := range s[:i+1] { + at += f * avail + } + at += float32(i) * theme.BorderWidth + if vertical { + grip.Left(0).Right(0).Top(at - 1.5*u).Height(3 * u) + } else { + grip.Top(0).Bottom(0).Left(at - 1.5*u).Width(3 * u) + } + active := held || grip.Hovered() + div.Draw(func(p *ui.Painter, r ui.Rect) { + line := k.ControlBorder + if active { + line = k.AccentText + } + p.Fill(r, line, 0) + if div.FocusVisible() { + internal.FocusRing(p, r, 0, k.Focus) + } + }) +} diff --git a/resizable_panel_group_test.go b/resizable_panel_group_test.go new file mode 100644 index 0000000..7deb347 --- /dev/null +++ b/resizable_panel_group_test.go @@ -0,0 +1,135 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func panelTester(sizes *[]float32, opts *ResizablePanelGroupOptions, commits *int) *ui.Tester { + panels := []ResizablePanel{ + {Label: "Files", Min: 100, Collapsible: true, Build: func() {}}, + {Label: "Editor", Min: 150, Build: func() {}}, + {Label: "Outline", Min: 80, Build: func() {}}, + } + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := ResizablePanelGroup(c, sizes, panels, *opts) + r.Element.Size(602, 300) + if r.Committed { + *commits++ + } + }, 700, 400) +} + +func near(a, b float32) bool { return math.Abs(float64(a-b)) < 0.002 } + +func TestResizablePanelGroupKeyboard(t *testing.T) { + var sizes []float32 + opts := ResizablePanelGroupOptions{} + commits := 0 + tt := panelTester(&sizes, &opts, &commits) + tt.Frame() + if len(sizes) != 3 || !near(sizes[0], 1.0/3) { + t.Fatalf("default sizes %v", sizes) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Resize Files and Editor") { + t.Fatal("first divider not focused") + } + tt.Key(0, ui.KeyRight) // 16 DIPs of 600 + if !near(sizes[0], (200.0+16)/600) || !near(sizes[1], (200.0-16)/600) || commits != 1 { + t.Fatalf("Right: %v (%d)", sizes, commits) + } + tt.Key(0, ui.KeyEnd) // Editor keeps its 150 minimum + if !near(sizes[1], 150.0/600) { + t.Fatalf("End: %v", sizes) + } + tt.Key(0, ui.KeyEnter) + if sizes[0] != 0 || !near(sizes[1], 1-sizes[2]) { + t.Fatalf("collapse: %v", sizes) + } + tt.Key(0, ui.KeyEnter) + if !near(sizes[0], 250.0/600) { + t.Fatalf("restore: %v", sizes) + } + opts.Disabled = true + tt.Frame() + before := sizes[0] + tt.Key(0, ui.KeyLeft) + if sizes[0] != before { + t.Fatal("disabled group resized") + } +} + +func TestResizablePanelGroupDrag(t *testing.T) { + sizes := []float32{0.5, 0.25, 0.25} + opts := ResizablePanelGroupOptions{} + commits := 0 + tt := panelTester(&sizes, &opts, &commits) + tt.Frame() + x := float32(300) // the first boundary + tt.Press(x, 100) + tt.Move(x-60, 100) + if commits != 0 || !near(sizes[0], 240.0/600) { + t.Fatalf("while dragging: %v (%d)", sizes, commits) + } + tt.Release(x-60, 100) + if commits != 1 { + t.Fatalf("release did not commit: %d", commits) + } + tt.Press(x-60, 100) + tt.Move(x-60-200, 100) // past half of Files' minimum: it collapses + tt.Release(x-60-200, 100) + if sizes[0] != 0 { + t.Fatalf("drag collapse: %v", sizes) + } +} + +func TestResizablePanelGroupRejects(t *testing.T) { + ok := ResizablePanel{Label: "A", Build: func() {}} + for name, tc := range map[string]struct { + sizes []float32 + panels []ResizablePanel + }{ + "one panel": {nil, []ResizablePanel{ok}}, + "count": {[]float32{1}, []ResizablePanel{ok, ok}}, + "sum": {[]float32{0.5, 0.2}, []ResizablePanel{ok, ok}}, + "zero fixed": {[]float32{0, 1}, []ResizablePanel{ok, ok}}, + "no label": {nil, []ResizablePanel{ok, {Build: func() {}}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + s := tc.sizes + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ResizablePanelGroup(c, &s, tc.panels, ResizablePanelGroupOptions{}) }, 300, 200) + }() + } +} + +func TestResizablePanelGroupSnapshot(t *testing.T) { + sizes := []float32{0.25, 0.5, 0.25} + vs := []float32{0.6, 0.4} + snapshot(t, "resizable_panel_group", 560, 260, func(c *ui.Context) { + k := Tokens(c) + pane := func(s string) func() { + return func() { + ui.Column(c).Fill().Padding(12).Background(k.Surface).Children(func() { ui.Text(c, s) }) + } + } + ui.Row(c).Padding(16).Children(func() { + ResizablePanelGroup(c, &sizes, []ResizablePanel{ + {Label: "Files", Build: pane("Files")}, + {Label: "Editor", Build: func() { + ResizablePanelGroup(c, &vs, []ResizablePanel{{Label: "Code", Build: pane("Code")}, {Label: "Terminal", Build: pane("Terminal")}}, + ResizablePanelGroupOptions{Vertical: true}).Element.Fill() + }}, + {Label: "Outline", Build: pane("Outline")}, + }, ResizablePanelGroupOptions{}).Element.Size(528, 228) + }) + }) +} diff --git a/testdata/snapshots/darwin/resizable_panel_group-dark.png b/testdata/snapshots/darwin/resizable_panel_group-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a7a9b6389de1de1ae398ce0598123133518bbd60 GIT binary patch literal 6459 zcmeHMXEYpKw-yluAwfiRqJ$`sh%!QA5EIPkbr>O0MosiG2!_ar5+xW$?@YXU@6o$4 zdP($7m>5L)+_k><$6f3GxcC0}zIT1={k8YmXYIAmde%PYdCuA&H6bcDs92~-NJwtH z1Sx5gkdQtkA-O_ENqQNniAv-kA-PNXQb|GAGkFUG*VGxmsWRkrHx%h- zx#m|b08vA&Pk4GV6b~vCT9~a=xF8v)jAhki)GbG;RrG)g1qP$1nPx_)t=>m%taW9tx`0`-Tdb;{P z5iU*s7gvx3Z{*TRBQEDg;W6T>I}l`NXQ!lVZ|qImaK0-oZ5YY%gyyp0=J>fMr=gt= zM)w%0Ca!ZkI81RyS}S40eQLD$!EBpb_7`|`|2B|kaIS}q-GOi6LY!vUwnTBmLY5Z@ z@>9@SIP`UWAtY4D%EDp2x>R7dDa{s=LXL?DZf^#&>NA8G5#3I+)q<_8&h*UsKB5dW z^zFXhjvF$#vgc@SHFl9S(FHrHPpHOtc^&8EL^s1@;$VxMQj+Z*e8UE3Hy{!2oPtkX zfhP}AMGPU#IoKP3lVZSzE;oMsnNYPiW?k~AGQdy9vozrW9)jU8~gA zi3!XfLa;2G;?6C(kIPC2!;jxqm*i=5i)G)^60fZiE5gJVW5!QZZjc6~>k6%BU(9%@ z&8fC8C?60A)$nzhFWafhB7Oao+z$+p5o|xcBzj58Eq+QWbXh`+t+Ri}Ge4a;Z3*(m zgu7=-dLtc>gRtVAtKk= zYVmz2U33eB1Z780Bv4Y+fUU+R$G(gmyW~>z?`6`Fr%k_durxB8Pb>TEC?WMsCeaVw zyWI08$n$B6uFx&2PZQKg!cKp@Dj(u%5cM5uTU*--V}}&u_Ha`dP>R;v}(Jgv{ZL|tleoEH!|XUh?t|E2bsGf63)#Kzae~>hS1dA zEaU#!#Ija28m-LpW4KM$*H<9NGWqaO+eU&IsHhtr4*q?Zi_q|oWng?w_9CQ(yP(ys zUC!rFI*M&~J9{Dtft06vzY~zJYhtXgVC}E1uMeTTEBv?#Q4^Ko6R{{zn#{H>EJDs_ zuVHoJw36E78yYtMYc^dM*i{lcyOSwDQ7v=JZgq0%I0)kt3E$XSBsow1!ekHsY#`3V z<&>lT2pk)p^!&<|L`Z+~xR(cdt#8E@Ewey2nhyDp=V@e;9M2rS;I^Ur2`3{aeTC1R zN?MaNkCLikUOLx$eOV$DqsjleZ27*N? znUY?;%EH2El-SF@iE24Jp$G2oQ@v<vY#7la3|gzkyxyT z9p0HR7+vvIwqYMdsr6)#f1nBu%t<}??x%o^ijIEtJDPmHF|TE@roPr(B7rT*SW6aL z#9rn8tW7ObH{Z_I)(tQ_UMPv~w?buHCQpePcBP#MS1k!ZFbrYJtY!L2$Pb<``BdZ3 ztf+EHkFeeDH@B!)24PJt^4m;Ik~Y69FL?knVA!z!b8%{DC_wV-)2A~OH@3`Ru;HP! zJgm>gTD+ZB5#|dnZ3iqkqyGkuYP%kj!4~n2|awI_mOMa$(uwhH1d4})V~0HG^QMiSrmV;qgd#|Z zvRjVW`)Qq_^bt<%%v)< z%P;Z-m|3k#kFZ92;2VVLZ48=hO(gqSn$ba70_-g4NsW)8^_5tDS96{Pf~iKS<^{0Ck}HGHsyc_8pxO>aL!?4Hm?dWH}$YTR=&x-Mzyd0I$3dKj8E{ zs3)*>+V?k^v9(2*4zB6UbEmvLY(<-qFaA!h$lwwrmgo_?W9rZ9cgVWBUOQUS< zc6Pb=N-gA+m)G{*lr*ivO)>=-oP_>4DTw4c*A?FPWKn-F{+}3(_*4Cj-(RAZzwaCMqp~j7j)1K%H17cMmIG?ZfS-*u>&YG6?S1L|Nf}pH zknPkEX4uUYOF*1<h7#aG}J+ifK3n>=B20YPRfe%RT%V#4P1 z1GsAI#*?J4urgcXpD%ZX4wtgBvg2Ge5IWmk5?vZQDs`Eio!NBpy`>~x)NI&s#;MN= zx2C1xINiwb&l{`#n?hR;AAGlay}w|I_@kC@eu=YZU{ZvqX?n9!`D~OD%!i7tQErJsg8x{&HzVn#{inSKxDG?vW<$@!^x1kQ$Zn67Q)$qE|r*bFx z24;RGc}Htqx7r*knz9=oNx4j)WOhQ7h#yg*e_Rs0<1#v2Pz-!*W^d+W>Z^bFwXeve z0e0~-#L{T8&e?s-=%Ih*uuWcZleqob^Z4F4GsAM%NpC&9@E3~S#v5r0XR;^1Q)Mp> zC>}^A%j@L(z6fDqwtbZskWL=LQtz49Vj+$-uHoXB1UOw(fg^H8WAEgE!Hl0@DMqzh zs3uEPp1x^6ze;g-dfM97#t#WCF2OVT``b6a_k+rt!b=jIJ+^<1Z{l!P=r*A2X>S#@ zx~5FrP)R*URD*_-o&mS|{M-W!uA*n?=;%-i>w>z#SP%NTN=tFqg4?y^?u~ziEiEo) z`ZCd6S8R=X$V<~rO_N`Nie$BQGcJD%iR8Et5fsEj+~Rw)2SAW2V+UCu zzqpth^KYR)vMqBDcOFc{8yIRD3iBM+p5(ub5oU@xr2UiSVKiB5b8>om;Fl0vSWOE9 z{fG=#T8t03^U>Zrhs-)vHH}&`r6T<#8R7B{{mtWbkkaYt&KX0k>oE#%dvJ=t4-GNX zj~FSbMqEgnBW6bUoG2(*`&KkPsI_u&b65T;CAz}`vWVk?sIuDH9522qFGMX(5`SD!KrJsXZ=UmYis{ee&fUmDm93sX zd(&(Ub$+@y)sRuk8XgkTku-HssjeiYM(hfm_1T|;Cj2;-gYTs=Z*%`CYj(-C{S<}| zVmh(VGHF?Bk5{jn%kw`rndGMsOiB;yMRcIQ3p4Jwh0v-eFwcw3BtH~~`LxEe>$s~= zid%NZVu;|${DR(PZ49dSv84gbZw2EKFKaqr1Q!(Sei;|HinA7b>#@qHtdwjVIk4

WMhMhR!zk372#lFt!MpL+5?adET<>tFFlvaS$7 b=p->L;6g71Wr|LzKP(Uj_aH$zAMa literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/resizable_panel_group-light.png b/testdata/snapshots/darwin/resizable_panel_group-light.png new file mode 100644 index 0000000000000000000000000000000000000000..70099f432e3ac8fd7d8e44727dffb9b44814f317 GIT binary patch literal 6121 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 Date: Mon, 5 Oct 2026 14:32:23 -0400 Subject: [PATCH 13/20] WIP essentials 13 --- dock_panel.go | 193 ++++++++++++++++++ dock_panel_test.go | 102 +++++++++ messages_essentials.go | 6 + testdata/snapshots/darwin/dock_panel-dark.png | Bin 0 -> 13872 bytes .../snapshots/darwin/dock_panel-light.png | Bin 0 -> 13699 bytes 5 files changed, 301 insertions(+) create mode 100644 dock_panel.go create mode 100644 dock_panel_test.go create mode 100644 testdata/snapshots/darwin/dock_panel-dark.png create mode 100644 testdata/snapshots/darwin/dock_panel-light.png diff --git a/dock_panel.go b/dock_panel.go new file mode 100644 index 0000000..8d34e49 --- /dev/null +++ b/dock_panel.go @@ -0,0 +1,193 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// DockZone is an area of a DockPanel. +type DockZone int + +const ( + DockCenter DockZone = iota + DockLeft + DockRight + DockBottom +) + +// DockLayout is the caller-held arrangement: panel IDs per zone in tab order, and each zone's shown tab. +type DockLayout struct { + Zones [4][]string + Active [4]string +} + +// DockItem is one dockable panel. +type DockItem struct { + ID, Title string + Build func() +} + +// DockPanelOptions configure a DockPanel. +type DockPanelOptions struct { + Disabled bool +} + +// dockPanelDrag is a tab being dragged. +type dockPanelDrag struct{ id string } + +// DockPanel arranges panels in left, center, right and bottom zones; tabs drag to an edge or zone to dock there. +func DockPanel(c *ui.Context, layout *DockLayout, items []DockItem, opts DockPanelOptions) ChoiceResult { + if layout == nil || len(items) == 0 { + panic("mujica: DockPanel needs a layout and items") + } + byID := map[string]DockItem{} + for _, it := range items { + if it.ID == "" || it.Title == "" || it.Build == nil { + panic("mujica: DockPanel item needs an ID, Title and Build") + } + if _, dup := byID[it.ID]; dup { + panic("mujica: DockPanel item ID repeated: " + it.ID) + } + byID[it.ID] = it + } + placed := map[string]bool{} + for z, ids := range layout.Zones { + for _, id := range ids { + if _, ok := byID[id]; !ok || placed[id] { + panic(fmt.Sprintf("mujica: DockPanel zone %d holds unknown or repeated %q", z, id)) + } + placed[id] = true + } + } + changed := false + for _, it := range items { + if !placed[it.ID] { + layout.Zones[DockCenter] = append(layout.Zones[DockCenter], it.ID) + changed = true + } + } + for z, ids := range layout.Zones { + if len(ids) > 0 && !slices.Contains(ids, layout.Active[z]) { + layout.Active[z] = ids[0] + } + } + k := Tokens(c) + u := Density(c).Unit() + root := ui.Box(c).Disabled(opts.Disabled).MinHeight(0) + disabled := root.IsDisabled() + // follow is the tab that moved; it keeps the focus in its new zone. + follow := ui.Local(root, "dock_panel.follow", func() string { return "" }) + move := func(id string, to DockZone) { + *follow = id + for z := range layout.Zones { + layout.Zones[z] = slices.DeleteFunc(slices.Clone(layout.Zones[z]), func(s string) bool { return s == id }) + } + layout.Zones[to] = append(layout.Zones[to], id) + layout.Active[to] = id + changed = true + slog.Debug("mujica: dock_panel docked", "panel", id, "zone", to) + } + dragging := false + root.Children(func() { + ui.Column(c).Fill().Children(func() { + ui.Row(c).Grow(1).MinHeight(0).AlignItems(ui.Stretch).Children(func() { + for _, z := range []DockZone{DockLeft, DockCenter, DockRight} { + if dockPanelZone(c, k, u, layout, byID, z, disabled, move, &changed, follow) { + dragging = true + } + } + }) + if dockPanelZone(c, k, u, layout, byID, DockBottom, disabled, move, &changed, follow) { + dragging = true + } + }) + // Edges stay one frame after the drag, when the drop arrives. + was := ui.Local(root, "dock_panel.drag", func() bool { return false }) + if (dragging || *was) && !disabled { + dockPanelEdges(c, k, u, move) + } + *was = dragging + }) + return ChoiceResult{root, changed} +} + +// dockPanelZone builds zone z's tabs and shown panel; it reports a tab of it being dragged. +func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout, byID map[string]DockItem, z DockZone, disabled bool, move func(string, DockZone), changed *bool, follow *string) bool { + ids := layout.Zones[z] + if len(ids) == 0 { + return false + } + box := ui.Column(c).Key(z).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup). + Label(msg(c, fmt.Sprintf("dock_panel.zone%d", z))) + switch z { + case DockLeft, DockRight: + box.Width(220).Shrink(0).Border(theme.BorderWidth, k.Border) + case DockBottom: + box.Height(160).Shrink(0).Border(theme.BorderWidth, k.Border) + default: + box.Grow(1) + } + if d, ok := ui.DragOver[dockPanelDrag](box); ok && !slices.Contains(ids, d.id) { + box.Border(2, k.AccentText) + } + if d, ok := ui.Drop[dockPanelDrag](box); ok && !disabled { + move(d.id, z) + } + dragging := false + box.Children(func() { + at := slices.Index(ids, layout.Active[z]) + base := ui.TabsBase(c, &at, len(ids)) + base.List.Gap(u).Padding(theme.FocusGap+theme.FocusWidth, u*2, 0, u*2).Children(func() { + for i, id := range ids { + tab := base.Tab(i).Drag(dockPanelDrag{id}) + tabsStyle(c, tab, Tab{Label: byID[id].Title}, i == at, false) + tab.Description(msg(c, "dock_panel.hint")) + if *follow == id { + tab.Focus() + *follow = "" + } + dragging = dragging || tab.Dragging() + for key, to := range map[ui.Key]DockZone{ui.KeyLeft: DockLeft, ui.KeyRight: DockRight, ui.KeyDown: DockBottom, ui.KeyUp: DockCenter} { + if tab.Shortcut(ui.Shift|ui.Alt, key) && !disabled && to != z { + move(id, to) + } + } + } + }) + if base.List.Changed() && !disabled && ids[at] != layout.Active[z] { + layout.Active[z], *changed = ids[at], true + } + ui.Box(c).FillWidth().Height(theme.BorderWidth).Background(k.Border).Role(ui.RoleNone) + ui.Scroll(c).Grow(1).MinHeight(0).Padding(u * 3).Children(byID[layout.Active[z]].Build) + }) + return dragging +} + +// dockPanelEdges shows drop strips on the four edges while a tab is dragged. +func dockPanelEdges(c *ui.Context, k theme.Tokens, u float32, move func(string, DockZone)) { + for _, z := range []DockZone{DockLeft, DockRight, DockBottom, DockCenter} { + e := ui.Box(c).Key(fmt.Sprint("edge", z)).Absolute().Role(ui.RoleNone) + switch z { + case DockLeft: + e.Left(0).Top(0).Bottom(0).Width(12 * u) + case DockRight: + e.Right(0).Top(0).Bottom(0).Width(12 * u) + case DockBottom: + e.Left(12 * u).Right(12 * u).Bottom(0).Height(10 * u) + default: + e.Left(0).Right(0).Top(0).Height(4 * u) + } + _, over := ui.DragOver[dockPanelDrag](e) + if over { + e.Background(k.Selection).Border(2, k.AccentText) + } + if d, ok := ui.Drop[dockPanelDrag](e); ok { + move(d.id, z) + } + } +} diff --git a/dock_panel_test.go b/dock_panel_test.go new file mode 100644 index 0000000..20b5aa0 --- /dev/null +++ b/dock_panel_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func dockItems(c *ui.Context) []DockItem { + var out []DockItem + for _, id := range []string{"files", "editor", "terminal"} { + out = append(out, DockItem{ID: id, Title: "Panel " + id, Build: func() { ui.Text(c, "Body of "+id) }}) + } + return out +} + +func dockTester(l *DockLayout, dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := DockPanel(c, l, dockItems(c), DockPanelOptions{Disabled: *dis}) + r.Element.Size(800, 500) + if r.Changed() { + *n++ + } + }, 820, 520) +} + +func TestDockPanelTabsAndKeyboardDock(t *testing.T) { + var l DockLayout + dis, n := false, 0 + tt := dockTester(&l, &dis, &n) + if !slices.Equal(l.Zones[DockCenter], []string{"files", "editor", "terminal"}) || l.Active[DockCenter] != "files" || !tt.HasText("Body of files") { + t.Fatalf("default layout %+v", l) + } + if err := tt.Click("Panel editor"); err != nil { + t.Fatal(err) + } + if l.Active[DockCenter] != "editor" || !tt.HasText("Body of editor") { + t.Fatalf("tab click: %+v", l) + } + tt.Key(ui.Shift|ui.Alt, ui.KeyLeft) + if !slices.Equal(l.Zones[DockLeft], []string{"editor"}) || slices.Contains(l.Zones[DockCenter], "editor") { + t.Fatalf("Shift+Alt+Left: %+v", l) + } + if _, ok := tt.Find("Left dock"); !ok { + t.Fatal("left zone not shown") + } + tt.Key(ui.Shift|ui.Alt, ui.KeyUp) + if len(l.Zones[DockLeft]) != 0 || l.Active[DockCenter] != "editor" { + t.Fatalf("back to center: %+v", l) + } + before := n + dis = true + tt.Frame() + tt.Key(ui.Shift|ui.Alt, ui.KeyDown) + if len(l.Zones[DockBottom]) != 0 || n != before { + t.Fatalf("disabled docked: %+v", l) + } +} + +func TestDockPanelDragToEdge(t *testing.T) { + var l DockLayout + dis, n := false, 0 + tt := dockTester(&l, &dis, &n) + r, _ := tt.Find("Panel terminal") + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + for _, px := range []float32{x + 10, x + 200, 600, 790} { + tt.Move(px, 250) + } + tt.Release(790, 250) + tt.Frame() + if !slices.Equal(l.Zones[DockRight], []string{"terminal"}) || n == 0 { + t.Fatalf("drag to the right edge: %+v", l) + } +} + +func TestDockPanelRejects(t *testing.T) { + for name, l := range map[string]DockLayout{ + "unknown": {Zones: [4][]string{{"nope"}}}, + "repeated": {Zones: [4][]string{{"files"}, {"files"}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); DockPanel(c, &l, dockItems(c), DockPanelOptions{}) }, 300, 200) + }() + } +} + +func TestDockPanelSnapshot(t *testing.T) { + l := DockLayout{Zones: [4][]string{{"editor"}, {"files"}, nil, {"terminal"}}} + snapshot(t, "dock_panel", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + DockPanel(c, &l, dockItems(c), DockPanelOptions{}).Element.Size(616, 336) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 618816a..5c9e9d2 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -30,6 +30,12 @@ func init() { "resizable_panel_group.divider": {"Resize %s and %s", "调整 %s 与 %s 的大小"}, + "dock_panel.zone0": {"Center dock", "中央停靠区"}, + "dock_panel.zone1": {"Left dock", "左侧停靠区"}, + "dock_panel.zone2": {"Right dock", "右侧停靠区"}, + "dock_panel.zone3": {"Bottom dock", "底部停靠区"}, + "dock_panel.hint": {"Drag to an edge, or Shift+Alt+arrow, to dock", "拖到边缘,或按 Shift+Alt+方向键停靠"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/dock_panel-dark.png b/testdata/snapshots/darwin/dock_panel-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..cf54f97fda3b2de38553bb2ba999cbccd4812625 GIT binary patch literal 13872 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/dock_panel-light.png b/testdata/snapshots/darwin/dock_panel-light.png new file mode 100644 index 0000000000000000000000000000000000000000..0cab0c0a651f434b56beabfc3ec0d34014bcae04 GIT binary patch literal 13699 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�sWG+o)g|uOyWy9-Mk*n52tjCrHk!83h^LN?(AC2Jy5dsxCIeDQf6Y;k@ zfa}Fh&G~kFajghhqk)l{TAh`bmb5fu<$dGs&JMhcPaM_r&AGW$z`yR>x6BcEiS|!a zTM{pt1ny(}H&hEvl zw_0kzBcv=v-Li7quge2AHjQ#v^%SC#n`l1NOWP}(UA}_r`HC5vxisj zJWjE(4I1rc^j#Ybn3H{7d=wRjNl20nl-r%y4U2&hVtFTPXA=3bFT$`Ivq6W4bYe2H zvU7VU9!jxCqmJ{7v$KWsGHU7*#v4un3GGWRH9!{^8xM~d(o}67osE5por8^G#%&w}JychA zbT>E{xx4^wCV$GyqxH4>D;rKIky*9-!@|O45n=Rw=^CfiZqz@WlR>^^fG?0*6EFn#l^tjtUb<9tS3VK zNRWeXW^KK)Vv-Y&Z)R@py{F-^hPALY45Ue*o>^F^f5p|*+#H5KF?ntV4-BxGiGktA zv}c}39*Y|t2`vPtcH~G}O${wQ0c6N52Q)xYv(h_h<4-tN=5&vL(c-~Z0 zv_?eKz~2%$ICzeQ<(B=()#ODFKNM=N9U4-cl5a#iTjU3qIpHS%>T#Scsj#$k?z6?v zc6(tu0S?Xb+ZKBz-wWG${y1JVC- zqj}e1TC+7;kMGB9@7VV9>U?zBf95QASFni^;h*^5W)`TpHRR&vrfNtCx6g|43RASG zH0>3wguZ^%5glAOAdJvpPQK3vSKdiah1Av5fblip3g`Ze(}}nIZc2j8%ZXF=pVMka z@`Y-8pXo6}OikY9SCv>|i#Si<^?jVV9rSf5;2U`%BR-E_5^I;{bNMg2_<_xJ6bNZE8uENrgvX*c$( z_NOjKM!-i$jyJZ1#2Y!r&JuJ9O)dsMvldQk>grh8NyB^Ao^e}#NpFfFCs&Xk9EfZG z`0?YObeGn2%GI(+>S&z0mS!9X0)aTqz4Z_NH?_ClI5_xxcN06R4ARw}!EX+!s;b)F+j|3q zwc6Jip6vtLe%<}Np2u>j@k9M6x*YKf?H8mzLioCnpQ%4*x_9k#hmavD0lytmvzlMM zCZVT?Iy2~Yb0Leu!wsz7`?-4#Aw@+)L%vIeH5utVR`5TgeN;~OreX=$Z2HUhUt z>uG|`+wQlpFc$I)*3G|)+Sv`Z-a;*FboOpiK9kbD$kAq!k&y58xxb?cw1U}N=Y$f zIBaiT?kLv$s)tg;DNf^lcLUTv#OToy%sZnG5R|j>`qbGenx^16Ds(m0B?E_ho;St2 zI|?OFHp!?`(9kLkOw6)UQWSdG7t<;DZmfpJp83!F0>l}TY;0nLM2>c^$2&USiJR@+ zA`;Oh%b}s7S`MdQ?hlgv{scUp4mTzhYt^~Kw6g*kV~UyGc~jE3ttJK&&_i-Isrv2g zhz@=1wu6f>#;3Bb_p#cAdiTp8xVT&I+zKi&vsRyRWFxNswNhT^fz_$()=9o5s2~^!2F#Q^FKqK9)O7dncM%QJ^$y~ z*}wYvgJS-o#;qn_d941Is`3Adl<-^Ch@%9&K}10L$Pu~nz%3BCw1L9Q$ZQX^o*5l#=_uY;KlOaQ?3p=$8?_aS&{F WEvlYGQYj Date: Mon, 5 Oct 2026 14:33:30 -0400 Subject: [PATCH 14/20] WIP essentials 14 --- activity_feed.go | 137 ++++++++++++++++++ activity_feed_test.go | 92 ++++++++++++ messages_essentials.go | 6 + resizable_panel_group_test.go | 5 +- .../snapshots/darwin/activity_feed-dark.png | Bin 0 -> 30238 bytes .../snapshots/darwin/activity_feed-light.png | Bin 0 -> 29704 bytes 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 activity_feed.go create mode 100644 activity_feed_test.go create mode 100644 testdata/snapshots/darwin/activity_feed-dark.png create mode 100644 testdata/snapshots/darwin/activity_feed-light.png diff --git a/activity_feed.go b/activity_feed.go new file mode 100644 index 0000000..c0406b1 --- /dev/null +++ b/activity_feed.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "cmp" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Activity is one event of an ActivityFeed: Actor did Action to Object at Time. +type Activity struct { + ID string + Actor string + Action string + Object string + Time time.Time +} + +// ActivityFeedOptions configure an ActivityFeed. +type ActivityFeedOptions struct { + // Location decides which day an event falls on; nil is time.Local. + Location *time.Location + // Today fixes the day called Today; zero uses the clock. + Today Date +} + +// activityRow is a day header (item -1) or an event. +type activityRow struct { + day Date + item int +} + +// activityFeedCache keeps the grouped rows until the count, first or last event, or location changes. +type activityFeedCache struct { + n int + first, last string + loc *time.Location + rows []activityRow + scroll ui.ScrollState + sized bool +} + +// ActivityFeed lists events grouped by day, newest first as given, building only the rows in view. +func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element { + loc := cmp.Or(opts.Location, time.Local) + for _, a := range items { + if a.ID == "" || a.Actor == "" || a.Action == "" || a.Time.IsZero() { + panic("mujica: ActivityFeed event needs an ID, Actor, Action and Time") + } + } + k := Tokens(c) + d := Density(c) + sc := ui.Scroll(c).MinHeight(0).Role(ui.RoleList).Label(msg(c, "activity_feed.label")) + if len(items) == 0 { + sc.Children(func() { + EmptyState(c, EmptyStateOptions{Title: msg(c, "activity_feed.empty"), Description: msg(c, "activity_feed.empty_hint"), Icon: icons.Must("clock")}) + }) + return sc + } + cache := *ui.Local(sc, "activity_feed", func() *activityFeedCache { return &activityFeedCache{} }) + sc.TrackScroll(&cache.scroll) + if cache.n != len(items) || cache.first != items[0].ID || cache.last != items[len(items)-1].ID || cache.loc != loc { + cache.n, cache.first, cache.last, cache.loc = len(items), items[0].ID, items[len(items)-1].ID, loc + cache.rows = cache.rows[:0] + var prev Date + for i, a := range items { + if day := DateOf(a.Time.In(loc)); day != prev || i == 0 { + cache.rows = append(cache.rows, activityRow{day: day, item: -1}) + prev = day + } + cache.rows = append(cache.rows, activityRow{item: i}) + } + slog.Debug("mujica: activity_feed grouped", "events", len(items), "rows", len(cache.rows)) + } + rowH := d.ControlHeight() + 2*d.Unit() + view := sc.Bounds() + if view.H == 0 && !cache.sized { + // Unmeasured: build again once the box has its height. + cache.sized = true + c.Invalidate() + } + first := max(0, int(cache.scroll.Y/rowH)-2) + last := min(len(cache.rows), int((cache.scroll.Y+max(view.H, rowH))/rowH)+3) + today := datetimeToday(c, opts.Today) + sc.Children(func() { + ui.Box(c).FillWidth().Height(rowH * float32(len(cache.rows))).Children(func() { + for r := first; r < last; r++ { + row := cache.rows[r] + box := ui.Row(c).Key(r).Absolute().Left(0).Right(0).Top(rowH*float32(r)).Height(rowH). + AlignItems(ui.Center).Gap(d.Unit()*3).Padding(0, d.Unit()*3) + if row.item < 0 { + activityFeedHeader(c, k, box, row.day, today) + continue + } + activityFeedEvent(c, k, box, items[row.item], loc) + } + }) + }) + return sc +} + +// activityFeedHeader is a day's title: Today, Yesterday or the date, over an ornament rule. +func activityFeedHeader(c *ui.Context, k theme.Tokens, box *ui.Element, day, today Date) { + label := datetimeDayLabel(c, day) + switch day { + case today: + label = msg(c, "activity_feed.today") + case today.AddDays(-1): + label = msg(c, "activity_feed.yesterday") + } + box.Role(ui.RoleText).AlignItems(ui.End).Padding(0, Density(c).Unit()*3, Density(c).Unit(), Density(c).Unit()*3) + box.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Ornament, 0) }) + box.Children(func() { + ui.Text(c, label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize+1)).FontWeight(600).TextColor(k.Text).SingleLine() + }) +} + +// activityFeedEvent is one event: avatar, "Actor action Object", and the time. +func activityFeedEvent(c *ui.Context, k theme.Tokens, box *ui.Element, a Activity, loc *time.Location) { + when := a.Time.In(loc).Format("15:04") + box.Role(ui.RoleListItem).Label(a.Actor + " " + a.Action + " " + a.Object + ", " + when) + box.Children(func() { + Avatar(c, a.Actor, AvatarOptions{Size: 28}) + ui.Row(c).Grow(1).Shrink(1).MinWidth(0).Gap(4).Children(func() { + ui.Text(c, a.Actor).Bold().TextColor(k.Text).SingleLine().Shrink(0) + ui.Text(c, a.Action).TextColor(k.TextMuted).SingleLine().Shrink(0) + if a.Object != "" { + ui.Text(c, a.Object).TextColor(k.AccentText).SingleLine().Shrink(1).Ellipsis("…") + } + }) + ui.Text(c, when).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() + }) +} diff --git a/activity_feed_test.go b/activity_feed_test.go new file mode 100644 index 0000000..2d408de --- /dev/null +++ b/activity_feed_test.go @@ -0,0 +1,92 @@ +package mujica + +import ( + "strconv" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func feedItems(n int) []Activity { + base := time.Date(2026, 10, 5, 18, 0, 0, 0, time.UTC) + out := make([]Activity, n) + for i := range out { + out[i] = Activity{ID: strconv.Itoa(i), Actor: "Mira", Action: "edited", Object: "Doc " + strconv.Itoa(i), Time: base.Add(-time.Duration(i) * 5 * time.Hour)} + } + return out +} + +func TestActivityFeedGroupsByDay(t *testing.T) { + items := feedItems(6) // 18:00, 13:00, 08:00, 03:00 on the 5th, then 22:00 and 17:00 on the 4th + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: Date{2026, 10, 5}}).Size(600, 500) + }, 640, 540) + tt.Frame() + for _, s := range []string{"Today", "Yesterday", "Mira edited Doc 0, 18:00", "Mira edited Doc 4, 22:00"} { + if _, ok := tt.Find(s); !ok { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + today, _ := tt.Find("Today") + yest, _ := tt.Find("Yesterday") + doc3, _ := tt.Find("Mira edited Doc 3, 03:00") + doc4, _ := tt.Find("Mira edited Doc 4, 22:00") + if !(today.Y < doc3.Y && doc3.Y < yest.Y && yest.Y < doc4.Y) { + t.Fatalf("order: today %v, doc3 %v, yesterday %v, doc4 %v", today, doc3, yest, doc4) + } + // Another zone moves 03:00 UTC to the previous day. + tt2 := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + ActivityFeed(c, items, ActivityFeedOptions{Location: time.FixedZone("W", -5*3600), Today: Date{2026, 10, 5}}).Size(600, 500) + }, 640, 540) + tt2.Frame() + if _, ok := tt2.Find("Mira edited Doc 3, 22:00"); !ok || !tt2.HasText("今天") || !tt2.HasText("昨天") { + t.Fatalf("zone texts %q", tt2.Texts()) + } +} + +func TestActivityFeedVirtualizesAndEmpty(t *testing.T) { + items := feedItems(100000) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: Date{2026, 10, 5}}).Size(600, 400) + }, 640, 440) + tt.Frame() + if n := len(tt.Texts()); n == 0 || n > 120 { + t.Fatalf("built %d texts for 100000 events", n) + } + tt.Scroll(100, 100, 0, 2000000) + tt.Frame() + if _, ok := tt.Find("Mira edited Doc 0, 18:00"); ok { + t.Fatal("first event still built after scrolling far") + } + tt3 := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, nil, ActivityFeedOptions{}).Size(400, 300) + }, 440, 340) + if !tt3.HasText("No activity yet") { + t.Fatalf("empty: %q", tt3.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("event without ID accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, []Activity{{Actor: "x"}}, ActivityFeedOptions{}) + }, 200, 100) +} + +func TestActivityFeedSnapshot(t *testing.T) { + items := feedItems(5) + items[1].Actor, items[1].Action, items[1].Object = "Lin", "commented on", "Quarterly report" + items[3].Actor, items[3].Action, items[3].Object = "Sakiko", "approved", "Merge request !42" + snapshot(t, "activity_feed", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: Date{2026, 10, 5}}).Size(496, 296) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 5c9e9d2..4c8abe1 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -36,6 +36,12 @@ func init() { "dock_panel.zone3": {"Bottom dock", "底部停靠区"}, "dock_panel.hint": {"Drag to an edge, or Shift+Alt+arrow, to dock", "拖到边缘,或按 Shift+Alt+方向键停靠"}, + "activity_feed.label": {"Activity", "活动"}, + "activity_feed.today": {"Today", "今天"}, + "activity_feed.yesterday": {"Yesterday", "昨天"}, + "activity_feed.empty": {"No activity yet", "暂无活动"}, + "activity_feed.empty_hint": {"Changes people make will show here.", "成员的操作会显示在这里。"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/resizable_panel_group_test.go b/resizable_panel_group_test.go index 7deb347..6c56463 100644 --- a/resizable_panel_group_test.go +++ b/resizable_panel_group_test.go @@ -106,7 +106,10 @@ func TestResizablePanelGroupRejects(t *testing.T) { } }() s := tc.sizes - ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ResizablePanelGroup(c, &s, tc.panels, ResizablePanelGroupOptions{}) }, 300, 200) + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ResizablePanelGroup(c, &s, tc.panels, ResizablePanelGroupOptions{}) + }, 300, 200) }() } } diff --git a/testdata/snapshots/darwin/activity_feed-dark.png b/testdata/snapshots/darwin/activity_feed-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b6a09e59ef2a5fccdd493a958373c20afe16a30e GIT binary patch literal 30238 zcmeFYWl&sA*Y^v-CAbBLAPMen!QBZ2cXxM!2M_KLJh*#s8QdKP$>6SoGiR>nxu5I( za_ZDu=hJ(t&aSCFvwL>!>Rx+w_gcUHca*BK%zIQ~R2UeT_j0n5>M$_xkYQk8#gN}Y zOTGr+{)U0^*prj|qUoJ;x@shwYXO6JdB4v8D|g>r)yk0@(X0i#zM(FLr9#SnHh-3~ zCA!5!4d|_QND7~Y^zny!r)tVz*iW`{$~eg)bOvOnLY6`?WDI26LiO?k=Xmdqkp%H~ zM-^lb!rB);MpI|^pdC4K#brcn>_k4lzi>poFoOVv>t2N*JVe}ts@1aO2-F4USa)yh$*!y2kWNJKDfLWN{1UPSiW(j#Lsq&ru zm#>(jo6o(V`TT&Updb%_E3-QG6(W0do`~x$B13yy2Zx{l4-4+FpOaGt0gsFE%N-zq zK|_6_p5Zs3PbIrGI&tXmFZL80vxl>L(JbAyvYr>Z?v<(MB8u8tCrLH;x2Q%HVW0C- ziRO0wx3YXI!L8%`+*jH#)gXWa^JJWdhs^TQ666~p#!{(X7RiO<@7mv0BL~;G$wuy; zcX9FPG&oT`$NdVE4w)^C+}w+LdU}L;fd#6chJzM~xDeyMn9UH?1P`k+5s2nK_yh7t z#XqIWgYp#(y1lzUy4_t)Mo#5*a=mOu-fwwLk2phu;dsgUT_1o2d7bA%kBiG&JO(x@ zr>k#=kyv>_=b19(fzE*@SAX}0-Jc??SPiVLe_tF09Hey=(Z^F{8|h3~9{4^-D3glv zu<xx!Q!4Jkie<~^s}Fq~RbNbUJE zA@u(?Q$>DA63ys^t-HEmT&R+h#{O|RmdBqt6CGq>E+;ebJe zf%^{I3uEkU+1tD4YR-WoE?cfi6^3^!9unE9|H)kp`N;h#Yt8q`xzhCp6%9?{FQ`yd z2U5x(tXPaf)#PJGEtjN7cXOw0q~Aj0>G#*L1L?n>2g3$Kl!wh`-P}>bf{N0@qaXG& zx4N1?PFHumS(mJNtZ$It-m^z5zB>DjoPy9mORLd))h>}0}~q)r?-)>6PERXjU8>vW|xqQ;I4kINIZl2%-> z4{taL5&>o;XB%l~IGLH@3efS{pZ;!lE9GR1owVeSmU?>=NoP5Th)3~s@w~pjx3N3H ziV08DpmS$Pp{3nBJIgIF3pqPI6M1tlnzb%AB87h!EGF?WqH!T=liYB1#koX1>T9Kg zgF~6yQo(mw8hhK0C{Tu>QyVw(PuW3#^67?`?frdQHX{8T3%7#_KJQO=MMjWgaZ9k* z)>O-)XwjO2Xr5I$bE73<+kPDY2&md^`^qt&^XfuGot*zC@S7@qx}+0 zg4g!HdwOpXybk&i*I4`xduu9wyxycCgGjny#GC^gG4yMh*8Oc$IiBxFp>MExRWK@F zztc1JCgVrEjiRam0gi6p?Avwdwo#nJ6cPK)DP{X*vlKPIkwvg)@C}=qGL>Q{GQ@S% zH)`3<%p1U>|HmqqMo5T+1(I~oF2-h-KUM5EgHHjfb0NX_lBJ7JHaP}{jfV*qqnyEA zs1xd&R5)h9jsAf6aTch66Dm}j&qb^x02jaGPo6Mf?saEIW{{9uPivX9zrn~Z8ws;mi zXH!=hVt{{IVXTeNdCY02z1Oh9k@&HkJ`fh4$w<07Zvhv2nLgB=3jbey589T2MpVRh z9ZN%prMW00YP0;b(cWFt54rz(z(6AkBF_}gCHz9iJGr@bBbp>G@DaQaEQ!@@c|oFB zZyE2uCSiLq{)sB7*%hJ;sUtgQO;%3GR*|Jg#EvlGi~lBnaE=$R;e!|&jJ5VMd63Rp zN;4=kA2(29;eysbAdW50?q*f3k0Vn?@eLOcXVAbFvDwMouJf1Ht+cyX*?u2unSRa9 z!7>g5&Do;}mlEqx?nj^TrH_!W6AU{Mx=i8^=1T|GUMb?IZ|Z7c9?xq$-=$-&#}t5gVG5HWlMC98TJ`4R?K->N=i z=0Xe#iYHJ!gfc8XU)HGg&8sK`jvXD196Q5(n+WKn!^Le%ojuu|lrHM&{zH;!vzhe+ zRSFgfaAm=t|DGXWVH@@KhKx|acB`Psy}zQ`ZYv#`bRt_;1#6l31Vg|3r{^6i@NK-= z^?0InpV@e4K{RSCZJ(DXsNft94Mqp1_Z?db0Zf5d-Tu%X)yH?dFK01X9lqoEqJdu) zHa^Vrk$O5AZ3Tb)Oh0Wo6RF4j*?%KCEc~5FPm#tIuqI!=R!c{qyTw*&@PUmK!04zM z(C(w>WJCc3mBYfNo0)Y0@@+X1sYOH?Op|;t7dQT_mTFRmnU*P%9qolUsY}6F3AwVSNDu9NYzw;eGWH9-q5f!CKAI5|d6dgpn|!`71t+tru_nJ-^L; zcZ&(r>G1H}3)U_XyJr|M>4c9JwigOgDA9u3JfXz~DQjgd8EC z{)gI@w_mEBU63U^RwlzHhl9Mx07eybbVY*sUq?ZqtCjQQ9Y5J=&G8~kg!Io*@?RwVfRad4+D9l(tK z0s9BY``b{QupCmSpD&iUP~PjD;)6lylpe%7a2K6x2!4YFb@#GMRnv_(q=%HgG-F%lFVyLb9pf{nFZ3KgWFHib-!Mx^!x8KvF z?`Sbj*w0$b<}Op8jkda3(?nGo_mwU;jop+ej`AY0UJ_2<-JZ8cK;p-DGr6zWy3f}Y zA2jIG(~PDWney<+!RI8kY!klEi;LKxfoS4wr00C?s;GRTZ~8`V)S~pV z=iO)3m`@)|iL;7wm7i77#KMYWTnl3*k2(MasKt9i>|o8|>!*N2DBn(=tk**5kfjFP+ullx7I zAmuf$L+hVC`HhMU$@0?h4`sO`o!l<>=dYt%kOy4os2##qnkT<}au+u_Szb1$W$Q8c z;POu7;-;48Ow|9B0KLkrPO2xQeOe1`1>H1KqpYv8nJ5 zkM|i|b8ztfFoJHao;}|LX&;k;qOu4W({ZF{46d=H*w^zzjU<7}F9(UQJzJecE!9JS z(Qpx1d`D9oyY!?@i8KdZnUh|OckfIy`i&`VPe`DZWsQ#mUpv=Ulv7>sc&@Fl+H`at z2^Y^ohqMQDM2X%5;8^7(i3D;4Rkl1wrXo7}#|uu_xy_O)d;8neaqWqmk_Bts)KDfy zPPPei4&lQee(scyD^8-m^p0u^t8SSeLtfU`1*fgdAD_JE&R#vZ_7>_FG)Ag+CCb%q ztODPbx6t5)=#q7l4|%sQUA<(G!()+e-HRM2&dyGMli2dM&%V&Pp`ljuu7wYUb~~_Q z@)nJ{kGt&l9-xI|=wkqe*ducDJc=-4*5A{ z;2)eG)?`adYrr zmPZ9EOA!VvXuu)%8ymA-!S;!*%CR$5HVtaCc;Un(7>pI_oiifh(xXSf5}#8bgwtv* zHj8ZL1wGCKkPAX1HICAPOEh(ze$p!=Kbx%gIFp{7pO@E^a}q1vcZjXpSNs+)QLp4$ zuzLBdRS~hjuNhJN*-Lgf=M)TL~-_XF;O6|eI(5QzsjDNoJ|Q9@#BLEJEwmF7Hgf| zBf8p(>gtN}BJ{M9W!-f_p^d!_p(*X4XHa`gI+%vc-o`GQoIh%O&$?MaCUGQq+R33v zB)p@f8VDHoy*NBMD_=a;w7sKb1@D$6H3=~D%(&LS&o*;112H7-rWtW!N!EV#yYoee z>AuySNW4ciV{cCI?vzj34K^Qh-@Uj9#gdl~)PbA3wMlj%)hJ)UrJFXoe=Fk?>D`iZ zfa5mMaIH3dKF^2Fu|5~U1DUJ*cS=fkaZ1PqzhFdf)JC?Jvwc{#s;uib6P9_n8gwRh zi)_*~N^*pf6H|e}`T4a?Y`kr&@($xhoxn}Q*D1DNA-m+PO8N5zU%5XEFl3dZ0N9*@ z+|aJwJonaD(s&osdDR|K0>3Qn5zwb&5-@qwDF2n&Nv*8}CWWkx%O6m%5# zJHlu?mGj#4bqkM9+&tX_}lzS=UNR{D`KX3;3)=N^R!`@%hUL$sl2hPs^Gl&+l>;PMwxV%?=UGn(yUk61+m)M zxKKV8``vsPVN0I;hrJ!Lm=&rBa^+kFM`eO3F` z#ruf-imHN3w5Exgxq=O4a36jaTUl%_>#2Pjs!Co!(ZM)_P zihKL z5Kv*9p%_P|d?B&znH`5N`G~?Os1uy3r9i-9%;8cZe>Zs_#k>y-OwVrVYPVc$Xwx~? z_W0~)FI#896P2aO@h#}yrfiz!8&Q|#UPCr>^>SqeZ$N&Vx5-3yZIw8(L2cD$14zd9 zha1h%7fNIbLCdkh%ZSqb{v-~scE;DIRtp-Xk3b=r4xc2NZ|h604U`wo%KOdIwxqVy zqxUUNPXi5$PE+4{`>!gNvU`*_=4QSqA$9cx+)$+T+&-OJ^CYj%l{~Py#8lkZ3^_=( zJ|#Q{=~Fa5ureAg;tr06Y>-*0%)~*5myu$1=nHvf>59BX9Pkpnx8G`B-rO{-p1sxc zY02_(y>7USP}0~EIN;Zs22O7i?Cya6(lj1+ zUL3l}5e+8`dU$#n%r0%|4hV84s>=S9yq{cpcjZ&%>x;BiPlZ?4XDc`wi7S`1;E@Pd zFAF6P#<1Mo_lCfyoKHkIQ>ER(rX5KFFc&Mf9MZPJ&UMGJQ1^1kv&5x7p7`H zu-5QH^?8Zv4sHuC@N9kIHY1nOaD(mUUYkm=LAhM|o) zm-nFLu%DuB_U_Wik3{+QM{S6jFbpvG-%-WFJH^B6r?)NHz^Jm_I)B4f$?f{uWoXk- z#lQHa*K`{QO0xfp&CX@m>UbUQiAe2xqh=dm2axNLaWB_9-50ClLQ((Co}Gh2`=$`h zrEus`R6dD)<=Qx#dA0i~OV1wn(LshJ7T>rE#teaou~%n@B_4lR3nrR-;TCQJV%;{`2f0MuDK5sgqJ9Z-y1lX$(FMO*7*9{0MRv)nM58I?Hc(1LL}$~DEVJ%kZ!;Qxi2J;-eEpStudroI+M zopZW$XOG(!u!Rlc&_g*6>=y#QDd~EzBCJjo+4Ui1M*Zb`=?V=U~h?H{Xl?r{pT>W!|Nsd_^8@ijur?%>R z8a(7;^)kQvDXUD$ew|N_cHd<~GDJS-tcyDc7<_Yh-^$%YghrbztBTYrT*#6)y?f;i zY4!kEpM(?-#%ip#y6ba-OdtIN``XcR%+T?E zsXOU)%}xW^FP5!%E=DIeQeD0?_I#Yo#KS%(yu2TH<_ApfogJs)WY@8ZmH}sm9?!cS zL1Mju<@?m>qTLMKLhk(vPJgrv^Z*@LS^0c^R|A+o!o-Van=X2HVkektIwa=JxW?0& zQpU}f*91i#{8eneA^2spML6hm39$%7Q~rn^t7!G=+FD-*G`Cqvql*Vy^Rekve}zp* z&$`Xlx=H57rOROE4T3>5D+qgs-Rgmh-GZiNy1jQCUYL|_2z;AkTt}UX-z&c~{8=fU z$R+sYXn&0VJbrczk*krCL*X8AXylO{lWAG|I{PmO8rLnF0 zS*Irh4z^Pwn@H&Gju0ZTcOKE`ijJ|sus0^7F^54Ou>VNN!O{4Y$$%zV9wo#ZTpoww zGLI$hh&r@}pOD+7`T`&{tgfkCzwJyl#rAW|NyefOW%fNl%K!85w57>#kFP-J^4y*- zjZTx$L=<6YCeJx>)!*n=#8u<^7(OTGyb?mEa|lKRG{dCUtBYrn;Hw_BF!C z>wpBgU_d6GRaRNk?>7UB_Um2=Z|&D**F`ea%bNi6zCYW&E&GbUE`F6Z381^j+-<1*-M4aCoRP>UC8t_go|S#KeOs zJx2Rywy&rY>l!Kq&SqwxhD&!mC%Ij%XPdmeGW~D-E^@m14SO_OZKn-CZ#CPDuMO_1 zC|isSB_-qqIsyusswiTx#5G>vj0{`$G4o#<<=63!L9KgzL%|FlwX2#s9*=QNtZ@W4 zr*hbO-KqlvIzCUeOEF=(RW z0`XYH3&$}V$t?*Hc^MGZx!v;0&DAw4%LASR8S6a7z=zcYF9QdgeG!qTX<9}+Wpy+q z8Km$RmEnTgn+5^^-3yOxR{mU3Q_cQ-mDxcB%X;OgOV#MYd;erdo@~))*DXOkBNPl`ZB2FJ%BTrZ zzjOUrsj!*8A^?Tn?d5ZRK;ND3bS#lGX)DKs@x|HL7#kRH25cELoqhQA{pt%WPQ5HO z_WG8SkENNcGjusNZb{N!rzffM<;e^uCA8dRNC5t%A}_AuzAM> zpu>V)x~Tl@MAYepu;v)EO3l4$$9?PF{oSZQ?$-KzwvbnReuB1HBiNt)S>qGK=hrv% z@iO|Q8s(f1M!JEI`Yxll8NaP~st4?SeP1vB78bFjrM;?p@3h_|N96qQw|%|XpU5!u zX@0uJ495)#eS5gYbF9DRE9lB^wZ7 zH`=z)3uoHC@~rY311!Ft{5g%`)~1M!4lsR_U>XjiW({AD&Xg0?OrsDs`ssFLs-=KC zH1mAz7HwCN;(9=Kl81f+RuD{HUp0FO!er7F2v~}%Zo>?bKe2n+(3+R%x7V}oTObk{ zt+ZKm-K#3!W#N>dkM#DX_%y<3_gg7G)<=XkwJe!pqB&&3+JGQu&WJO?~N+CL5pF%ntlf ziX?$$PySf5r@o#JX1P;I-46Xi*@|?XJ|Q zy;p7MQN%21=xEtoLaL~##zlT$nR2aJ7R2XA4A>AA@IGAWguLvs1_ko4eC70-LMH`e zo){Al;?gF|8wK8+F=|(0i-|ps2LDub9rKJ?*1ig0^cfz4#8R`_F0TZ*Eo0c^--G%6j z!CK!(23<%L&SzcWhe&3E%@dJlsMY~Fa=}srd$ro(D5~10LsRjkC?e#uAVT(V??ShxbYv4R6K9e`=>i+87$u6JJwri>8f}?E& z$p{<%;fXq|UxI!mn@^4gnM#zxX$|pDf1zm$j`%^eg5!(GYe0a#6d{@R>K?vy^5{9~ zWX&WVEq+uxbEG*>LV?qvoD@L`ft!yR@XzNgu~c0Kg38?~T?2aUA5&Udu1-iRlB`Bq zCVBFKgX}HIV=1nLB61kZ(S=8#p#ySbBvkYML1*$WhN5|g+Nmw0$_Po6Aq#oXEQ?p$ zVRv8j2#Xb>>+Ru_9szHY>iKx(+2p26Zo3I4k6^!-~#TJkKSg(20kjHil2;?+h$fH4iijaA$ zS1>`@a5c~eimScSzs^qN#py>Hhu?$mJdW?c+RASq{9=QF%N|DWq~2q%AKa{V3g5QC zT9z3aZ7rl^`gL>_C0hyFsBZu)?OmI8Vo7xXw#{?5Dh0XO=N*6QMMeg2Tbb)L_(q2- zyS(8)oQ~!xj%Sm8+;~IQgAD$v?YuUc7V7Z@3&G|a1yw7FVo`B%-iC*N2k>fW>ZMP; zjn%f@I`kdAH3vwv2)K5OF<~jAnN(w&{IBeG!ADmr~>kFsD=+AdUGGZ=2R!;mXL^sPKYY{tk~gJb6_P z4tJWBVdB979qZrd>})LZ#j710(9kbiMZ*QGb9!d2=e*)pO@&;0m&it}Iih`Du+Pov zwrnZ?qMf!9Gi3@xDfY@^>OB`ReLD);&%56`t!^PFnxjPVUf7s_T?qY zpBCXRF3l6(de!;rLY@7dH6~1)!fCo=cv$4)^O+L*2O|SOUQuZ1a3T%=@AS2&h?A2C zZy))=zgZnr%D*JmN}jSFv9pwJ$k$R`C^;xE@3crbqEX8t!7_wf`hwGwxp9_cZ( zrPz=$+GcH%wRQ3g0cIAO+W}k|BtlHCq9Og|rNWb=&8(J4ptN;TZIDbW$Ru8*=A3*A zwL0EFxEck$MYmdOtu%W0DJ*KomQc!#{v?5zFcz!Ax*`D$W6ziDuNWT^@-9~VP6CVn z$PCb}{#JcrLAF3u)j-d#MMs_`b$R8@Cq`aAy+o+N|Eh2Rpv+>_P*D_T>g@x#+%@+iAhGjaStEX;DX9Bz^Inc?H+kPWTRnkO8NBLbKlXGMyN#2AhmYp zpQch@v96P_n~|EDslH8YhjC7lRv}OZ((ga&uMo%sVU2kc15?Mt_DeCdiX&f&OaIWf z?uVT%L#D1lWu1KC)`tQlo|h z_SQt+qs3bA03aea$qGS)><9%Ft48_jXNDDZAYbslCYWjZ5=&6>@ zLBXuib9A6b3i%Fv$Lvp;Y4@6()x_L<_Z}<|ah?~M9Aw6o6&=L1H|A6r@W|gtFIAF2i3k$=*x4)$uAuaRBdu0KYi-ZaX`XivXQ3ZCRW2^|zCt3H@t55%1-Kay-#{|Wh7Ne%_FIE|+_jXCh7PZ&5|r~_6QJH|m+wp(WOs%O857yL zIIp)}i+zb!=(5!KYIBvvfAa?Jq=(LlJ3D_HET{gUY1itX%q93!F2ccW|1v5#r3!l! z6Q4~eiR4hO@^!Bl`?+Stb*>=lULKOm_5kpsSRGRsJ{@BkBWCyk;nPfDKYl}R+ zD1@m#C89^^;8EZTf8>$gT5N_~dePfgimrlVLAB#LtB-|m7#}K&Ze|xA!Ixe*_13uz}VC47u}FuP-S{a-OV-sqp0Hh{Dq#AH;mScmjhfoZPtL@FCmn~Sy^5$HvxX@ zJDveu6L@zUWssW#TE>0lcz+tpjz!xu&TJ(DH+;gxK#z+aLql+5~})^rJmwE6@2x{x6!> z1GS}Z@vlY|<-SMbDo*wmkd7zTi3CHw&K=&KF4cgczMNH@X_JQjyDvSn@W zi+N!Fh0;fqhH>LIFU~dupNBTSQKqMJL0^5Gh^t-YT%dt=(;eK4Xc3h1D9wjD4i2y> zQWLC$>LG;vY!jn$bL;bhV<<@I1J0xuMo+^B^~XnKq<8qJEA4{Mw8PGSZ@l^96ZEa% zclgc^YL405eHb=Ge2)_z@ze8O%?Y)CTLX*TM` zB|e!Fl`acaBnWW2UvCB84~`oJcph$K!XgZy4X|0&7gt`C_4IF11g*(^(!bwYFQLYs z)N#t$NkI;`MB_e$aq^`!Hok9&Tm1n`wJ@_uoyvzzO$GFO_OisKrn0agUen_P7^vpE z{J4x*w0-R<3VH4CGd?5_An~ipSQod9u^7=ClX2^yXxKT7Gs!qkvjnUEZarv)+)*I& zoA!g#16HRgKQQmBl|GQW)1C3egUgy7CQIA4XF6JzE}|Vx&QlFe*%y#nn45`xmxU!> zb)k93Kk}(s1a#?`_7YH5S!I&(Lu-0cFxEA@$m-mtz*r;VN|)s;?0To8cJpNVvE4k{ z^`|WIj^V=;@WB1+iD_SNAT3XthFqwI-zGJQ4g)g6nL)?#61(0F93u4atmFan=R?ol^4qHy0`Yjf>BNzI|L2AWbr2ody2*3J)CH)W-q7@|HTcV%%ayc|Y1u)-cM_MI7 z??jGhN?nT2tSg-&hx~iML;g_JRE^zfS<(~j?p2q87L)=iZJQMkp;?rWGRm-%0%YGE zB=RK`Cu%tz+FCmWxAg}QYyXGmM$Crv)#D{?f8ImQl%&S{Axpm6W1Y~T6H8~Nt)f!d zJ)o#JY^?IXFe58ex%^KXfr)~1+Kgk>9-8rA}uN!4(Bw759_BZ>r) zVXH^s-Um+L{km0eZ!;yxIzDe!=Q<$Q{hDQ|Nbru{T)q7J?ujqDKFYtV1%cSc$f~~W zV>#8*xInAF{=Dgn;wH#Zw95Y_{`&_Sw{_YRnX%Pz%Cgij;m5X;vel_K<-Q+@Mjtku zdOxmZ($2u79sUnGzUwxqVc5OWLglT@?0C=KN6cVg*xvS1kqFtxUzvVD$Tk@<3^&OQ#+s3X!cALy~rF8UB62XrSo&O+j0!0j%b7la)ANlbwBe0ll9vR zrS?NmiN$A3fTR?47`go0B$Pw)-7_$8^yX9bI1(7AvtdlE)O`PrIl8O9Wjv zdR+f)ll(e-^#gJvNCE9cxlllx%=>a+N<^0|-(F1>W|EOD-skX2Qy@l}Dgf;C5*eq# z85VcZ!{CpSgoZ-)PKl$YZ##3e0BCTxCUca*^i=<3E2B-g+)p(uK1zeL(C3iwJLZ7X zN665sOvGgBBS8#^?l|LcBq*Q(m+IC)djBFnz~s6Zg)d$OHMi~}TSC7Rf;$--vv>1| zaKNINi*tT{{PehNgIqwHAha^G;$C_wq()%l~-dgz1VAx#{;8(m9^%;FOqI zI7V#S!=LPT>kq3Pn2mGwxV~hc#S>WQfTqR6`_hBy*+SA0QS%kRbSqs0MJY? zmTvw^gC7iWL?0j!eOZTMr+2VDt+f}~aFGeQV>`NhT*A_g{-fgUik{V7pwQ|2lr*gs zwR)4g>$byBA(RXZm7AM)flyW8^4~nzP*qA(N1gmJwy)ue;Ptkn7G_Q0#>U!e6qc{~ z@I>8rn6R09TEd{J?lCQhTx>W_wB*K;k|Y+q_{fly2rv6iXRKm2rf|pZ&WS=0H=Gjul|VKjm;KFMJ2yoA0FSyVg9LyDAYjp)WGtkT0`Ze-F(AUVp2ZG zvuFTAjV3gJwbo38V_RJZi1XOJ}uRF zpFVRUY~_ylGVK*8_2@mmCHN5kV#QK*t`9+%O`rHua zS|4Syz$={%!GWd2S(Zg$pol=gGNIp`=j(koRH$M#ZHSD4fsTCn8q@6P(mLf3kP?Jq$hk1x%T7hZaLxrv1lls*vTO_X9`i5KzVQKZ4xaO9!a?u^U| zm6cS)vw!KGFto_tJ*9uO{nV9K3DeKaII_=q=NLas^=(h#`MS}|VdEM?q5I;{cW)Vu zbA!+m#3qGHM2(Hgx?~t|u~NXeM)&-EJ9Dda5MJqL^bmgBofFxL9S7MXHu#034>rB0vc%1wsA zNvZMsh%)&)2RAnAmt9=LrJub$hjRp;P5g;*V1tOW zt-KT<bNmA{CAU#}PNlos%`!(;k~eD19-=q7$> zeFpBj+6<=Dkp=m2GjYKiY>^lI??KzAgJ-;{sTx#5Gz6R>UcMs(l8@fAB%!X}+J$D%kRIzZ$zWJ7iRK{%R<7fRC zh2q_G&r4+S-(5u?J+8!?(cB7iKS+tWg>j6p8CXHojJ)hr-B6&s-0G#vlx9}uY8Z4S+9u*y zIIi>_xd6pbq$BVA11|k5Vd@Jpy zT!G$s3mIBY-7f{B3Fguyy?wREjBvDSM;~BoS6sWeXy|frN%K0s)gSVMLq{Jl=5r*o zd{?Zr>{C)eHL~vKGv_4mJ|b-@J1mu*)QZTmn3(r4yGddivJO%BcDt_etxSR*v9-C` z>R>kxCL%O4fkMD_158j7;J4d~Nz?WCWVGx0S+0RS>I+;vjFC~8s`nv$5Cyn@Lyr0j z^5diTd<*6`9Zmbn@G1QCjaxIMhsPQ8`JTTc-{{<2TnJF%^T;be-}u4XKG2-yQC@3? zFNn+4h$lH}5Sc2#=mMUVB20BDAs$o1x#Z8Xeb)Bg{NlK>k42h(UAvm0kB#Bu%tszl z0K{$x1QPCoyf)rvIe~ehn`(-*Bbom&q>ru%w(;^zhu$QMON1Etf}EfVz~liskxn1b zVphlLA5MRfEfM)c4);nehOil^(1qMtO@E8Lr&F*o+`w}+ey;Kpv1Hb-koVjS4AIE8 zb;beRkgX(B(+)cJQBGENpDdze9UKL8Ffn7+4+M6cFYuwVsTku80ap6~5x}SrV+g={ z>f&KG*R2di@Fv%9y4~$fv9s36zW7r`f!asxb==IH!P{OjgMg22O+D|4stC}~Kuu_f z0=X%ZQzMgC9v&WPMC)!TsfBDKE__i8`JI zEiy}l8R^v7Tc`EtO4MrySf}1k&)3nj5)lje*1Dpq)QId1v}XesTHAUsImQt7vn<cJ zZV^MUm5h;-O3`ewjco@&?&Xdhv1oL}kyW2*`&#l{aE!Xoj&0tM?IKAWA=y=kxgk4u!FCg|WlnhZ=(y-6iq2?oBVtPn_2E2}fI>mtauu&_8-#eu7uQo7p}X#RF}@-X0EdtRLKGFHlujU@x(hFz*k zYXS#5 zWaorpYP*GZMpR+Zm5evz3I`Z55=;xf?echxGd9hI0=QfM)a=XKTurR((8w*>WS3%) z9-q=Y?^{=rYxzrVeF%*tT-M}1&cdZGa!95Dn{ReHK?U>Y8vnS7^=8$cQyLx$-xb)`!sBC-?#;SU z>q2_OoH0Ia{((PT z?@g+<)?naa4Lch^i=|cFzQyhsLb+n!vM9f>$eSZ8N+!rh=O8HTHfT%h_zL70wit(P zZi$`KVFIhwl67L-o{?bBv(Amr3*XCk%a^N`<1H;&(8wm~Ukz!ym6p-Oe+W0pL5#Cp zY$dS|7*Nr!&CSkA;_~ErtrT#p(M~vTnWgbnjcP(l#2^<=TZ<+JyTLt&*zdzp|}x`Uupq^icn>DI+67vYWGobmMRM z=K_nV>!s#jWNQ^n>z$*17qlNGjLjjfU(PD_9=Ne7yEWG2wyX~h*&N+ESlM6txYw;o zU&ljJ%e$75;7~EKB=c+~Hgu3nyVp4+cyPc4Bc__SRrmwBGJwQ=SjKmpDg_)im#4ah zEAJ;FW}_l9b#{C{(DF0vZO6*ejTD|{YKrl57|x99J7Re;2^8y3u&rya^3MFw}=zUfz0bQAoB`H%QeB@8v8CnS66_koE%Qo)bKYhASA0Heb4 z-h)MM?zAPXmU-y*{QutH7g2_dep~F2yEKK!a}c=NYwAJ@&qUr@Q(pg@cykcO$@Duf zyHE=+`#%^Y2#sxJ<@+ZZJQNoZOts%dM>9eDlqS?jA#n^RcE4(20uT-v#f)H|P*v(` zFCRju9R>mIHLlHrYW-h+H0j92q&DER5T4bq*b@w!fQU!{$dWcqN){ek|0oaN4dPSQ zz_^6d<7+>}^cqiJ>~1qeDKg<*`@BOhGkOuT+DI;5G-c00_zFOGflSU;+;SC}^T zji7(6s;mkgNxbR~!N!fl?g~h%9A+B4L=OWR;P+Teg-=|}Z?3yt^ zyKQC&haN=;YKv&ttYT(0^fJK>;{Ly2k_|#=r9KeHfrleL65E8$l&Pgsf0FNCm~`nL z5JUBc>L6Q6Q-n)<@%#;>^zndQzZG{E?Y}W%iv2Zjgv_7=}GU<_Eib5(6!U zxZRD}kN2wZ!D!wza@-%>V=p$7WCy=Ydw^~piIs3YwE3bW`)Oki?h~RqzUmY&@^Ct? zb?e_<`CYKoRMfO5X|p-V&t<3$wCVr3^ktuaLzgenq!|XJZdEOuypK)bDOX*Pqg&2a zgqPdArrzxZbuy&D%Vku59loDV-B7FYOyW7#4S>qA99>KFnbzK;OG?omMcQX+vP$eI|uVzvAg~+1?)S2VRG14@~36RbVGVvBMUx>BBZT zuoH*I=iyMU^z}oEJ_FfGt;%pa8h5itY(mRXXr9Wk;C9e`sl($QAE?N|^7L|aHdJ)0 z=jJ(ChG>hEg)q}p{q|RNnM%rnCZ|Xz!7wd43Mz_`P=d?M()jvC6LXe8nA;fT=_;(iYxoN1&FSL6v&t|;rwo;4S6{kza&;HMT7fpIG8 zxZn(%@5Gfg2R?;7dJ8~f4mBv=z#Wzy{JYliB1#Z3H%f_jk|$>u1pQ^Dw`(yfbY>&sBgtK5ktY-;UrpGPnJl)m``tb04ht^noHhcNrj)<2#7i*(pc3cV)C+0+@ zrqov-eiIR9hPZ6te8Dk4dG6HNzMKk6;Ry)2Pbxc78cvxOC2l!ggd?1$mA?DGnmg~H zCcAdszZQxVK}1BVC`~|$l+aPCAVokxdha!q(1{8NNE4La1f=)g34(z1UPF-@TBr#v zBsq_J@9+KgDSKw0GiT1(`QsUenS>{cHEZ4L{$1DgP#m3F(!Ib_54}tYH=lKUmE@`n zzX#$GDf9>Vl`52_ri(&=k`f<;9?FU>(4b@HgLtmHiAHV??c9H0-qlyDeD@i2AZUBY zT7iwNra&D+IeOM}4SR`-AjZI4r(xI?4u?AT@$>WB6jwL>$!KHrYZ+r6D?liyNkjEs zeG+u%Z7@<_|MvMc@YOW|=65_}W z9S7Z^9sz-E7m_Am<+;mfLkcDb^i`z!Y(W+e`q&dhO9t%6X(DWWU0r3G6Sckrw{7mt znL|pk}10Rlcx2M^I)YI3i<_8R_aZ>W@F&U z`}k^ihF+O~-MUvv9@v0ZaR~|7@vy&N8;h8^DhMj`{vEWe(yq5X7=XzGiLFL&-?Rn| zh)m}A3g(NGgff>Kb+3WH^u}zDJ$=`;V$=t#2M!=LG419A5F`~yXc|7zmS4}iJAN?u zq~S3|p7>zHt4-#Mr>{6g3X^`mnElA`SoV?Ro8cxlkjQuDVJ@EM#BG`U%Ixm7l=8{0 zNy0J*@WeQ2LCDW>77mLa8t?}8%yO=(9!H|_}66PUY>?VZ#(<5 zc<&c4J{G^F5V=a#8PM{Yx(m}NZuL@e3Cy|M0Yq^$XmvZ3cA&GNG5X7BI? zB{PXS3p^!_4<68rOVsKCRUA!Z-F87nSWL!qVryKYl;8R5h}khap=QX=8&>WY#02Oq zn>s4P`U8=)A^L{=GNbm%;cS*$YXs zvxE%2b!*;c@qKQua(sGf`qtXUdrF|V?EK*Wk&q~3P}I=KKtwd(R7LB$?5`g`UgUi) zU}al3<-Xb&yl}zcND;%-;-ZyvJPBF?R%THquA-ij#;*K>X#!r)mkS+dBGt26D1RSd z#-Sr|;oTb`Zj5}0Ic>C7+-usU5?gx(0$ID^+OO}&0^KWUtgKCANGLAO_O^kIqwLLa zR>sc!+{J94siVX0s*?0bx|iXUeVT(%^sTtDs2kCdQNw(ESPwF*w{GVpmS4(lrX+&< zjf)Hftn1TcLn*aheIHxQ+#R3u8u7h!NgkJh=Zv|e$Guw5j3P$or z$UV`s;L{|+j8oIDwO*xe{;=wjq4IgDN0Hr^W?DOyzbk>+ssx3J2L3*gT4-}x6B$;2 zmhdxT?t!}wr!y{Q_$oo*uEq5%c+hE5tzf~Us~bx%lLOkSSJ&@d&SQl6Y@yToHX0E9 zTb(Kk*X0^bJ3eP-1CyVxqQW|$aL(?$qC$`9^63Qb^JHj{I!_^|<@^ggu~@@`4$!}M z@=n<8W*c_6HMSFYi%UHn=ZLAZXei|*b zcNn!x*Fk+8=F-sw+Yb!C)&$>*?M)C@PR_+T`;mD!L=s$7nVXYDXn!lv`&N*oTe0l6 z^>E_+WpQb-v^&ab?>DV0U+mdZ;60W%2D(+22wX+`66Y6X3I<6<76)|F?#oZ&0l$@` zv+gs|T%D}kJzY7T>>I=SX;Y(jb`P>H^N_->HSc)a*H)D(=k~HFvff@UajcmCX#0>Q zM;zE{F{J@NE5BN{){HYRnqV*2$6%3iJ;26X(yk8`z*Ns!h^3{Vk6K*Mef{szj8Q}Z z+RuYxVFfmntLKmO2V##Jc&`O@cb{StB?WZR__@iuBS1GRVz zv*DZ5j9c0`Bj*9W-iG#b_Oxz?6_sRFR`tu}GZJp@S*KGF9=$rdgbnJqqaSMu)Z>dy zaYNjRy~@0|3Ev3QR?^$g#rV#FNMr)c*=xKoJ6nS{j20pti>I^B=Ts;k=y_y> zHFpV5DFg}wU=5*pOmZc4UeOzZpY}Yr(MBe15EHa`ysTgp6x=Xu`dE!=`rw zUfP_X@67d5`k)6W{ZIn)c28JYNu+&(^~m zi!NyTMV+OZp}kycT)l1BLv&eC*q;4<_~ZS^-JKMQr%dpysb!ZAuw=f*!4u`R3M{y- z@cY++jd{OYYV(`wJBBCCtW@AY%f||u|I92>nkor4YADCCe@S^b4>QVQ7Wvhci$%1^uq7#TL=_yE8u3c8 z!5!-68?8m)8snRIZ8^f8R@AbGtUuucwfym8_a3#hjrx|%x(&gYxg#g@oHJC1umbjh zHA{-76WlB=K|eZ&^TW4b0aoSAzCn_p^01d%qiv2I+a9Pwf}~fuFDJ&&oJQ?bxVd8R z?#<=x*Lo}ABP*vP-o^RKUoz9AL~<&Az7Q1>by^OsfwnU;T6%~&#Y~$4i*+q_F3p!c z@&O3%vA*b3XFyyM4&_eh&@{sjb~V|Qpht#A)=lTUa6%<{g5VZxQ%OvW9p;)rGQE&7 zGI%ZomtW-)*yk*j(2{=99|$*vqnuBpJ8|!&WKzTt*ocV~HTX9GlIDOQL7-1`fqp=m9Q-LYfl(}0)c z2DV9+7)ZWYY>8$NPi_}{%hRila}Y{xCh%N2@xh-R`QN>}<>PMc<%LjpfZr{XW%r

z9S*}^K3F6o8^5+cEX0|m*^K?@wEVJojTRc$?wigqcDoFu4%i}oSd~cDi?8b za0BOvQP8qWYJB|G?}z8hcwgJjpL5fvE^!>{nGoHQX2SEftJPMI6c|vs%MsrSy&X(= zDgn7l(ge%hktNKns;ms3`%oYL`8UuZT-iN%O}(+QNe9PI^S2okeIEE;U+@uVmUDjM zDruZ+%A2619nGsvk0C$DoV(5L#aENqJZ`y{G=5{kxaEBAlEH;HBlPxdkcu5*+vEO6 za=OowE@VIxR1^v8)3}9MBiUx6T7VGsA2f8*Cf*r2KDktyj;Bi!+Myci=~` z4UX`Ubh^pM=UC+l0wb6C6zwQ_K%Nz!TC8Xc_?QiG3>-zSE|}}`A6QAb`omCUi&q0j z(y@Mtt*ro<&j!uzJ*blwbowMTgr z<3&LCQ2$?0Q&YZ?S}Dx7KVxKuD7!km6a(~YeJaePv-I8=0TvZtm-frucn0$n1KICw zMXar!F-waGn5zcLS>ozsLtgh>kOHO8ZSvd2u6DFc5}a!RHYICVwT{v66A|mToztQH z4s5BY<{9mLBfbhZ?IKUbwav$2^l52uQ+gJW7-3=IT6DcFhen0~ZS-|%d`n%H?wdE_ zV#*=1mqIZNQug*u-7o*Gx9?8YHSYvHP7?G&lSi834))()BgIL1bmN2iY8SM8eK+u> zZ`;-i4$cFX@VJo{?R<$Fn%O4PupKiA61w#DCy&`KaCTt= zImoARVsr4RUcY}GsSod53(O4~84^t0tH@@Qv0+Uc8mA|-R3w|&CD$vjA2KK^Eal>T z`pRxDyIvpU@V0c!u75bY>l~z_GT^GKsDy4eI1Gyx|Bo01-s1G&aTu8a%CrV3m!_4= z=%^Qy<^nPOSC8?EUe+O|GlMe6&cPzk9XH5y>Fga>wstqJvon4eZ-gcmCR%wiJeY%D zOgo+0Be3Ud^Qx&@Qpi6SOc0a;N#sHI`nOMlM7>mhB1RZRid)M?Hclu~kn@@?N(;bj z%qdRu_!#q@GEaERmW*k*G^Tu!hvMi6>Z`gMDnnut{$Q{XzC1!v5ECd8$fO+4eR^e8 z{dVDNaT>W7dt_d1eQHi2P@~fiZ3+^Ub&{3X;#$JOWg|Oe^sX?rd&?&&NZ{%lD0q7q=f;d2M91aaTga%iyTB9N(Hm7#a}bFL@kR zI*WJar7r}^FbA@L8Df*1_~|Af2QBeXau!tg%yOMm2pyg9F32?vXkI*r6#_K{&_S5KHH!?_YMn%+C~(Na>1oHCf1 z^h~0>{%=U5V%r%2m&$xsGfD%?s1vk1c-!jn$;@&9ENCqjkT1C|hbITcnPl!a=^g8+ zgsS9K8n}{^7?%BPCO6E*pkwwJ#Vs$tOJVlbe#;LmPbaESg-jXvV0*&wC*2aR?dyo$ z1WA^leiRhidNuS7(G2kX9>t*%rSWCs`kWzssJyXG#kOp4HW7%-8PSQ%-Ln807`9lljJRa>v8GxA7NBL7u-o+OW;zQXh}UXFwHcAg9Ud z^&-@lvFJ|k`udC)<2HIornrZF_h+DWPOG(0@>tuNx4pdhQ^W+;yZ5#KtZdS>+S(x18SyZVqgHp?U7 z$*1{x#{-R)vj|4UwAy)*I9qs@V2VDXI!OE1cVRDdL&YKbqngZB?Ts}vH3Q^w!-uqr znzT2p^>(Su+g{2%G`x3&g+N$M{rURsa`w1zV{wUH&&&AnV>lFYT{0eLVj$~E$+Gk`6r-d5(695hK(5}#atF_F+ zoiu|H5Kfzsr63o(HIEs7O+EFIcn8VM*E&Ky1uBid}riI-9N#U$PcrTgnx&!dYukb^GprQs%X`E*fYuv zv|ZVGC|XVb4)>JI19G#0mw$59v|3fAB6)<}Lh%Nok zgpK%!`~h&8j9^hGB7^HhL@zdm9D>Jw{W~~Tab4~{)D$Y?YI6%X0zFpJAoM&!pVUb} zrQJK7>iLm)ee4-DZ#Xlhb7eHwN?RL|CFcL${@OooqRW)v<8a2LhmFP&`beJmK~&Gd zw6hq{KSHSQK!?)yS5_c<=X~1*&-U+Y4@FAN18_%48`DKK8UY9?Z!Bathy#G)R57O>gU1FBt(x1*$%&7%H1Pqiq?`O0 zQ428VeL-oVG&aCD6uHB=xz5_|TeFkqmFA6<-YV6_&MXuGT`7PqIU5HC1+gf{jRpEg zcEH7Km zmf{>1r=430T0e`jfNNEX?3|q3y^tuxYb{mJ^@Ta@g+*h4KXeZjCzzH>1+6A!)dzL0 zxH-uwlbQsaWPnWF-Qz#AikYvL!ki{a$;im|T&dZ0q4h*z>S%IJv24dX`e^oB?8kZLr zD-!1+XYo@r^yGwVD~~w}G}IJ{jbTBSz2!FmvST$&3>a~$1h?Q9_ zs&cn}_Dv8`Fe(wFckiy0>JpyqfyEzBY`*_3cr%OToa~1!v9yfC;f2Q3^fYn_<8d4! zK%5O2d=^*bfD#WSL)_HT0!S*OmtEzEH%y)Y0oaSpmnS|8z?oF3n;Z*Sw6Os}Zk=DL z4|84W@?EJG#r_Rj4WG2pryKY<% zKm$-R_%KR$PwI$}&L1~0ZMEl%V_>=5M_r-}!{67zrdP`iPq`m46le^Z2|3ga#l$=k z2|K}saXWKtuB`Mh>?D#YT6)x|vr>`V4Ct{<%Bq-|F)arsCo^^gZ=aU7?0=?X2)fSV ziJV90_NsT^j!{gkedv7~6^VuMD=(+)HE=un+t zWVb9az}722XNtgHTE&(I0Ara59Rup|qC}UQe^Q}j7MN@gAF$L0!W(X2&YUm8sqQN3 z>C71QaaX?ONx9EUKy>EjfdoPn9GtEWJrI1n`16xV`@8;x1TnPIK8GD!&CQqii{&Z3 zW0KU=S^)t~Ee_??b4|atm4S+B@BO{K-L)yfLVt@+PNMg}O-U1Z0uc$o7f>Ls8_Yft zN1faX8SP$nm-UlE?2OxcF1&W-)2NyqbEx|mN|*(6>+9{+=JFz;AT2K{Lavu?x`}?_ zh=I3zz|;#Wii$}0=Zrb2czxGye0Z3QC;D6K0H_|MV?3w)RoDSs^m2!u&N)|=Dq_}* z-*ac(NrY;Zamp=ISzCCrX($qFthM)BGuSOJC_!ySP{hCy9>E%%P7_ z?%2=9xY!2U8g)G#oyWgVj2FfAbf(TRz7+K0Ziw<56|F)CNfU_n3&`CX-oITyI4=IH zvf9f?3L^47B}#dK(r#60o9$m8G=A4a%J03BCF#8)Bl;CZt?#8)R^o^#(W_`{JzrE* z)0S|29(&D?Y}E1{(+w0YPE(4IA9z(lqOn=?|I8%&TafXO82>I$ig%RbbtAj&o{4OGPNn(@UaO=0Mss~q_MHLo zYtr}Sp@&g0NLs9i@rFZ0Zb&eteN{XSw>2AQ{P%*XsyUvht{wrAm^BEJIc9V~RDAomy4We}3*QexD+vbqkJuyGLaC_0Ej+|7Ddqev*LN zW>D$x74r)5<6Hc%tqT(N6RgW<)ypp_6V-A(V2Y|ar`uu9Y2w~3q73sR7FCq8rsbFC zdli_wJ<(Qlcx@Fr2`))iS{7;$55#@-Z4b6%hjE@8tjbX1`KsJKFRA%1R%v6f8~6C+ z?xMl%A6;Nn)~p0EYUH(2*1A?v@beCyxo-0s9TfsZOxkQ#k3|*rvj@OifyX9{X`;?D zE_i5202!Ko^($6N)bYXi@tqW&O%J{DS-~W6FL&)ZqDc2ohaPYU(wYGhC}o?iwK@sh zOWAa5^(t`87xOuioWK?CY#`5ot`ZN8BhI_zS^4HOz%hs_bobb9wOir|RaMlSOc^W% znl6us#nWW!p`PQ7V@3=hL!M=9B^uSFYD3zR17v})M*&c|&I`SKRmle8TmJYd<;mU0 zU9zGK>bSa24VY00h&K5tPQ>i@$<1}fiy~I#zMrdi;xwPWD%=9}Cm#tzMz`9>-kX>Z zXPffjGqpW4^s$LABNL!RZzP`G4hyD?S7H0pDMp=u=R;pxbQZ!55qy4vk~}_`VeFtH z9$a?5$}N(%U-~eF@HF}o={|hp{R%H+-(abs_x7=X%exW`0FS*nRKe>KP6ID(=b23` z-gimd)e`c{m{?P{AbkIPf`enRv@{6Or4i67d4Dh^&TISZiw#hfPOU}oezP7tNggE;2s(;#b!S8Ss?1?!gKJr;uud?*>B>n9s zs&`!BtPYU)Y^f6<9z?d zD{NpJpYbq~kuHE;kQ>nD5NEd$#j1ugqiKJ1v@2l(BZq9@OTnIi(ho;dih>8Wvs1J|&BrFNf-n_ATi%>@qPyLFMI}c!5%OC26PmIjGD%1K?0&R@|wGt44 z)Mu@gGHE~2Glji8)h(f>im)8{)-fWjPcvIa+8pQ~@~5dsyU20o@Iv==qww%EK)Z(> z#-Jm~bPs5b{au!tNMX^m;^!kr(kWlQ7_ryAecG#zbr3NGyp%B+S9zMb3CbBJ=^!|K zV(q+WVt#Y;dsQys*+Cd3-2%+Cb1P0U_vq#26Wh9en!?6{T0*(`Ic?gowVA$qEGXRf z$@B1b*nLwOpQ8XiS6D(3{;BtOx%~qTRmPc?<&F59Ea<@A&QAL&%%oM2MRbAB?B&R4 zoziL2%*->uK7^V@O*J3jIh)6xz1~}6y#trNe=jMZZr{IpWAniSx*YrQ>B;Gor-=%# ztt}pSmXOaiD!w(<{Z9E^4biFjgwZ`s8$agt^SHQp>nM7tlYqLV=u3#$3mJnW@%$@2 zn>EpGCU|1Kt=V29F1VL28ujk2&)X7AwJa|w@?xRyAXw)x}O0WJfP7N|63_G>IM%>q&uDWtJ50J6xOFZ0z7HDefDxC%LQVGg`5yw4Pt%NL=dUZ_Q9 zdcFPSqs#n*H&g{cFZF&(j%fpvVUX0(a4fy_+{pyc0$naFM}hYT_uh#_b@}>wuA+<6 z($pC(|M=*SUOl40>+MVe5;czs8h+_{KrM9p({_tjuS*X4Zv9KnNfD6XtCuDJy*8lB z@@AJMy$bM%rEVK-MzVL4LLyh1x|_Q@;$SzYU|qv0x7f;Okwfgt$1aWw=5X&le{=#g zI+_{$2q4B3V!-sJ|8pmKoOBwQLLQU$AV+mY54S8Prr47={#_wH(WR6uL z{e65U=&4T0pM0vOx_Q4#;5Gt+&##vB%})Ogpd-c9B7r+-i{lX^_J&TP9u;Ug7lJsb(fbj zX%mu_IgfvxnQ=F?VG8lVAQHv|xMAwNK!GrI9AC15YEn4&YSS`M4Kqxah`n`CgY@*X z2c9^G;GyHVIsAhI^xy)XE$$h!bNo8LO`iXca<^F}viaC1Pfe95k<#HcwvO$dVe2|mJ5FsCK+=eK7I;0;)0 zIoGa2AMmk;5fV*-gveqUzk;P}u({G-&AlB1C>_VQ<=u&F`gre$y5r8}8M-z(SaELW zh?*ubgWw+u*~t$;&3TMNQS@kJds`FeQ}WY2i?z4f+H%}8fp# zt3CX$W2xFIW4iYuK6u$TMPGJtQECQz#$ACuBpL!PArg~+gU;b@^g%ohkWsJd~f0HlWnw3&Yy zlka_@@js)-K`>6;tZ;g=WP?PcLZ=|RwSF^!Pfy?#=VOHX0Bj3g=SXWRVR6!UGY1Gd z2epy2{I3jEe1rd4Lsh*qo*DqP?dJ}|sINZ-a`~eszKxY$LgcJph3x|p0YrX2Yc!)8 zzIZr&cPh8VStx?(%_F2Mk(dukel}JPk^xaU>*oRgva;gY`ocEgdjo^4 zy#^=pR-RNgfPB85uCFbW?@K$8DsPb^E5OXF0KZGm(978|1{3O;VYRCuc`hdeP>>)!jI=(`$s^^{|D-EcC!ml@fSba@Qqxy T90lOHuYV~ksLPkjntk|hBe3>2 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/activity_feed-light.png b/testdata/snapshots/darwin/activity_feed-light.png new file mode 100644 index 0000000000000000000000000000000000000000..9e259713a51160a90f639697d08bfd8ef385ea5f GIT binary patch literal 29704 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 Date: Mon, 5 Oct 2026 14:34:38 -0400 Subject: [PATCH 15/20] WIP essentials 15 --- example_essentials_test.go | 248 +++++++++++++++++++++++++++++++++++++ signature_pad.go | 2 +- 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 example_essentials_test.go diff --git a/example_essentials_test.go b/example_essentials_test.go new file mode 100644 index 0000000..bd39af7 --- /dev/null +++ b/example_essentials_test.go @@ -0,0 +1,248 @@ +package mujica_test + +import ( + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleSplitButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Save as…", mujica.PopupMenuItemOptions{}) { + // ask for a file name + } + }) + if r.Changed() { + // save + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCopyButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHoldToConfirm() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() { + // delete the project + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBulkActionBar() { + selected := 3 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() { + mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + }) + if r.Changed() { + selected = 0 + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScrubInput() { + opacity := 0.6 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCurrencyInput() { + cents := int64(123450) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMaskedInput() { + phone := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMentionInput() { + text, mentions := "", []mujica.Mention(nil) + people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{ + Label: "Comment", + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChoiceChips() { + var status []string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{ + {Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"}, + }, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleKnob() { + gain := 35.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVerticalSlider() { + level := 40.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleColorPalette() { + color := ui.Hex("#7D2034") + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ColorPalette(c, &color, []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, + {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + }, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSignaturePad() { + var strokes [][]mujica.SignaturePoint + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() { + _ = mujica.SignaturePath(strokes) // SVG path data to store + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBanner() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{ + Severity: mujica.SeverityWarning, + Action: "Details", + }) + if r.Action { + // show the details + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleUndoToast() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{ + OnUndo: func() { + // restore the note + }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMasonry() { + titles := []string{"Sea", "Forest", "Desert", "Glacier"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Masonry(c, len(titles), mujica.MasonryOptions{ + Key: func(i int) string { return titles[i] }, + }, func(i int) { + ui.Text(c, titles[i]) + }).Size(380, 280) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleResizablePanelGroup() { + var sizes []float32 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }}, + {Label: "Editor", Build: func() { ui.Text(c, "Editor") }}, + }, mujica.ResizablePanelGroupOptions{}) + if r.Committed { + // save sizes + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDockPanel() { + var layout mujica.DockLayout + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.DockPanel(c, &layout, []mujica.DockItem{ + {ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }}, + {ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }}, + }, mujica.DockPanelOptions{}) + if r.Changed() { + // save layout + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQRCode() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleActivityFeed() { + events := []mujica.Activity{ + {ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280) + } + ui.Render(view, 400, 300, 1) +} diff --git a/signature_pad.go b/signature_pad.go index 53352b8..226162f 100644 --- a/signature_pad.go +++ b/signature_pad.go @@ -112,7 +112,7 @@ func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadO 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.Fill(ui.Rect{X: r.X + 16, Y: line, W: r.W - 32, H: 1}, k.Border, 0) 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}) From 719a0591131fcf77d0420323a4573fc9277362f5 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:39:12 -0400 Subject: [PATCH 16/20] WIP essentials 16 --- docs/components/481-split-button.md | 50 ++++ docs/components/482-copy-button.md | 40 +++ docs/components/483-hold-to-confirm.md | 42 +++ docs/components/484-bulk-action-bar.md | 46 +++ docs/components/485-scrub-input.md | 43 +++ docs/components/486-currency-input.md | 45 +++ docs/components/487-masked-input.md | 43 +++ docs/components/488-mention-input.md | 57 ++++ docs/components/489-choice-chips.md | 45 +++ docs/components/490-knob.md | 45 +++ docs/components/491-vertical-slider.md | 46 +++ docs/components/492-color-palette.md | 47 +++ docs/components/493-signature-pad.md | 44 +++ docs/components/494-banner.md | 48 +++ docs/components/495-undo-toast.md | 47 +++ docs/components/496-masonry.md | 47 +++ docs/components/497-resizable-panel-group.md | 48 +++ docs/components/498-dock-panel.md | 47 +++ docs/components/499-qr-code.md | 41 +++ docs/components/500-activity-feed.md | 43 +++ examples/gallery/pages_essentials.go | 261 ++++++++++++++++- examples/gallery/pages_essentials_demos.go | 267 +++++++++++++++++ examples/gallery/pages_essentials_more.go | 292 +++++++++++++++++++ 23 files changed, 1733 insertions(+), 1 deletion(-) create mode 100644 docs/components/481-split-button.md create mode 100644 docs/components/482-copy-button.md create mode 100644 docs/components/483-hold-to-confirm.md create mode 100644 docs/components/484-bulk-action-bar.md create mode 100644 docs/components/485-scrub-input.md create mode 100644 docs/components/486-currency-input.md create mode 100644 docs/components/487-masked-input.md create mode 100644 docs/components/488-mention-input.md create mode 100644 docs/components/489-choice-chips.md create mode 100644 docs/components/490-knob.md create mode 100644 docs/components/491-vertical-slider.md create mode 100644 docs/components/492-color-palette.md create mode 100644 docs/components/493-signature-pad.md create mode 100644 docs/components/494-banner.md create mode 100644 docs/components/495-undo-toast.md create mode 100644 docs/components/496-masonry.md create mode 100644 docs/components/497-resizable-panel-group.md create mode 100644 docs/components/498-dock-panel.md create mode 100644 docs/components/499-qr-code.md create mode 100644 docs/components/500-activity-feed.md create mode 100644 examples/gallery/pages_essentials_demos.go create mode 100644 examples/gallery/pages_essentials_more.go diff --git a/docs/components/481-split-button.md b/docs/components/481-split-button.md new file mode 100644 index 0000000..d2385f2 --- /dev/null +++ b/docs/components/481-split-button.md @@ -0,0 +1,50 @@ +# 481 SplitButton 拆分按钮 + +## 用途 + +主操作与下拉菜单合为一体:左半执行主操作,右半打开 `PopupMenu` 列出相关操作。两半各自获得焦点。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Save as…", mujica.PopupMenuItemOptions{}) { + // ask for a file name + } + }) + if r.Changed() { + // save + } +} +``` + +签名:`func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build func(m *PopupMenu)) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 主操作文字。 | +| `build` | `func(*PopupMenu)` | 必填 | 填充菜单项,`Item` 报告选择。 | +| `Variant` | `ButtonVariant` | `Primary` | 只接受 `Primary` 或 `Secondary`,其余 panic。 | +| `Icon` | `*ui.SVG` | `nil` | 主操作图标。 | +| `MenuLabel` | `string` | “More options” | 菜单半的无障碍名称。 | +| `Disabled` | `bool` | `false` | 两半都不可用,打开的菜单关闭。 | + +## 状态 + +两半分别有悬停、按下、焦点环;菜单打开时右半保持按下色。禁用时 MyGo 统一减淡。 + +## 事件 + +`Changed()`:主操作被触发。菜单项通过 `m.Item` 的返回值报告。 + +## 键盘操作 + +Tab 依次聚焦主操作与菜单按钮;Enter/Space 触发主操作;在菜单按钮上 Enter、Space 或 Down 打开菜单,方向键选择,Escape 关闭。 + +## 限制 + +菜单宽度至少等于整个按钮组;菜单内容由调用方提供。 diff --git a/docs/components/482-copy-button.md b/docs/components/482-copy-button.md new file mode 100644 index 0000000..4c76b83 --- /dev/null +++ b/docs/components/482-copy-button.md @@ -0,0 +1,40 @@ +# 482 CopyButton 复制按钮 + +## 用途 + +把文字放到剪贴板,按钮随即显示“已复制”与对勾两秒,并向屏幕阅读器播报。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"}) +} +``` + +签名:`func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `text` | `string` | 必填 | 要复制的文字;不进日志,只记录长度。 | +| `Label` | `string` | `""` | 可见文字;为空时是方形图标按钮。 | +| `Disabled` | `bool` | `false` | 不复制。 | + +## 状态 + +默认、悬停、按下、焦点;复制后两秒显示“已复制”,宽度按两段文字中较长者预留,不跳动。 + +## 事件 + +`Changed()`:本帧完成复制。 + +## 键盘操作 + +Tab 聚焦;Enter 或 Space 复制。 + +## 限制 + +剪贴板写入由 MyGo 完成,无法得知系统是否拒绝写入。 diff --git a/docs/components/483-hold-to-confirm.md b/docs/components/483-hold-to-confirm.md new file mode 100644 index 0000000..22fa635 --- /dev/null +++ b/docs/components/483-hold-to-confirm.md @@ -0,0 +1,42 @@ +# 483 HoldToConfirm 按住确认 + +## 用途 + +危险操作必须按住指针或空格键一段时间才执行,按住期间填充进度;减少动画时改为显示剩余秒数。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() { + // delete the project + } +} +``` + +签名:`func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 按钮文字。 | +| `Duration` | `time.Duration` | 1.5 秒 | 需按住的时长;负数 panic。 | +| `Disabled` | `bool` | `false` | 不响应。 | + +## 状态 + +危险配色;按住时从左向右填充;减少动画时文字变为“继续按住… 1.2 秒”,宽度预留。松开或失去焦点即重置。完成后播报“已确认”。 + +## 事件 + +`Changed()`:按满时长后报告一次;同一次按住不会重复报告。 + +## 键盘操作 + +Tab 聚焦后按住空格;单击、Enter 或短按空格都不会执行。 + +## 限制 + +按住空格依赖系统送来的按键抬起;窗口失去焦点时按住状态即重置。 diff --git a/docs/components/484-bulk-action-bar.md b/docs/components/484-bulk-action-bar.md new file mode 100644 index 0000000..1d0b1c0 --- /dev/null +++ b/docs/components/484-bulk-action-bar.md @@ -0,0 +1,46 @@ +# 484 BulkActionBar 批量操作条 + +## 用途 + +多选后出现的操作条:显示选中数、调用方提供的批量操作和“清除选择”。 + +## 最小示例 + +```go +selected := 3 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() { + mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + }) + if r.Changed() { + selected = 0 + } +} +``` + +签名:`func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actions func()) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `selected` | `int` | 必填 | 选中数;0 时不显示(`Element` 为 nil),负数 panic。 | +| `actions` | `func()` | `nil` | 批量操作按钮插槽。 | +| `Disabled` | `bool` | `false` | 整条不可用。 | + +## 状态 + +浮层样式,160 ms 淡入;选中数变化时播报。 + +## 事件 + +`Changed()`:用户清除选择(按钮或 Escape)。 + +## 键盘操作 + +Tab 进入操作按钮与“清除选择”;焦点在条内时 Escape 清除选择。 + +## 限制 + +位置由调用方放置,不自动悬浮在窗口底部。 diff --git a/docs/components/485-scrub-input.md b/docs/components/485-scrub-input.md new file mode 100644 index 0000000..1a6fc45 --- /dev/null +++ b/docs/components/485-scrub-input.md @@ -0,0 +1,43 @@ +# 485 ScrubInput 拖动数值输入 + +## 用途 + +按住标签左右拖动调整数值,每 4 DIP 一步;旁边的 NumberInput 可直接输入。范围、步长与精度规则与 NumberInput 相同。 + +## 最小示例 + +```go +opacity := 0.6 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1}) +} +``` + +签名:`func ScrubInput(c *ui.Context, label string, value *float64, opts ScrubInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 可拖动的标签,也是字段名称。 | +| `Min`, `Max` | `float64` | 无界 | 相等表示无界。 | +| `Step` | `float64` | 1 | 每一步的增量。 | +| `Precision` | `int` | 取自 Step | 保留小数位。 | +| `Disabled` | `bool` | `false` | 不可拖动也不可输入。 | + +## 状态 + +标签悬停或拖动时变为强调色;拖到边界即停止。 + +## 事件 + +`Changed()`:拖动或输入改变了值;`Submitted()` 同 NumberInput。 + +## 键盘操作 + +标签不可聚焦;键盘在输入框内用 Up/Down 步进或直接输入。 + +## 限制 + +拖动只对指针可用。 diff --git a/docs/components/486-currency-input.md b/docs/components/486-currency-input.md new file mode 100644 index 0000000..9da7a7a --- /dev/null +++ b/docs/components/486-currency-input.md @@ -0,0 +1,45 @@ +# 486 CurrencyInput 货币输入 + +## 用途 + +以最小单位(分)的整数保存金额,避免浮点误差;显示币种符号,未聚焦时按千分位分组,聚焦后显示纯数字便于编辑。 + +## 最小示例 + +```go +cents := int64(123450) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"}) +} +``` + +签名:`func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*int64` | 必填 | 金额的最小单位数。 | +| `Symbol` | `string` | `""` | 前缀符号,如 `$`、`¥`。 | +| `Decimals` | `int` | 2 | 小数位,0–6,越界 panic。 | +| `NoFraction` | `bool` | `false` | 无小数(如日元);与 `Decimals` 同设 panic。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 不可编辑。 | +| `Error` | `string` | `""` | 调用方错误信息。 | + +## 状态 + +非法输入或小数位过多时保留输入并显示具体错误,值不变。 + +## 事件 + +`Changed()`:合法输入改变了值。 + +## 键盘操作 + +Tab 聚焦;文本编辑、选择、剪贴板与撤销由 MyGo 编辑器提供。 + +## 限制 + +千分位与小数点固定为 `,` 与 `.`。 diff --git a/docs/components/487-masked-input.md b/docs/components/487-masked-input.md new file mode 100644 index 0000000..7b15439 --- /dev/null +++ b/docs/components/487-masked-input.md @@ -0,0 +1,43 @@ +# 487 MaskedInput 掩码输入 + +## 用途 + +按格式掩码输入电话、卡号等:`9` 数字、`a` 字母、`*` 二者皆可,其余字符原样显示。值只保存填入的字符。 + +## 最小示例 + +```go +phone := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"}) +} +``` + +签名:`func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Mask` | `string` | 必填 | 掩码;没有可填位 panic。 | +| `value` | `*string` | 必填 | 已填字符;超长或不符掩码 panic。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 不可编辑。 | +| `Error` | `string` | `""` | 错误信息。 | + +## 状态 + +未填位置显示下划线;输入法组合期间显示带下划线的组合文字,不提交、不跳格,只有提交的文字进入掩码。 + +## 事件 + +`Changed()`:值变化;`Submitted()`:Enter。另有 `MaskFormat(mask, raw)` 生成带格式文字。 + +## 键盘操作 + +左右方向键、Home/End 移动光标;Backspace/Delete 删除;粘贴只取符合掩码的字符;Enter 提交。 + +## 限制 + +自绘编辑器:没有文字选择与撤销,复制得到未格式化的值。 diff --git a/docs/components/488-mention-input.md b/docs/components/488-mention-input.md new file mode 100644 index 0000000..8f8472b --- /dev/null +++ b/docs/components/488-mention-input.md @@ -0,0 +1,57 @@ +# 488 MentionInput 提及输入 + +## 用途 + +输入 @ 后弹出候选,选中后插入整体提及;提及作为一个单元被光标跳过、被一次删除。文本与提及列表都由调用方持有。 + +## 最小示例 + +```go +text, mentions := "", []mujica.Mention(nil) +people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{ + Label: "Comment", + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) +} +``` + +签名:`func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts MentionInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*string` | 必填 | 全文。 | +| `mentions` | `*[]Mention` | 必填 | 提及(ID、Name、Start、End,按 rune 计);与文本不符 panic。 | +| `Candidates` | `func(string) []MentionCandidate` | 必填 | 按 @ 后文字返回候选,仅在该文字变化时调用。 | +| `Placeholder` | `string` | `""` | 占位文字。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 不可编辑。 | +| `Error` | `string` | `""` | 错误信息。 | + +## 状态 + +提及以强调色加浅底显示;候选列表用浮层样式,当前项有选中底色与左侧标记。输入法组合期间不提交。 + +## 事件 + +`Changed()`:文本或提及变化;`Submitted()`:候选关闭时按 Enter。 + +## 键盘操作 + +@ 后 Up/Down 选择候选,Enter 或 Tab 插入,Escape 关闭;左右键跨过提及,Backspace/Delete 整体删除;Shift+方向键选择;支持复制、剪切、粘贴、全选、撤销、重做命令。 + +## 限制 + +MyGo 编辑器没有设置光标的公开接口(ui/editor.go:117 只在文本变化时截断光标),无法在插入提及后把光标放到提及之后,因此本组件自绘单行编辑器;不支持多行与双击选词。 diff --git a/docs/components/489-choice-chips.md b/docs/components/489-choice-chips.md new file mode 100644 index 0000000..3d61812 --- /dev/null +++ b/docs/components/489-choice-chips.md @@ -0,0 +1,45 @@ +# 489 ChoiceChips 筛选芯片 + +## 用途 + +单选或多选的筛选芯片,自动换行。选中芯片用选中底色、强调边框和对勾表示。 + +## 最小示例 + +```go +var status []string +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{ + {Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"}, + }, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"}) +} +``` + +签名:`func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ChoiceChipsOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*[]T` | 必填 | 选中的值;单选时多于一个 panic。 | +| `items` | `[]ToggleItem[T]` | 必填 | 芯片(Value、Label、Icon、Name、Disabled)。 | +| `Multiple` | `bool` | `false` | 允许多选;单选时可再次点击取消。 | +| `Disabled` | `bool` | `false` | 整组禁用。 | +| `Label` | `string` | `""` | 组名称。 | + +## 状态 + +默认、悬停、按下、焦点、选中、禁用;颜色过渡 120 ms。 + +## 事件 + +`Changed()`:选择变化。 + +## 键盘操作 + +整组是一个 Tab 停靠点;左右方向键移动并跳过禁用芯片;Space/Enter 切换。 + +## 限制 + +无。 diff --git a/docs/components/490-knob.md b/docs/components/490-knob.md new file mode 100644 index 0000000..0e77e1e --- /dev/null +++ b/docs/components/490-knob.md @@ -0,0 +1,45 @@ +# 490 Knob 旋钮 + +## 用途 + +圆形旋钮:上下拖动或方向键调整数值,显示刻度、数值弧和菱形指针。 + +## 最小示例 + +```go +gain := 35.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11}) +} +``` + +签名:`func Knob(c *ui.Context, value *float64, opts KnobOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Min`, `Max` | `float64` | 0–100 | 范围;`Max <= Min` panic。 | +| `Step` | `float64` | 连续 | 步长,按 NumberInput 网格吸附。 | +| `Ticks` | `int` | 0 | 刻度数;负数 panic。 | +| `Size` | `float32` | 56 | 直径。 | +| `Format` | `func(float64) string` | 自动 | 数值文字。 | +| `Disabled` | `bool` | `false` | 不可调。 | +| `Label` | `string` | “Knob” | 名称与标题。 | + +## 状态 + +悬停与拖动时表盘变亮;焦点环为圆形。基于 MyGo SliderBase,读屏视为滑块。 + +## 事件 + +`Changed()`:值变化。 + +## 键盘操作 + +方向键按步调整(连续时每步 1%),Home/End 到两端;上拖增大,160 DIP 走完全程。 + +## 限制 + +无。 diff --git a/docs/components/491-vertical-slider.md b/docs/components/491-vertical-slider.md new file mode 100644 index 0000000..6aee4e4 --- /dev/null +++ b/docs/components/491-vertical-slider.md @@ -0,0 +1,46 @@ +# 491 VerticalSlider 纵向滑块 + +## 用途 + +纵向放置的 Slider:上大下小,规则与 Slider 相同(步长网格、刻度、数值气泡)。 + +## 最小示例 + +```go +level := 40.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"}) +} +``` + +签名:`func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Min`, `Max` | `float64` | 0–100 | 范围。 | +| `Step` | `float64` | 连续 | 步长。 | +| `Marks` | `[]SliderMark` | `nil` | 刻度与标签。 | +| `Height` | `float32` | 160 | 轨道长度;负数 panic。 | +| `ShowValue` | `bool` | `false` | 悬停、拖动或聚焦时显示数值。 | +| `Format` | `func(float64) string` | 自动 | 数值文字。 | +| `Disabled` | `bool` | `false` | 不可调。 | +| `Label` | `string` | `""` | 名称。 | + +## 状态 + +菱形滑块,悬停与拖动放大;填充段为 AccentText。 + +## 事件 + +`Changed()`:值变化。 + +## 键盘操作 + +Up/Right 增大,Down/Left 减小,Home/End 到两端;点击或拖动设置。 + +## 限制 + +无。 diff --git a/docs/components/492-color-palette.md b/docs/components/492-color-palette.md new file mode 100644 index 0000000..4ce538f --- /dev/null +++ b/docs/components/492-color-palette.md @@ -0,0 +1,47 @@ +# 492 ColorPalette 预设色板 + +## 用途 + +从预设色块中选择颜色,可附带自定义颜色入口(复用 ColorPicker)。选中色块有内框与对勾,标记颜色按对比度自动取深或浅。 + +## 最小示例 + +```go +color := ui.Hex("#7D2034") +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ColorPalette(c, &color, []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, + {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + }, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"}) +} +``` + +签名:`func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts ColorPaletteOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `swatches` | `[]ColorSwatch` | 必填 | 颜色与名称;缺名称 panic。 | +| `Columns` | `int` | 8 | 每行色块数;负数 panic。 | +| `Custom` | `bool` | `false` | 显示自定义颜色入口。 | +| `Disabled` | `bool` | `false` | 不可选。 | +| `Label` | `string` | `""` | 组名称。 | + +## 状态 + +悬停加深边框;选中有内框与对勾;每个色块提示其名称。 + +## 事件 + +`Changed()`:颜色变化(含自定义)。 + +## 键盘操作 + +色板是一个 Tab 停靠点;左右键逐个移动,上下键跨行,Enter/Space 选择。 + +## 限制 + +无。 diff --git a/docs/components/493-signature-pad.md b/docs/components/493-signature-pad.md new file mode 100644 index 0000000..bbd9b13 --- /dev/null +++ b/docs/components/493-signature-pad.md @@ -0,0 +1,44 @@ +# 493 SignaturePad 签名板 + +## 用途 + +用指针书写签名,可清除,并可用 `SignaturePath` 导出 SVG 路径数据。 + +## 最小示例 + +```go +var strokes [][]mujica.SignaturePoint +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() { + _ = mujica.SignaturePath(strokes) // SVG path data to store + } +} +``` + +签名:`func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `strokes` | `*[][]SignaturePoint` | 必填 | 笔画,坐标为相对画板的 DIP。 | +| `Height` | `float32` | 160 | 画板高度;宽度填满。 | +| `Label` | `string` | “Signature” | 名称。 | +| `Disabled` | `bool` | `false` | 不可书写与清除。 | + +## 状态 + +空白时显示“请在此签名”;有签名基线;清除按钮在无笔画时禁用。 + +## 事件 + +`Changed()`:完成一笔或清除。 + +## 键盘操作 + +Tab 聚焦画板,Delete/Backspace 清除;Tab 到“清除”按钮。 + +## 限制 + +书写只能用指针,键盘无法替代手写。 diff --git a/docs/components/494-banner.md b/docs/components/494-banner.md new file mode 100644 index 0000000..688a2ad --- /dev/null +++ b/docs/components/494-banner.md @@ -0,0 +1,48 @@ +# 494 Banner 页面横幅 + +## 用途 + +页面顶部的通栏提示:信息、成功、警告、错误;可带操作和关闭按钮。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{ + Severity: mujica.SeverityWarning, + Action: "Details", + }) + if r.Action { + // show the details + } +} +``` + +签名:`func Banner(c *ui.Context, open *bool, message string, opts BannerOptions) AlertResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | `nil` | 非 nil 时显示关闭按钮;为 false 时不显示。 | +| `message` | `string` | 必填 | 文字。 | +| `Severity` | `Severity` | `SeverityInfo` | 严重程度;未知值 panic。 | +| `Action` | `string` | `""` | 操作按钮文字。 | + +## 状态 + +图标、底边色条与浅色底同时表达严重程度;警告与错误出现或变化时播报。 + +## 事件 + +`AlertResult.Action`、`AlertResult.Closed`。 + +## 键盘操作 + +Tab 到操作与关闭按钮;Enter/Space 触发。 + +## 限制 + +无。 diff --git a/docs/components/495-undo-toast.md b/docs/components/495-undo-toast.md new file mode 100644 index 0000000..fe9fe22 --- /dev/null +++ b/docs/components/495-undo-toast.md @@ -0,0 +1,47 @@ +# 495 UndoToast 撤销提示 + +## 用途 + +在窗口底部显示可撤销操作的消息与倒计时;撤销时调用回调。悬停或焦点在其中时暂停计时,从不抢夺焦点。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{ + OnUndo: func() { + // restore the note + }, + }) +} +``` + +签名:`func UndoToast(c *ui.Context, open *bool, message string, opts UndoToastOptions) UndoToastResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | 必填 | 显示与否;结束时置 false。 | +| `message` | `string` | 必填 | 文字,出现时播报。 | +| `OnUndo` | `func()` | 必填 | 撤销回调。 | +| `Duration` | `time.Duration` | 5 秒 | 倒计时;负数 panic。 | +| `Action` | `string` | “Undo” | 按钮文字。 | + +## 状态 + +按钮显示剩余秒数,下方细条随时间缩短;减少动画时不画细条,只更新秒数。 + +## 事件 + +`Undone`:已撤销(回调已执行);`Expired`:倒计时结束或用户关闭,操作生效。 + +## 键盘操作 + +Tab 到“撤销”与关闭按钮;焦点在其中时 Escape 关闭。 + +## 限制 + +每个窗口同时只显示一条。 diff --git a/docs/components/496-masonry.md b/docs/components/496-masonry.md new file mode 100644 index 0000000..6925378 --- /dev/null +++ b/docs/components/496-masonry.md @@ -0,0 +1,47 @@ +# 496 Masonry 瀑布流 + +## 用途 + +按可用宽度决定列数,把每一项放进当前最短的列;只构建可见范围内的项,以稳定键记住测得的高度。 + +## 最小示例 + +```go +titles := []string{"Sea", "Forest", "Desert", "Glacier"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Masonry(c, len(titles), mujica.MasonryOptions{ + Key: func(i int) string { return titles[i] }, + }, func(i int) { + ui.Text(c, titles[i]) + }).Size(380, 280) +} +``` + +签名:`func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `n` | `int` | 必填 | 项数;负数 panic。 | +| `Key` | `func(int) string` | 必填 | 稳定且唯一的键;重复 panic。 | +| `ColumnWidth` | `float32` | 220 | 最窄列宽。 | +| `Gap` | `float32` | 3 个间距单位 | 间隔。 | +| `Estimate` | `float32` | 160 | 测量前的估计高度。 | + +## 状态 + +自身是滚动容器,需由调用方给定尺寸。 + +## 事件 + +无。 + +## 键盘操作 + +滚动容器按 MyGo 规则滚动;项内控件按各自规则聚焦。 + +## 限制 + +排布只在项数、列数、宽度、测得高度或可见项的键变化时重算。 diff --git a/docs/components/497-resizable-panel-group.md b/docs/components/497-resizable-panel-group.md new file mode 100644 index 0000000..6bc2bfe --- /dev/null +++ b/docs/components/497-resizable-panel-group.md @@ -0,0 +1,48 @@ +# 497 ResizablePanelGroup 可调面板组 + +## 用途 + +多个面板以分隔条相连,拖动或键盘调整尺寸;可折叠面板拖到最小值一半以下即折叠。尺寸以比例保存在调用方的切片中。 + +## 最小示例 + +```go +var sizes []float32 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }}, + {Label: "Editor", Build: func() { ui.Text(c, "Editor") }}, + }, mujica.ResizablePanelGroupOptions{}) + if r.Committed { + // save sizes + } +} +``` + +签名:`func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePanel, opts ResizablePanelGroupOptions) ResizablePanelGroupResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `sizes` | `*[]float32` | 空则平分 | 各面板比例,和为 1,否则 panic;0 只允许可折叠面板。 | +| `panels` | `[]ResizablePanel` | 必填 | Label、Min(默认 10 个间距单位)、Collapsible、Build;至少两个。 | +| `Vertical` | `bool` | `false` | 上下排列。 | +| `Disabled` | `bool` | `false` | 不可调整。 | + +## 状态 + +分隔条悬停或拖动时变为强调色;折叠后分隔条报告“已折叠”。 + +## 事件 + +`Resized`:拖动中尺寸变化;`Committed`:一次调整完成(松开、按键、折叠),用于保存。 + +## 键盘操作 + +Tab 聚焦分隔条;方向键每次 4 个间距单位;Home/End 到最小与最大;Enter 折叠或恢复相邻的可折叠面板。 + +## 限制 + +无。 diff --git a/docs/components/498-dock-panel.md b/docs/components/498-dock-panel.md new file mode 100644 index 0000000..3f37046 --- /dev/null +++ b/docs/components/498-dock-panel.md @@ -0,0 +1,47 @@ +# 498 DockPanel 停靠面板 + +## 用途 + +左、中、右、下四个停靠区,每区以标签页显示面板;把标签拖到区域或窗口边缘即停靠到那里。布局由调用方保存。 + +## 最小示例 + +```go +var layout mujica.DockLayout +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.DockPanel(c, &layout, []mujica.DockItem{ + {ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }}, + {ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }}, + }, mujica.DockPanelOptions{}) + if r.Changed() { + // save layout + } +} +``` + +签名:`func DockPanel(c *ui.Context, layout *DockLayout, items []DockItem, opts DockPanelOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `layout` | `*DockLayout` | 全部在中央 | 各区面板 ID 与当前标签;未知或重复 ID panic。 | +| `items` | `[]DockItem` | 必填 | ID、Title、Build;ID 重复 panic。 | +| `Disabled` | `bool` | `false` | 不可切换与停靠。 | + +## 状态 + +拖动时四边出现停靠条,悬停的目标显示选中底色与强调边框;当前标签有下划线与选中底色。 + +## 事件 + +`Changed()`:布局变化(停靠或切换标签),用于保存。 + +## 键盘操作 + +每区标签是一个 Tab 停靠点,左右键切换;在标签上按 Shift+Alt+←/→/↓ 停靠到左、右、下,Shift+Alt+↑ 回到中央,焦点跟随标签。 + +## 限制 + +区域尺寸固定(侧栏 220 DIP,底部 160 DIP);需要调整时与 ResizablePanelGroup 组合。 diff --git a/docs/components/499-qr-code.md b/docs/components/499-qr-code.md new file mode 100644 index 0000000..4ec22d3 --- /dev/null +++ b/docs/components/499-qr-code.md @@ -0,0 +1,41 @@ +# 499 QRCode 二维码 + +## 用途 + +在本地用 Go 编码文字为二维码(字节模式,版本 1–40,纠错 L/M/Q/H),不访问网络。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile}) +} +``` + +签名:`func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `text` | `string` | 必填 | 按 UTF-8 字节编码。 | +| `Level` | `QRLevel` | `QRMedium` | `QRLow`、`QRMedium`、`QRQuartile`、`QRHigh`;未知值 panic。 | +| `Size` | `float32` | 160 | 含静区的边长;负数或非有限 panic。 | +| `Label` | `string` | “QR code” | 无障碍名称。 | + +## 状态 + +始终深色模块印在浅色底上,深色主题也一样,以便扫码;模块对齐设备像素。文字过长时显示错误。 + +## 事件 + +无。另有 `EncodeQR(data, level) (QRMatrix, error)` 供调用方导出。 + +## 键盘操作 + +不可聚焦。 + +## 限制 + +只用字节模式;只在文字或纠错级别变化时重新编码。 diff --git a/docs/components/500-activity-feed.md b/docs/components/500-activity-feed.md new file mode 100644 index 0000000..7af1758 --- /dev/null +++ b/docs/components/500-activity-feed.md @@ -0,0 +1,43 @@ +# 500 ActivityFeed 活动流 + +## 用途 + +按日分组显示“参与者 动作 对象 时间”,今天与昨天用文字,其余显示日期;只构建可见行。 + +## 最小示例 + +```go +events := []mujica.Activity{ + {ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280) +} +``` + +签名:`func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `items` | `[]Activity` | 必填 | ID、Actor、Action、Object、Time;缺 ID、Actor、Action 或 Time panic。 | +| `Location` | `*time.Location` | `time.Local` | 按哪个时区划分日期。 | +| `Today` | `Date` | 时钟 | 固定“今天”。 | + +## 状态 + +日期标题使用衬线字与旧金细线;无数据时显示空状态。 + +## 事件 + +无。 + +## 键盘操作 + +滚动容器按 MyGo 规则滚动。 + +## 限制 + +分组只在条数、首尾 ID 或时区变化时重算;中间条目原地修改需换新切片。 diff --git a/examples/gallery/pages_essentials.go b/examples/gallery/pages_essentials.go index 3dcc596..6562f5f 100644 --- a/examples/gallery/pages_essentials.go +++ b/examples/gallery/pages_essentials.go @@ -1,3 +1,262 @@ package main -var essentialsPages = []page{} +var essentialsPages = []page{ + { + ID: "481", Name: "SplitButton", ZhName: "拆分按钮 主操作 下拉", Category: "Essentials", + Demo: splitButtonDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Save as…", mujica.PopupMenuItemOptions{}) { + // ask for a file name + } + }) + if r.Changed() { + // save + } +}`, + Keys: "Tab reaches the main half, then the menu half. Enter or Space runs the action; on the menu half, Enter, Space or Down opens the menu.", + }, + { + ID: "482", Name: "CopyButton", ZhName: "复制按钮 剪贴板", Category: "Essentials", + Demo: copyButtonDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"}) +}`, + Keys: "Tab focuses; Enter or Space copies. The copied state is announced.", + }, + { + ID: "483", Name: "HoldToConfirm", ZhName: "按住确认 危险操作", Category: "Essentials", + Demo: holdToConfirmDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() { + // delete the project + } +}`, + Keys: "Focus it and hold Space for the full duration; a click, Enter or a short Space does nothing.", + }, + { + ID: "484", Name: "BulkActionBar", ZhName: "批量操作条 多选", Category: "Essentials", + Demo: bulkActionBarDemo, + Code: `selected := 3 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() { + mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + }) + if r.Changed() { + selected = 0 + } +}`, + Keys: "Tab moves through the bar's actions; Escape inside it clears the selection.", + }, + { + ID: "485", Name: "ScrubInput", ZhName: "拖动数值 拖动标签", Category: "Essentials", + Demo: scrubInputDemo, + Code: `opacity := 0.6 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1}) +}`, + Keys: "The label is pointer-only; in the field, Up and Down step and typing sets the value.", + }, + { + ID: "486", Name: "CurrencyInput", ZhName: "货币输入 金额 千分位", Category: "Essentials", + Demo: currencyInputDemo, + Code: `cents := int64(123450) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"}) +}`, + Keys: "Tab focuses the field, which shows plain digits while focused and groups thousands when left.", + }, + { + ID: "487", Name: "MaskedInput", ZhName: "掩码输入 电话 卡号", Category: "Essentials", + Demo: maskedInputDemo, + Code: `phone := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"}) +}`, + Keys: "Arrows, Home and End move the caret; Backspace and Delete remove; paste keeps the characters that fit; Enter submits.", + }, + { + ID: "488", Name: "MentionInput", ZhName: "提及输入 @ 候选", Category: "Essentials", + Demo: mentionInputDemo, + Code: `text, mentions := "", []mujica.Mention(nil) +people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{ + Label: "Comment", + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) +}`, + Keys: "After @, Up and Down pick a candidate, Enter or Tab inserts it, Escape closes the list. Arrows step over a mention; Backspace removes it whole.", + }, + { + ID: "489", Name: "ChoiceChips", ZhName: "筛选芯片 单选 多选", Category: "Essentials", + Demo: choiceChipsDemo, + Code: `var status []string +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{ + {Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"}, + }, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"}) +}`, + Keys: "The chips are one Tab stop; Left and Right move, skipping disabled chips; Space or Enter toggles.", + }, + { + ID: "490", Name: "Knob", ZhName: "旋钮 调节", Category: "Essentials", + Demo: knobDemo, + Code: `gain := 35.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11}) +}`, + Keys: "Arrows step the value; Home and End go to the ends; drag up or down with the pointer.", + }, + { + ID: "491", Name: "VerticalSlider", ZhName: "纵向滑块", Category: "Essentials", + Demo: verticalSliderDemo, + Code: `level := 40.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"}) +}`, + Keys: "Up and Right raise the value, Down and Left lower it, Home and End go to the ends.", + }, + { + ID: "492", Name: "ColorPalette", ZhName: "预设色板 颜色", Category: "Essentials", + Demo: colorPaletteDemo, + Code: `color := ui.Hex("#7D2034") +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ColorPalette(c, &color, []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, + {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + }, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"}) +}`, + Keys: "The palette is one Tab stop; Left and Right move by one swatch, Up and Down by a row; Enter or Space chooses.", + }, + { + ID: "493", Name: "SignaturePad", ZhName: "签名板 手写", Category: "Essentials", + Demo: signaturePadDemo, + Code: `var strokes [][]mujica.SignaturePoint +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() { + _ = mujica.SignaturePath(strokes) // SVG path data to store + } +}`, + Keys: "Tab focuses the pad; Delete or Backspace clears it. Drawing needs the pointer.", + }, + { + ID: "494", Name: "Banner", ZhName: "横幅 页面提示", Category: "Essentials", + Demo: bannerDemo, + Code: `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{ + Severity: mujica.SeverityWarning, + Action: "Details", + }) + if r.Action { + // show the details + } +}`, + Keys: "Tab reaches the action and the dismiss button; Enter or Space presses them.", + }, + { + ID: "495", Name: "UndoToast", ZhName: "撤销提示 倒计时", Category: "Essentials", + Demo: undoToastDemo, + Code: `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{ + OnUndo: func() { + // restore the note + }, + }) +}`, + Keys: "It never takes the focus. Tab reaches Undo and the close button after the page; Escape inside closes it. Hover or focus pauses the countdown.", + }, + { + ID: "496", Name: "Masonry", ZhName: "瀑布流 卡片", Category: "Essentials", + Demo: masonryDemo, + Code: `titles := []string{"Sea", "Forest", "Desert", "Glacier"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Masonry(c, len(titles), mujica.MasonryOptions{ + Key: func(i int) string { return titles[i] }, + }, func(i int) { + ui.Text(c, titles[i]) + }).Size(380, 280) +}`, + Keys: "Scroll with the wheel or the scrollbar; only cards in view are built.", + }, + { + ID: "497", Name: "ResizablePanelGroup", ZhName: "可调面板 分隔条", Category: "Essentials", + Demo: resizablePanelGroupDemo, + Code: `var sizes []float32 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }}, + {Label: "Editor", Build: func() { ui.Text(c, "Editor") }}, + }, mujica.ResizablePanelGroupOptions{}) + if r.Committed { + // save sizes + } +}`, + Keys: "Tab focuses a divider; arrows move it, Home and End go to the limits, Enter collapses or restores a collapsible neighbor.", + }, + { + ID: "498", Name: "DockPanel", ZhName: "停靠面板 标签", Category: "Essentials", + Demo: dockPanelDemo, + Code: `var layout mujica.DockLayout +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.DockPanel(c, &layout, []mujica.DockItem{ + {ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }}, + {ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }}, + }, mujica.DockPanelOptions{}) + if r.Changed() { + // save layout + } +}`, + Keys: "Each zone's tabs are one Tab stop; Left and Right switch tabs. Shift+Alt+Left, Right or Down docks the focused tab to that edge; Shift+Alt+Up returns it to the center.", + }, + { + ID: "499", Name: "QRCode", ZhName: "二维码", Category: "Essentials", + Demo: qrCodeDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile}) +}`, + Keys: "Not focusable; assistive technology reads its name.", + }, + { + ID: "500", Name: "ActivityFeed", ZhName: "活动流 动态", Category: "Essentials", + Demo: activityFeedDemo, + Code: `events := []mujica.Activity{ + {ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280) +}`, + Keys: "Scroll with the wheel or the scrollbar; only rows in view are built.", + }, +} diff --git a/examples/gallery/pages_essentials_demos.go b/examples/gallery/pages_essentials_demos.go new file mode 100644 index 0000000..6e5ab05 --- /dev/null +++ b/examples/gallery/pages_essentials_demos.go @@ -0,0 +1,267 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func splitButtonDemo(c *ui.Context, log func(string)) { + type state struct{ variant, disabled bool } + s := ui.Local(c.Root(), "481", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.variant, "Secondary") + ui.Checkbox(c, &s.disabled, "Disabled") + }) + }) + feedbackSection(c, "Live", func() { + v := mujica.Primary + if s.variant { + v = mujica.Secondary + } + ui.Row(c).Children(func() { + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{Variant: v, Disabled: s.disabled}, func(m *mujica.PopupMenu) { + for _, it := range []string{"Save as…", "Save a copy", "Save all"} { + if m.Item(it, mujica.PopupMenuItemOptions{}) { + log("split button: menu " + it) + } + } + }) + if r.Changed() { + log("split button: main action") + } + }) + }) + }) +} + +func copyButtonDemo(c *ui.Context, log func(string)) { + type state struct { + text string + disabled bool + } + s := ui.Local(c.Root(), "482", func() state { return state{text: "https://example.com/invite/42"} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.TextInput(c, &s.text).Width(320) + ui.Checkbox(c, &s.disabled, "Disabled") + }) + feedbackSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + if mujica.CopyButton(c, s.text, mujica.CopyButtonOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("copy button: copied %d characters", len([]rune(s.text)))) + } + if mujica.CopyButton(c, s.text, mujica.CopyButtonOptions{Label: "Copy link", Disabled: s.disabled}).Changed() { + log("copy button: copied with label") + } + }) + }) + }) +} + +func holdToConfirmDemo(c *ui.Context, log func(string)) { + type state struct { + seconds float64 + disabled bool + deleted int + } + s := ui.Local(c.Root(), "483", func() state { return state{seconds: 1.5} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Hold %.1f s", s.seconds)) + ui.Slider(c, &s.seconds, 0.5, 4).Width(200) + }) + ui.Checkbox(c, &s.disabled, "Disabled") + }) + feedbackSection(c, "Live (turn on reduced motion to see the countdown text)", func() { + ui.Row(c).Children(func() { + d := time.Duration(s.seconds * float64(time.Second)) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{Duration: d, Disabled: s.disabled}).Changed() { + s.deleted++ + log("hold to confirm: confirmed") + } + }) + ui.Text(c, fmt.Sprintf("Confirmed %d times", s.deleted)) + }) + }) +} + +func bulkActionBarDemo(c *ui.Context, log func(string)) { + type state struct { + picked [6]bool + disabled bool + } + s := ui.Local(c.Root(), "484", func() state { return state{picked: [6]bool{true, false, true}} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Rows", func() { + for i := range s.picked { + if ui.Checkbox(c, &s.picked[i], fmt.Sprintf("Invoice %d", 1001+i)).Changed() { + log(fmt.Sprintf("bulk bar: row %d %v", i, s.picked[i])) + } + } + ui.Checkbox(c, &s.disabled, "Disable the bar") + }) + n := 0 + for _, p := range s.picked { + if p { + n++ + } + } + r := mujica.BulkActionBar(c, n, mujica.BulkActionBarOptions{Disabled: s.disabled}, func() { + if mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + log(fmt.Sprintf("bulk bar: archive %d", n)) + } + if mujica.Button(c, "Delete", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + log(fmt.Sprintf("bulk bar: delete %d", n)) + } + }) + if r.Changed() { + s.picked = [6]bool{} + log("bulk bar: cleared") + } + }) +} + +func scrubInputDemo(c *ui.Context, log func(string)) { + type state struct { + opacity, size float64 + disabled bool + } + s := ui.Local(c.Root(), "485", func() state { return state{opacity: 0.6, size: 14} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + feedbackSection(c, "Drag a label sideways, or type", func() { + if mujica.ScrubInput(c, "Opacity", &s.opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.05, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("scrub: opacity %.2f", s.opacity)) + } + if mujica.ScrubInput(c, "Size", &s.size, mujica.ScrubInputOptions{Min: 8, Max: 72, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("scrub: size %.0f", s.size)) + } + }) + }) +} + +func currencyInputDemo(c *ui.Context, log func(string)) { + type state struct { + cents, yen int64 + disabled bool + } + s := ui.Local(c.Root(), "486", func() state { return state{cents: 123450, yen: 98000} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + feedbackSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + if mujica.CurrencyInput(c, &s.cents, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price", Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("currency: %d cents", s.cents)) + } + if mujica.CurrencyInput(c, &s.yen, mujica.CurrencyInputOptions{Symbol: "¥", NoFraction: true, Label: "Yen", Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("currency: %d yen", s.yen)) + } + }) + ui.Text(c, "Stored as "+mujica.CurrencyFormat(s.cents, 2, true)+" and "+mujica.CurrencyFormat(s.yen, 0, true)) + }) + }) +} + +func maskedInputDemo(c *ui.Context, log func(string)) { + type state struct { + mask int + value string + disabled bool + } + masks := []string{"(999) 999-9999", "9999 9999 9999 9999", "99/99/9999", "aa-9999"} + s := ui.Local(c.Root(), "487", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + if ui.Segmented(c, &s.mask, masks...).Changed() { + s.value = "" + log("masked: mask " + masks[s.mask]) + } + ui.Checkbox(c, &s.disabled, "Disabled") + }) + feedbackSection(c, "Live (try an input method: nothing commits until it does)", func() { + r := mujica.MaskedInput(c, &s.value, mujica.MaskedInputOptions{Mask: masks[s.mask], Label: "Masked field", Disabled: s.disabled}) + r.Element.Width(260) + if r.Changed() { + log(fmt.Sprintf("masked: %d characters filled", len([]rune(s.value)))) + } + if r.Submitted() { + log("masked: submitted") + } + }) + }) +} + +func choiceChipsDemo(c *ui.Context, log func(string)) { + type state struct { + value []string + multiple, disabled bool + } + s := ui.Local(c.Root(), "489", func() state { return state{multiple: true, value: []string{"open"}} }) + items := []mujica.ToggleItem[string]{{Value: "open", Label: "Open"}, {Value: "draft", Label: "Draft"}, {Value: "review", Label: "In review"}, {Value: "closed", Label: "Closed", Disabled: true}, {Value: "merged", Label: "Merged"}} + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + if ui.Checkbox(c, &s.multiple, "Multiple").Changed() && !s.multiple && len(s.value) > 1 { + s.value = s.value[:1] + } + ui.Checkbox(c, &s.disabled, "Disabled") + }) + }) + feedbackSection(c, "Live", func() { + if mujica.ChoiceChips(c, &s.value, items, mujica.ChoiceChipsOptions{Multiple: s.multiple, Disabled: s.disabled, Label: "Status"}).Changed() { + log(fmt.Sprintf("chips: %v", s.value)) + } + }) + }) +} + +func knobDemo(c *ui.Context, log func(string)) { + type state struct { + gain, mix float64 + disabled bool + } + s := ui.Local(c.Root(), "490", func() state { return state{gain: 35, mix: 0.5} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + ui.Row(c).Gap(32).Children(func() { + if mujica.Knob(c, &s.gain, mujica.KnobOptions{Label: "Gain", Ticks: 11, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("knob: gain %.1f", s.gain)) + } + if mujica.Knob(c, &s.mix, mujica.KnobOptions{Max: 1, Step: 0.1, Size: 72, Label: "Mix", Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("knob: mix %.1f", s.mix)) + } + }) + }) +} + +func verticalSliderDemo(c *ui.Context, log func(string)) { + type state struct { + levels [4]float64 + showValue, disable bool + } + s := ui.Local(c.Root(), "491", func() state { return state{levels: [4]float64{40, 70, 20, 90}, showValue: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.showValue, "Show value") + ui.Checkbox(c, &s.disable, "Disabled") + }) + ui.Row(c).Gap(16).Children(func() { + for i := range s.levels { + opts := mujica.VerticalSliderOptions{Step: 10, ShowValue: s.showValue, Disabled: s.disable, Label: fmt.Sprintf("Band %d", i+1)} + if i == 0 { + opts.Marks = []mujica.SliderMark{{Value: 0, Label: "0"}, {Value: 50, Label: "50"}, {Value: 100, Label: "100"}} + } + if mujica.VerticalSlider(c, &s.levels[i], opts).Changed() { + log(fmt.Sprintf("vertical slider: band %d = %.0f", i+1, s.levels[i])) + } + } + }) + }) +} diff --git a/examples/gallery/pages_essentials_more.go b/examples/gallery/pages_essentials_more.go new file mode 100644 index 0000000..0334815 --- /dev/null +++ b/examples/gallery/pages_essentials_more.go @@ -0,0 +1,292 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func mentionInputDemo(c *ui.Context, log func(string)) { + type state struct { + text string + mentions []mujica.Mention + disabled bool + } + people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Albert"}, {ID: "u3", Name: "Bob"}, {ID: "u4", Name: "Sakiko"}, {ID: "u5", Name: "Mutsumi"}} + s := ui.Local(c.Root(), "488", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + feedbackSection(c, "Type @ to mention someone", func() { + r := mujica.MentionInput(c, &s.text, &s.mentions, mujica.MentionInputOptions{ + Placeholder: "Write a comment…", Label: "Comment", Disabled: s.disabled, + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) + r.Element.Width(420) + if r.Changed() { + log(fmt.Sprintf("mention: %d mentions", len(s.mentions))) + } + if r.Submitted() { + log("mention: submitted") + } + for _, m := range s.mentions { + ui.Text(c, fmt.Sprintf("@%s → %s", m.Name, m.ID)).Key(m.ID) + } + }) + }) +} + +func colorPaletteDemo(c *ui.Context, log func(string)) { + type state struct { + color ui.Color + custom, disabled bool + } + s := ui.Local(c.Root(), "492", func() state { return state{color: ui.Hex("#7D2034"), custom: true} }) + swatches := []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, {Color: ui.Hex("#A12635"), Name: "Crimson"}, {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#805714"), Name: "Bronze"}, {Color: ui.Hex("#326247"), Name: "Ivy"}, {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + {Color: ui.Hex("#4B3A5A"), Name: "Plum"}, {Color: ui.Hex("#211C1D"), Name: "Ink"}, {Color: ui.Hex("#6A5B55"), Name: "Taupe"}, + {Color: ui.Hex("#D2C3AF"), Name: "Parchment"}, {Color: ui.Hex("#F2EBDD"), Name: "Ivory"}, {Color: ui.Hex("#FFFFFF"), Name: "White"}, + } + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.custom, "Custom entry") + ui.Checkbox(c, &s.disabled, "Disabled") + }) + if mujica.ColorPalette(c, &s.color, swatches, mujica.ColorPaletteOptions{Columns: 6, Custom: s.custom, Disabled: s.disabled, Label: "Tag color"}).Changed() { + log(fmt.Sprintf("palette: %v", s.color)) + } + }) +} + +func signaturePadDemo(c *ui.Context, log func(string)) { + type state struct { + strokes [][]mujica.SignaturePoint + disabled bool + } + s := ui.Local(c.Root(), "493", func() state { return state{} }) + ui.Column(c).Gap(16).Width(480).Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + if mujica.SignaturePad(c, &s.strokes, mujica.SignaturePadOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("signature: %d strokes, %d bytes of path", len(s.strokes), len(mujica.SignaturePath(s.strokes)))) + } + }) +} + +func bannerDemo(c *ui.Context, log func(string)) { + type state struct { + sev int + action, closable, op bool + } + s := ui.Local(c.Root(), "494", func() state { return state{sev: 2, action: true, closable: true, op: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error") + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.action, "Action") + ui.Checkbox(c, &s.closable, "Closable") + }) + }) + opts := mujica.BannerOptions{Severity: feedbackSeverities[s.sev]} + if s.action { + opts.Action = "Details" + } + var open *bool + if s.closable { + open = &s.op + } + r := mujica.Banner(c, open, "Scheduled maintenance tonight from 22:00 to 23:00.", opts) + if r.Action { + log("banner: action") + } + if r.Closed { + log("banner: closed") + } + if !s.op && mujica.Button(c, "Show again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.op = true + log("banner: reopened") + } + }) +} + +func undoToastDemo(c *ui.Context, log func(string)) { + type state struct { + notes []string + removed string + open bool + seconds float64 + } + s := ui.Local(c.Root(), "495", func() state { + return state{notes: []string{"Call the florist", "Book the hall", "Order candles"}, seconds: 5} + }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Countdown %.0f s", s.seconds)) + ui.Slider(c, &s.seconds, 2, 10).Width(200) + }) + for i, n := range s.notes { + ui.Row(c).Key(n).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, n).Grow(1) + if mujica.Button(c, "Delete", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + s.removed, s.open = n, true + s.notes = append(s.notes[:i:i], s.notes[i+1:]...) + log("undo toast: deleted " + n) + } + }) + } + }) + if !s.open { + return + } + removed := s.removed + r := mujica.UndoToast(c, &s.open, "Deleted “"+removed+"”", mujica.UndoToastOptions{ + Duration: time.Duration(s.seconds * float64(time.Second)), + OnUndo: func() { s.notes = append(s.notes, removed) }, + }) + if r.Undone { + log("undo toast: undone " + removed) + } + if r.Expired { + log("undo toast: expired") + } +} + +func masonryDemo(c *ui.Context, log func(string)) { + type state struct { + count, width float64 + } + s := ui.Local(c.Root(), "496", func() state { return state{count: 40, width: 180} }) + k := mujica.Tokens(c) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Items %.0f", s.count)) + if ui.Slider(c, &s.count, 1, 10000).Width(200).Changed() { + log(fmt.Sprintf("masonry: %d items", int(s.count))) + } + ui.Text(c, fmt.Sprintf("Column %.0f", s.width)) + ui.Slider(c, &s.width, 120, 320).Width(160) + }) + mujica.Masonry(c, int(s.count), mujica.MasonryOptions{Key: strconv.Itoa, ColumnWidth: float32(s.width)}, func(i int) { + card := ui.Column(c).Height(float32(60+i*37%90)).Padding(10).Background(k.Surface).Border(1, k.Border).Radius(4).Cursor(ui.CursorPointer) + card.Children(func() { ui.Text(c, "Card "+strconv.Itoa(i+1)) }) + if card.Clicked() { + log("masonry: clicked card " + strconv.Itoa(i+1)) + } + }).FillWidth().Height(420) + }) +} + +func resizablePanelGroupDemo(c *ui.Context, log func(string)) { + type state struct { + sizes []float32 + vertical, disabled bool + } + s := ui.Local(c.Root(), "497", func() state { return state{} }) + k := mujica.Tokens(c) + pane := func(name string) func() { + return func() { + ui.Column(c).Fill().Padding(12).Background(k.Surface).Children(func() { ui.Text(c, name) }) + } + } + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(16).Children(func() { + if ui.Checkbox(c, &s.vertical, "Vertical").Changed() { + s.sizes = nil + } + ui.Checkbox(c, &s.disabled, "Disabled") + }) + r := mujica.ResizablePanelGroup(c, &s.sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: pane("Files")}, + {Label: "Editor", Min: 160, Build: pane("Editor")}, + {Label: "Outline", Collapsible: true, Build: pane("Outline")}, + }, mujica.ResizablePanelGroupOptions{Vertical: s.vertical, Disabled: s.disabled}) + r.Element.FillWidth().Height(320) + if r.Committed { + log(fmt.Sprintf("panels: save %.2f", s.sizes)) + } + }) +} + +func dockPanelDemo(c *ui.Context, log func(string)) { + type state struct { + layout mujica.DockLayout + disabled bool + } + s := ui.Local(c.Root(), "498", func() state { + return state{layout: mujica.DockLayout{Zones: [4][]string{{"editor"}, {"files"}, {"outline"}, {"terminal", "problems"}}}} + }) + var items []mujica.DockItem + for _, id := range []string{"files", "editor", "outline", "terminal", "problems"} { + items = append(items, mujica.DockItem{ID: id, Title: strings.ToUpper(id[:1]) + id[1:], Build: func() { ui.Text(c, "Contents of "+id) }}) + } + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + r := mujica.DockPanel(c, &s.layout, items, mujica.DockPanelOptions{Disabled: s.disabled}) + r.Element.FillWidth().Height(440) + if r.Changed() { + log(fmt.Sprintf("dock: save %v", s.layout.Zones)) + } + }) +} + +func qrCodeDemo(c *ui.Context, log func(string)) { + type state struct { + text string + level int + size float64 + } + s := ui.Local(c.Root(), "499", func() state { return state{text: "https://example.com/mujica", level: 1, size: 200} }) + levels := []mujica.QRLevel{mujica.QRLow, mujica.QRMedium, mujica.QRQuartile, mujica.QRHigh} + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + if ui.TextInput(c, &s.text).Width(360).Changed() { + log(fmt.Sprintf("qr: %d bytes", len(s.text))) + } + if ui.Segmented(c, &s.level, "L", "M", "Q", "H").Changed() { + log("qr: level " + levels[s.level].String()) + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Size %.0f", s.size)) + ui.Slider(c, &s.size, 100, 320).Width(200) + }) + }) + mujica.QRCode(c, s.text, mujica.QRCodeOptions{Level: levels[s.level], Size: float32(s.size)}) + if m, err := mujica.EncodeQR([]byte(s.text), levels[s.level]); err == nil { + ui.Text(c, fmt.Sprintf("Version %d, mask %d, %d × %d modules", m.Version, m.Mask, m.Size(), m.Size())) + } + }) +} + +func activityFeedDemo(c *ui.Context, log func(string)) { + type state struct{ events []mujica.Activity } + s := ui.Local(c.Root(), "500", func() state { + now := time.Now() + var ev []mujica.Activity + actors := []string{"Sakiko", "Mutsumi", "Uika", "Umiri", "Nyamu"} + verbs := []string{"commented on", "edited", "approved", "closed", "opened"} + for i := range 400 { + ev = append(ev, mujica.Activity{ID: strconv.Itoa(i), Actor: actors[i%5], Action: verbs[i*3%5], Object: fmt.Sprintf("Score #%d", 400-i), Time: now.Add(-time.Duration(i) * 97 * time.Minute)}) + } + return state{events: ev} + }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + if mujica.Button(c, "Add event", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() { + e := mujica.Activity{ID: fmt.Sprint("n", len(s.events)), Actor: "You", Action: "added", Object: "a new note", Time: time.Now()} + s.events = append([]mujica.Activity{e}, s.events...) + log("activity: added " + e.ID) + } + mujica.ActivityFeed(c, s.events, mujica.ActivityFeedOptions{}).FillWidth().Height(420) + }) +} From 37f432caf8a31c4b4f488c13f68d44a779398f15 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:40:08 -0400 Subject: [PATCH 17/20] Essentials: components 481-500 From b57d8e7da7e62e80fac65aed3664fb47147ee4e9 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:44:58 -0400 Subject: [PATCH 18/20] Form fields and ToggleGroup items keyed by name and value; shared stableKey --- form.go | 2 +- form_field.go | 25 +++++++++++++----- radio_group.go | 17 +++++++----- stable_keys_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++ toggle_group.go | 18 ++++++++----- 5 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 stable_keys_test.go diff --git a/form.go b/form.go index 4c6314c..c3ea2b1 100644 --- a/form.go +++ b/form.go @@ -293,7 +293,7 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo desc = msg(c, "form.checking") } var ctrl *ui.Element - field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { + field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { ctrl = control() return ctrl }) diff --git a/form_field.go b/form_field.go index 47f9e8c..ee589fc 100644 --- a/form_field.go +++ b/form_field.go @@ -20,10 +20,10 @@ type FormFieldOptions struct { // FormField labels the element control returns and shows help and error text for it. func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element { if !opts.Horizontal { - return formFieldBuild(c, label, opts, false, control) + return formFieldBuild(c, "", label, opts, false, control) } var f *ui.Element - formFieldGrid(c, func() { f = formFieldBuild(c, label, opts, true, control) }) + formFieldGrid(c, func() { f = formFieldBuild(c, "", label, opts, true, control) }) return f } @@ -35,7 +35,8 @@ func formFieldGrid(c *ui.Context, fields func()) *ui.Element { } // formFieldBuild builds the label and the control column; beside makes them two grid cells. -func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { +// formFieldBuild keys its cells by key when set, so focus follows a reordered field. +func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { if label == "" { panic("mujica: FormField needs a label") } @@ -43,7 +44,11 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b d := Density(c) var head, content, ctrl *ui.Element body := func() { - head = ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center) + head = ui.Row(c) + if key != "" && beside { + head.Key(key + "/label") + } + head.Gap(d.Unit()).AlignItems(ui.Center) if beside { head.MinHeight(d.ControlHeight()).Justify(ui.End) } @@ -55,7 +60,11 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b } }) clicked := head.Clicked() - content = ui.Column(c).Gap(d.Unit() * 1.5).MinWidth(0) + content = ui.Column(c) + if key != "" && beside { + content.Key(key + "/control") + } + content.Gap(d.Unit() * 1.5).MinWidth(0) content.Children(func() { ctrl = control() if ctrl == nil { @@ -82,5 +91,9 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b body() return content } - return ui.Column(c).Gap(d.Unit() * 1.5).Children(body) + root := ui.Column(c) + if key != "" { + root.Key(key) + } + return root.Gap(d.Unit() * 1.5).Children(body) } diff --git a/radio_group.go b/radio_group.go index 6a87ade..0cfca88 100644 --- a/radio_group.go +++ b/radio_group.go @@ -51,12 +51,7 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt wrap.Children(func() { r.Element = ui.RadioGroup(c, func() { for _, it := range items { - key := slices.Index(*known, it.Value) - if key < 0 { - key = len(*known) - *known = append(*known, it.Value) - } - if radioGroupItem(c, k, d, value, it, key) { + if radioGroupItem(c, k, d, value, it, stableKey(known, it.Value)) { r.changed = true slog.Debug("mujica: radio_group choose", "label", it.Label) } @@ -103,3 +98,13 @@ func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density }) return row.Changed() } + +// stableKey keys v by its first appearance in *known, so equal-looking values stay apart. +func stableKey[T comparable](known *[]T, v T) int { + i := slices.Index(*known, v) + if i < 0 { + i = len(*known) + *known = append(*known, v) + } + return i +} diff --git a/stable_keys_test.go b/stable_keys_test.go new file mode 100644 index 0000000..8cc1d50 --- /dev/null +++ b/stable_keys_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFormFocusFollowsFieldAfterReorder(t *testing.T) { + var st FormState + first, second := "", "" + swapped := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Form(c, &st, FormOptions{}, func(f *FormScope) { + fields := []func(){ + func() { + FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() *ui.Element { + return TextInput(c, &first, TextInputOptions{Label: "First"}).Input + }) + }, + func() { + FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() *ui.Element { + return TextInput(c, &second, TextInputOptions{Label: "Second"}).Input + }) + }, + } + if swapped { + slices.Reverse(fields) + } + for _, fn := range fields { + fn() + } + }) + }, 500, 400) + if err := tt.Click("First"); err != nil { + t.Fatal(err) + } + tt.Type("a") + swapped = true + tt.Frame() + tt.Type("b") + if first != "ab" || second != "" { + t.Fatalf("first %q second %q, want typing to stay in First", first, second) + } +} + +func TestToggleGroupFocusFollowsValueAfterReorder(t *testing.T) { + var on []string + items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToggleGroup(c, &on, items, ToggleGroupOptions{Multiple: true, Label: "Align"}) + }, 400, 200) + tt.Key(0, ui.KeyTab) + slices.Reverse(items) + tt.Frame() + tt.Key(0, ui.KeySpace) + if !slices.Equal(on, []string{"left"}) { + t.Fatalf("pressed %v, want [left]", on) + } +} diff --git a/toggle_group.go b/toggle_group.go index e8617ec..1c87cc3 100644 --- a/toggle_group.go +++ b/toggle_group.go @@ -59,17 +59,21 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } g.Dividers(theme.BorderWidth, k.ControlBorder) changed := false + known := ui.Local(g, "values", func() []T { return nil }) g.Children(func() { for _, it := range items { on := slices.Contains(*value, it.Value) pressed := on - b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) - // The base took input before Disabled applied: drop a disabled item's press. - if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { - changed = true - on = pressed - } - toggleGroupLook(c, k, b, on, it) + // Keyed by value so focus follows the item, not its position. + ui.Box(c).Key(stableKey(known, it.Value)).Children(func() { + b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) + // The base took input before Disabled applied: drop a disabled item's press. + if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { + changed = true + on = pressed + } + toggleGroupLook(c, k, b, on, it) + }) } }) if changed { From e0c388101f42c5b1b73e338a82184a455d4a801b 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:46:22 -0400 Subject: [PATCH 19/20] Web mirror: modified click at release point, every app clipboard write notified, NumLock-off numpad navigation --- examples/gallery/web.go | 1 + examples/gallery/web/index.html | 4 ++- examples/gallery/web_input.go | 23 +++++++++++-- examples/gallery/web_test.go | 59 +++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/examples/gallery/web.go b/examples/gallery/web.go index 26c424f..8180d6b 100644 --- a/examples/gallery/web.go +++ b/examples/gallery/web.go @@ -31,6 +31,7 @@ type mirror struct { urls int clip string copied bool + probes int cursor ui.Cursor down *input enc png.Encoder diff --git a/examples/gallery/web/index.html b/examples/gallery/web/index.html index 96ca311..9f5280c 100644 --- a/examples/gallery/web/index.html +++ b/examples/gallery/web/index.html @@ -154,7 +154,9 @@ keys.addEventListener("keydown", (e) => { if (cmd && commands[e.code] && !e.altKey) { send({ type: "command", text: e.code === "KeyZ" && e.shiftKey ? "redo" : commands[e.code] }); } else { - send({ type: "key", code: e.code, text: e.key.length === 1 ? e.key : "", mods: mods(e) }); + // NumLock off: numpad keys act as navigation keys, named by e.key. + const code = e.code.startsWith("Numpad") && e.key.length > 1 && e.key !== "Enter" ? e.key : e.code; + send({ type: "key", code, text: e.key.length === 1 ? e.key : "", mods: mods(e) }); } e.preventDefault(); }); diff --git a/examples/gallery/web_input.go b/examples/gallery/web_input.go index bd0456d..47230d6 100644 --- a/examples/gallery/web_input.go +++ b/examples/gallery/web_input.go @@ -36,7 +36,7 @@ func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { return } m.mu.Lock() - err := m.apply(in) + err := m.applyWatchingClipboard(in) if err == nil { m.capture() } @@ -67,7 +67,8 @@ func (m *mirror) apply(in input) error { if d := m.down; d != nil { m.down = nil if dx, dy := in.X-d.X, in.Y-d.Y; dx*dx+dy*dy <= 16 { - m.tt.ClickAtWith(modifiers(d.Mods), d.X, d.Y) + // The click lands where it is released, as a real press and release would. + m.tt.ClickAtWith(modifiers(d.Mods), in.X, in.Y) } else { slog.Info("gallery: modified drag ignored; tester clicks only", "mods", modifiers(d.Mods)) } @@ -124,3 +125,21 @@ func modifiers(m []bool) ui.Modifiers { } return out } + +// applyWatchingClipboard applies in and notes any clipboard write it caused, even of equal text. +func (m *mirror) applyWatchingClipboard(in input) error { + if in.Type == "paste" || in.Type == "command" && in.Text == "paste" { + return m.apply(in) + } + before := m.tt.Clipboard() + m.probes++ + probe := fmt.Sprintf("\x00mujica-mirror-probe-%d", m.probes) + m.tt.SetClipboard(probe) + err := m.apply(in) + if m.tt.Clipboard() == probe { + m.tt.SetClipboard(before) + } else { + m.copied = true + } + return err +} diff --git a/examples/gallery/web_test.go b/examples/gallery/web_test.go index 66e5b35..b8798f9 100644 --- a/examples/gallery/web_test.go +++ b/examples/gallery/web_test.go @@ -260,3 +260,62 @@ func TestMirrorForwardsEveryAnnouncement(t *testing.T) { t.Fatalf("announcements forwarded %d times, want 2", heard) } } + +func TestMirrorModifiedClickReleasedJustOutsideDoesNotClick(t *testing.T) { + clicks := 0 + var box ui.Rect + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + box = b.Bounds() + if b.Clicked() { + clicks++ + } + }) + }) + m.mu.Lock() + m.tt.Frame() + r := box + m.mu.Unlock() + shift := []bool{true} + y := r.Y + r.H/2 + postInput(t, srv, input{Type: "down", X: r.X + r.W - 1, Y: y, Mods: shift}) + postInput(t, srv, input{Type: "up", X: r.X + r.W + 2, Y: y, Mods: shift}) + if clicks != 0 { + t.Fatalf("clicks %d after releasing 2 DIP outside", clicks) + } +} + +func TestMirrorNotifiesEveryCopyOfEqualText(t *testing.T) { + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).Children(func() { + if mujica.Button(c, "Copy", mujica.ButtonOptions{}).Clicked() { + c.WriteClipboard("snippet") + } + }) + }) + ch := make(chan []byte, 64) + m.mu.Lock() + m.subs[ch] = struct{}{} + m.mu.Unlock() + for range 2 { + postInput(t, srv, input{Type: "down", X: 40, Y: 35}) + postInput(t, srv, input{Type: "up", X: 40, Y: 35}) + } + copies := 0 + for len(ch) > 0 { + if bytes.Contains(<-ch, []byte(`"clipboard":"snippet"`)) { + copies++ + } + } + if copies != 2 { + t.Fatalf("copies forwarded %d, want 2", copies) + } + m.mu.Lock() + defer m.mu.Unlock() + if got := m.tt.Clipboard(); got != "snippet" { + t.Fatalf("clipboard %q", got) + } +} From c804f2ab946e65978e8cd0443e669eda3c9ddfca 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:46:46 -0400 Subject: [PATCH 20/20] TASKS/AGENTS: record the category package split decision --- AGENTS.md | 3 ++- TASKS.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 376e0de..5ee7e4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # AGENTS.md MujicaUI: court-gothic components for MyGo native UI. Go library, package `mujica`, module `mujicaui`. -Progress truth: `TASKS.md`. Requirements: `PRD.md`. +Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/ext-*.md` (101–500). +Planned: once 001–500 land, the root package splits into category sub-packages (TASKS T22). Until then everything stays in the root package. ## Stack diff --git a/TASKS.md b/TASKS.md index 1762d31..d3c27b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -43,4 +43,5 @@ Ordered by PRD sections. Check an item only after its codex review passes. Revie - [ ] T19 §8 Docs: install, minimal example, theme, compat; 100 component pages; examples compile - [ ] T20 §9 Snapshots, density baselines, large-data and lifecycle tests - [ ] T21 §9 CI workflow -- [ ] T22 §10 Acceptance checklist +- [ ] T22 §6.3 Split into category sub-packages after 001–500 land (user decision 2026-10-05); update PRD §6.3, docs, Gallery, CI +- [ ] T23 §10 Acceptance checklist