Merge branch 'main' into worktree-agent-ae66cd6d621c16060

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:05:07 -04:00
commit 1095ed2b9d
126 files changed
+7769 -26

No files matched your search

+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+137
View File
@@ -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()
})
}
+92
View File
@@ -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)
})
})
}
+56
View File
@@ -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
}
+75
View File
@@ -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"})
})
})
}
+53
View File
@@ -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}
}
+75
View File
@@ -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})
})
})
})
}
+84
View File
@@ -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()
}
})
}
+85
View File
@@ -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})
})
})
}
+109
View File
@@ -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
}
+78
View File
@@ -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})
})
})
}
+64
View File
@@ -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}
}
+77
View File
@@ -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})
})
})
}
+146
View File
@@ -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
}
+120
View File
@@ -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)
})
})
}
+193
View File
@@ -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)
}
}
}
+102
View File
@@ -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)
})
})
}
+50
View File
@@ -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 关闭。
## 限制
菜单宽度至少等于整个按钮组;菜单内容由调用方提供。
+40
View File
@@ -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 完成,无法得知系统是否拒绝写入。
+42
View File
@@ -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 或短按空格都不会执行。
## 限制
按住空格依赖系统送来的按键抬起;窗口失去焦点时按住状态即重置。
+46
View File
@@ -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 清除选择。
## 限制
位置由调用方放置,不自动悬浮在窗口底部。
+43
View File
@@ -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 步进或直接输入。
## 限制
拖动只对指针可用。
+45
View File
@@ -0,0 +1,45 @@
# 486 CurrencyInput 货币输入
## 用途
以最小单位(分)的整数保存金额,避免浮点误差;显示币种符号,未聚焦时按千分位分组,聚焦后显示纯数字便于编辑。
## 最小示例
```go
cents := int64(123450)
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CurrencyInput(c, &cents, 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 编辑器提供。
## 限制
千分位与小数点固定为 `,` 与 `.`。
+43
View File
@@ -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 提交。
## 限制
自绘编辑器:没有文字选择与撤销,复制得到未格式化的值。
+57
View File
@@ -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 只在文本变化时截断光标),无法在插入提及后把光标放到提及之后,因此本组件自绘单行编辑器;不支持多行与双击选词。
+45
View File
@@ -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 切换。
## 限制
无。
+45
View File
@@ -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 走完全程。
## 限制
无。
+46
View File
@@ -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 到两端;点击或拖动设置。
## 限制
无。
+47
View File
@@ -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 选择。
## 限制
无。
+44
View File
@@ -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 到“清除”按钮。
## 限制
书写只能用指针,键盘无法替代手写。
+48
View File
@@ -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 触发。
## 限制
无。
+47
View File
@@ -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 关闭。
## 限制
每个窗口同时只显示一条。
+47
View File
@@ -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 规则滚动;项内控件按各自规则聚焦。
## 限制
排布只在项数、列数、宽度、测得高度或可见项的键变化时重算。
@@ -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 折叠或恢复相邻的可折叠面板。
## 限制
无。
+47
View File
@@ -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 组合。
+41
View File
@@ -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)` 供调用方导出。
## 键盘操作
不可聚焦。
## 限制
只用字节模式;只在文字或纠错级别变化时重新编码。
+43
View File
@@ -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 或时区变化时重算;中间条目原地修改需换新切片。
+248
View File
@@ -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, &cents, 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)
}
+260 -1
View File
@@ -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, &cents, 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.",
},
}
+267
View File
@@ -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]))
}
}
})
})
}
+292
View File
@@ -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)
})
}
+1
View File
@@ -31,6 +31,7 @@ type mirror struct {
urls int
clip string
copied bool
probes int
cursor ui.Cursor
down *input
enc png.Encoder
+3 -1
View File
@@ -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();
});
+21 -2
View File
@@ -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
}
+59
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
})
+19 -6
View File
@@ -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)
}
+113
View File
@@ -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}
}
+124
View File
@@ -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})
})
})
}
+118
View File
@@ -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)
}
}
+98
View File
@@ -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})
})
})
}
+209
View File
@@ -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
}
+125
View File
@@ -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)
})
})
}
+139
View File
@@ -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
}
+78
View File
@@ -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)
})
}
+335
View File
@@ -0,0 +1,335 @@
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()
}
}
})
})
})
}
+142
View File
@@ -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 })
}
+149
View File
@@ -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…"})
})
})
}
+48
View File
@@ -0,0 +1,48 @@
package mujica
func init() {
addMessages(map[string][2]string{
"copy_button.copy": {"Copy", "复制"},
"copy_button.copied": {"Copied", "已复制"},
"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", "清除选择"},
"color_palette.custom": {"Custom color", "自定义颜色"},
"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 位小数"},
"signature_pad.label": {"Signature", "签名"},
"signature_pad.hint": {"Draw with the pointer; Delete clears", "用鼠标书写;Delete 键清除"},
"signature_pad.empty": {"Sign here", "请在此签名"},
"signature_pad.clear": {"Clear", "清除"},
"undo_toast.undo": {"Undo", "撤销"},
"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+方向键停靠"},
"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", "文字过长,无法生成二维码"},
})
}
+79
View File
@@ -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
}
+262
View File
@@ -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<<cc {
continue
}
if 4+cc+8*len(data) <= 8*qrCodeTable[v-1][level.index()].data() {
return qrCodeEncodeAt(data, level, v), nil
}
}
return QRMatrix{}, fmt.Errorf("mujica: %d bytes do not fit a QR code at level %v", len(data), level)
}
// qrCodeEncodeAt builds version v with the lowest-penalty mask.
func qrCodeEncodeAt(data []byte, level QRLevel, v int) QRMatrix {
b := qrCodeTable[v-1][level.index()]
var w qrCodeBits
w.put(4, 4)
if v >= 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
}
+81
View File
@@ -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)
}
}
+242
View File
@@ -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
}
+74
View File
@@ -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})
})
})
}
+11 -6
View File
@@ -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
}
+256
View File
@@ -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)
}
})
}
+138
View File
@@ -0,0 +1,138 @@
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)
})
})
}
+75
View File
@@ -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
}
+82
View File
@@ -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})
})
})
}
+153
View File
@@ -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
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})
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
}
+72
View File
@@ -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}) })
})
})
}
+86
View File
@@ -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)
}
}
+99
View File
@@ -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)
})
})
}
+63
View File
@@ -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)
}
}
+22
View File
@@ -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
+90
View File
@@ -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
+50
View File
@@ -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
+26
View File
@@ -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
+50
View File
@@ -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
+26
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more