Merge branch 'main' into worktree-agent-ae66cd6d621c16060
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
菜单宽度至少等于整个按钮组;菜单内容由调用方提供。
|
||||
@@ -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 完成,无法得知系统是否拒绝写入。
|
||||
@@ -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 或短按空格都不会执行。
|
||||
|
||||
## 限制
|
||||
|
||||
按住空格依赖系统送来的按键抬起;窗口失去焦点时按住状态即重置。
|
||||
@@ -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 清除选择。
|
||||
|
||||
## 限制
|
||||
|
||||
位置由调用方放置,不自动悬浮在窗口底部。
|
||||
@@ -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 步进或直接输入。
|
||||
|
||||
## 限制
|
||||
|
||||
拖动只对指针可用。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 486 CurrencyInput 货币输入
|
||||
|
||||
## 用途
|
||||
|
||||
以最小单位(分)的整数保存金额,避免浮点误差;显示币种符号,未聚焦时按千分位分组,聚焦后显示纯数字便于编辑。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
cents := int64(123450)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"})
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `*int64` | 必填 | 金额的最小单位数。 |
|
||||
| `Symbol` | `string` | `""` | 前缀符号,如 `$`、`¥`。 |
|
||||
| `Decimals` | `int` | 2 | 小数位,0–6,越界 panic。 |
|
||||
| `NoFraction` | `bool` | `false` | 无小数(如日元);与 `Decimals` 同设 panic。 |
|
||||
| `Label` | `string` | `""` | 无障碍名称。 |
|
||||
| `Disabled` | `bool` | `false` | 不可编辑。 |
|
||||
| `Error` | `string` | `""` | 调用方错误信息。 |
|
||||
|
||||
## 状态
|
||||
|
||||
非法输入或小数位过多时保留输入并显示具体错误,值不变。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()`:合法输入改变了值。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;文本编辑、选择、剪贴板与撤销由 MyGo 编辑器提供。
|
||||
|
||||
## 限制
|
||||
|
||||
千分位与小数点固定为 `,` 与 `.`。
|
||||
@@ -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 提交。
|
||||
|
||||
## 限制
|
||||
|
||||
自绘编辑器:没有文字选择与撤销,复制得到未格式化的值。
|
||||
@@ -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 只在文本变化时截断光标),无法在插入提及后把光标放到提及之后,因此本组件自绘单行编辑器;不支持多行与双击选词。
|
||||
@@ -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 切换。
|
||||
|
||||
## 限制
|
||||
|
||||
无。
|
||||
@@ -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 走完全程。
|
||||
|
||||
## 限制
|
||||
|
||||
无。
|
||||
@@ -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 到两端;点击或拖动设置。
|
||||
|
||||
## 限制
|
||||
|
||||
无。
|
||||
@@ -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 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
无。
|
||||
@@ -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 到“清除”按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
书写只能用指针,键盘无法替代手写。
|
||||
@@ -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 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
无。
|
||||
@@ -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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
每个窗口同时只显示一条。
|
||||
@@ -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 折叠或恢复相邻的可折叠面板。
|
||||
|
||||
## 限制
|
||||
|
||||
无。
|
||||
@@ -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 组合。
|
||||
@@ -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)` 供调用方导出。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不可聚焦。
|
||||
|
||||
## 限制
|
||||
|
||||
只用字节模式;只在文字或纠错级别变化时重新编码。
|
||||
@@ -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 或时区变化时重算;中间条目原地修改需换新切片。
|
||||
@@ -0,0 +1,248 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleSplitButton() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) {
|
||||
if m.Item("Save as…", mujica.PopupMenuItemOptions{}) {
|
||||
// ask for a file name
|
||||
}
|
||||
})
|
||||
if r.Changed() {
|
||||
// save
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCopyButton() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHoldToConfirm() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() {
|
||||
// delete the project
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBulkActionBar() {
|
||||
selected := 3
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() {
|
||||
mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
})
|
||||
if r.Changed() {
|
||||
selected = 0
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScrubInput() {
|
||||
opacity := 0.6
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCurrencyInput() {
|
||||
cents := int64(123450)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMaskedInput() {
|
||||
phone := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMentionInput() {
|
||||
text, mentions := "", []mujica.Mention(nil)
|
||||
people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{
|
||||
Label: "Comment",
|
||||
Candidates: func(q string) []mujica.MentionCandidate {
|
||||
var out []mujica.MentionCandidate
|
||||
for _, p := range people {
|
||||
if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChoiceChips() {
|
||||
var status []string
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{
|
||||
{Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"},
|
||||
}, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleKnob() {
|
||||
gain := 35.0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleVerticalSlider() {
|
||||
level := 40.0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleColorPalette() {
|
||||
color := ui.Hex("#7D2034")
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ColorPalette(c, &color, []mujica.ColorSwatch{
|
||||
{Color: ui.Hex("#7D2034"), Name: "Wine"},
|
||||
{Color: ui.Hex("#AD9164"), Name: "Old gold"},
|
||||
{Color: ui.Hex("#355E80"), Name: "Slate blue"},
|
||||
}, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSignaturePad() {
|
||||
var strokes [][]mujica.SignaturePoint
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() {
|
||||
_ = mujica.SignaturePath(strokes) // SVG path data to store
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBanner() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{
|
||||
Severity: mujica.SeverityWarning,
|
||||
Action: "Details",
|
||||
})
|
||||
if r.Action {
|
||||
// show the details
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleUndoToast() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{
|
||||
OnUndo: func() {
|
||||
// restore the note
|
||||
},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMasonry() {
|
||||
titles := []string{"Sea", "Forest", "Desert", "Glacier"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Masonry(c, len(titles), mujica.MasonryOptions{
|
||||
Key: func(i int) string { return titles[i] },
|
||||
}, func(i int) {
|
||||
ui.Text(c, titles[i])
|
||||
}).Size(380, 280)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleResizablePanelGroup() {
|
||||
var sizes []float32
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{
|
||||
{Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }},
|
||||
{Label: "Editor", Build: func() { ui.Text(c, "Editor") }},
|
||||
}, mujica.ResizablePanelGroupOptions{})
|
||||
if r.Committed {
|
||||
// save sizes
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDockPanel() {
|
||||
var layout mujica.DockLayout
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.DockPanel(c, &layout, []mujica.DockItem{
|
||||
{ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }},
|
||||
{ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }},
|
||||
}, mujica.DockPanelOptions{})
|
||||
if r.Changed() {
|
||||
// save layout
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleQRCode() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleActivityFeed() {
|
||||
events := []mujica.Activity{
|
||||
{ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,262 @@
|
||||
package main
|
||||
|
||||
var essentialsPages = []page{}
|
||||
var essentialsPages = []page{
|
||||
{
|
||||
ID: "481", Name: "SplitButton", ZhName: "拆分按钮 主操作 下拉", Category: "Essentials",
|
||||
Demo: splitButtonDemo,
|
||||
Code: `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) {
|
||||
if m.Item("Save as…", mujica.PopupMenuItemOptions{}) {
|
||||
// ask for a file name
|
||||
}
|
||||
})
|
||||
if r.Changed() {
|
||||
// save
|
||||
}
|
||||
}`,
|
||||
Keys: "Tab reaches the main half, then the menu half. Enter or Space runs the action; on the menu half, Enter, Space or Down opens the menu.",
|
||||
},
|
||||
{
|
||||
ID: "482", Name: "CopyButton", ZhName: "复制按钮 剪贴板", Category: "Essentials",
|
||||
Demo: copyButtonDemo,
|
||||
Code: `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"})
|
||||
}`,
|
||||
Keys: "Tab focuses; Enter or Space copies. The copied state is announced.",
|
||||
},
|
||||
{
|
||||
ID: "483", Name: "HoldToConfirm", ZhName: "按住确认 危险操作", Category: "Essentials",
|
||||
Demo: holdToConfirmDemo,
|
||||
Code: `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() {
|
||||
// delete the project
|
||||
}
|
||||
}`,
|
||||
Keys: "Focus it and hold Space for the full duration; a click, Enter or a short Space does nothing.",
|
||||
},
|
||||
{
|
||||
ID: "484", Name: "BulkActionBar", ZhName: "批量操作条 多选", Category: "Essentials",
|
||||
Demo: bulkActionBarDemo,
|
||||
Code: `selected := 3
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() {
|
||||
mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
})
|
||||
if r.Changed() {
|
||||
selected = 0
|
||||
}
|
||||
}`,
|
||||
Keys: "Tab moves through the bar's actions; Escape inside it clears the selection.",
|
||||
},
|
||||
{
|
||||
ID: "485", Name: "ScrubInput", ZhName: "拖动数值 拖动标签", Category: "Essentials",
|
||||
Demo: scrubInputDemo,
|
||||
Code: `opacity := 0.6
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1})
|
||||
}`,
|
||||
Keys: "The label is pointer-only; in the field, Up and Down step and typing sets the value.",
|
||||
},
|
||||
{
|
||||
ID: "486", Name: "CurrencyInput", ZhName: "货币输入 金额 千分位", Category: "Essentials",
|
||||
Demo: currencyInputDemo,
|
||||
Code: `cents := int64(123450)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"})
|
||||
}`,
|
||||
Keys: "Tab focuses the field, which shows plain digits while focused and groups thousands when left.",
|
||||
},
|
||||
{
|
||||
ID: "487", Name: "MaskedInput", ZhName: "掩码输入 电话 卡号", Category: "Essentials",
|
||||
Demo: maskedInputDemo,
|
||||
Code: `phone := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"})
|
||||
}`,
|
||||
Keys: "Arrows, Home and End move the caret; Backspace and Delete remove; paste keeps the characters that fit; Enter submits.",
|
||||
},
|
||||
{
|
||||
ID: "488", Name: "MentionInput", ZhName: "提及输入 @ 候选", Category: "Essentials",
|
||||
Demo: mentionInputDemo,
|
||||
Code: `text, mentions := "", []mujica.Mention(nil)
|
||||
people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{
|
||||
Label: "Comment",
|
||||
Candidates: func(q string) []mujica.MentionCandidate {
|
||||
var out []mujica.MentionCandidate
|
||||
for _, p := range people {
|
||||
if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
})
|
||||
}`,
|
||||
Keys: "After @, Up and Down pick a candidate, Enter or Tab inserts it, Escape closes the list. Arrows step over a mention; Backspace removes it whole.",
|
||||
},
|
||||
{
|
||||
ID: "489", Name: "ChoiceChips", ZhName: "筛选芯片 单选 多选", Category: "Essentials",
|
||||
Demo: choiceChipsDemo,
|
||||
Code: `var status []string
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{
|
||||
{Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"},
|
||||
}, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"})
|
||||
}`,
|
||||
Keys: "The chips are one Tab stop; Left and Right move, skipping disabled chips; Space or Enter toggles.",
|
||||
},
|
||||
{
|
||||
ID: "490", Name: "Knob", ZhName: "旋钮 调节", Category: "Essentials",
|
||||
Demo: knobDemo,
|
||||
Code: `gain := 35.0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11})
|
||||
}`,
|
||||
Keys: "Arrows step the value; Home and End go to the ends; drag up or down with the pointer.",
|
||||
},
|
||||
{
|
||||
ID: "491", Name: "VerticalSlider", ZhName: "纵向滑块", Category: "Essentials",
|
||||
Demo: verticalSliderDemo,
|
||||
Code: `level := 40.0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"})
|
||||
}`,
|
||||
Keys: "Up and Right raise the value, Down and Left lower it, Home and End go to the ends.",
|
||||
},
|
||||
{
|
||||
ID: "492", Name: "ColorPalette", ZhName: "预设色板 颜色", Category: "Essentials",
|
||||
Demo: colorPaletteDemo,
|
||||
Code: `color := ui.Hex("#7D2034")
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ColorPalette(c, &color, []mujica.ColorSwatch{
|
||||
{Color: ui.Hex("#7D2034"), Name: "Wine"},
|
||||
{Color: ui.Hex("#AD9164"), Name: "Old gold"},
|
||||
{Color: ui.Hex("#355E80"), Name: "Slate blue"},
|
||||
}, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"})
|
||||
}`,
|
||||
Keys: "The palette is one Tab stop; Left and Right move by one swatch, Up and Down by a row; Enter or Space chooses.",
|
||||
},
|
||||
{
|
||||
ID: "493", Name: "SignaturePad", ZhName: "签名板 手写", Category: "Essentials",
|
||||
Demo: signaturePadDemo,
|
||||
Code: `var strokes [][]mujica.SignaturePoint
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() {
|
||||
_ = mujica.SignaturePath(strokes) // SVG path data to store
|
||||
}
|
||||
}`,
|
||||
Keys: "Tab focuses the pad; Delete or Backspace clears it. Drawing needs the pointer.",
|
||||
},
|
||||
{
|
||||
ID: "494", Name: "Banner", ZhName: "横幅 页面提示", Category: "Essentials",
|
||||
Demo: bannerDemo,
|
||||
Code: `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{
|
||||
Severity: mujica.SeverityWarning,
|
||||
Action: "Details",
|
||||
})
|
||||
if r.Action {
|
||||
// show the details
|
||||
}
|
||||
}`,
|
||||
Keys: "Tab reaches the action and the dismiss button; Enter or Space presses them.",
|
||||
},
|
||||
{
|
||||
ID: "495", Name: "UndoToast", ZhName: "撤销提示 倒计时", Category: "Essentials",
|
||||
Demo: undoToastDemo,
|
||||
Code: `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{
|
||||
OnUndo: func() {
|
||||
// restore the note
|
||||
},
|
||||
})
|
||||
}`,
|
||||
Keys: "It never takes the focus. Tab reaches Undo and the close button after the page; Escape inside closes it. Hover or focus pauses the countdown.",
|
||||
},
|
||||
{
|
||||
ID: "496", Name: "Masonry", ZhName: "瀑布流 卡片", Category: "Essentials",
|
||||
Demo: masonryDemo,
|
||||
Code: `titles := []string{"Sea", "Forest", "Desert", "Glacier"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Masonry(c, len(titles), mujica.MasonryOptions{
|
||||
Key: func(i int) string { return titles[i] },
|
||||
}, func(i int) {
|
||||
ui.Text(c, titles[i])
|
||||
}).Size(380, 280)
|
||||
}`,
|
||||
Keys: "Scroll with the wheel or the scrollbar; only cards in view are built.",
|
||||
},
|
||||
{
|
||||
ID: "497", Name: "ResizablePanelGroup", ZhName: "可调面板 分隔条", Category: "Essentials",
|
||||
Demo: resizablePanelGroupDemo,
|
||||
Code: `var sizes []float32
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{
|
||||
{Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }},
|
||||
{Label: "Editor", Build: func() { ui.Text(c, "Editor") }},
|
||||
}, mujica.ResizablePanelGroupOptions{})
|
||||
if r.Committed {
|
||||
// save sizes
|
||||
}
|
||||
}`,
|
||||
Keys: "Tab focuses a divider; arrows move it, Home and End go to the limits, Enter collapses or restores a collapsible neighbor.",
|
||||
},
|
||||
{
|
||||
ID: "498", Name: "DockPanel", ZhName: "停靠面板 标签", Category: "Essentials",
|
||||
Demo: dockPanelDemo,
|
||||
Code: `var layout mujica.DockLayout
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.DockPanel(c, &layout, []mujica.DockItem{
|
||||
{ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }},
|
||||
{ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }},
|
||||
}, mujica.DockPanelOptions{})
|
||||
if r.Changed() {
|
||||
// save layout
|
||||
}
|
||||
}`,
|
||||
Keys: "Each zone's tabs are one Tab stop; Left and Right switch tabs. Shift+Alt+Left, Right or Down docks the focused tab to that edge; Shift+Alt+Up returns it to the center.",
|
||||
},
|
||||
{
|
||||
ID: "499", Name: "QRCode", ZhName: "二维码", Category: "Essentials",
|
||||
Demo: qrCodeDemo,
|
||||
Code: `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile})
|
||||
}`,
|
||||
Keys: "Not focusable; assistive technology reads its name.",
|
||||
},
|
||||
{
|
||||
ID: "500", Name: "ActivityFeed", ZhName: "活动流 动态", Category: "Essentials",
|
||||
Demo: activityFeedDemo,
|
||||
Code: `events := []mujica.Activity{
|
||||
{ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280)
|
||||
}`,
|
||||
Keys: "Scroll with the wheel or the scrollbar; only rows in view are built.",
|
||||
},
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type mirror struct {
|
||||
urls int
|
||||
clip string
|
||||
copied bool
|
||||
probes int
|
||||
cursor ui.Cursor
|
||||
down *input
|
||||
enc png.Encoder
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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…"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "文字过长,无法生成二维码"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 10 KiB |