diff --git a/AGENTS.md b/AGENTS.md index 376e0de..5ee7e4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # AGENTS.md MujicaUI: court-gothic components for MyGo native UI. Go library, package `mujica`, module `mujicaui`. -Progress truth: `TASKS.md`. Requirements: `PRD.md`. +Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/ext-*.md` (101–500). +Planned: once 001–500 land, the root package splits into category sub-packages (TASKS T22). Until then everything stays in the root package. ## Stack diff --git a/TASKS.md b/TASKS.md index 1762d31..d3c27b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -43,4 +43,5 @@ Ordered by PRD sections. Check an item only after its codex review passes. Revie - [ ] T19 §8 Docs: install, minimal example, theme, compat; 100 component pages; examples compile - [ ] T20 §9 Snapshots, density baselines, large-data and lifecycle tests - [ ] T21 §9 CI workflow -- [ ] T22 §10 Acceptance checklist +- [ ] T22 §6.3 Split into category sub-packages after 001–500 land (user decision 2026-10-05); update PRD §6.3, docs, Gallery, CI +- [ ] T23 §10 Acceptance checklist diff --git a/activity_feed.go b/activity_feed.go new file mode 100644 index 0000000..c0406b1 --- /dev/null +++ b/activity_feed.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "cmp" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Activity is one event of an ActivityFeed: Actor did Action to Object at Time. +type Activity struct { + ID string + Actor string + Action string + Object string + Time time.Time +} + +// ActivityFeedOptions configure an ActivityFeed. +type ActivityFeedOptions struct { + // Location decides which day an event falls on; nil is time.Local. + Location *time.Location + // Today fixes the day called Today; zero uses the clock. + Today Date +} + +// activityRow is a day header (item -1) or an event. +type activityRow struct { + day Date + item int +} + +// activityFeedCache keeps the grouped rows until the count, first or last event, or location changes. +type activityFeedCache struct { + n int + first, last string + loc *time.Location + rows []activityRow + scroll ui.ScrollState + sized bool +} + +// ActivityFeed lists events grouped by day, newest first as given, building only the rows in view. +func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element { + loc := cmp.Or(opts.Location, time.Local) + for _, a := range items { + if a.ID == "" || a.Actor == "" || a.Action == "" || a.Time.IsZero() { + panic("mujica: ActivityFeed event needs an ID, Actor, Action and Time") + } + } + k := Tokens(c) + d := Density(c) + sc := ui.Scroll(c).MinHeight(0).Role(ui.RoleList).Label(msg(c, "activity_feed.label")) + if len(items) == 0 { + sc.Children(func() { + EmptyState(c, EmptyStateOptions{Title: msg(c, "activity_feed.empty"), Description: msg(c, "activity_feed.empty_hint"), Icon: icons.Must("clock")}) + }) + return sc + } + cache := *ui.Local(sc, "activity_feed", func() *activityFeedCache { return &activityFeedCache{} }) + sc.TrackScroll(&cache.scroll) + if cache.n != len(items) || cache.first != items[0].ID || cache.last != items[len(items)-1].ID || cache.loc != loc { + cache.n, cache.first, cache.last, cache.loc = len(items), items[0].ID, items[len(items)-1].ID, loc + cache.rows = cache.rows[:0] + var prev Date + for i, a := range items { + if day := DateOf(a.Time.In(loc)); day != prev || i == 0 { + cache.rows = append(cache.rows, activityRow{day: day, item: -1}) + prev = day + } + cache.rows = append(cache.rows, activityRow{item: i}) + } + slog.Debug("mujica: activity_feed grouped", "events", len(items), "rows", len(cache.rows)) + } + rowH := d.ControlHeight() + 2*d.Unit() + view := sc.Bounds() + if view.H == 0 && !cache.sized { + // Unmeasured: build again once the box has its height. + cache.sized = true + c.Invalidate() + } + first := max(0, int(cache.scroll.Y/rowH)-2) + last := min(len(cache.rows), int((cache.scroll.Y+max(view.H, rowH))/rowH)+3) + today := datetimeToday(c, opts.Today) + sc.Children(func() { + ui.Box(c).FillWidth().Height(rowH * float32(len(cache.rows))).Children(func() { + for r := first; r < last; r++ { + row := cache.rows[r] + box := ui.Row(c).Key(r).Absolute().Left(0).Right(0).Top(rowH*float32(r)).Height(rowH). + AlignItems(ui.Center).Gap(d.Unit()*3).Padding(0, d.Unit()*3) + if row.item < 0 { + activityFeedHeader(c, k, box, row.day, today) + continue + } + activityFeedEvent(c, k, box, items[row.item], loc) + } + }) + }) + return sc +} + +// activityFeedHeader is a day's title: Today, Yesterday or the date, over an ornament rule. +func activityFeedHeader(c *ui.Context, k theme.Tokens, box *ui.Element, day, today Date) { + label := datetimeDayLabel(c, day) + switch day { + case today: + label = msg(c, "activity_feed.today") + case today.AddDays(-1): + label = msg(c, "activity_feed.yesterday") + } + box.Role(ui.RoleText).AlignItems(ui.End).Padding(0, Density(c).Unit()*3, Density(c).Unit(), Density(c).Unit()*3) + box.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Ornament, 0) }) + box.Children(func() { + ui.Text(c, label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize+1)).FontWeight(600).TextColor(k.Text).SingleLine() + }) +} + +// activityFeedEvent is one event: avatar, "Actor action Object", and the time. +func activityFeedEvent(c *ui.Context, k theme.Tokens, box *ui.Element, a Activity, loc *time.Location) { + when := a.Time.In(loc).Format("15:04") + box.Role(ui.RoleListItem).Label(a.Actor + " " + a.Action + " " + a.Object + ", " + when) + box.Children(func() { + Avatar(c, a.Actor, AvatarOptions{Size: 28}) + ui.Row(c).Grow(1).Shrink(1).MinWidth(0).Gap(4).Children(func() { + ui.Text(c, a.Actor).Bold().TextColor(k.Text).SingleLine().Shrink(0) + ui.Text(c, a.Action).TextColor(k.TextMuted).SingleLine().Shrink(0) + if a.Object != "" { + ui.Text(c, a.Object).TextColor(k.AccentText).SingleLine().Shrink(1).Ellipsis("…") + } + }) + ui.Text(c, when).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() + }) +} diff --git a/activity_feed_test.go b/activity_feed_test.go new file mode 100644 index 0000000..2d408de --- /dev/null +++ b/activity_feed_test.go @@ -0,0 +1,92 @@ +package mujica + +import ( + "strconv" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func feedItems(n int) []Activity { + base := time.Date(2026, 10, 5, 18, 0, 0, 0, time.UTC) + out := make([]Activity, n) + for i := range out { + out[i] = Activity{ID: strconv.Itoa(i), Actor: "Mira", Action: "edited", Object: "Doc " + strconv.Itoa(i), Time: base.Add(-time.Duration(i) * 5 * time.Hour)} + } + return out +} + +func TestActivityFeedGroupsByDay(t *testing.T) { + items := feedItems(6) // 18:00, 13:00, 08:00, 03:00 on the 5th, then 22:00 and 17:00 on the 4th + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: Date{2026, 10, 5}}).Size(600, 500) + }, 640, 540) + tt.Frame() + for _, s := range []string{"Today", "Yesterday", "Mira edited Doc 0, 18:00", "Mira edited Doc 4, 22:00"} { + if _, ok := tt.Find(s); !ok { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + today, _ := tt.Find("Today") + yest, _ := tt.Find("Yesterday") + doc3, _ := tt.Find("Mira edited Doc 3, 03:00") + doc4, _ := tt.Find("Mira edited Doc 4, 22:00") + if !(today.Y < doc3.Y && doc3.Y < yest.Y && yest.Y < doc4.Y) { + t.Fatalf("order: today %v, doc3 %v, yesterday %v, doc4 %v", today, doc3, yest, doc4) + } + // Another zone moves 03:00 UTC to the previous day. + tt2 := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + ActivityFeed(c, items, ActivityFeedOptions{Location: time.FixedZone("W", -5*3600), Today: Date{2026, 10, 5}}).Size(600, 500) + }, 640, 540) + tt2.Frame() + if _, ok := tt2.Find("Mira edited Doc 3, 22:00"); !ok || !tt2.HasText("今天") || !tt2.HasText("昨天") { + t.Fatalf("zone texts %q", tt2.Texts()) + } +} + +func TestActivityFeedVirtualizesAndEmpty(t *testing.T) { + items := feedItems(100000) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: Date{2026, 10, 5}}).Size(600, 400) + }, 640, 440) + tt.Frame() + if n := len(tt.Texts()); n == 0 || n > 120 { + t.Fatalf("built %d texts for 100000 events", n) + } + tt.Scroll(100, 100, 0, 2000000) + tt.Frame() + if _, ok := tt.Find("Mira edited Doc 0, 18:00"); ok { + t.Fatal("first event still built after scrolling far") + } + tt3 := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, nil, ActivityFeedOptions{}).Size(400, 300) + }, 440, 340) + if !tt3.HasText("No activity yet") { + t.Fatalf("empty: %q", tt3.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("event without ID accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ActivityFeed(c, []Activity{{Actor: "x"}}, ActivityFeedOptions{}) + }, 200, 100) +} + +func TestActivityFeedSnapshot(t *testing.T) { + items := feedItems(5) + items[1].Actor, items[1].Action, items[1].Object = "Lin", "commented on", "Quarterly report" + items[3].Actor, items[3].Action, items[3].Object = "Sakiko", "approved", "Merge request !42" + snapshot(t, "activity_feed", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: Date{2026, 10, 5}}).Size(496, 296) + }) + }) +} diff --git a/banner.go b/banner.go new file mode 100644 index 0000000..db5f1e1 --- /dev/null +++ b/banner.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// BannerOptions configure a Banner. +type BannerOptions struct { + Severity Severity + // Action labels a button after the message; empty shows none. + Action string +} + +// Banner is a full-width notice for the top of a page; a non-nil open adds a close button. +func Banner(c *ui.Context, open *bool, message string, opts BannerOptions) AlertResult { + if message == "" { + panic("mujica: Banner needs a message") + } + k := Tokens(c) + tone, icon := severityLook(k, opts.Severity) + if open != nil && !*open { + return AlertResult{} + } + u := Density(c).Unit() + name := msg(c, "alert."+opts.Severity.String()) + var res AlertResult + bar := ui.Row(c).FillWidth().MinHeight(Density(c).ControlHeight()+2*u).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3, u*2, u*4). + Background(k.Surface.Mix(tone, 0.08)).Role(ui.RoleGroup).Label(name) + bar.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, tone, 0) + }) + said := ui.Local(bar, "banner.said", func() string { return "" }) + if news := name + ": " + message; opts.Severity >= SeverityWarning && *said != news { + *said = news + c.Announce(news) + } + bar.Children(func() { + ui.Icon(c, icon).Size(20, 20).TextColor(tone) + ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight) + if opts.Action != "" && Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() { + slog.Debug("mujica: banner action", "severity", opts.Severity) + res.Action = true + } + if open != nil && iconAction(c, icons.Must("x"), msg(c, "alert.close")).Clicked() { + slog.Debug("mujica: banner closed", "severity", opts.Severity) + *open, res.Closed = false, true + } + }) + res.Element = bar + return res +} diff --git a/banner_test.go b/banner_test.go new file mode 100644 index 0000000..bba858e --- /dev/null +++ b/banner_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBannerActionCloseAndAnnounce(t *testing.T) { + open := true + var last AlertResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if r := Banner(c, &open, "Maintenance tonight at 22:00", BannerOptions{Severity: SeverityWarning, Action: "Details"}); r.Action || r.Closed { + last = r + } + }, 700, 200) + if a := tt.Announcements(); !slices.Equal(a, []string{"Warning: Maintenance tonight at 22:00"}) { + t.Fatalf("announcements %q", a) + } + if err := tt.Click("Details"); err != nil { + t.Fatal(err) + } + if !last.Action || !open { + t.Fatalf("action: %+v open %v", last, open) + } + tt.Key(0, ui.KeyTab) // from Details to Dismiss + tt.Key(0, ui.KeyEnter) + if open || !last.Closed || tt.HasText("Maintenance") { + t.Fatalf("keyboard close: open %v %+v", open, last) + } +} + +func TestBannerDisabledAndFixed(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + Banner(c, &open, "Read only", BannerOptions{Severity: SeverityError}) + }) + Banner(c, nil, "Always here", BannerOptions{}) + }, 700, 200) + _ = tt.Click("Dismiss") + if !open { + t.Fatal("disabled banner closed") + } + if !tt.HasText("Always here") { + t.Fatal("banner without open missing") + } + for _, f := range []func(c *ui.Context){ + func(c *ui.Context) { Banner(c, nil, "", BannerOptions{}) }, + func(c *ui.Context) { Banner(c, nil, "x", BannerOptions{Severity: 7}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 100) + }() + } +} + +func TestBannerSnapshot(t *testing.T) { + open := true + snapshot(t, "banner", 640, 200, func(c *ui.Context) { + ui.Column(c).Gap(8).Children(func() { + Banner(c, &open, "A new version is available.", BannerOptions{Action: "Update"}) + Banner(c, &open, "Your trial ends in 3 days.", BannerOptions{Severity: SeverityWarning}) + Banner(c, nil, "Sync is failing; changes stay on this device.", BannerOptions{Severity: SeverityError, Action: "Retry"}) + }) + }) +} diff --git a/bulk_action_bar.go b/bulk_action_bar.go new file mode 100644 index 0000000..0ca0e62 --- /dev/null +++ b/bulk_action_bar.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// BulkActionBarOptions configure a BulkActionBar. +type BulkActionBarOptions struct { + Disabled bool +} + +// BulkActionBar shows while selected > 0 with the count, actions and a clear button; Changed reports a clear. +func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actions func()) ChoiceResult { + if selected < 0 { + panic(fmt.Sprintf("mujica: BulkActionBar selected %d is negative", selected)) + } + if selected == 0 { + return ChoiceResult{} + } + k := Tokens(c) + u := Density(c).Unit() + count := fmt.Sprintf(msg(c, "bulk_action_bar.selected"), selected) + bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight() + 4*u). + Role(ui.RoleToolbar).Label(count).Disabled(opts.Disabled) + overlayStyle(c, bar, overlayEnter(c, bar)) + said := ui.Local(bar, "bulk_action_bar.said", func() int { return 0 }) + if *said != selected { + *said = selected + c.Announce(count) + } + var clear bool + bar.Children(func() { + ui.Text(c, count).Bold().TextColor(k.Text).SingleLine() + ui.Box(c).Size(theme.BorderWidth, u*5).Background(k.Border).Role(ui.RoleNone) + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Grow(1).Children(func() { + if actions != nil { + actions() + } + }) + b := Button(c, msg(c, "bulk_action_bar.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("x")}) + clear = b.Clicked() || bar.Shortcut(0, ui.KeyEscape) && !bar.IsDisabled() + }) + if clear { + slog.Debug("mujica: bulk_action_bar clear", "selected", selected) + } + return ChoiceResult{bar, clear} +} diff --git a/bulk_action_bar_test.go b/bulk_action_bar_test.go new file mode 100644 index 0000000..b1c14c2 --- /dev/null +++ b/bulk_action_bar_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBulkActionBarCountActionsClear(t *testing.T) { + selected, deletes := 0, 0 + dis := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Select", ButtonOptions{}).Clicked() { + selected += 3 + } + r := BulkActionBar(c, selected, BulkActionBarOptions{Disabled: dis}, func() { + if Button(c, "Delete", ButtonOptions{Variant: Danger}).Clicked() { + deletes++ + } + }) + if r.Changed() { + selected = 0 + } + }, 600, 300) + if tt.HasText("selected") { + t.Fatal("bar shown with nothing selected") + } + _ = tt.Click("Select") + if !tt.HasText("3 selected") || !slices.Contains(tt.Announcements(), "3 selected") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Delete") + if deletes != 1 { + t.Fatal("action slot did not run") + } + dis = true + tt.Frame() + _ = tt.Click("Clear selection") + if selected != 3 { + t.Fatal("disabled bar cleared") + } + dis = false + tt.Frame() + _ = tt.Click("Delete") // focus inside the bar + tt.Key(0, ui.KeyEscape) + if selected != 0 || tt.HasText("selected") { + t.Fatalf("Escape left %d selected", selected) + } + _ = tt.Click("Select") + if err := tt.Click("Clear selection"); err != nil || selected != 0 { + t.Fatalf("clear button: %v %d", err, selected) + } +} + +func TestBulkActionBarRejectsNegative(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); BulkActionBar(c, -1, BulkActionBarOptions{}, nil) }, 200, 100) +} + +func TestBulkActionBarSnapshot(t *testing.T) { + snapshot(t, "bulk_action_bar", 600, 90, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + BulkActionBar(c, 12, BulkActionBarOptions{}, func() { + Button(c, "Archive", ButtonOptions{Variant: Secondary}) + Button(c, "Delete", ButtonOptions{Variant: Danger}) + }) + }) + }) +} diff --git a/choice_chips.go b/choice_chips.go new file mode 100644 index 0000000..ac94e76 --- /dev/null +++ b/choice_chips.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ChoiceChipsOptions configure ChoiceChips. +type ChoiceChipsOptions struct { + // Multiple lets several chips be on; otherwise one or none. + Multiple bool + Disabled bool + // Label names the chip set for assistive technology. + Label string +} + +// ChoiceChips are wrapping filter chips toggling values in *value; arrows move between them. +func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ChoiceChipsOptions) ChoiceResult { + if value == nil { + panic("mujica: ChoiceChips needs a value") + } + if !opts.Multiple && len(*value) > 1 { + panic("mujica: single ChoiceChips holds more than one value") + } + k := Tokens(c) + d := Density(c) + g := ui.Row(c).Wrap().Gap(d.Unit() * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal) + if opts.Label != "" { + g.Label(opts.Label) + } + changed := false + g.Children(func() { + for _, it := range items { + on := slices.Contains(*value, it.Value) + pressed := on + b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) + // The base took input before Disabled applied: drop a disabled chip's press. + if b.Changed() && !b.IsDisabled() && toggleGroupApply(value, it.Value, pressed, ToggleGroupOptions{Multiple: opts.Multiple, AllowEmpty: true}) { + changed, on = true, pressed + } + choiceChipLook(c, k, d, b, on, it) + } + }) + if changed { + slog.Debug("mujica: choice_chips changed", "label", opts.Label, "value", *value) + } + return ChoiceResult{g, changed} +} + +func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) { + b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 1.5). + Radius(theme.PopupRadius) + internal.Ring(b, k.Focus, theme.PopupRadius) + bg, fg, line := k.Surface, k.Text, k.ControlBorder + switch { + case on: + bg, fg, line = k.Selection, k.AccentText, k.AccentText + case b.Pressed(): + bg = k.Selection + case b.Hovered(): + bg = k.SurfaceHover + } + b.Background(bg).TextColor(fg).Border(theme.BorderWidth, line).Transition(colorFade) + if it.Name != "" { + b.Label(it.Name) + } + b.Children(func() { + switch { + case on: + ui.Icon(c, icons.Must("check")).Size(14, 14) + case it.Icon != nil: + ui.Icon(c, it.Icon).Size(14, 14) + } + if it.Label != "" { + ui.Text(c, it.Label).SingleLine() + } + }) +} diff --git a/choice_chips_test.go b/choice_chips_test.go new file mode 100644 index 0000000..531a86a --- /dev/null +++ b/choice_chips_test.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +var chipItems = []ToggleItem[string]{{Value: "open", Label: "Open"}, {Value: "draft", Label: "Draft"}, {Value: "closed", Label: "Closed", Disabled: true}, {Value: "merged", Label: "Merged"}} + +func chipTester(v *[]string, opts *ChoiceChipsOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ChoiceChips(c, v, chipItems, *opts).Changed() { + *n++ + } + }, 600, 200) +} + +func TestChoiceChipsSingleAndMultiple(t *testing.T) { + var v []string + opts := ChoiceChipsOptions{} + n := 0 + tt := chipTester(&v, &opts, &n) + _ = tt.Click("Open") + _ = tt.Click("Draft") + if !slices.Equal(v, []string{"draft"}) || n != 2 { + t.Fatalf("single: %v %d", v, n) + } + _ = tt.Click("Draft") + if len(v) != 0 { + t.Fatalf("single chip did not turn off: %v", v) + } + _ = tt.Click("Closed") + if len(v) != 0 || n != 3 { + t.Fatalf("disabled chip: %v %d", v, n) + } + opts.Multiple = true + _ = tt.Click("Open") + _ = tt.Click("Merged") + if !slices.Equal(v, []string{"open", "merged"}) { + t.Fatalf("multiple: %v", v) + } +} + +func TestChoiceChipsKeyboard(t *testing.T) { + var v []string + opts := ChoiceChipsOptions{Multiple: true} + n := 0 + tt := chipTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) // skips the disabled chip + tt.Key(0, ui.KeySpace) + if !slices.Equal(v, []string{"draft", "merged"}) { + t.Fatalf("keyboard: %v", v) + } + opts.Disabled = true + tt.Frame() + _ = tt.Click("Open") + if len(v) != 2 { + t.Fatalf("disabled set changed: %v", v) + } +} + +func TestChoiceChipsRejectsTwoInSingle(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + v := []string{"open", "draft"} + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{}) }, 300, 100) +} + +func TestChoiceChipsSnapshot(t *testing.T) { + v := []string{"open", "merged"} + snapshot(t, "choice_chips", 480, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Children(func() { + ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{Multiple: true}) + }) + }) +} diff --git a/color_palette.go b/color_palette.go new file mode 100644 index 0000000..89b0df0 --- /dev/null +++ b/color_palette.go @@ -0,0 +1,109 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ColorSwatch is one preset of a ColorPalette; Name is read out. +type ColorSwatch struct { + Color ui.Color + Name string +} + +// ColorPaletteOptions configure a ColorPalette. +type ColorPaletteOptions struct { + // Columns is the grid width in swatches; zero is 8. + Columns int + // Custom adds a ColorPicker after the swatches for any other color. + Custom bool + Disabled bool + // Label names the palette for assistive technology. + Label string +} + +// ColorPalette picks *value from preset swatches in a grid; arrows move in two dimensions. +func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts ColorPaletteOptions) ChoiceResult { + if value == nil || len(swatches) == 0 { + panic("mujica: ColorPalette needs a value and swatches") + } + if opts.Columns < 0 { + panic(fmt.Sprintf("mujica: ColorPalette Columns %d is negative", opts.Columns)) + } + cols := opts.Columns + if cols == 0 { + cols = 8 + } + for _, s := range swatches { + if s.Name == "" { + panic("mujica: ColorPalette swatch needs a Name") + } + } + k := Tokens(c) + u := Density(c).Unit() + g := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleRadioGroup).Label(opts.Label) + disabled := g.IsDisabled() + changed := false + g.Children(func() { + grid := ui.Box(c).Grid().Columns(cols).Gap(u * 1.5).FocusGroup(ui.Horizontal) + var cells []*ui.Element + grid.Children(func() { + for i, s := range swatches { + b := colorPaletteSwatch(c, k, s, *value == s.Color).Key(i) + cells = append(cells, b) + if b.Clicked() && !disabled && *value != s.Color { + *value, changed = s.Color, true + } + } + }) + // Up and Down move a whole row; FocusGroup moves by one. + for i, b := range cells { + switch { + case b.Shortcut(0, ui.KeyDown) && i+cols < len(cells): + cells[i+cols].Focus() + case b.Shortcut(0, ui.KeyUp) && i-cols >= 0: + cells[i-cols].Focus() + } + } + if opts.Custom { + ui.Row(c).Children(func() { + if ColorPicker(c, value, ColorPickerOptions{Label: msg(c, "color_palette.custom"), Disabled: disabled}).Changed() { + changed = true + } + }) + } + }) + if changed { + slog.Debug("mujica: color_palette changed", "color", colorPickerHex(*value)) + } + return ChoiceResult{g, changed} +} + +// colorPaletteSwatch is one square swatch; the chosen one has an inset frame and a check. +func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) *ui.Element { + b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio). + Label(s.Name).Tooltip(s.Name).Selected(on) + internal.Ring(b, k.Focus, theme.ControlRadius) + edge := k.ControlBorder + if b.Hovered() { + edge = k.Text + } + b.Background(s.Color).Border(theme.BorderWidth, edge) + mark := theme.Light().Text + if theme.Contrast(s.Color, mark) < theme.Contrast(s.Color, theme.Light().OnAccent) { + mark = theme.Light().OnAccent + } + b.DrawOver(func(p *ui.Painter, r ui.Rect) { + if !on { + return + } + p.Stroke(ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, mark, 1, 1.5) + internal.Mark(p, ui.Rect{X: r.X + 7, Y: r.Y + 7, W: r.W - 14, H: r.H - 14}, "check", mark) + }) + return b +} diff --git a/color_palette_test.go b/color_palette_test.go new file mode 100644 index 0000000..93eb283 --- /dev/null +++ b/color_palette_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func paletteSwatches() []ColorSwatch { + var s []ColorSwatch + for i, c := range colorPickerPresets { + s = append(s, ColorSwatch{Color: c, Name: "Tone " + string(rune('A'+i))}) + } + return s +} + +func TestColorPaletteChooseAndKeyboard(t *testing.T) { + v := colorPickerPresets[0] + dis := false + n := 0 + sw := paletteSwatches() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ColorPalette(c, &v, sw, ColorPaletteOptions{Columns: 4, Custom: true, Disabled: dis, Label: "Tag color"}).Changed() { + n++ + } + }, 500, 400) + if err := tt.Click("Tone C"); err != nil || v != sw[2].Color || n != 1 { + t.Fatalf("click: %v %v %d", err, v, n) + } + tt.Key(0, ui.KeyDown) // focus stays on C: down a row is G + tt.Key(0, ui.KeyEnter) + if v != sw[6].Color { + t.Fatalf("Down then Enter chose %v, want %v", v, sw[6].Color) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeySpace) + if v != sw[3].Color { + t.Fatalf("Right, Up, Space chose %v", v) + } + if _, ok := tt.Find("Custom color"); !ok { + t.Fatal("no custom color entry") + } + dis = true + tt.Frame() + _ = tt.Click("Tone A") + if v != sw[3].Color || n != 3 { + t.Fatalf("disabled palette changed: %v %d", v, n) + } +} + +func TestColorPaletteRejects(t *testing.T) { + v := ui.Hex("#000000") + for name, f := range map[string]func(c *ui.Context){ + "no name": func(c *ui.Context) { ColorPalette(c, &v, []ColorSwatch{{Color: v}}, ColorPaletteOptions{}) }, + "columns": func(c *ui.Context) { ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: -1}) }, + "no items": func(c *ui.Context) { ColorPalette(c, &v, nil, ColorPaletteOptions{}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestColorPaletteSnapshot(t *testing.T) { + v := colorPickerPresets[2] + snapshot(t, "color_palette", 300, 150, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: 6, Custom: true}) + }) + }) +} diff --git a/copy_button.go b/copy_button.go new file mode 100644 index 0000000..2dd07c9 --- /dev/null +++ b/copy_button.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// copyButtonShown is how long the button says Copied. +const copyButtonShown = 2 * time.Second + +// CopyButtonOptions configure a CopyButton. +type CopyButtonOptions struct { + // Label shows beside the icon; empty makes a square icon button. + Label string + Disabled bool +} + +// CopyButton puts text on the clipboard, then shows and announces Copied for two seconds. +func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) ChoiceResult { + copied := msg(c, "copy_button.copied") + name := msg(c, "copy_button.copy") + label, icon := opts.Label, icons.Must("copy") + var b *ui.Element + box := ui.Box(c) + at := ui.Local(box, "copy_button.at", func() time.Time { return time.Time{} }) + left := copyButtonShown - c.Now().Sub(*at) + done := !at.IsZero() && left > 0 + if done { + icon, name = icons.Must("check"), copied + if label != "" { + label = copied + } + c.After(left) + } + box.Children(func() { + bo := ButtonOptions{Variant: Secondary, Icon: icon, Disabled: opts.Disabled} + if opts.Label == "" { + bo.Label = name + } + b = Button(c, label, bo) + if opts.Label == "" { + b.Padding(0).Size(Density(c).ControlHeight(), Density(c).ControlHeight()).Tooltip(name) + } else { + // Room for the longer word, so the width holds. + w1, _ := c.MeasureText(0, ui.Span{Text: copied}) + w2, _ := c.MeasureText(0, ui.Span{Text: opts.Label}) + b.MinWidth(max(w1, w2) + buttonSpinnerSize + Density(c).Unit()*10 + 2*theme.BorderWidth) + } + }) + if b.Clicked() && !b.IsDisabled() { + c.WriteClipboard(text) + *at = c.Now() + c.Announce(copied) + c.Invalidate() + slog.Debug("mujica: copy_button copied", "bytes", len(text)) + return ChoiceResult{box, true} + } + return ChoiceResult{box, false} +} diff --git a/copy_button_test.go b/copy_button_test.go new file mode 100644 index 0000000..4e6d07b --- /dev/null +++ b/copy_button_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestCopyButtonCopiesAndAnnounces(t *testing.T) { + n := 0 + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + r := CopyButton(c, "sk-live-123", CopyButtonOptions{Label: "Copy key"}) + if r.Changed() { + n++ + } + w = r.Element.Bounds().W + }) + }, 400, 200) + tt.Frame() + before := w + if err := tt.Click("Copy key"); err != nil { + t.Fatal(err) + } + if tt.Clipboard() != "sk-live-123" || n != 1 { + t.Fatalf("clipboard %q, copies %d", tt.Clipboard(), n) + } + if !tt.HasText("Copied") || !slices.Contains(tt.Announcements(), "Copied") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Frame() + if w != before { + t.Fatalf("width %v changed from %v", w, before) + } +} + +func TestCopyButtonKeyboardRevertAndDisabled(t *testing.T) { + dis := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + CopyButton(c, "abc", CopyButtonOptions{Disabled: dis}) + }, 300, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if tt.Clipboard() != "abc" { + t.Fatalf("Space copied %q", tt.Clipboard()) + } + if _, ok := tt.Find("已复制"); !ok { + t.Fatal("icon button lacks the copied name") + } + time.Sleep(copyButtonShown + 50*time.Millisecond) + tt.Frame() + if _, ok := tt.Find("复制"); !ok { + t.Fatal("did not revert to Copy") + } + tt.SetClipboard("") + dis = true + tt.Frame() + _ = tt.Click("复制") + if tt.Clipboard() != "" { + t.Fatal("disabled button copied") + } +} + +func TestCopyButtonSnapshot(t *testing.T) { + snapshot(t, "copy_button", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + CopyButton(c, "x", CopyButtonOptions{}) + CopyButton(c, "x", CopyButtonOptions{Label: "Copy link"}) + CopyButton(c, "x", CopyButtonOptions{Label: "Copy", Disabled: true}) + }) + }) +} diff --git a/currency_input.go b/currency_input.go new file mode 100644 index 0000000..9b8f1cf --- /dev/null +++ b/currency_input.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +// CurrencyInputOptions configure a CurrencyInput. +type CurrencyInputOptions struct { + // Symbol goes before the amount, as "$" or "¥"; empty shows none. + Symbol string + // Decimals is the minor unit digits; zero is 2. NoFraction makes it 0, as for JPY. + Decimals int + NoFraction bool + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// currencyInputDigits resolves the decimals of opts. +func currencyInputDigits(opts CurrencyInputOptions) int { + switch { + case opts.Decimals < 0 || opts.Decimals > 6: + panic(fmt.Sprintf("mujica: CurrencyInput Decimals %d outside 0..6", opts.Decimals)) + case opts.NoFraction && opts.Decimals != 0: + panic("mujica: CurrencyInput NoFraction with Decimals") + case opts.NoFraction: + return 0 + case opts.Decimals == 0: + return 2 + } + return opts.Decimals +} + +// CurrencyFormat writes minor units as an amount with thousands separators. +func CurrencyFormat(minor int64, decimals int, group bool) string { + neg := minor < 0 + u := uint64(minor) + if neg { + u = -u + } + p := uint64(math.Pow10(decimals)) + whole := strconv.FormatUint(u/p, 10) + if group { + var b strings.Builder + for i, r := range whole { + if i > 0 && (len(whole)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(r) + } + whole = b.String() + } + s := whole + if decimals > 0 { + s += "." + fmt.Sprintf("%0*d", decimals, u%p) + } + if neg { + s = "-" + s + } + return s +} + +// currencyInputParse reads an amount into minor units; reason is a message key when it fails. +func currencyInputParse(text string, decimals int) (minor int64, reason string) { + t := strings.NewReplacer(",", "", " ", "", " ", "").Replace(strings.TrimSpace(text)) + neg := strings.HasPrefix(t, "-") + t = strings.TrimPrefix(t, "-") + whole, frac, dot := strings.Cut(t, ".") + if whole == "" && frac == "" || strings.Trim(whole+frac, "0123456789") != "" || dot && strings.Contains(frac, ".") { + return 0, "currency_input.invalid" + } + if len(frac) > decimals { + return 0, "currency_input.precision" + } + frac += strings.Repeat("0", decimals-len(frac)) + v, err := strconv.ParseInt(whole+frac, 10, 64) + if err != nil { + return 0, "currency_input.invalid" + } + if neg { + v = -v + } + return v, "" +} + +// CurrencyInput edits *value, an amount in minor units (cents); it groups thousands while not focused. +func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult { + if value == nil { + panic("mujica: CurrencyInput needs a value") + } + dec := currencyInputDigits(opts) + k := Tokens(c) + var r InputResult + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + if opts.Symbol != "" { + ui.Text(c, opts.Symbol).TextColor(k.TextMuted).SingleLine() + } + text := ui.Local(frame, "text", func() string { return CurrencyFormat(*value, dec, false) }) + bad := ui.Local(frame, "bad", func() string { return "" }) + seen := ui.Local(frame, "seen", func() int64 { return *value }) + was := ui.Local(frame, "was", func() bool { return false }) + if *seen != *value { + *bad = "" + } + // Grouped while resting; plain digits once focused, for editing. + focused := frame.FocusWithin() + switch { + case *bad != "": + case !focused: + *text = CurrencyFormat(*value, dec, true) + case !*was || *seen != *value: + *text = CurrencyFormat(*value, dec, false) + } + *was = focused + in := textInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 16).Label(opts.Label) + if in.Changed() && !look.disabled { + v, why := currencyInputParse(*text, dec) + *bad = "" + switch { + case why == "currency_input.precision": + *bad = fmt.Sprintf(msg(c, why), dec) + case why != "": + *bad = msg(c, why) + case v != *value: + *value, r.changed = v, true + } + } + *seen = *value + if look.err == "" { + look.err = *bad + } + return in + }, nil) + if r.changed { + slog.Debug("mujica: currency_input change", "minor", *value) + } + return r +} diff --git a/currency_input_test.go b/currency_input_test.go new file mode 100644 index 0000000..cef2209 --- /dev/null +++ b/currency_input_test.go @@ -0,0 +1,120 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCurrencyFormatAndParse(t *testing.T) { + for _, tc := range []struct { + minor int64 + dec int + group bool + want string + }{ + {123450, 2, true, "1,234.50"}, {123450, 2, false, "1234.50"}, {-5, 2, true, "-0.05"}, + {1234567, 0, true, "1,234,567"}, {0, 3, true, "0.000"}, {100000000, 2, true, "1,000,000.00"}, + } { + if got := CurrencyFormat(tc.minor, tc.dec, tc.group); got != tc.want { + t.Errorf("CurrencyFormat(%d, %d) = %q, want %q", tc.minor, tc.dec, got, tc.want) + } + } + for text, want := range map[string]int64{"1,234.5": 123450, "0.07": 7, "-12": -1200, " 3 ": 300, "1.": 100} { + if v, why := currencyInputParse(text, 2); v != want || why != "" { + t.Errorf("parse %q = %d %q, want %d", text, v, why, want) + } + } + for text, want := range map[string]string{"1.234": "currency_input.precision", "abc": "currency_input.invalid", "": "currency_input.invalid", "1.2.3": "currency_input.invalid", "99999999999999999999": "currency_input.invalid"} { + if _, why := currencyInputParse(text, 2); why != want { + t.Errorf("parse %q: %q, want %q", text, why, want) + } + } +} + +// fieldText copies the focused field's text through the clipboard. +func fieldText(tt *ui.Tester) string { + tt.Command("selectAll") + tt.Command("copy") + return tt.Clipboard() +} + +func TestCurrencyInputTypingGroupingAndErrors(t *testing.T) { + v := int64(123450) + dis := false + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "$", Label: "Price", Disabled: dis}).Changed() { + n++ + } + Button(c, "Elsewhere", ButtonOptions{}) + }, 500, 200) + if !tt.HasText("$") { + t.Fatalf("no symbol: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + if got := fieldText(tt); got != "1234.50" { + t.Fatalf("focused text %q, want ungrouped", got) + } + tt.Command("selectAll") + tt.Type("2500.255") + if v != 123450 || !tt.HasText("Use at most 2 decimal places") { + t.Fatalf("too precise: %d %q", v, tt.Texts()) + } + tt.Command("selectAll") + tt.Type("2500.25") + if v != 250025 || n != 1 || tt.HasText("decimal places") { + t.Fatalf("typed: %d (%d)", v, n) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + if got := fieldText(tt); got != "2500.25" { + t.Fatalf("refocused text %q", got) + } + dis = true + tt.Frame() + _ = tt.Click("Price") + tt.Type("9") + if v != 250025 { + t.Fatalf("disabled edit: %d", v) + } +} + +func TestCurrencyInputNoFractionAndRejects(t *testing.T) { + v := int64(1500) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CurrencyInput(c, &v, CurrencyInputOptions{Symbol: "¥", NoFraction: true, Label: "Amount"}) + }, 300, 100) + tt.Key(0, ui.KeyTab) + if got := fieldText(tt); got != "1500" { + t.Fatalf("yen %q", got) + } + tt.Command("selectAll") + tt.Type("12.5") + if v != 1500 || !tt.HasText("Use at most 0 decimal places") { + t.Fatalf("fraction accepted: %d", v) + } + for _, o := range []CurrencyInputOptions{{Decimals: -1}, {Decimals: 7}, {Decimals: 2, NoFraction: true}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", o) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); CurrencyInput(c, &v, o) }, 200, 100) + }() + } +} + +func TestCurrencyInputSnapshot(t *testing.T) { + a, b, d := int64(1234550), int64(98000), int64(0) + snapshot(t, "currency_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + CurrencyInput(c, &a, CurrencyInputOptions{Symbol: "$"}).Element.Width(150) + CurrencyInput(c, &b, CurrencyInputOptions{Symbol: "¥", NoFraction: true}).Element.Width(150) + CurrencyInput(c, &d, CurrencyInputOptions{Symbol: "€", Disabled: true}).Element.Width(150) + }) + }) +} diff --git a/dock_panel.go b/dock_panel.go new file mode 100644 index 0000000..8d34e49 --- /dev/null +++ b/dock_panel.go @@ -0,0 +1,193 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// DockZone is an area of a DockPanel. +type DockZone int + +const ( + DockCenter DockZone = iota + DockLeft + DockRight + DockBottom +) + +// DockLayout is the caller-held arrangement: panel IDs per zone in tab order, and each zone's shown tab. +type DockLayout struct { + Zones [4][]string + Active [4]string +} + +// DockItem is one dockable panel. +type DockItem struct { + ID, Title string + Build func() +} + +// DockPanelOptions configure a DockPanel. +type DockPanelOptions struct { + Disabled bool +} + +// dockPanelDrag is a tab being dragged. +type dockPanelDrag struct{ id string } + +// DockPanel arranges panels in left, center, right and bottom zones; tabs drag to an edge or zone to dock there. +func DockPanel(c *ui.Context, layout *DockLayout, items []DockItem, opts DockPanelOptions) ChoiceResult { + if layout == nil || len(items) == 0 { + panic("mujica: DockPanel needs a layout and items") + } + byID := map[string]DockItem{} + for _, it := range items { + if it.ID == "" || it.Title == "" || it.Build == nil { + panic("mujica: DockPanel item needs an ID, Title and Build") + } + if _, dup := byID[it.ID]; dup { + panic("mujica: DockPanel item ID repeated: " + it.ID) + } + byID[it.ID] = it + } + placed := map[string]bool{} + for z, ids := range layout.Zones { + for _, id := range ids { + if _, ok := byID[id]; !ok || placed[id] { + panic(fmt.Sprintf("mujica: DockPanel zone %d holds unknown or repeated %q", z, id)) + } + placed[id] = true + } + } + changed := false + for _, it := range items { + if !placed[it.ID] { + layout.Zones[DockCenter] = append(layout.Zones[DockCenter], it.ID) + changed = true + } + } + for z, ids := range layout.Zones { + if len(ids) > 0 && !slices.Contains(ids, layout.Active[z]) { + layout.Active[z] = ids[0] + } + } + k := Tokens(c) + u := Density(c).Unit() + root := ui.Box(c).Disabled(opts.Disabled).MinHeight(0) + disabled := root.IsDisabled() + // follow is the tab that moved; it keeps the focus in its new zone. + follow := ui.Local(root, "dock_panel.follow", func() string { return "" }) + move := func(id string, to DockZone) { + *follow = id + for z := range layout.Zones { + layout.Zones[z] = slices.DeleteFunc(slices.Clone(layout.Zones[z]), func(s string) bool { return s == id }) + } + layout.Zones[to] = append(layout.Zones[to], id) + layout.Active[to] = id + changed = true + slog.Debug("mujica: dock_panel docked", "panel", id, "zone", to) + } + dragging := false + root.Children(func() { + ui.Column(c).Fill().Children(func() { + ui.Row(c).Grow(1).MinHeight(0).AlignItems(ui.Stretch).Children(func() { + for _, z := range []DockZone{DockLeft, DockCenter, DockRight} { + if dockPanelZone(c, k, u, layout, byID, z, disabled, move, &changed, follow) { + dragging = true + } + } + }) + if dockPanelZone(c, k, u, layout, byID, DockBottom, disabled, move, &changed, follow) { + dragging = true + } + }) + // Edges stay one frame after the drag, when the drop arrives. + was := ui.Local(root, "dock_panel.drag", func() bool { return false }) + if (dragging || *was) && !disabled { + dockPanelEdges(c, k, u, move) + } + *was = dragging + }) + return ChoiceResult{root, changed} +} + +// dockPanelZone builds zone z's tabs and shown panel; it reports a tab of it being dragged. +func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout, byID map[string]DockItem, z DockZone, disabled bool, move func(string, DockZone), changed *bool, follow *string) bool { + ids := layout.Zones[z] + if len(ids) == 0 { + return false + } + box := ui.Column(c).Key(z).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup). + Label(msg(c, fmt.Sprintf("dock_panel.zone%d", z))) + switch z { + case DockLeft, DockRight: + box.Width(220).Shrink(0).Border(theme.BorderWidth, k.Border) + case DockBottom: + box.Height(160).Shrink(0).Border(theme.BorderWidth, k.Border) + default: + box.Grow(1) + } + if d, ok := ui.DragOver[dockPanelDrag](box); ok && !slices.Contains(ids, d.id) { + box.Border(2, k.AccentText) + } + if d, ok := ui.Drop[dockPanelDrag](box); ok && !disabled { + move(d.id, z) + } + dragging := false + box.Children(func() { + at := slices.Index(ids, layout.Active[z]) + base := ui.TabsBase(c, &at, len(ids)) + base.List.Gap(u).Padding(theme.FocusGap+theme.FocusWidth, u*2, 0, u*2).Children(func() { + for i, id := range ids { + tab := base.Tab(i).Drag(dockPanelDrag{id}) + tabsStyle(c, tab, Tab{Label: byID[id].Title}, i == at, false) + tab.Description(msg(c, "dock_panel.hint")) + if *follow == id { + tab.Focus() + *follow = "" + } + dragging = dragging || tab.Dragging() + for key, to := range map[ui.Key]DockZone{ui.KeyLeft: DockLeft, ui.KeyRight: DockRight, ui.KeyDown: DockBottom, ui.KeyUp: DockCenter} { + if tab.Shortcut(ui.Shift|ui.Alt, key) && !disabled && to != z { + move(id, to) + } + } + } + }) + if base.List.Changed() && !disabled && ids[at] != layout.Active[z] { + layout.Active[z], *changed = ids[at], true + } + ui.Box(c).FillWidth().Height(theme.BorderWidth).Background(k.Border).Role(ui.RoleNone) + ui.Scroll(c).Grow(1).MinHeight(0).Padding(u * 3).Children(byID[layout.Active[z]].Build) + }) + return dragging +} + +// dockPanelEdges shows drop strips on the four edges while a tab is dragged. +func dockPanelEdges(c *ui.Context, k theme.Tokens, u float32, move func(string, DockZone)) { + for _, z := range []DockZone{DockLeft, DockRight, DockBottom, DockCenter} { + e := ui.Box(c).Key(fmt.Sprint("edge", z)).Absolute().Role(ui.RoleNone) + switch z { + case DockLeft: + e.Left(0).Top(0).Bottom(0).Width(12 * u) + case DockRight: + e.Right(0).Top(0).Bottom(0).Width(12 * u) + case DockBottom: + e.Left(12 * u).Right(12 * u).Bottom(0).Height(10 * u) + default: + e.Left(0).Right(0).Top(0).Height(4 * u) + } + _, over := ui.DragOver[dockPanelDrag](e) + if over { + e.Background(k.Selection).Border(2, k.AccentText) + } + if d, ok := ui.Drop[dockPanelDrag](e); ok { + move(d.id, z) + } + } +} diff --git a/dock_panel_test.go b/dock_panel_test.go new file mode 100644 index 0000000..20b5aa0 --- /dev/null +++ b/dock_panel_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func dockItems(c *ui.Context) []DockItem { + var out []DockItem + for _, id := range []string{"files", "editor", "terminal"} { + out = append(out, DockItem{ID: id, Title: "Panel " + id, Build: func() { ui.Text(c, "Body of "+id) }}) + } + return out +} + +func dockTester(l *DockLayout, dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := DockPanel(c, l, dockItems(c), DockPanelOptions{Disabled: *dis}) + r.Element.Size(800, 500) + if r.Changed() { + *n++ + } + }, 820, 520) +} + +func TestDockPanelTabsAndKeyboardDock(t *testing.T) { + var l DockLayout + dis, n := false, 0 + tt := dockTester(&l, &dis, &n) + if !slices.Equal(l.Zones[DockCenter], []string{"files", "editor", "terminal"}) || l.Active[DockCenter] != "files" || !tt.HasText("Body of files") { + t.Fatalf("default layout %+v", l) + } + if err := tt.Click("Panel editor"); err != nil { + t.Fatal(err) + } + if l.Active[DockCenter] != "editor" || !tt.HasText("Body of editor") { + t.Fatalf("tab click: %+v", l) + } + tt.Key(ui.Shift|ui.Alt, ui.KeyLeft) + if !slices.Equal(l.Zones[DockLeft], []string{"editor"}) || slices.Contains(l.Zones[DockCenter], "editor") { + t.Fatalf("Shift+Alt+Left: %+v", l) + } + if _, ok := tt.Find("Left dock"); !ok { + t.Fatal("left zone not shown") + } + tt.Key(ui.Shift|ui.Alt, ui.KeyUp) + if len(l.Zones[DockLeft]) != 0 || l.Active[DockCenter] != "editor" { + t.Fatalf("back to center: %+v", l) + } + before := n + dis = true + tt.Frame() + tt.Key(ui.Shift|ui.Alt, ui.KeyDown) + if len(l.Zones[DockBottom]) != 0 || n != before { + t.Fatalf("disabled docked: %+v", l) + } +} + +func TestDockPanelDragToEdge(t *testing.T) { + var l DockLayout + dis, n := false, 0 + tt := dockTester(&l, &dis, &n) + r, _ := tt.Find("Panel terminal") + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + for _, px := range []float32{x + 10, x + 200, 600, 790} { + tt.Move(px, 250) + } + tt.Release(790, 250) + tt.Frame() + if !slices.Equal(l.Zones[DockRight], []string{"terminal"}) || n == 0 { + t.Fatalf("drag to the right edge: %+v", l) + } +} + +func TestDockPanelRejects(t *testing.T) { + for name, l := range map[string]DockLayout{ + "unknown": {Zones: [4][]string{{"nope"}}}, + "repeated": {Zones: [4][]string{{"files"}, {"files"}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); DockPanel(c, &l, dockItems(c), DockPanelOptions{}) }, 300, 200) + }() + } +} + +func TestDockPanelSnapshot(t *testing.T) { + l := DockLayout{Zones: [4][]string{{"editor"}, {"files"}, nil, {"terminal"}}} + snapshot(t, "dock_panel", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + DockPanel(c, &l, dockItems(c), DockPanelOptions{}).Element.Size(616, 336) + }) + }) +} diff --git a/docs/components/481-split-button.md b/docs/components/481-split-button.md new file mode 100644 index 0000000..d2385f2 --- /dev/null +++ b/docs/components/481-split-button.md @@ -0,0 +1,50 @@ +# 481 SplitButton 拆分按钮 + +## 用途 + +主操作与下拉菜单合为一体:左半执行主操作,右半打开 `PopupMenu` 列出相关操作。两半各自获得焦点。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Save as…", mujica.PopupMenuItemOptions{}) { + // ask for a file name + } + }) + if r.Changed() { + // save + } +} +``` + +签名:`func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build func(m *PopupMenu)) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 主操作文字。 | +| `build` | `func(*PopupMenu)` | 必填 | 填充菜单项,`Item` 报告选择。 | +| `Variant` | `ButtonVariant` | `Primary` | 只接受 `Primary` 或 `Secondary`,其余 panic。 | +| `Icon` | `*ui.SVG` | `nil` | 主操作图标。 | +| `MenuLabel` | `string` | “More options” | 菜单半的无障碍名称。 | +| `Disabled` | `bool` | `false` | 两半都不可用,打开的菜单关闭。 | + +## 状态 + +两半分别有悬停、按下、焦点环;菜单打开时右半保持按下色。禁用时 MyGo 统一减淡。 + +## 事件 + +`Changed()`:主操作被触发。菜单项通过 `m.Item` 的返回值报告。 + +## 键盘操作 + +Tab 依次聚焦主操作与菜单按钮;Enter/Space 触发主操作;在菜单按钮上 Enter、Space 或 Down 打开菜单,方向键选择,Escape 关闭。 + +## 限制 + +菜单宽度至少等于整个按钮组;菜单内容由调用方提供。 diff --git a/docs/components/482-copy-button.md b/docs/components/482-copy-button.md new file mode 100644 index 0000000..4c76b83 --- /dev/null +++ b/docs/components/482-copy-button.md @@ -0,0 +1,40 @@ +# 482 CopyButton 复制按钮 + +## 用途 + +把文字放到剪贴板,按钮随即显示“已复制”与对勾两秒,并向屏幕阅读器播报。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"}) +} +``` + +签名:`func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `text` | `string` | 必填 | 要复制的文字;不进日志,只记录长度。 | +| `Label` | `string` | `""` | 可见文字;为空时是方形图标按钮。 | +| `Disabled` | `bool` | `false` | 不复制。 | + +## 状态 + +默认、悬停、按下、焦点;复制后两秒显示“已复制”,宽度按两段文字中较长者预留,不跳动。 + +## 事件 + +`Changed()`:本帧完成复制。 + +## 键盘操作 + +Tab 聚焦;Enter 或 Space 复制。 + +## 限制 + +剪贴板写入由 MyGo 完成,无法得知系统是否拒绝写入。 diff --git a/docs/components/483-hold-to-confirm.md b/docs/components/483-hold-to-confirm.md new file mode 100644 index 0000000..22fa635 --- /dev/null +++ b/docs/components/483-hold-to-confirm.md @@ -0,0 +1,42 @@ +# 483 HoldToConfirm 按住确认 + +## 用途 + +危险操作必须按住指针或空格键一段时间才执行,按住期间填充进度;减少动画时改为显示剩余秒数。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() { + // delete the project + } +} +``` + +签名:`func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 按钮文字。 | +| `Duration` | `time.Duration` | 1.5 秒 | 需按住的时长;负数 panic。 | +| `Disabled` | `bool` | `false` | 不响应。 | + +## 状态 + +危险配色;按住时从左向右填充;减少动画时文字变为“继续按住… 1.2 秒”,宽度预留。松开或失去焦点即重置。完成后播报“已确认”。 + +## 事件 + +`Changed()`:按满时长后报告一次;同一次按住不会重复报告。 + +## 键盘操作 + +Tab 聚焦后按住空格;单击、Enter 或短按空格都不会执行。 + +## 限制 + +按住空格依赖系统送来的按键抬起;窗口失去焦点时按住状态即重置。 diff --git a/docs/components/484-bulk-action-bar.md b/docs/components/484-bulk-action-bar.md new file mode 100644 index 0000000..1d0b1c0 --- /dev/null +++ b/docs/components/484-bulk-action-bar.md @@ -0,0 +1,46 @@ +# 484 BulkActionBar 批量操作条 + +## 用途 + +多选后出现的操作条:显示选中数、调用方提供的批量操作和“清除选择”。 + +## 最小示例 + +```go +selected := 3 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() { + mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + }) + if r.Changed() { + selected = 0 + } +} +``` + +签名:`func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actions func()) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `selected` | `int` | 必填 | 选中数;0 时不显示(`Element` 为 nil),负数 panic。 | +| `actions` | `func()` | `nil` | 批量操作按钮插槽。 | +| `Disabled` | `bool` | `false` | 整条不可用。 | + +## 状态 + +浮层样式,160 ms 淡入;选中数变化时播报。 + +## 事件 + +`Changed()`:用户清除选择(按钮或 Escape)。 + +## 键盘操作 + +Tab 进入操作按钮与“清除选择”;焦点在条内时 Escape 清除选择。 + +## 限制 + +位置由调用方放置,不自动悬浮在窗口底部。 diff --git a/docs/components/485-scrub-input.md b/docs/components/485-scrub-input.md new file mode 100644 index 0000000..1a6fc45 --- /dev/null +++ b/docs/components/485-scrub-input.md @@ -0,0 +1,43 @@ +# 485 ScrubInput 拖动数值输入 + +## 用途 + +按住标签左右拖动调整数值,每 4 DIP 一步;旁边的 NumberInput 可直接输入。范围、步长与精度规则与 NumberInput 相同。 + +## 最小示例 + +```go +opacity := 0.6 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1}) +} +``` + +签名:`func ScrubInput(c *ui.Context, label string, value *float64, opts ScrubInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 可拖动的标签,也是字段名称。 | +| `Min`, `Max` | `float64` | 无界 | 相等表示无界。 | +| `Step` | `float64` | 1 | 每一步的增量。 | +| `Precision` | `int` | 取自 Step | 保留小数位。 | +| `Disabled` | `bool` | `false` | 不可拖动也不可输入。 | + +## 状态 + +标签悬停或拖动时变为强调色;拖到边界即停止。 + +## 事件 + +`Changed()`:拖动或输入改变了值;`Submitted()` 同 NumberInput。 + +## 键盘操作 + +标签不可聚焦;键盘在输入框内用 Up/Down 步进或直接输入。 + +## 限制 + +拖动只对指针可用。 diff --git a/docs/components/486-currency-input.md b/docs/components/486-currency-input.md new file mode 100644 index 0000000..9da7a7a --- /dev/null +++ b/docs/components/486-currency-input.md @@ -0,0 +1,45 @@ +# 486 CurrencyInput 货币输入 + +## 用途 + +以最小单位(分)的整数保存金额,避免浮点误差;显示币种符号,未聚焦时按千分位分组,聚焦后显示纯数字便于编辑。 + +## 最小示例 + +```go +cents := int64(123450) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"}) +} +``` + +签名:`func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*int64` | 必填 | 金额的最小单位数。 | +| `Symbol` | `string` | `""` | 前缀符号,如 `$`、`¥`。 | +| `Decimals` | `int` | 2 | 小数位,0–6,越界 panic。 | +| `NoFraction` | `bool` | `false` | 无小数(如日元);与 `Decimals` 同设 panic。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 不可编辑。 | +| `Error` | `string` | `""` | 调用方错误信息。 | + +## 状态 + +非法输入或小数位过多时保留输入并显示具体错误,值不变。 + +## 事件 + +`Changed()`:合法输入改变了值。 + +## 键盘操作 + +Tab 聚焦;文本编辑、选择、剪贴板与撤销由 MyGo 编辑器提供。 + +## 限制 + +千分位与小数点固定为 `,` 与 `.`。 diff --git a/docs/components/487-masked-input.md b/docs/components/487-masked-input.md new file mode 100644 index 0000000..7b15439 --- /dev/null +++ b/docs/components/487-masked-input.md @@ -0,0 +1,43 @@ +# 487 MaskedInput 掩码输入 + +## 用途 + +按格式掩码输入电话、卡号等:`9` 数字、`a` 字母、`*` 二者皆可,其余字符原样显示。值只保存填入的字符。 + +## 最小示例 + +```go +phone := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"}) +} +``` + +签名:`func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Mask` | `string` | 必填 | 掩码;没有可填位 panic。 | +| `value` | `*string` | 必填 | 已填字符;超长或不符掩码 panic。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 不可编辑。 | +| `Error` | `string` | `""` | 错误信息。 | + +## 状态 + +未填位置显示下划线;输入法组合期间显示带下划线的组合文字,不提交、不跳格,只有提交的文字进入掩码。 + +## 事件 + +`Changed()`:值变化;`Submitted()`:Enter。另有 `MaskFormat(mask, raw)` 生成带格式文字。 + +## 键盘操作 + +左右方向键、Home/End 移动光标;Backspace/Delete 删除;粘贴只取符合掩码的字符;Enter 提交。 + +## 限制 + +自绘编辑器:没有文字选择与撤销,复制得到未格式化的值。 diff --git a/docs/components/488-mention-input.md b/docs/components/488-mention-input.md new file mode 100644 index 0000000..8f8472b --- /dev/null +++ b/docs/components/488-mention-input.md @@ -0,0 +1,57 @@ +# 488 MentionInput 提及输入 + +## 用途 + +输入 @ 后弹出候选,选中后插入整体提及;提及作为一个单元被光标跳过、被一次删除。文本与提及列表都由调用方持有。 + +## 最小示例 + +```go +text, mentions := "", []mujica.Mention(nil) +people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{ + Label: "Comment", + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) +} +``` + +签名:`func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts MentionInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*string` | 必填 | 全文。 | +| `mentions` | `*[]Mention` | 必填 | 提及(ID、Name、Start、End,按 rune 计);与文本不符 panic。 | +| `Candidates` | `func(string) []MentionCandidate` | 必填 | 按 @ 后文字返回候选,仅在该文字变化时调用。 | +| `Placeholder` | `string` | `""` | 占位文字。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 不可编辑。 | +| `Error` | `string` | `""` | 错误信息。 | + +## 状态 + +提及以强调色加浅底显示;候选列表用浮层样式,当前项有选中底色与左侧标记。输入法组合期间不提交。 + +## 事件 + +`Changed()`:文本或提及变化;`Submitted()`:候选关闭时按 Enter。 + +## 键盘操作 + +@ 后 Up/Down 选择候选,Enter 或 Tab 插入,Escape 关闭;左右键跨过提及,Backspace/Delete 整体删除;Shift+方向键选择;支持复制、剪切、粘贴、全选、撤销、重做命令。 + +## 限制 + +MyGo 编辑器没有设置光标的公开接口(ui/editor.go:117 只在文本变化时截断光标),无法在插入提及后把光标放到提及之后,因此本组件自绘单行编辑器;不支持多行与双击选词。 diff --git a/docs/components/489-choice-chips.md b/docs/components/489-choice-chips.md new file mode 100644 index 0000000..3d61812 --- /dev/null +++ b/docs/components/489-choice-chips.md @@ -0,0 +1,45 @@ +# 489 ChoiceChips 筛选芯片 + +## 用途 + +单选或多选的筛选芯片,自动换行。选中芯片用选中底色、强调边框和对勾表示。 + +## 最小示例 + +```go +var status []string +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{ + {Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"}, + }, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"}) +} +``` + +签名:`func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ChoiceChipsOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*[]T` | 必填 | 选中的值;单选时多于一个 panic。 | +| `items` | `[]ToggleItem[T]` | 必填 | 芯片(Value、Label、Icon、Name、Disabled)。 | +| `Multiple` | `bool` | `false` | 允许多选;单选时可再次点击取消。 | +| `Disabled` | `bool` | `false` | 整组禁用。 | +| `Label` | `string` | `""` | 组名称。 | + +## 状态 + +默认、悬停、按下、焦点、选中、禁用;颜色过渡 120 ms。 + +## 事件 + +`Changed()`:选择变化。 + +## 键盘操作 + +整组是一个 Tab 停靠点;左右方向键移动并跳过禁用芯片;Space/Enter 切换。 + +## 限制 + +无。 diff --git a/docs/components/490-knob.md b/docs/components/490-knob.md new file mode 100644 index 0000000..0e77e1e --- /dev/null +++ b/docs/components/490-knob.md @@ -0,0 +1,45 @@ +# 490 Knob 旋钮 + +## 用途 + +圆形旋钮:上下拖动或方向键调整数值,显示刻度、数值弧和菱形指针。 + +## 最小示例 + +```go +gain := 35.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11}) +} +``` + +签名:`func Knob(c *ui.Context, value *float64, opts KnobOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Min`, `Max` | `float64` | 0–100 | 范围;`Max <= Min` panic。 | +| `Step` | `float64` | 连续 | 步长,按 NumberInput 网格吸附。 | +| `Ticks` | `int` | 0 | 刻度数;负数 panic。 | +| `Size` | `float32` | 56 | 直径。 | +| `Format` | `func(float64) string` | 自动 | 数值文字。 | +| `Disabled` | `bool` | `false` | 不可调。 | +| `Label` | `string` | “Knob” | 名称与标题。 | + +## 状态 + +悬停与拖动时表盘变亮;焦点环为圆形。基于 MyGo SliderBase,读屏视为滑块。 + +## 事件 + +`Changed()`:值变化。 + +## 键盘操作 + +方向键按步调整(连续时每步 1%),Home/End 到两端;上拖增大,160 DIP 走完全程。 + +## 限制 + +无。 diff --git a/docs/components/491-vertical-slider.md b/docs/components/491-vertical-slider.md new file mode 100644 index 0000000..6aee4e4 --- /dev/null +++ b/docs/components/491-vertical-slider.md @@ -0,0 +1,46 @@ +# 491 VerticalSlider 纵向滑块 + +## 用途 + +纵向放置的 Slider:上大下小,规则与 Slider 相同(步长网格、刻度、数值气泡)。 + +## 最小示例 + +```go +level := 40.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"}) +} +``` + +签名:`func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Min`, `Max` | `float64` | 0–100 | 范围。 | +| `Step` | `float64` | 连续 | 步长。 | +| `Marks` | `[]SliderMark` | `nil` | 刻度与标签。 | +| `Height` | `float32` | 160 | 轨道长度;负数 panic。 | +| `ShowValue` | `bool` | `false` | 悬停、拖动或聚焦时显示数值。 | +| `Format` | `func(float64) string` | 自动 | 数值文字。 | +| `Disabled` | `bool` | `false` | 不可调。 | +| `Label` | `string` | `""` | 名称。 | + +## 状态 + +菱形滑块,悬停与拖动放大;填充段为 AccentText。 + +## 事件 + +`Changed()`:值变化。 + +## 键盘操作 + +Up/Right 增大,Down/Left 减小,Home/End 到两端;点击或拖动设置。 + +## 限制 + +无。 diff --git a/docs/components/492-color-palette.md b/docs/components/492-color-palette.md new file mode 100644 index 0000000..4ce538f --- /dev/null +++ b/docs/components/492-color-palette.md @@ -0,0 +1,47 @@ +# 492 ColorPalette 预设色板 + +## 用途 + +从预设色块中选择颜色,可附带自定义颜色入口(复用 ColorPicker)。选中色块有内框与对勾,标记颜色按对比度自动取深或浅。 + +## 最小示例 + +```go +color := ui.Hex("#7D2034") +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ColorPalette(c, &color, []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, + {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + }, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"}) +} +``` + +签名:`func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts ColorPaletteOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `swatches` | `[]ColorSwatch` | 必填 | 颜色与名称;缺名称 panic。 | +| `Columns` | `int` | 8 | 每行色块数;负数 panic。 | +| `Custom` | `bool` | `false` | 显示自定义颜色入口。 | +| `Disabled` | `bool` | `false` | 不可选。 | +| `Label` | `string` | `""` | 组名称。 | + +## 状态 + +悬停加深边框;选中有内框与对勾;每个色块提示其名称。 + +## 事件 + +`Changed()`:颜色变化(含自定义)。 + +## 键盘操作 + +色板是一个 Tab 停靠点;左右键逐个移动,上下键跨行,Enter/Space 选择。 + +## 限制 + +无。 diff --git a/docs/components/493-signature-pad.md b/docs/components/493-signature-pad.md new file mode 100644 index 0000000..bbd9b13 --- /dev/null +++ b/docs/components/493-signature-pad.md @@ -0,0 +1,44 @@ +# 493 SignaturePad 签名板 + +## 用途 + +用指针书写签名,可清除,并可用 `SignaturePath` 导出 SVG 路径数据。 + +## 最小示例 + +```go +var strokes [][]mujica.SignaturePoint +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() { + _ = mujica.SignaturePath(strokes) // SVG path data to store + } +} +``` + +签名:`func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `strokes` | `*[][]SignaturePoint` | 必填 | 笔画,坐标为相对画板的 DIP。 | +| `Height` | `float32` | 160 | 画板高度;宽度填满。 | +| `Label` | `string` | “Signature” | 名称。 | +| `Disabled` | `bool` | `false` | 不可书写与清除。 | + +## 状态 + +空白时显示“请在此签名”;有签名基线;清除按钮在无笔画时禁用。 + +## 事件 + +`Changed()`:完成一笔或清除。 + +## 键盘操作 + +Tab 聚焦画板,Delete/Backspace 清除;Tab 到“清除”按钮。 + +## 限制 + +书写只能用指针,键盘无法替代手写。 diff --git a/docs/components/494-banner.md b/docs/components/494-banner.md new file mode 100644 index 0000000..688a2ad --- /dev/null +++ b/docs/components/494-banner.md @@ -0,0 +1,48 @@ +# 494 Banner 页面横幅 + +## 用途 + +页面顶部的通栏提示:信息、成功、警告、错误;可带操作和关闭按钮。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{ + Severity: mujica.SeverityWarning, + Action: "Details", + }) + if r.Action { + // show the details + } +} +``` + +签名:`func Banner(c *ui.Context, open *bool, message string, opts BannerOptions) AlertResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | `nil` | 非 nil 时显示关闭按钮;为 false 时不显示。 | +| `message` | `string` | 必填 | 文字。 | +| `Severity` | `Severity` | `SeverityInfo` | 严重程度;未知值 panic。 | +| `Action` | `string` | `""` | 操作按钮文字。 | + +## 状态 + +图标、底边色条与浅色底同时表达严重程度;警告与错误出现或变化时播报。 + +## 事件 + +`AlertResult.Action`、`AlertResult.Closed`。 + +## 键盘操作 + +Tab 到操作与关闭按钮;Enter/Space 触发。 + +## 限制 + +无。 diff --git a/docs/components/495-undo-toast.md b/docs/components/495-undo-toast.md new file mode 100644 index 0000000..fe9fe22 --- /dev/null +++ b/docs/components/495-undo-toast.md @@ -0,0 +1,47 @@ +# 495 UndoToast 撤销提示 + +## 用途 + +在窗口底部显示可撤销操作的消息与倒计时;撤销时调用回调。悬停或焦点在其中时暂停计时,从不抢夺焦点。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{ + OnUndo: func() { + // restore the note + }, + }) +} +``` + +签名:`func UndoToast(c *ui.Context, open *bool, message string, opts UndoToastOptions) UndoToastResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | 必填 | 显示与否;结束时置 false。 | +| `message` | `string` | 必填 | 文字,出现时播报。 | +| `OnUndo` | `func()` | 必填 | 撤销回调。 | +| `Duration` | `time.Duration` | 5 秒 | 倒计时;负数 panic。 | +| `Action` | `string` | “Undo” | 按钮文字。 | + +## 状态 + +按钮显示剩余秒数,下方细条随时间缩短;减少动画时不画细条,只更新秒数。 + +## 事件 + +`Undone`:已撤销(回调已执行);`Expired`:倒计时结束或用户关闭,操作生效。 + +## 键盘操作 + +Tab 到“撤销”与关闭按钮;焦点在其中时 Escape 关闭。 + +## 限制 + +每个窗口同时只显示一条。 diff --git a/docs/components/496-masonry.md b/docs/components/496-masonry.md new file mode 100644 index 0000000..6925378 --- /dev/null +++ b/docs/components/496-masonry.md @@ -0,0 +1,47 @@ +# 496 Masonry 瀑布流 + +## 用途 + +按可用宽度决定列数,把每一项放进当前最短的列;只构建可见范围内的项,以稳定键记住测得的高度。 + +## 最小示例 + +```go +titles := []string{"Sea", "Forest", "Desert", "Glacier"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Masonry(c, len(titles), mujica.MasonryOptions{ + Key: func(i int) string { return titles[i] }, + }, func(i int) { + ui.Text(c, titles[i]) + }).Size(380, 280) +} +``` + +签名:`func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `n` | `int` | 必填 | 项数;负数 panic。 | +| `Key` | `func(int) string` | 必填 | 稳定且唯一的键;重复 panic。 | +| `ColumnWidth` | `float32` | 220 | 最窄列宽。 | +| `Gap` | `float32` | 3 个间距单位 | 间隔。 | +| `Estimate` | `float32` | 160 | 测量前的估计高度。 | + +## 状态 + +自身是滚动容器,需由调用方给定尺寸。 + +## 事件 + +无。 + +## 键盘操作 + +滚动容器按 MyGo 规则滚动;项内控件按各自规则聚焦。 + +## 限制 + +排布只在项数、列数、宽度、测得高度或可见项的键变化时重算。 diff --git a/docs/components/497-resizable-panel-group.md b/docs/components/497-resizable-panel-group.md new file mode 100644 index 0000000..6bc2bfe --- /dev/null +++ b/docs/components/497-resizable-panel-group.md @@ -0,0 +1,48 @@ +# 497 ResizablePanelGroup 可调面板组 + +## 用途 + +多个面板以分隔条相连,拖动或键盘调整尺寸;可折叠面板拖到最小值一半以下即折叠。尺寸以比例保存在调用方的切片中。 + +## 最小示例 + +```go +var sizes []float32 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }}, + {Label: "Editor", Build: func() { ui.Text(c, "Editor") }}, + }, mujica.ResizablePanelGroupOptions{}) + if r.Committed { + // save sizes + } +} +``` + +签名:`func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePanel, opts ResizablePanelGroupOptions) ResizablePanelGroupResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `sizes` | `*[]float32` | 空则平分 | 各面板比例,和为 1,否则 panic;0 只允许可折叠面板。 | +| `panels` | `[]ResizablePanel` | 必填 | Label、Min(默认 10 个间距单位)、Collapsible、Build;至少两个。 | +| `Vertical` | `bool` | `false` | 上下排列。 | +| `Disabled` | `bool` | `false` | 不可调整。 | + +## 状态 + +分隔条悬停或拖动时变为强调色;折叠后分隔条报告“已折叠”。 + +## 事件 + +`Resized`:拖动中尺寸变化;`Committed`:一次调整完成(松开、按键、折叠),用于保存。 + +## 键盘操作 + +Tab 聚焦分隔条;方向键每次 4 个间距单位;Home/End 到最小与最大;Enter 折叠或恢复相邻的可折叠面板。 + +## 限制 + +无。 diff --git a/docs/components/498-dock-panel.md b/docs/components/498-dock-panel.md new file mode 100644 index 0000000..3f37046 --- /dev/null +++ b/docs/components/498-dock-panel.md @@ -0,0 +1,47 @@ +# 498 DockPanel 停靠面板 + +## 用途 + +左、中、右、下四个停靠区,每区以标签页显示面板;把标签拖到区域或窗口边缘即停靠到那里。布局由调用方保存。 + +## 最小示例 + +```go +var layout mujica.DockLayout +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.DockPanel(c, &layout, []mujica.DockItem{ + {ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }}, + {ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }}, + }, mujica.DockPanelOptions{}) + if r.Changed() { + // save layout + } +} +``` + +签名:`func DockPanel(c *ui.Context, layout *DockLayout, items []DockItem, opts DockPanelOptions) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `layout` | `*DockLayout` | 全部在中央 | 各区面板 ID 与当前标签;未知或重复 ID panic。 | +| `items` | `[]DockItem` | 必填 | ID、Title、Build;ID 重复 panic。 | +| `Disabled` | `bool` | `false` | 不可切换与停靠。 | + +## 状态 + +拖动时四边出现停靠条,悬停的目标显示选中底色与强调边框;当前标签有下划线与选中底色。 + +## 事件 + +`Changed()`:布局变化(停靠或切换标签),用于保存。 + +## 键盘操作 + +每区标签是一个 Tab 停靠点,左右键切换;在标签上按 Shift+Alt+←/→/↓ 停靠到左、右、下,Shift+Alt+↑ 回到中央,焦点跟随标签。 + +## 限制 + +区域尺寸固定(侧栏 220 DIP,底部 160 DIP);需要调整时与 ResizablePanelGroup 组合。 diff --git a/docs/components/499-qr-code.md b/docs/components/499-qr-code.md new file mode 100644 index 0000000..4ec22d3 --- /dev/null +++ b/docs/components/499-qr-code.md @@ -0,0 +1,41 @@ +# 499 QRCode 二维码 + +## 用途 + +在本地用 Go 编码文字为二维码(字节模式,版本 1–40,纠错 L/M/Q/H),不访问网络。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile}) +} +``` + +签名:`func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `text` | `string` | 必填 | 按 UTF-8 字节编码。 | +| `Level` | `QRLevel` | `QRMedium` | `QRLow`、`QRMedium`、`QRQuartile`、`QRHigh`;未知值 panic。 | +| `Size` | `float32` | 160 | 含静区的边长;负数或非有限 panic。 | +| `Label` | `string` | “QR code” | 无障碍名称。 | + +## 状态 + +始终深色模块印在浅色底上,深色主题也一样,以便扫码;模块对齐设备像素。文字过长时显示错误。 + +## 事件 + +无。另有 `EncodeQR(data, level) (QRMatrix, error)` 供调用方导出。 + +## 键盘操作 + +不可聚焦。 + +## 限制 + +只用字节模式;只在文字或纠错级别变化时重新编码。 diff --git a/docs/components/500-activity-feed.md b/docs/components/500-activity-feed.md new file mode 100644 index 0000000..7af1758 --- /dev/null +++ b/docs/components/500-activity-feed.md @@ -0,0 +1,43 @@ +# 500 ActivityFeed 活动流 + +## 用途 + +按日分组显示“参与者 动作 对象 时间”,今天与昨天用文字,其余显示日期;只构建可见行。 + +## 最小示例 + +```go +events := []mujica.Activity{ + {ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280) +} +``` + +签名:`func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `items` | `[]Activity` | 必填 | ID、Actor、Action、Object、Time;缺 ID、Actor、Action 或 Time panic。 | +| `Location` | `*time.Location` | `time.Local` | 按哪个时区划分日期。 | +| `Today` | `Date` | 时钟 | 固定“今天”。 | + +## 状态 + +日期标题使用衬线字与旧金细线;无数据时显示空状态。 + +## 事件 + +无。 + +## 键盘操作 + +滚动容器按 MyGo 规则滚动。 + +## 限制 + +分组只在条数、首尾 ID 或时区变化时重算;中间条目原地修改需换新切片。 diff --git a/example_essentials_test.go b/example_essentials_test.go new file mode 100644 index 0000000..bd39af7 --- /dev/null +++ b/example_essentials_test.go @@ -0,0 +1,248 @@ +package mujica_test + +import ( + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleSplitButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Save as…", mujica.PopupMenuItemOptions{}) { + // ask for a file name + } + }) + if r.Changed() { + // save + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCopyButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHoldToConfirm() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() { + // delete the project + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBulkActionBar() { + selected := 3 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() { + mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + }) + if r.Changed() { + selected = 0 + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScrubInput() { + opacity := 0.6 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCurrencyInput() { + cents := int64(123450) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMaskedInput() { + phone := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMentionInput() { + text, mentions := "", []mujica.Mention(nil) + people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{ + Label: "Comment", + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChoiceChips() { + var status []string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{ + {Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"}, + }, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleKnob() { + gain := 35.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVerticalSlider() { + level := 40.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleColorPalette() { + color := ui.Hex("#7D2034") + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ColorPalette(c, &color, []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, + {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + }, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSignaturePad() { + var strokes [][]mujica.SignaturePoint + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() { + _ = mujica.SignaturePath(strokes) // SVG path data to store + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBanner() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{ + Severity: mujica.SeverityWarning, + Action: "Details", + }) + if r.Action { + // show the details + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleUndoToast() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{ + OnUndo: func() { + // restore the note + }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMasonry() { + titles := []string{"Sea", "Forest", "Desert", "Glacier"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Masonry(c, len(titles), mujica.MasonryOptions{ + Key: func(i int) string { return titles[i] }, + }, func(i int) { + ui.Text(c, titles[i]) + }).Size(380, 280) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleResizablePanelGroup() { + var sizes []float32 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }}, + {Label: "Editor", Build: func() { ui.Text(c, "Editor") }}, + }, mujica.ResizablePanelGroupOptions{}) + if r.Committed { + // save sizes + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDockPanel() { + var layout mujica.DockLayout + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.DockPanel(c, &layout, []mujica.DockItem{ + {ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }}, + {ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }}, + }, mujica.DockPanelOptions{}) + if r.Changed() { + // save layout + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQRCode() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleActivityFeed() { + events := []mujica.Activity{ + {ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_essentials.go b/examples/gallery/pages_essentials.go index 3dcc596..6562f5f 100644 --- a/examples/gallery/pages_essentials.go +++ b/examples/gallery/pages_essentials.go @@ -1,3 +1,262 @@ package main -var essentialsPages = []page{} +var essentialsPages = []page{ + { + ID: "481", Name: "SplitButton", ZhName: "拆分按钮 主操作 下拉", Category: "Essentials", + Demo: splitButtonDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{}, func(m *mujica.PopupMenu) { + if m.Item("Save as…", mujica.PopupMenuItemOptions{}) { + // ask for a file name + } + }) + if r.Changed() { + // save + } +}`, + Keys: "Tab reaches the main half, then the menu half. Enter or Space runs the action; on the menu half, Enter, Space or Down opens the menu.", + }, + { + ID: "482", Name: "CopyButton", ZhName: "复制按钮 剪贴板", Category: "Essentials", + Demo: copyButtonDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CopyButton(c, "https://example.com/invite/42", mujica.CopyButtonOptions{Label: "Copy link"}) +}`, + Keys: "Tab focuses; Enter or Space copies. The copied state is announced.", + }, + { + ID: "483", Name: "HoldToConfirm", ZhName: "按住确认 危险操作", Category: "Essentials", + Demo: holdToConfirmDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{}).Changed() { + // delete the project + } +}`, + Keys: "Focus it and hold Space for the full duration; a click, Enter or a short Space does nothing.", + }, + { + ID: "484", Name: "BulkActionBar", ZhName: "批量操作条 多选", Category: "Essentials", + Demo: bulkActionBarDemo, + Code: `selected := 3 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BulkActionBar(c, selected, mujica.BulkActionBarOptions{}, func() { + mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}) + }) + if r.Changed() { + selected = 0 + } +}`, + Keys: "Tab moves through the bar's actions; Escape inside it clears the selection.", + }, + { + ID: "485", Name: "ScrubInput", ZhName: "拖动数值 拖动标签", Category: "Essentials", + Demo: scrubInputDemo, + Code: `opacity := 0.6 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScrubInput(c, "Opacity", &opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.1}) +}`, + Keys: "The label is pointer-only; in the field, Up and Down step and typing sets the value.", + }, + { + ID: "486", Name: "CurrencyInput", ZhName: "货币输入 金额 千分位", Category: "Essentials", + Demo: currencyInputDemo, + Code: `cents := int64(123450) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CurrencyInput(c, ¢s, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price"}) +}`, + Keys: "Tab focuses the field, which shows plain digits while focused and groups thousands when left.", + }, + { + ID: "487", Name: "MaskedInput", ZhName: "掩码输入 电话 卡号", Category: "Essentials", + Demo: maskedInputDemo, + Code: `phone := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MaskedInput(c, &phone, mujica.MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone"}) +}`, + Keys: "Arrows, Home and End move the caret; Backspace and Delete remove; paste keeps the characters that fit; Enter submits.", + }, + { + ID: "488", Name: "MentionInput", ZhName: "提及输入 @ 候选", Category: "Essentials", + Demo: mentionInputDemo, + Code: `text, mentions := "", []mujica.Mention(nil) +people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MentionInput(c, &text, &mentions, mujica.MentionInputOptions{ + Label: "Comment", + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) +}`, + Keys: "After @, Up and Down pick a candidate, Enter or Tab inserts it, Escape closes the list. Arrows step over a mention; Backspace removes it whole.", + }, + { + ID: "489", Name: "ChoiceChips", ZhName: "筛选芯片 单选 多选", Category: "Essentials", + Demo: choiceChipsDemo, + Code: `var status []string +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChoiceChips(c, &status, []mujica.ToggleItem[string]{ + {Value: "open", Label: "Open"}, {Value: "closed", Label: "Closed"}, + }, mujica.ChoiceChipsOptions{Multiple: true, Label: "Status"}) +}`, + Keys: "The chips are one Tab stop; Left and Right move, skipping disabled chips; Space or Enter toggles.", + }, + { + ID: "490", Name: "Knob", ZhName: "旋钮 调节", Category: "Essentials", + Demo: knobDemo, + Code: `gain := 35.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Knob(c, &gain, mujica.KnobOptions{Label: "Gain", Ticks: 11}) +}`, + Keys: "Arrows step the value; Home and End go to the ends; drag up or down with the pointer.", + }, + { + ID: "491", Name: "VerticalSlider", ZhName: "纵向滑块", Category: "Essentials", + Demo: verticalSliderDemo, + Code: `level := 40.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VerticalSlider(c, &level, mujica.VerticalSliderOptions{Step: 10, Label: "Level"}) +}`, + Keys: "Up and Right raise the value, Down and Left lower it, Home and End go to the ends.", + }, + { + ID: "492", Name: "ColorPalette", ZhName: "预设色板 颜色", Category: "Essentials", + Demo: colorPaletteDemo, + Code: `color := ui.Hex("#7D2034") +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ColorPalette(c, &color, []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, + {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + }, mujica.ColorPaletteOptions{Custom: true, Label: "Tag color"}) +}`, + Keys: "The palette is one Tab stop; Left and Right move by one swatch, Up and Down by a row; Enter or Space chooses.", + }, + { + ID: "493", Name: "SignaturePad", ZhName: "签名板 手写", Category: "Essentials", + Demo: signaturePadDemo, + Code: `var strokes [][]mujica.SignaturePoint +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SignaturePad(c, &strokes, mujica.SignaturePadOptions{}).Changed() { + _ = mujica.SignaturePath(strokes) // SVG path data to store + } +}`, + Keys: "Tab focuses the pad; Delete or Backspace clears it. Drawing needs the pointer.", + }, + { + ID: "494", Name: "Banner", ZhName: "横幅 页面提示", Category: "Essentials", + Demo: bannerDemo, + Code: `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Banner(c, &open, "Maintenance tonight at 22:00.", mujica.BannerOptions{ + Severity: mujica.SeverityWarning, + Action: "Details", + }) + if r.Action { + // show the details + } +}`, + Keys: "Tab reaches the action and the dismiss button; Enter or Space presses them.", + }, + { + ID: "495", Name: "UndoToast", ZhName: "撤销提示 倒计时", Category: "Essentials", + Demo: undoToastDemo, + Code: `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UndoToast(c, &open, "Note deleted", mujica.UndoToastOptions{ + OnUndo: func() { + // restore the note + }, + }) +}`, + Keys: "It never takes the focus. Tab reaches Undo and the close button after the page; Escape inside closes it. Hover or focus pauses the countdown.", + }, + { + ID: "496", Name: "Masonry", ZhName: "瀑布流 卡片", Category: "Essentials", + Demo: masonryDemo, + Code: `titles := []string{"Sea", "Forest", "Desert", "Glacier"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Masonry(c, len(titles), mujica.MasonryOptions{ + Key: func(i int) string { return titles[i] }, + }, func(i int) { + ui.Text(c, titles[i]) + }).Size(380, 280) +}`, + Keys: "Scroll with the wheel or the scrollbar; only cards in view are built.", + }, + { + ID: "497", Name: "ResizablePanelGroup", ZhName: "可调面板 分隔条", Category: "Essentials", + Demo: resizablePanelGroupDemo, + Code: `var sizes []float32 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ResizablePanelGroup(c, &sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: func() { ui.Text(c, "Files") }}, + {Label: "Editor", Build: func() { ui.Text(c, "Editor") }}, + }, mujica.ResizablePanelGroupOptions{}) + if r.Committed { + // save sizes + } +}`, + Keys: "Tab focuses a divider; arrows move it, Home and End go to the limits, Enter collapses or restores a collapsible neighbor.", + }, + { + ID: "498", Name: "DockPanel", ZhName: "停靠面板 标签", Category: "Essentials", + Demo: dockPanelDemo, + Code: `var layout mujica.DockLayout +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.DockPanel(c, &layout, []mujica.DockItem{ + {ID: "files", Title: "Files", Build: func() { ui.Text(c, "Files") }}, + {ID: "terminal", Title: "Terminal", Build: func() { ui.Text(c, "$") }}, + }, mujica.DockPanelOptions{}) + if r.Changed() { + // save layout + } +}`, + Keys: "Each zone's tabs are one Tab stop; Left and Right switch tabs. Shift+Alt+Left, Right or Down docks the focused tab to that edge; Shift+Alt+Up returns it to the center.", + }, + { + ID: "499", Name: "QRCode", ZhName: "二维码", Category: "Essentials", + Demo: qrCodeDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QRCode(c, "https://example.com/mujica", mujica.QRCodeOptions{Level: mujica.QRQuartile}) +}`, + Keys: "Not focusable; assistive technology reads its name.", + }, + { + ID: "500", Name: "ActivityFeed", ZhName: "活动流 动态", Category: "Essentials", + Demo: activityFeedDemo, + Code: `events := []mujica.Activity{ + {ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ActivityFeed(c, events, mujica.ActivityFeedOptions{}).Size(380, 280) +}`, + Keys: "Scroll with the wheel or the scrollbar; only rows in view are built.", + }, +} diff --git a/examples/gallery/pages_essentials_demos.go b/examples/gallery/pages_essentials_demos.go new file mode 100644 index 0000000..6e5ab05 --- /dev/null +++ b/examples/gallery/pages_essentials_demos.go @@ -0,0 +1,267 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func splitButtonDemo(c *ui.Context, log func(string)) { + type state struct{ variant, disabled bool } + s := ui.Local(c.Root(), "481", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.variant, "Secondary") + ui.Checkbox(c, &s.disabled, "Disabled") + }) + }) + feedbackSection(c, "Live", func() { + v := mujica.Primary + if s.variant { + v = mujica.Secondary + } + ui.Row(c).Children(func() { + r := mujica.SplitButton(c, "Save", mujica.SplitButtonOptions{Variant: v, Disabled: s.disabled}, func(m *mujica.PopupMenu) { + for _, it := range []string{"Save as…", "Save a copy", "Save all"} { + if m.Item(it, mujica.PopupMenuItemOptions{}) { + log("split button: menu " + it) + } + } + }) + if r.Changed() { + log("split button: main action") + } + }) + }) + }) +} + +func copyButtonDemo(c *ui.Context, log func(string)) { + type state struct { + text string + disabled bool + } + s := ui.Local(c.Root(), "482", func() state { return state{text: "https://example.com/invite/42"} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.TextInput(c, &s.text).Width(320) + ui.Checkbox(c, &s.disabled, "Disabled") + }) + feedbackSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + if mujica.CopyButton(c, s.text, mujica.CopyButtonOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("copy button: copied %d characters", len([]rune(s.text)))) + } + if mujica.CopyButton(c, s.text, mujica.CopyButtonOptions{Label: "Copy link", Disabled: s.disabled}).Changed() { + log("copy button: copied with label") + } + }) + }) + }) +} + +func holdToConfirmDemo(c *ui.Context, log func(string)) { + type state struct { + seconds float64 + disabled bool + deleted int + } + s := ui.Local(c.Root(), "483", func() state { return state{seconds: 1.5} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Hold %.1f s", s.seconds)) + ui.Slider(c, &s.seconds, 0.5, 4).Width(200) + }) + ui.Checkbox(c, &s.disabled, "Disabled") + }) + feedbackSection(c, "Live (turn on reduced motion to see the countdown text)", func() { + ui.Row(c).Children(func() { + d := time.Duration(s.seconds * float64(time.Second)) + if mujica.HoldToConfirm(c, "Delete project", mujica.HoldToConfirmOptions{Duration: d, Disabled: s.disabled}).Changed() { + s.deleted++ + log("hold to confirm: confirmed") + } + }) + ui.Text(c, fmt.Sprintf("Confirmed %d times", s.deleted)) + }) + }) +} + +func bulkActionBarDemo(c *ui.Context, log func(string)) { + type state struct { + picked [6]bool + disabled bool + } + s := ui.Local(c.Root(), "484", func() state { return state{picked: [6]bool{true, false, true}} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Rows", func() { + for i := range s.picked { + if ui.Checkbox(c, &s.picked[i], fmt.Sprintf("Invoice %d", 1001+i)).Changed() { + log(fmt.Sprintf("bulk bar: row %d %v", i, s.picked[i])) + } + } + ui.Checkbox(c, &s.disabled, "Disable the bar") + }) + n := 0 + for _, p := range s.picked { + if p { + n++ + } + } + r := mujica.BulkActionBar(c, n, mujica.BulkActionBarOptions{Disabled: s.disabled}, func() { + if mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + log(fmt.Sprintf("bulk bar: archive %d", n)) + } + if mujica.Button(c, "Delete", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() { + log(fmt.Sprintf("bulk bar: delete %d", n)) + } + }) + if r.Changed() { + s.picked = [6]bool{} + log("bulk bar: cleared") + } + }) +} + +func scrubInputDemo(c *ui.Context, log func(string)) { + type state struct { + opacity, size float64 + disabled bool + } + s := ui.Local(c.Root(), "485", func() state { return state{opacity: 0.6, size: 14} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + feedbackSection(c, "Drag a label sideways, or type", func() { + if mujica.ScrubInput(c, "Opacity", &s.opacity, mujica.ScrubInputOptions{Max: 1, Step: 0.05, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("scrub: opacity %.2f", s.opacity)) + } + if mujica.ScrubInput(c, "Size", &s.size, mujica.ScrubInputOptions{Min: 8, Max: 72, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("scrub: size %.0f", s.size)) + } + }) + }) +} + +func currencyInputDemo(c *ui.Context, log func(string)) { + type state struct { + cents, yen int64 + disabled bool + } + s := ui.Local(c.Root(), "486", func() state { return state{cents: 123450, yen: 98000} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + feedbackSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + if mujica.CurrencyInput(c, &s.cents, mujica.CurrencyInputOptions{Symbol: "$", Label: "Price", Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("currency: %d cents", s.cents)) + } + if mujica.CurrencyInput(c, &s.yen, mujica.CurrencyInputOptions{Symbol: "¥", NoFraction: true, Label: "Yen", Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("currency: %d yen", s.yen)) + } + }) + ui.Text(c, "Stored as "+mujica.CurrencyFormat(s.cents, 2, true)+" and "+mujica.CurrencyFormat(s.yen, 0, true)) + }) + }) +} + +func maskedInputDemo(c *ui.Context, log func(string)) { + type state struct { + mask int + value string + disabled bool + } + masks := []string{"(999) 999-9999", "9999 9999 9999 9999", "99/99/9999", "aa-9999"} + s := ui.Local(c.Root(), "487", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + if ui.Segmented(c, &s.mask, masks...).Changed() { + s.value = "" + log("masked: mask " + masks[s.mask]) + } + ui.Checkbox(c, &s.disabled, "Disabled") + }) + feedbackSection(c, "Live (try an input method: nothing commits until it does)", func() { + r := mujica.MaskedInput(c, &s.value, mujica.MaskedInputOptions{Mask: masks[s.mask], Label: "Masked field", Disabled: s.disabled}) + r.Element.Width(260) + if r.Changed() { + log(fmt.Sprintf("masked: %d characters filled", len([]rune(s.value)))) + } + if r.Submitted() { + log("masked: submitted") + } + }) + }) +} + +func choiceChipsDemo(c *ui.Context, log func(string)) { + type state struct { + value []string + multiple, disabled bool + } + s := ui.Local(c.Root(), "489", func() state { return state{multiple: true, value: []string{"open"}} }) + items := []mujica.ToggleItem[string]{{Value: "open", Label: "Open"}, {Value: "draft", Label: "Draft"}, {Value: "review", Label: "In review"}, {Value: "closed", Label: "Closed", Disabled: true}, {Value: "merged", Label: "Merged"}} + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + if ui.Checkbox(c, &s.multiple, "Multiple").Changed() && !s.multiple && len(s.value) > 1 { + s.value = s.value[:1] + } + ui.Checkbox(c, &s.disabled, "Disabled") + }) + }) + feedbackSection(c, "Live", func() { + if mujica.ChoiceChips(c, &s.value, items, mujica.ChoiceChipsOptions{Multiple: s.multiple, Disabled: s.disabled, Label: "Status"}).Changed() { + log(fmt.Sprintf("chips: %v", s.value)) + } + }) + }) +} + +func knobDemo(c *ui.Context, log func(string)) { + type state struct { + gain, mix float64 + disabled bool + } + s := ui.Local(c.Root(), "490", func() state { return state{gain: 35, mix: 0.5} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + ui.Row(c).Gap(32).Children(func() { + if mujica.Knob(c, &s.gain, mujica.KnobOptions{Label: "Gain", Ticks: 11, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("knob: gain %.1f", s.gain)) + } + if mujica.Knob(c, &s.mix, mujica.KnobOptions{Max: 1, Step: 0.1, Size: 72, Label: "Mix", Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("knob: mix %.1f", s.mix)) + } + }) + }) +} + +func verticalSliderDemo(c *ui.Context, log func(string)) { + type state struct { + levels [4]float64 + showValue, disable bool + } + s := ui.Local(c.Root(), "491", func() state { return state{levels: [4]float64{40, 70, 20, 90}, showValue: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.showValue, "Show value") + ui.Checkbox(c, &s.disable, "Disabled") + }) + ui.Row(c).Gap(16).Children(func() { + for i := range s.levels { + opts := mujica.VerticalSliderOptions{Step: 10, ShowValue: s.showValue, Disabled: s.disable, Label: fmt.Sprintf("Band %d", i+1)} + if i == 0 { + opts.Marks = []mujica.SliderMark{{Value: 0, Label: "0"}, {Value: 50, Label: "50"}, {Value: 100, Label: "100"}} + } + if mujica.VerticalSlider(c, &s.levels[i], opts).Changed() { + log(fmt.Sprintf("vertical slider: band %d = %.0f", i+1, s.levels[i])) + } + } + }) + }) +} diff --git a/examples/gallery/pages_essentials_more.go b/examples/gallery/pages_essentials_more.go new file mode 100644 index 0000000..0334815 --- /dev/null +++ b/examples/gallery/pages_essentials_more.go @@ -0,0 +1,292 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func mentionInputDemo(c *ui.Context, log func(string)) { + type state struct { + text string + mentions []mujica.Mention + disabled bool + } + people := []mujica.MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Albert"}, {ID: "u3", Name: "Bob"}, {ID: "u4", Name: "Sakiko"}, {ID: "u5", Name: "Mutsumi"}} + s := ui.Local(c.Root(), "488", func() state { return state{} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + feedbackSection(c, "Type @ to mention someone", func() { + r := mujica.MentionInput(c, &s.text, &s.mentions, mujica.MentionInputOptions{ + Placeholder: "Write a comment…", Label: "Comment", Disabled: s.disabled, + Candidates: func(q string) []mujica.MentionCandidate { + var out []mujica.MentionCandidate + for _, p := range people { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out + }, + }) + r.Element.Width(420) + if r.Changed() { + log(fmt.Sprintf("mention: %d mentions", len(s.mentions))) + } + if r.Submitted() { + log("mention: submitted") + } + for _, m := range s.mentions { + ui.Text(c, fmt.Sprintf("@%s → %s", m.Name, m.ID)).Key(m.ID) + } + }) + }) +} + +func colorPaletteDemo(c *ui.Context, log func(string)) { + type state struct { + color ui.Color + custom, disabled bool + } + s := ui.Local(c.Root(), "492", func() state { return state{color: ui.Hex("#7D2034"), custom: true} }) + swatches := []mujica.ColorSwatch{ + {Color: ui.Hex("#7D2034"), Name: "Wine"}, {Color: ui.Hex("#A12635"), Name: "Crimson"}, {Color: ui.Hex("#AD9164"), Name: "Old gold"}, + {Color: ui.Hex("#805714"), Name: "Bronze"}, {Color: ui.Hex("#326247"), Name: "Ivy"}, {Color: ui.Hex("#355E80"), Name: "Slate blue"}, + {Color: ui.Hex("#4B3A5A"), Name: "Plum"}, {Color: ui.Hex("#211C1D"), Name: "Ink"}, {Color: ui.Hex("#6A5B55"), Name: "Taupe"}, + {Color: ui.Hex("#D2C3AF"), Name: "Parchment"}, {Color: ui.Hex("#F2EBDD"), Name: "Ivory"}, {Color: ui.Hex("#FFFFFF"), Name: "White"}, + } + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.custom, "Custom entry") + ui.Checkbox(c, &s.disabled, "Disabled") + }) + if mujica.ColorPalette(c, &s.color, swatches, mujica.ColorPaletteOptions{Columns: 6, Custom: s.custom, Disabled: s.disabled, Label: "Tag color"}).Changed() { + log(fmt.Sprintf("palette: %v", s.color)) + } + }) +} + +func signaturePadDemo(c *ui.Context, log func(string)) { + type state struct { + strokes [][]mujica.SignaturePoint + disabled bool + } + s := ui.Local(c.Root(), "493", func() state { return state{} }) + ui.Column(c).Gap(16).Width(480).Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + if mujica.SignaturePad(c, &s.strokes, mujica.SignaturePadOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("signature: %d strokes, %d bytes of path", len(s.strokes), len(mujica.SignaturePath(s.strokes)))) + } + }) +} + +func bannerDemo(c *ui.Context, log func(string)) { + type state struct { + sev int + action, closable, op bool + } + s := ui.Local(c.Root(), "494", func() state { return state{sev: 2, action: true, closable: true, op: true} }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error") + ui.Row(c).Gap(16).Children(func() { + ui.Checkbox(c, &s.action, "Action") + ui.Checkbox(c, &s.closable, "Closable") + }) + }) + opts := mujica.BannerOptions{Severity: feedbackSeverities[s.sev]} + if s.action { + opts.Action = "Details" + } + var open *bool + if s.closable { + open = &s.op + } + r := mujica.Banner(c, open, "Scheduled maintenance tonight from 22:00 to 23:00.", opts) + if r.Action { + log("banner: action") + } + if r.Closed { + log("banner: closed") + } + if !s.op && mujica.Button(c, "Show again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.op = true + log("banner: reopened") + } + }) +} + +func undoToastDemo(c *ui.Context, log func(string)) { + type state struct { + notes []string + removed string + open bool + seconds float64 + } + s := ui.Local(c.Root(), "495", func() state { + return state{notes: []string{"Call the florist", "Book the hall", "Order candles"}, seconds: 5} + }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Countdown %.0f s", s.seconds)) + ui.Slider(c, &s.seconds, 2, 10).Width(200) + }) + for i, n := range s.notes { + ui.Row(c).Key(n).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, n).Grow(1) + if mujica.Button(c, "Delete", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + s.removed, s.open = n, true + s.notes = append(s.notes[:i:i], s.notes[i+1:]...) + log("undo toast: deleted " + n) + } + }) + } + }) + if !s.open { + return + } + removed := s.removed + r := mujica.UndoToast(c, &s.open, "Deleted “"+removed+"”", mujica.UndoToastOptions{ + Duration: time.Duration(s.seconds * float64(time.Second)), + OnUndo: func() { s.notes = append(s.notes, removed) }, + }) + if r.Undone { + log("undo toast: undone " + removed) + } + if r.Expired { + log("undo toast: expired") + } +} + +func masonryDemo(c *ui.Context, log func(string)) { + type state struct { + count, width float64 + } + s := ui.Local(c.Root(), "496", func() state { return state{count: 40, width: 180} }) + k := mujica.Tokens(c) + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Items %.0f", s.count)) + if ui.Slider(c, &s.count, 1, 10000).Width(200).Changed() { + log(fmt.Sprintf("masonry: %d items", int(s.count))) + } + ui.Text(c, fmt.Sprintf("Column %.0f", s.width)) + ui.Slider(c, &s.width, 120, 320).Width(160) + }) + mujica.Masonry(c, int(s.count), mujica.MasonryOptions{Key: strconv.Itoa, ColumnWidth: float32(s.width)}, func(i int) { + card := ui.Column(c).Height(float32(60+i*37%90)).Padding(10).Background(k.Surface).Border(1, k.Border).Radius(4).Cursor(ui.CursorPointer) + card.Children(func() { ui.Text(c, "Card "+strconv.Itoa(i+1)) }) + if card.Clicked() { + log("masonry: clicked card " + strconv.Itoa(i+1)) + } + }).FillWidth().Height(420) + }) +} + +func resizablePanelGroupDemo(c *ui.Context, log func(string)) { + type state struct { + sizes []float32 + vertical, disabled bool + } + s := ui.Local(c.Root(), "497", func() state { return state{} }) + k := mujica.Tokens(c) + pane := func(name string) func() { + return func() { + ui.Column(c).Fill().Padding(12).Background(k.Surface).Children(func() { ui.Text(c, name) }) + } + } + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Row(c).Gap(16).Children(func() { + if ui.Checkbox(c, &s.vertical, "Vertical").Changed() { + s.sizes = nil + } + ui.Checkbox(c, &s.disabled, "Disabled") + }) + r := mujica.ResizablePanelGroup(c, &s.sizes, []mujica.ResizablePanel{ + {Label: "Files", Collapsible: true, Build: pane("Files")}, + {Label: "Editor", Min: 160, Build: pane("Editor")}, + {Label: "Outline", Collapsible: true, Build: pane("Outline")}, + }, mujica.ResizablePanelGroupOptions{Vertical: s.vertical, Disabled: s.disabled}) + r.Element.FillWidth().Height(320) + if r.Committed { + log(fmt.Sprintf("panels: save %.2f", s.sizes)) + } + }) +} + +func dockPanelDemo(c *ui.Context, log func(string)) { + type state struct { + layout mujica.DockLayout + disabled bool + } + s := ui.Local(c.Root(), "498", func() state { + return state{layout: mujica.DockLayout{Zones: [4][]string{{"editor"}, {"files"}, {"outline"}, {"terminal", "problems"}}}} + }) + var items []mujica.DockItem + for _, id := range []string{"files", "editor", "outline", "terminal", "problems"} { + items = append(items, mujica.DockItem{ID: id, Title: strings.ToUpper(id[:1]) + id[1:], Build: func() { ui.Text(c, "Contents of "+id) }}) + } + ui.Column(c).Gap(16).FillWidth().Children(func() { + ui.Checkbox(c, &s.disabled, "Disabled") + r := mujica.DockPanel(c, &s.layout, items, mujica.DockPanelOptions{Disabled: s.disabled}) + r.Element.FillWidth().Height(440) + if r.Changed() { + log(fmt.Sprintf("dock: save %v", s.layout.Zones)) + } + }) +} + +func qrCodeDemo(c *ui.Context, log func(string)) { + type state struct { + text string + level int + size float64 + } + s := ui.Local(c.Root(), "499", func() state { return state{text: "https://example.com/mujica", level: 1, size: 200} }) + levels := []mujica.QRLevel{mujica.QRLow, mujica.QRMedium, mujica.QRQuartile, mujica.QRHigh} + ui.Column(c).Gap(16).FillWidth().Children(func() { + feedbackSection(c, "Parameters", func() { + if ui.TextInput(c, &s.text).Width(360).Changed() { + log(fmt.Sprintf("qr: %d bytes", len(s.text))) + } + if ui.Segmented(c, &s.level, "L", "M", "Q", "H").Changed() { + log("qr: level " + levels[s.level].String()) + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("Size %.0f", s.size)) + ui.Slider(c, &s.size, 100, 320).Width(200) + }) + }) + mujica.QRCode(c, s.text, mujica.QRCodeOptions{Level: levels[s.level], Size: float32(s.size)}) + if m, err := mujica.EncodeQR([]byte(s.text), levels[s.level]); err == nil { + ui.Text(c, fmt.Sprintf("Version %d, mask %d, %d × %d modules", m.Version, m.Mask, m.Size(), m.Size())) + } + }) +} + +func activityFeedDemo(c *ui.Context, log func(string)) { + type state struct{ events []mujica.Activity } + s := ui.Local(c.Root(), "500", func() state { + now := time.Now() + var ev []mujica.Activity + actors := []string{"Sakiko", "Mutsumi", "Uika", "Umiri", "Nyamu"} + verbs := []string{"commented on", "edited", "approved", "closed", "opened"} + for i := range 400 { + ev = append(ev, mujica.Activity{ID: strconv.Itoa(i), Actor: actors[i%5], Action: verbs[i*3%5], Object: fmt.Sprintf("Score #%d", 400-i), Time: now.Add(-time.Duration(i) * 97 * time.Minute)}) + } + return state{events: ev} + }) + ui.Column(c).Gap(16).FillWidth().Children(func() { + if mujica.Button(c, "Add event", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() { + e := mujica.Activity{ID: fmt.Sprint("n", len(s.events)), Actor: "You", Action: "added", Object: "a new note", Time: time.Now()} + s.events = append([]mujica.Activity{e}, s.events...) + log("activity: added " + e.ID) + } + mujica.ActivityFeed(c, s.events, mujica.ActivityFeedOptions{}).FillWidth().Height(420) + }) +} diff --git a/examples/gallery/web.go b/examples/gallery/web.go index 26c424f..8180d6b 100644 --- a/examples/gallery/web.go +++ b/examples/gallery/web.go @@ -31,6 +31,7 @@ type mirror struct { urls int clip string copied bool + probes int cursor ui.Cursor down *input enc png.Encoder diff --git a/examples/gallery/web/index.html b/examples/gallery/web/index.html index 96ca311..9f5280c 100644 --- a/examples/gallery/web/index.html +++ b/examples/gallery/web/index.html @@ -154,7 +154,9 @@ keys.addEventListener("keydown", (e) => { if (cmd && commands[e.code] && !e.altKey) { send({ type: "command", text: e.code === "KeyZ" && e.shiftKey ? "redo" : commands[e.code] }); } else { - send({ type: "key", code: e.code, text: e.key.length === 1 ? e.key : "", mods: mods(e) }); + // NumLock off: numpad keys act as navigation keys, named by e.key. + const code = e.code.startsWith("Numpad") && e.key.length > 1 && e.key !== "Enter" ? e.key : e.code; + send({ type: "key", code, text: e.key.length === 1 ? e.key : "", mods: mods(e) }); } e.preventDefault(); }); diff --git a/examples/gallery/web_input.go b/examples/gallery/web_input.go index bd0456d..47230d6 100644 --- a/examples/gallery/web_input.go +++ b/examples/gallery/web_input.go @@ -36,7 +36,7 @@ func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { return } m.mu.Lock() - err := m.apply(in) + err := m.applyWatchingClipboard(in) if err == nil { m.capture() } @@ -67,7 +67,8 @@ func (m *mirror) apply(in input) error { if d := m.down; d != nil { m.down = nil if dx, dy := in.X-d.X, in.Y-d.Y; dx*dx+dy*dy <= 16 { - m.tt.ClickAtWith(modifiers(d.Mods), d.X, d.Y) + // The click lands where it is released, as a real press and release would. + m.tt.ClickAtWith(modifiers(d.Mods), in.X, in.Y) } else { slog.Info("gallery: modified drag ignored; tester clicks only", "mods", modifiers(d.Mods)) } @@ -124,3 +125,21 @@ func modifiers(m []bool) ui.Modifiers { } return out } + +// applyWatchingClipboard applies in and notes any clipboard write it caused, even of equal text. +func (m *mirror) applyWatchingClipboard(in input) error { + if in.Type == "paste" || in.Type == "command" && in.Text == "paste" { + return m.apply(in) + } + before := m.tt.Clipboard() + m.probes++ + probe := fmt.Sprintf("\x00mujica-mirror-probe-%d", m.probes) + m.tt.SetClipboard(probe) + err := m.apply(in) + if m.tt.Clipboard() == probe { + m.tt.SetClipboard(before) + } else { + m.copied = true + } + return err +} diff --git a/examples/gallery/web_test.go b/examples/gallery/web_test.go index 66e5b35..b8798f9 100644 --- a/examples/gallery/web_test.go +++ b/examples/gallery/web_test.go @@ -260,3 +260,62 @@ func TestMirrorForwardsEveryAnnouncement(t *testing.T) { t.Fatalf("announcements forwarded %d times, want 2", heard) } } + +func TestMirrorModifiedClickReleasedJustOutsideDoesNotClick(t *testing.T) { + clicks := 0 + var box ui.Rect + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + box = b.Bounds() + if b.Clicked() { + clicks++ + } + }) + }) + m.mu.Lock() + m.tt.Frame() + r := box + m.mu.Unlock() + shift := []bool{true} + y := r.Y + r.H/2 + postInput(t, srv, input{Type: "down", X: r.X + r.W - 1, Y: y, Mods: shift}) + postInput(t, srv, input{Type: "up", X: r.X + r.W + 2, Y: y, Mods: shift}) + if clicks != 0 { + t.Fatalf("clicks %d after releasing 2 DIP outside", clicks) + } +} + +func TestMirrorNotifiesEveryCopyOfEqualText(t *testing.T) { + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).Children(func() { + if mujica.Button(c, "Copy", mujica.ButtonOptions{}).Clicked() { + c.WriteClipboard("snippet") + } + }) + }) + ch := make(chan []byte, 64) + m.mu.Lock() + m.subs[ch] = struct{}{} + m.mu.Unlock() + for range 2 { + postInput(t, srv, input{Type: "down", X: 40, Y: 35}) + postInput(t, srv, input{Type: "up", X: 40, Y: 35}) + } + copies := 0 + for len(ch) > 0 { + if bytes.Contains(<-ch, []byte(`"clipboard":"snippet"`)) { + copies++ + } + } + if copies != 2 { + t.Fatalf("copies forwarded %d, want 2", copies) + } + m.mu.Lock() + defer m.mu.Unlock() + if got := m.tt.Clipboard(); got != "snippet" { + t.Fatalf("clipboard %q", got) + } +} diff --git a/form.go b/form.go index 4c6314c..c3ea2b1 100644 --- a/form.go +++ b/form.go @@ -293,7 +293,7 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo desc = msg(c, "form.checking") } var ctrl *ui.Element - field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { + field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { ctrl = control() return ctrl }) diff --git a/form_field.go b/form_field.go index 47f9e8c..ee589fc 100644 --- a/form_field.go +++ b/form_field.go @@ -20,10 +20,10 @@ type FormFieldOptions struct { // FormField labels the element control returns and shows help and error text for it. func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element { if !opts.Horizontal { - return formFieldBuild(c, label, opts, false, control) + return formFieldBuild(c, "", label, opts, false, control) } var f *ui.Element - formFieldGrid(c, func() { f = formFieldBuild(c, label, opts, true, control) }) + formFieldGrid(c, func() { f = formFieldBuild(c, "", label, opts, true, control) }) return f } @@ -35,7 +35,8 @@ func formFieldGrid(c *ui.Context, fields func()) *ui.Element { } // formFieldBuild builds the label and the control column; beside makes them two grid cells. -func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { +// formFieldBuild keys its cells by key when set, so focus follows a reordered field. +func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { if label == "" { panic("mujica: FormField needs a label") } @@ -43,7 +44,11 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b d := Density(c) var head, content, ctrl *ui.Element body := func() { - head = ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center) + head = ui.Row(c) + if key != "" && beside { + head.Key(key + "/label") + } + head.Gap(d.Unit()).AlignItems(ui.Center) if beside { head.MinHeight(d.ControlHeight()).Justify(ui.End) } @@ -55,7 +60,11 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b } }) clicked := head.Clicked() - content = ui.Column(c).Gap(d.Unit() * 1.5).MinWidth(0) + content = ui.Column(c) + if key != "" && beside { + content.Key(key + "/control") + } + content.Gap(d.Unit() * 1.5).MinWidth(0) content.Children(func() { ctrl = control() if ctrl == nil { @@ -82,5 +91,9 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b body() return content } - return ui.Column(c).Gap(d.Unit() * 1.5).Children(body) + root := ui.Column(c) + if key != "" { + root.Key(key) + } + return root.Gap(d.Unit() * 1.5).Children(body) } diff --git a/hold_to_confirm.go b/hold_to_confirm.go new file mode 100644 index 0000000..76f1440 --- /dev/null +++ b/hold_to_confirm.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// HoldToConfirmOptions configure a HoldToConfirm. +type HoldToConfirmOptions struct { + // Duration is how long to hold; zero is 1.5 s. + Duration time.Duration + Disabled bool +} + +// holdToConfirmState tracks one hold: by key or pointer, since start. +type holdToConfirmState struct { + key, done bool + start time.Time +} + +// step advances the hold to now; it reports completion once per hold. +func (s *holdToConfirmState) step(held bool, now time.Time, d time.Duration) (progress float64, confirmed bool) { + switch { + case !held: + s.start, s.done = time.Time{}, false + return 0, false + case s.done: + return 1, false + case s.start.IsZero(): + s.start = now + } + progress = min(1, float64(now.Sub(s.start))/float64(d)) + if progress >= 1 { + s.done = true + return 1, true + } + return progress, false +} + +// HoldToConfirm runs a dangerous action only after the pointer or Space is held for Duration. +func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) ChoiceResult { + if label == "" { + panic("mujica: HoldToConfirm needs a label") + } + if opts.Duration < 0 { + panic("mujica: negative HoldToConfirm Duration") + } + d := opts.Duration + if d == 0 { + d = 1500 * time.Millisecond + } + k := Tokens(c) + den := Density(c) + b := ui.ButtonBase(c).MinHeight(den.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, den.Unit()*4). + Radius(theme.ControlRadius).Disabled(opts.Disabled).Description(msg(c, "hold_to_confirm.hint")).Clip() + internal.Ring(b, k.Focus, theme.ControlRadius) + st := ui.Local(b, "hold_to_confirm", func() *holdToConfirmState { return &holdToConfirmState{} }) + s := *st + // Space holds; Enter and Space never click, so no single key press confirms. + b.HandleInput(func(ev ui.InputEvent) bool { + if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || b.IsDisabled() { + return false + } + if ev.Key == ui.KeySpace && (ev.Kind == ui.InputKeyDown || ev.Kind == ui.InputKeyUp) { + s.key = ev.Kind == ui.InputKeyDown + } + return true + }) + _ = b.Clicked() // a click alone does nothing; it keeps the base pressable + disabled := b.IsDisabled() + held := !disabled && (s.key || b.Pressed()) + if !b.Focused() { + s.key = false + held = !disabled && b.Pressed() + } + progress, confirmed := s.step(held, c.Now(), d) + still := c.Preferences().ReduceMotion + if held && !confirmed && progress < 1 { + if still { + c.After(100 * time.Millisecond) + } else { + c.AnimationFrame() + } + } + bg, fg, _ := buttonColors(k, Danger, b.Hovered() && !disabled, false) + b.Background(bg).TextColor(fg) + text := label + if held && still && progress < 1 { + left := d - time.Duration(progress*float64(d)) + text = fmt.Sprintf(msg(c, "hold_to_confirm.left"), left.Seconds()) + } + b.Draw(func(p *ui.Painter, r ui.Rect) { + if progress > 0 && !still { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(progress), H: r.H}, k.OnDanger.Alpha(0.28), 0) + } + }) + // Room for the countdown, so the width holds. + w1, _ := c.MeasureText(0, ui.Span{Text: label}) + w2, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf(msg(c, "hold_to_confirm.left"), d.Seconds())}) + b.MinWidth(max(w1, w2) + den.Unit()*8) + b.Children(func() { ui.Text(c, text).SingleLine() }) + if confirmed { + c.Announce(msg(c, "hold_to_confirm.done")) + slog.Debug("mujica: hold_to_confirm confirmed", "label", label) + } + return ChoiceResult{b, confirmed} +} diff --git a/hold_to_confirm_test.go b/hold_to_confirm_test.go new file mode 100644 index 0000000..062449e --- /dev/null +++ b/hold_to_confirm_test.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestHoldToConfirmStep(t *testing.T) { + var s holdToConfirmState + t0 := time.Unix(0, 0) + d := time.Second + if p, ok := s.step(true, t0, d); p != 0 || ok { + t.Fatalf("start: %v %v", p, ok) + } + if p, ok := s.step(true, t0.Add(d/2), d); p != 0.5 || ok { + t.Fatalf("half: %v %v", p, ok) + } + if _, ok := s.step(true, t0.Add(d), d); !ok { + t.Fatal("full hold did not confirm") + } + if _, ok := s.step(true, t0.Add(2*d), d); ok { + t.Fatal("one hold confirmed twice") + } + s.step(false, t0.Add(2*d), d) + if p, _ := s.step(true, t0.Add(3*d), d); p != 0 { + t.Fatalf("release did not restart: %v", p) + } +} + +func holdTester(dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if HoldToConfirm(c, "Delete repo", HoldToConfirmOptions{Duration: 60 * time.Millisecond, Disabled: *dis}).Changed() { + *n++ + } + }) + }, 400, 200) +} + +func TestHoldToConfirmPointerHold(t *testing.T) { + dis, n := false, 0 + tt := holdTester(&dis, &n) + r, _ := tt.Find("Delete repo") + cx, cy := r.X+r.W/2, r.Y+r.H/2 + _ = tt.Click("Delete repo") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 0 { + t.Fatalf("a click or key tap confirmed: %d", n) + } + tt.Press(cx, cy) + time.Sleep(20 * time.Millisecond) + tt.Release(cx, cy) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if n != 0 { + t.Fatal("an early release confirmed") + } + tt.Press(cx, cy) + time.Sleep(80 * time.Millisecond) + tt.Frame() + tt.Frame() + tt.Release(cx, cy) + if n != 1 { + t.Fatalf("full hold confirmed %d times", n) + } + dis = true + tt.Frame() + tt.Press(cx, cy) + time.Sleep(80 * time.Millisecond) + tt.Frame() + tt.Release(cx, cy) + if n != 1 { + t.Fatal("disabled hold confirmed") + } +} + +func TestHoldToConfirmSpaceHold(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + // A nanosecond hold completes in the frames between Space down and up. + if HoldToConfirm(c, "Drop table", HoldToConfirmOptions{Duration: time.Nanosecond}).Changed() { + n++ + } + }, 300, 100) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("Enter confirmed") + } + tt.Key(0, ui.KeySpace) + if n != 1 { + t.Fatalf("Space hold confirmed %d times", n) + } +} + +func TestHoldToConfirmReducedMotionCountdown(t *testing.T) { + dis, n := false, 0 + tt := holdTester(&dis, &n) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + r, _ := tt.Find("Delete repo") + tt.Press(r.X+r.W/2, r.Y+r.H/2) + found := false + for _, s := range tt.Texts() { + found = found || strings.HasPrefix(s, "Keep holding… 0.") + } + if !found { + t.Fatalf("no countdown text: %q", tt.Texts()) + } +} + +func TestHoldToConfirmSnapshot(t *testing.T) { + snapshot(t, "hold_to_confirm", 400, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + HoldToConfirm(c, "Delete project", HoldToConfirmOptions{}) + HoldToConfirm(c, "Wipe", HoldToConfirmOptions{Disabled: true}) + }) + }) +} diff --git a/knob.go b/knob.go new file mode 100644 index 0000000..b2d5cf7 --- /dev/null +++ b/knob.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// KnobOptions configure a Knob. Min and Max both zero mean 0 to 100. +type KnobOptions struct { + Min, Max float64 + // Step snaps the value; zero is continuous, moving 1% per arrow press. + Step float64 + // Ticks draws that many evenly spaced marks around the dial; zero draws none. + Ticks int + // Size is the dial diameter; zero is 56. + Size float32 + // Format writes the value under the dial; nil prints as many decimals as Min and Step need. + Format func(float64) string + Disabled bool + // Label names the knob and shows above it. + Label string +} + +// knobSweep is the dial's travel, centered on twelve o'clock, in turns. +const knobSweep = 0.75 + +// knobDragPixels is how far a vertical drag travels the whole range. +const knobDragPixels float32 = 160 + +// Knob sets *value by dragging up and down, the arrows, Home and End; the dial shows the value and ticks. +func Knob(c *ui.Context, value *float64, opts KnobOptions) ChoiceResult { + if value == nil { + panic("mujica: Knob needs a value") + } + if opts.Ticks < 0 || !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { + panic(fmt.Sprintf("mujica: Knob Ticks %d or Size %v invalid", opts.Ticks, opts.Size)) + } + g, _ := sliderSetup(opts.Min, opts.Max, opts.Step, nil, false, c) + format := sliderFormat(opts.Format, g) + size := cmp.Or(opts.Size, 56) + k := Tokens(c) + old := g.snap(*value) + *value = old + col := ui.Column(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Disabled(opts.Disabled) + disabled := col.IsDisabled() + var s *ui.Element + col.Children(func() { + if opts.Label != "" { + ui.Text(c, opts.Label).TextColor(k.Text).FontSize(FontSize(c, theme.CaptionSize)) + } + var next float64 + var dragged bool + s, next, dragged = sliderControl(c, old, g, g.lo, g.hi) + want := ui.Local(s, "knob.want", func() float64 { return math.NaN() }) + if dragged { + if math.IsNaN(*want) { + *want = old + } + _, dy, _ := s.Dragged() + *want = max(g.lo, min(g.hi, *want-float64(dy)*(g.hi-g.lo)/float64(knobDragPixels))) + next = g.snap(*want) + } else { + *want = math.NaN() + } + if !disabled { + *value = next + } + s.Size(size, size).FocusRing(false).Cursor(ui.CursorResizeNS).Label(cmp.Or(opts.Label, msg(c, "knob.label"))) + hot := s.Hovered() || dragged + v := *value + s.Draw(func(p *ui.Painter, r ui.Rect) { knobPaint(p, r, k, g, v, opts.Ticks, hot, s.FocusVisible()) }) + ui.Text(c, format(*value)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + changed := *value != old + if changed { + slog.Debug("mujica: knob changed", "label", opts.Label, "value", *value) + } + return ChoiceResult{col, changed} +} + +// knobPaint draws the track arc, the value arc, ticks and the diamond pointer. +func knobPaint(p *ui.Painter, r ui.Rect, k theme.Tokens, g sliderGeom, v float64, ticks int, hot, focus bool) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + rad := r.W/2 - 6 + start := float32(-knobSweep / 2) + frac := float32((v - g.lo) / (g.hi - g.lo)) + p.StrokePath(progressArc(p, cx, cy, rad, start, start+knobSweep), 3, k.Border) + if frac > 0 { + p.StrokePath(progressArc(p, cx, cy, rad, start, start+knobSweep*frac), 3, k.AccentText) + } + for i := range ticks { + t := float64(start) + knobSweep*float64(i)/float64(max(ticks-1, 1)) + sin, cos := float32(math.Sin(2*math.Pi*t)), float32(math.Cos(2*math.Pi*t)) + p.Line(cx+sin*(rad+3), cy-cos*(rad+3), cx+sin*(rad+6), cy-cos*(rad+6), 1, k.TextMuted) + } + face := k.Surface + if hot { + face = k.SurfaceHover + } + var dial ui.Path + dial.Circle(cx, cy, rad-6) + p.FillPath(&dial, face) + p.StrokePath(&dial, 1, k.ControlBorder) + t := float64(start + knobSweep*frac) + px := internal.Snap(p, cx+float32(math.Sin(2*math.Pi*t))*(rad-12)) + py := internal.Snap(p, cy-float32(math.Cos(2*math.Pi*t))*(rad-12)) + p.FillPath(internal.Diamond(px, py, 7), k.AccentText) + if focus { + internal.FocusRing(p, r, r.W/2, k.Focus) + } +} diff --git a/knob_test.go b/knob_test.go new file mode 100644 index 0000000..79c3136 --- /dev/null +++ b/knob_test.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func knobTester(v *float64, opts *KnobOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(40).Children(func() { + if Knob(c, v, *opts).Changed() { + *n++ + } + }) + }, 300, 300) +} + +func TestKnobKeysAndStepGrid(t *testing.T) { + v := 0.0 + opts := KnobOptions{Max: 1, Step: 0.1, Label: "Gain"} + n := 0 + tt := knobTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + for range 3 { + tt.Key(0, ui.KeyUp) + } + if v != 0.3 || n != 3 || !tt.HasText("0.3") { + t.Fatalf("three Ups: %v (%d) %q", v, n, tt.Texts()) + } + tt.Key(0, ui.KeyLeft) + if v != 0.2 { + t.Fatalf("Left: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 1 { + t.Fatalf("End: %v", v) + } + tt.Key(0, ui.KeyUp) + if v != 1 || n != 5 { + t.Fatalf("past the end: %v %d", v, n) + } + tt.Key(0, ui.KeyHome) + if v != 0 { + t.Fatalf("Home: %v", v) + } +} + +func TestKnobDragAndDisabled(t *testing.T) { + v := 50.0 + opts := KnobOptions{Step: 1, Label: "Volume"} + n := 0 + tt := knobTester(&v, &opts, &n) + // The dial's center: padding 40, caption and gap above it. + x, y := float32(68), float32(40+21+30) + tt.Press(x, y) + tt.Move(x, y-32) // a fifth of 160 DIPs: 20 of 100 + tt.Release(x, y-32) + if v != 70 { + t.Fatalf("drag up 32 DIPs: %v", v) + } + opts.Disabled = true + tt.Frame() + tt.Press(x, y) + tt.Move(x, y-32) + tt.Release(x, y-32) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if v != 70 { + t.Fatalf("disabled knob moved: %v", v) + } +} + +func TestKnobRejects(t *testing.T) { + v := 0.0 + for name, o := range map[string]KnobOptions{"ticks": {Ticks: -1}, "size": {Size: -1}, "range": {Min: 5, Max: 1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Knob(c, &v, o) }, 200, 200) + }() + } +} + +func TestKnobSnapshot(t *testing.T) { + a, b, d := 35.0, 0.8, 10.0 + snapshot(t, "knob", 320, 130, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + Knob(c, &a, KnobOptions{Label: "Gain", Ticks: 11}) + Knob(c, &b, KnobOptions{Max: 1, Step: 0.1, Label: "Mix", Size: 72}) + Knob(c, &d, KnobOptions{Label: "Off", Disabled: true}) + }) + }) +} diff --git a/masked_input.go b/masked_input.go new file mode 100644 index 0000000..8e7fc8a --- /dev/null +++ b/masked_input.go @@ -0,0 +1,209 @@ +package mujica + +import ( + "log/slog" + "strings" + "unicode" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MaskedInputOptions configure a MaskedInput. +type MaskedInputOptions struct { + // Mask is required: 9 takes a digit, a a letter, * either; anything else shows as is. + Mask string + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// maskedInputState is the caret over the typed characters and any input method composition. +type maskedInputState struct { + caret int + compose string + changed, submitted bool +} + +// maskedInputFits reports whether r may fill the mask slot m. +func maskedInputFits(m, r rune) bool { + switch m { + case '9': + return unicode.IsDigit(r) + case 'a': + return unicode.IsLetter(r) + case '*': + return unicode.IsLetter(r) || unicode.IsDigit(r) + } + return false +} + +func maskedInputSlot(m rune) bool { return m == '9' || m == 'a' || m == '*' } + +// MaskFormat lays raw into mask; unfilled slots show as underscores. +func MaskFormat(mask, raw string) string { + rs := []rune(raw) + var b strings.Builder + i := 0 + for _, m := range mask { + switch { + case !maskedInputSlot(m): + b.WriteRune(m) + case i < len(rs): + b.WriteRune(rs[i]) + i++ + default: + b.WriteRune('_') + } + } + return b.String() +} + +// maskedInputAt is the mask index of slot n, or the mask's length past the last. +func maskedInputAt(mask []rune, n int) int { + for i, m := range mask { + if maskedInputSlot(m) { + if n == 0 { + return i + } + n-- + } + } + return len(mask) +} + +// MaskedInput edits *value, the characters typed into Mask's slots; an input method's composition commits only when done. +func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputResult { + if value == nil { + panic("mujica: MaskedInput needs a value") + } + mask := []rune(opts.Mask) + var slots []rune + for _, m := range mask { + if maskedInputSlot(m) { + slots = append(slots, m) + } + } + if len(slots) == 0 { + panic("mujica: MaskedInput Mask has no slots") + } + raw := []rune(*value) + if len(raw) > len(slots) { + panic("mujica: MaskedInput value is longer than its Mask") + } + for i, r := range raw { + if !maskedInputFits(slots[i], r) { + panic("mujica: MaskedInput value does not fit its Mask") + } + } + k := Tokens(c) + var r InputResult + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + in := ui.Row(c).Grow(1).Focusable().Role(ui.RoleTextField).Label(opts.Label).Description(MaskFormat(opts.Mask, *value)) + st := ui.Local(in, "masked_input", func() *maskedInputState { return &maskedInputState{caret: len(raw)} }) + s := *st + s.caret = min(s.caret, len(raw)) + insert := func(text string) { + for _, ch := range text { + if s.caret < len(slots) && len(raw) < len(slots) && maskedInputFits(slots[s.caret], ch) { + raw = append(raw[:s.caret], append([]rune{ch}, raw[s.caret:]...)...) + s.caret++ + s.changed = true + } + } + } + in.HandleInput(func(ev ui.InputEvent) bool { + if in.IsDisabled() { + return false + } + raw = []rune(*value) + switch ev.Kind { + case ui.InputText: + s.compose = "" + insert(ev.Text) + case ui.InputCompose: + s.compose = ev.Text + case ui.InputCommand: + switch ev.Text { + case "paste": + insert(c.ReadClipboard()) + case "copy": + c.WriteClipboard(*value) + default: + return false + } + case ui.InputKeyDown: + if s.compose != "" { + return false // the input method owns the keys while composing + } + switch ev.Key { + case ui.KeyBackspace: + if s.caret > 0 { + raw = append(raw[:s.caret-1], raw[s.caret:]...) + s.caret-- + s.changed = true + } + case ui.KeyDelete: + if s.caret < len(raw) { + raw = append(raw[:s.caret], raw[s.caret+1:]...) + s.changed = true + } + case ui.KeyLeft: + s.caret = max(0, s.caret-1) + case ui.KeyRight: + s.caret = min(len(raw), s.caret+1) + case ui.KeyHome: + s.caret = 0 + case ui.KeyEnd: + s.caret = len(raw) + case ui.KeyEnter: + s.submitted = true + default: + return false + } + default: + return false + } + *value = string(raw) + return true + }) + if s.changed { + r.changed, s.changed = true, false + } + if s.submitted { + r.submitted, s.submitted = true, false + } + shown := []rune(MaskFormat(opts.Mask, *value)) + at := maskedInputAt(mask, s.caret) + size := FontSize(c, theme.BodySize) + w, h := c.MeasureText(0, ui.Span{Text: string(shown[:at]), Size: size}) + if h == 0 { + _, h = c.MeasureText(0, ui.Span{Text: "0", Size: size}) + } + in.MinHeight(h).TextCaret(ui.Rect{X: w, Y: 0, W: 1, H: h}).Cursor(ui.CursorText) + filled := maskedInputAt(mask, len([]rune(*value))) + in.Children(func() { + ui.Text(c, string(shown[:at])).TextColor(k.Text).FontSize(size).SingleLine() + if s.compose != "" { + ui.Text(c, s.compose).TextColor(k.Text).FontSize(size).Underline().SingleLine() + } + if at < filled { + ui.Text(c, string(shown[at:filled])).TextColor(k.Text).FontSize(size).SingleLine() + } + ui.Text(c, string(shown[max(at, filled):])).TextColor(k.TextMuted).FontSize(size).SingleLine() + }) + in.DrawOver(func(p *ui.Painter, rr ui.Rect) { + if in.Focused() && !in.IsDisabled() && s.compose == "" { + p.Fill(ui.Rect{X: rr.X + w, Y: rr.Y + (rr.H-h)/2, W: 1, H: h}, k.Text, 0) + } + }) + return in + }, nil) + if r.changed { + slog.Debug("mujica: masked_input change", "filled", len([]rune(*value)), "slots", len(slots)) + } + return r +} diff --git a/masked_input_test.go b/masked_input_test.go new file mode 100644 index 0000000..8b1b8d0 --- /dev/null +++ b/masked_input_test.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMaskFormat(t *testing.T) { + for _, tc := range [][3]string{ + {"(999) 999-9999", "555123", "(555) 123-____"}, + {"9999 9999 9999 9999", "", "____ ____ ____ ____"}, + {"aa-99", "AB12", "AB-12"}, + } { + if got := MaskFormat(tc[0], tc[1]); got != tc[2] { + t.Errorf("MaskFormat(%q, %q) = %q, want %q", tc[0], tc[1], got, tc[2]) + } + } +} + +func maskedTester(v *string, dis *bool, n, submits *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := MaskedInput(c, v, MaskedInputOptions{Mask: "(999) 999-9999", Label: "Phone", Disabled: *dis}) + if r.Changed() { + *n++ + } + if r.Submitted() { + *submits++ + } + }, 400, 120) +} + +func TestMaskedInputTypingEditingAndPaste(t *testing.T) { + v := "" + dis := false + n, submits := 0, 0 + tt := maskedTester(&v, &dis, &n, &submits) + tt.Key(0, ui.KeyTab) + tt.Type("55x5-12") + if v != "55512" { + t.Fatalf("typed: %q", v) + } + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + tt.Type("9") + if v != "55951" { + t.Fatalf("insert at caret: %q", v) + } + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyDelete) + if v != "5951" { + t.Fatalf("Delete at start: %q", v) + } + tt.Key(0, ui.KeyEnd) + tt.SetClipboard("123-456-789") + tt.Command("paste") + if v != "5951123456" { + t.Fatalf("paste fills to the end: %q", v) + } + tt.Type("7") + if v != "5951123456" { + t.Fatalf("typed past the mask: %q", v) + } + tt.Key(0, ui.KeyEnter) + if submits != 1 || n == 0 { + t.Fatalf("submits %d changes %d", submits, n) + } + if _, ok := tt.Find("Phone"); !ok { + t.Fatal("no field name") + } +} + +func TestMaskedInputCompositionAndDisabled(t *testing.T) { + v := "12" + dis := false + n, submits := 0, 0 + tt := maskedTester(&v, &dis, &n, &submits) + tt.Key(0, ui.KeyTab) + tt.Compose("3", 1) + if v != "12" || !tt.HasText("3") { + t.Fatalf("composition committed early: %q", v) + } + tt.Key(0, ui.KeyBackspace) // the input method's, while composing + if v != "12" { + t.Fatalf("Backspace during composition edited: %q", v) + } + tt.Type("3") + if v != "123" { + t.Fatalf("commit: %q", v) + } + dis = true + tt.Frame() + tt.Type("4") + tt.Key(0, ui.KeyBackspace) + if v != "123" { + t.Fatalf("disabled edit: %q", v) + } +} + +func TestMaskedInputRejects(t *testing.T) { + for name, tc := range map[string][2]string{"no slots": {"---", ""}, "too long": {"99", "123"}, "wrong kind": {"99", "1a"}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + v := tc[1] + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); MaskedInput(c, &v, MaskedInputOptions{Mask: tc[0]}) }, 200, 100) + }() + } +} + +func TestMaskedInputSnapshot(t *testing.T) { + a, b, d := "5551234", "", "4111111111111111" + snapshot(t, "masked_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + MaskedInput(c, &a, MaskedInputOptions{Mask: "(999) 999-9999"}).Element.Width(160) + MaskedInput(c, &b, MaskedInputOptions{Mask: "99/99/9999"}).Element.Width(130) + MaskedInput(c, &d, MaskedInputOptions{Mask: "9999 9999 9999 9999", Disabled: true}).Element.Width(190) + }) + }) +} diff --git a/masonry.go b/masonry.go new file mode 100644 index 0000000..3c1d7ad --- /dev/null +++ b/masonry.go @@ -0,0 +1,139 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + "sort" + + "github.com/egoist/mygo/ui" +) + +// MasonryOptions configure a Masonry. +type MasonryOptions struct { + // Key is required: a stable, unique key per item. + Key func(i int) string + // ColumnWidth is the narrowest column; zero is 220. Columns fill the width. + ColumnWidth float32 + // Gap is between items; zero is 3 spacing units. + Gap float32 + // Estimate is the height assumed until an item is measured; zero is 160. + Estimate float32 +} + +// masonryState is the scroll, the measured heights and the cached placement. +type masonryState struct { + scroll ui.ScrollState + heights map[string]float32 + version int + laidOut masonryLayout + // built are the items built last frame; their keys are checked for changed data. + built []int + // sized: a frame was asked for once the box had a width. + sized bool +} + +// masonryLayout places n items in cols columns; byCol lists each column's items top to bottom. +type masonryLayout struct { + n, cols, version int + colW, gap float32 + keys []string + x, y, h []float32 + byCol [][]int + total float32 +} + +// Masonry lays n items in as many columns as fit, each into the shortest column, building only those in view. +func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element { + if opts.Key == nil || item == nil { + panic("mujica: Masonry needs Key and item") + } + if n < 0 || opts.ColumnWidth < 0 || opts.Gap < 0 || opts.Estimate < 0 { + panic("mujica: Masonry count and sizes must not be negative") + } + u := Density(c).Unit() + colMin := cmp.Or(opts.ColumnWidth, 220) + gap := cmp.Or(opts.Gap, 3*u) + est := cmp.Or(opts.Estimate, 160) + sc := ui.Scroll(c) + st := ui.Local(sc, "masonry", func() *masonryState { return &masonryState{heights: map[string]float32{}} }) + s := *st + sc.TrackScroll(&s.scroll) + view := sc.Bounds() + if view.W == 0 && !s.sized { + // Unmeasured: lay out again once the box has its width. + s.sized = true + c.Invalidate() + } + cols := max(1, int((view.W+gap)/(colMin+gap))) + colW := (view.W - gap*float32(cols-1)) / float32(cols) + l := &s.laidOut + stale := l.n != n || l.cols != cols || l.colW != colW || l.gap != gap || l.version != s.version + for _, i := range s.built { + stale = stale || i >= n || l.keys[i] != opts.Key(i) + } + if stale { + *l = masonryPlace(n, cols, colW, gap, est, s.heights, opts.Key) + l.version = s.version + slog.Debug("mujica: masonry layout", "items", n, "columns", cols) + } + top, bottom := s.scroll.Y-view.H/2, s.scroll.Y+view.H*1.5 + var seen []int + sc.Children(func() { + ui.Box(c).FillWidth().Height(l.total).Children(func() { masonryBuild(c, s, l, colW, top, bottom, item, &seen) }) + }) + s.built = seen + return sc +} + +// masonryBuild builds the items between top and bottom and records measured heights. +func masonryBuild(c *ui.Context, s *masonryState, l *masonryLayout, colW, top, bottom float32, item func(int), seen *[]int) { + for _, list := range l.byCol { + from := sort.Search(len(list), func(j int) bool { i := list[j]; return l.y[i]+l.h[i] >= top }) + for _, i := range list[from:] { + if l.y[i] > bottom { + break + } + *seen = append(*seen, i) + key := l.keys[i] + box := ui.Column(c).Key(key).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW) + box.Children(func() { item(i) }) + if h := box.Bounds().H; h > 0 && math.Abs(float64(h-l.h[i])) > 0.5 { + s.heights[key] = h + s.version++ + c.Invalidate() + } + } + } +} + +// masonryPlace puts each item into the shortest column, using measured heights or est. +func masonryPlace(n, cols int, colW, gap, est float32, heights map[string]float32, key func(int) string) masonryLayout { + l := masonryLayout{n: n, cols: cols, colW: colW, gap: gap, keys: make([]string, n), x: make([]float32, n), y: make([]float32, n), h: make([]float32, n), byCol: make([][]int, cols)} + colH := make([]float32, cols) + seen := make(map[string]bool, n) + for i := range n { + k := key(i) + if seen[k] { + panic("mujica: Masonry key repeated: " + k) + } + seen[k] = true + col := 0 + for j := range cols { + if colH[j] < colH[col] { + col = j + } + } + h, ok := heights[k] + if !ok { + h = est + } + l.keys[i], l.x[i], l.y[i], l.h[i] = k, float32(col)*(colW+gap), colH[col], h + l.byCol[col] = append(l.byCol[col], i) + colH[col] += h + gap + } + for _, h := range colH { + l.total = max(l.total, h-gap) + } + return l +} diff --git a/masonry_test.go b/masonry_test.go new file mode 100644 index 0000000..71349e8 --- /dev/null +++ b/masonry_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "strconv" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMasonryPlaceShortestColumn(t *testing.T) { + hs := map[string]float32{"0": 100, "1": 40, "2": 40, "3": 50} + l := masonryPlace(4, 2, 100, 10, 160, hs, strconv.Itoa) + // 0 left; 1 right; 2 right again (50 < 110); 3 right (100 = 110? no: left 110, right 100). + want := [][2]float32{{0, 0}, {110, 0}, {110, 50}, {110, 100}} + for i, w := range want { + if l.x[i] != w[0] || l.y[i] != w[1] { + t.Errorf("item %d at %v,%v, want %v", i, l.x[i], l.y[i], w) + } + } + if l.total != 150 { + t.Errorf("total %v", l.total) + } + defer func() { + if recover() == nil { + t.Error("repeated key accepted") + } + }() + masonryPlace(2, 1, 100, 0, 10, nil, func(int) string { return "same" }) +} + +func TestMasonryVirtualizesAndMeasures(t *testing.T) { + built := map[int]bool{} + n := 100000 + width := 700 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Masonry(c, n, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 200, Gap: 10}, func(i int) { + built[i] = true + ui.Box(c).Height(float32(40 + i%3*30)).Label("tile " + strconv.Itoa(i)) + }).Size(float32(width), 500) + }, 800, 600) + tt.Frame() + clear(built) + tt.Frame() + if len(built) == 0 || len(built) > 120 { + t.Fatalf("built %d of %d", len(built), n) + } + if !built[0] || !built[2] || built[5000] { + t.Fatalf("wrong items built: %v", built[5000]) + } + a, _ := tt.Find("tile 0") + b, _ := tt.Find("tile 1") + d, _ := tt.Find("tile 3") + if a.W < 226 || a.W > 227 || b.X < 236 || b.X > 237 { + t.Fatalf("three columns in 700: tile 0 %v, tile 1 %v", a, b) + } + // Measured: tile 0 is 40 high, so tile 3 goes under it at 40 + 10. + if d.X != 0 || d.Y != 50 { + t.Fatalf("tile 3 at %v; texts %q", d, tt.Texts()) + } + tt.Scroll(100, 100, 0, 600000) + clear(built) + tt.Frame() + if len(built) == 0 || len(built) > 120 || built[0] { + t.Fatalf("after scrolling built %d, includes first %v", len(built), built[0]) + } +} + +func TestMasonrySnapshot(t *testing.T) { + snapshot(t, "masonry", 520, 300, func(c *ui.Context) { + k := Tokens(c) + Masonry(c, 9, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 150}, func(i int) { + ui.Column(c).Height(float32(50+i%4*25)).Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).Children(func() { + ui.Text(c, "Card "+strconv.Itoa(i+1)) + }) + }).Size(500, 280).Margin(10) + }) +} diff --git a/mention_input.go b/mention_input.go new file mode 100644 index 0000000..e286fb0 --- /dev/null +++ b/mention_input.go @@ -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() + } + } + }) + }) + }) +} diff --git a/mention_input_model.go b/mention_input_model.go new file mode 100644 index 0000000..2384235 --- /dev/null +++ b/mention_input_model.go @@ -0,0 +1,142 @@ +package mujica + +import ( + "slices" + "unicode" +) + +// Mention is a whole "@Name" token in a MentionInput's text, at rune offsets [Start, End). +type Mention struct { + ID string + Name string + Start, End int +} + +// mentionDoc is a MentionInput's text, mentions, caret and selection anchor, in runes. +type mentionDoc struct { + text []rune + mentions []Mention + caret, anchor int +} + +func (d mentionDoc) clone() mentionDoc { + d.text = slices.Clone(d.text) + d.mentions = slices.Clone(d.mentions) + return d +} + +// span returns the selection, widened to whole mentions it touches. +func (d *mentionDoc) span() (int, int) { + a, b := min(d.caret, d.anchor), max(d.caret, d.anchor) + for _, m := range d.mentions { + if m.Start < b && m.End > a { + a, b = min(a, m.Start), max(b, m.End) + } + } + return a, b +} + +// replace puts ins in place of [a, b), dropping mentions inside and shifting later ones; the caret lands after ins. +func (d *mentionDoc) replace(a, b int, ins []rune) { + d.text = slices.Concat(d.text[:a], ins, d.text[b:]) + shift := len(ins) - (b - a) + kept := d.mentions[:0] + for _, m := range d.mentions { + switch { + case m.End <= a: + kept = append(kept, m) + case m.Start >= b: + m.Start, m.End = m.Start+shift, m.End+shift + kept = append(kept, m) + } + } + d.mentions = kept + d.caret = a + len(ins) + d.anchor = d.caret +} + +// insert types s at the caret, over any selection. +func (d *mentionDoc) insert(s string) { + a, b := d.span() + d.replace(a, b, []rune(s)) +} + +// erase deletes the selection, else one rune or one whole mention back (or forward). +func (d *mentionDoc) erase(forward bool) bool { + if d.caret != d.anchor { + a, b := d.span() + d.replace(a, b, nil) + return true + } + for _, m := range d.mentions { + if !forward && m.End == d.caret || forward && m.Start == d.caret { + d.replace(m.Start, m.End, nil) + return true + } + } + switch { + case !forward && d.caret > 0: + d.replace(d.caret-1, d.caret, nil) + case forward && d.caret < len(d.text): + d.replace(d.caret, d.caret+1, nil) + default: + return false + } + return true +} + +// move steps the caret by dir runes, over whole mentions; extend keeps the anchor. +func (d *mentionDoc) move(dir int, extend bool) { + at := max(0, min(len(d.text), d.caret+dir)) + for _, m := range d.mentions { + if at > m.Start && at < m.End { + if dir < 0 { + at = m.Start + } else { + at = m.End + } + } + } + d.moveTo(at, extend) +} + +func (d *mentionDoc) moveTo(at int, extend bool) { + d.caret = at + if !extend { + d.anchor = at + } +} + +// query returns where an "@" being typed starts and what follows it, or -1. +func (d *mentionDoc) query() (int, string) { + if d.caret != d.anchor { + return -1, "" + } + for i := d.caret - 1; i >= 0; i-- { + r := d.text[i] + if unicode.IsSpace(r) { + return -1, "" + } + if r != '@' { + continue + } + for _, m := range d.mentions { + if i >= m.Start && i < m.End { + return -1, "" + } + } + if i > 0 && !unicode.IsSpace(d.text[i-1]) { + return -1, "" + } + return i, string(d.text[i+1 : d.caret]) + } + return -1, "" +} + +// choose turns the "@query" before the caret into a mention of id, followed by a space. +func (d *mentionDoc) choose(at int, id, name string) { + tok := []rune("@" + name) + d.replace(at, d.caret, append(slices.Clone(tok), ' ')) + d.mentions = append(d.mentions, Mention{ID: id, Name: name, Start: at, End: at + len(tok)}) + slices.SortFunc(d.mentions, func(a, b Mention) int { return a.Start - b.Start }) +} diff --git a/mention_input_test.go b/mention_input_test.go new file mode 100644 index 0000000..d9b87eb --- /dev/null +++ b/mention_input_test.go @@ -0,0 +1,149 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +var mentionPeople = []MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Albert"}, {ID: "u3", Name: "Bob"}} + +func mentionFind(q string) []MentionCandidate { + var out []MentionCandidate + for _, p := range mentionPeople { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out +} + +func TestMentionDocWholeTokens(t *testing.T) { + d := mentionDoc{text: []rune("hi @al"), caret: 6, anchor: 6} + at, q := d.query() + if at != 3 || q != "al" { + t.Fatalf("query %d %q", at, q) + } + d.choose(at, "u1", "Alice") + d.insert("there") + if string(d.text) != "hi @Alice there" || d.mentions[0] != (Mention{ID: "u1", Name: "Alice", Start: 3, End: 9}) { + t.Fatalf("after choose: %q %+v", string(d.text), d.mentions) + } + d.moveTo(10, false) + d.move(-1, false) // to the mention's end + d.move(-1, false) // over the whole mention + if d.caret != 3 { + t.Fatalf("Left over a mention lands at %d", d.caret) + } + d.moveTo(9, false) + d.erase(false) + if string(d.text) != "hi there" || len(d.mentions) != 0 || d.caret != 3 { + t.Fatalf("Backspace after a mention: %q %+v %d", string(d.text), d.mentions, d.caret) + } + if at, _ := (&mentionDoc{text: []rune("a@b"), caret: 3, anchor: 3}).query(); at != -1 { + t.Fatal("an email-like @ started a query") + } +} + +func mentionTester(v *string, ms *[]Mention, dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(400).Children(func() { + if MentionInput(c, v, ms, MentionInputOptions{Candidates: mentionFind, Label: "Comment", Disabled: *dis}).Changed() { + *n++ + } + }) + }, 500, 400) +} + +func TestMentionInputCandidatesKeyboard(t *testing.T) { + v, ms := "", []Mention(nil) + dis, n := false, 0 + tt := mentionTester(&v, &ms, &dis, &n) + tt.Key(0, ui.KeyTab) + tt.Type("ping @al") + if !tt.HasText("@Alice") || !tt.HasText("@Albert") || tt.HasText("@Bob") { + t.Fatalf("candidates %q", tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if v != "ping @Albert " || len(ms) != 1 || ms[0].ID != "u2" || ms[0].Start != 5 || ms[0].End != 12 { + t.Fatalf("chose: %q %+v", v, ms) + } + if tt.HasText("@Alice") { + t.Fatal("popup stayed open") + } + tt.Type("ok") + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) // the space + tt.Key(0, ui.KeyBackspace) // the whole mention + if v != "ping " || len(ms) != 0 { + t.Fatalf("whole delete: %q %+v", v, ms) + } + tt.Command("undo") + if v != "ping @Albert" || len(ms) != 1 { + t.Fatalf("undo: %q %+v", v, ms) + } + tt.Type(" @b") + tt.Key(0, ui.KeyEscape) + if tt.HasText("@Bob") { + t.Fatal("Escape did not close the candidates") + } +} + +func TestMentionInputClickCompositionDisabled(t *testing.T) { + v, ms := "", []Mention(nil) + dis, n := false, 0 + tt := mentionTester(&v, &ms, &dis, &n) + tt.Key(0, ui.KeyTab) + tt.Type("@b") + if err := tt.Click("Bob"); err != nil { + t.Fatal(err) + } + if v != "@Bob " || !slices.Equal(ms, []Mention{{ID: "u3", Name: "Bob", Start: 0, End: 4}}) { + t.Fatalf("click: %q %+v", v, ms) + } + tt.Compose("你", 1) + if v != "@Bob " { + t.Fatalf("composition committed early: %q", v) + } + tt.Type("你好") + if v != "@Bob 你好" { + t.Fatalf("commit: %q", v) + } + before := n + dis = true + tt.Frame() + tt.Type("x") + tt.Key(0, ui.KeyBackspace) + if v != "@Bob 你好" || n != before { + t.Fatalf("disabled edit: %q", v) + } +} + +func TestMentionInputRejectsBadMentions(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + v, ms := "hi Bob", []Mention{{ID: "u3", Name: "Bob", Start: 3, End: 6}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind}) + }, 300, 100) +} + +func TestMentionInputSnapshot(t *testing.T) { + v, ms := "thanks @Alice, see @Bob", []Mention{{ID: "u1", Name: "Alice", Start: 7, End: 13}, {ID: "u3", Name: "Bob", Start: 19, End: 23}} + e, none := "", []Mention(nil) + snapshot(t, "mention_input", 420, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind}) + MentionInput(c, &e, &none, MentionInputOptions{Candidates: mentionFind, Placeholder: "Write a comment…"}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go new file mode 100644 index 0000000..4c8abe1 --- /dev/null +++ b/messages_essentials.go @@ -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", "文字过长,无法生成二维码"}, + }) +} diff --git a/qr_code.go b/qr_code.go new file mode 100644 index 0000000..09a90b6 --- /dev/null +++ b/qr_code.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// QRCodeOptions configure a QRCode. +type QRCodeOptions struct { + Level QRLevel + // Size is the side in DIPs with the quiet zone; zero is 160. + Size float32 + // Label names the code for assistive technology; empty uses "QR code". + Label string +} + +// qrCodeCache keeps the last encoding so it runs only when the text or level changes. +type qrCodeCache struct { + text string + level QRLevel + m QRMatrix + err error + done bool +} + +// QRCode draws text as a QR code, encoded offline; too long a text shows an error. +func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element { + _ = opts.Level.String() + if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { + panic("mujica: QRCode Size must be finite and >= 0") + } + size := opts.Size + if size == 0 { + size = 160 + } + k := Tokens(c) + e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(cmp.Or(opts.Label, msg(c, "qr_code.label"))) + cache := ui.Local(e, "qr_code", func() qrCodeCache { return qrCodeCache{} }) + if !cache.done || cache.text != text || cache.level != opts.Level { + m, err := EncodeQR([]byte(text), opts.Level) + *cache = qrCodeCache{text: text, level: opts.Level, m: m, err: err, done: true} + slog.Debug("mujica: qr_code encoded", "version", m.Version, "level", opts.Level, "err", err) + } + if cache.err != nil { + e.Center().Padding(Density(c).Unit()*2).Background(k.Surface).Border(theme.BorderWidth, k.Danger).Radius(theme.CardRadius). + Description(cache.err.Error()) + e.Children(func() { + ui.Text(c, msg(c, "qr_code.too_long")).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).TextAlign(ui.Center) + }) + return e + } + m := cache.m + // Scanners need dark modules on a light field, in either theme. + light, dark := theme.Light().Surface, theme.Light().Text + e.Draw(func(p *ui.Painter, r ui.Rect) { + n := m.Size() + 8 + s := p.Scale() + mod := float32(math.Floor(float64(r.W*s)/float64(n))) / s + if mod <= 0 { + return + } + ox := float32(math.Round(float64((r.X+(r.W-mod*float32(n))/2)*s))) / s + oy := float32(math.Round(float64((r.Y+(r.H-mod*float32(n))/2)*s))) / s + p.Fill(r, light, theme.ControlRadius) + for y, row := range m.Modules { + for x, on := range row { + if on { + p.Fill(ui.Rect{X: ox + float32(x+4)*mod, Y: oy + float32(y+4)*mod, W: mod, H: mod}, dark, 0) + } + } + } + }) + return e +} diff --git a/qr_code_encoder.go b/qr_code_encoder.go new file mode 100644 index 0000000..4408273 --- /dev/null +++ b/qr_code_encoder.go @@ -0,0 +1,262 @@ +package mujica + +import ( + "fmt" +) + +// QRLevel is a QR code's error correction level. The zero value is QRMedium. +type QRLevel int + +const ( + QRMedium QRLevel = iota // M: about 15% recoverable + QRLow // L: about 7% + QRQuartile // Q: about 25% + QRHigh // H: about 30% +) + +func (l QRLevel) String() string { + switch l { + case QRLow: + return "L" + case QRMedium: + return "M" + case QRQuartile: + return "Q" + case QRHigh: + return "H" + } + panic(fmt.Sprintf("mujica: unknown QRLevel %d", int(l))) +} + +// index is the level's column in qrCodeTable. +func (l QRLevel) index() int { return [4]int{1, 0, 2, 3}[l] } + +// formatBits are the level's two format information bits. +func (l QRLevel) formatBits() int { return [4]int{0, 1, 3, 2}[l] } + +// qrCodeBlocks is one version and level: EC codewords per block, then two block groups. +type qrCodeBlocks struct{ ec, n1, d1, n2, d2 int } + +func (b qrCodeBlocks) data() int { return b.n1*b.d1 + b.n2*b.d2 } + +// qrCodeTable is ISO/IEC 18004 Table 9, indexed [version-1][L, M, Q, H]. +var qrCodeTable = [40][4]qrCodeBlocks{ + {{7, 1, 19, 0, 0}, {10, 1, 16, 0, 0}, {13, 1, 13, 0, 0}, {17, 1, 9, 0, 0}}, + {{10, 1, 34, 0, 0}, {16, 1, 28, 0, 0}, {22, 1, 22, 0, 0}, {28, 1, 16, 0, 0}}, + {{15, 1, 55, 0, 0}, {26, 1, 44, 0, 0}, {18, 2, 17, 0, 0}, {22, 2, 13, 0, 0}}, + {{20, 1, 80, 0, 0}, {18, 2, 32, 0, 0}, {26, 2, 24, 0, 0}, {16, 4, 9, 0, 0}}, + {{26, 1, 108, 0, 0}, {24, 2, 43, 0, 0}, {18, 2, 15, 2, 16}, {22, 2, 11, 2, 12}}, + {{18, 2, 68, 0, 0}, {16, 4, 27, 0, 0}, {24, 4, 19, 0, 0}, {28, 4, 15, 0, 0}}, + {{20, 2, 78, 0, 0}, {18, 4, 31, 0, 0}, {18, 2, 14, 4, 15}, {26, 4, 13, 1, 14}}, + {{24, 2, 97, 0, 0}, {22, 2, 38, 2, 39}, {22, 4, 18, 2, 19}, {26, 4, 14, 2, 15}}, + {{30, 2, 116, 0, 0}, {22, 3, 36, 2, 37}, {20, 4, 16, 4, 17}, {24, 4, 12, 4, 13}}, + {{18, 2, 68, 2, 69}, {26, 4, 43, 1, 44}, {24, 6, 19, 2, 20}, {28, 6, 15, 2, 16}}, + {{20, 4, 81, 0, 0}, {30, 1, 50, 4, 51}, {28, 4, 22, 4, 23}, {24, 3, 12, 8, 13}}, + {{24, 2, 92, 2, 93}, {22, 6, 36, 2, 37}, {26, 4, 20, 6, 21}, {28, 7, 14, 4, 15}}, + {{26, 4, 107, 0, 0}, {22, 8, 37, 1, 38}, {24, 8, 20, 4, 21}, {22, 12, 11, 4, 12}}, + {{30, 3, 115, 1, 116}, {24, 4, 40, 5, 41}, {20, 11, 16, 5, 17}, {24, 11, 12, 5, 13}}, + {{22, 5, 87, 1, 88}, {24, 5, 41, 5, 42}, {30, 5, 24, 7, 25}, {24, 11, 12, 7, 13}}, + {{24, 5, 98, 1, 99}, {28, 7, 45, 3, 46}, {24, 15, 19, 2, 20}, {30, 3, 15, 13, 16}}, + {{28, 1, 107, 5, 108}, {28, 10, 46, 1, 47}, {28, 1, 22, 15, 23}, {28, 2, 14, 17, 15}}, + {{30, 5, 120, 1, 121}, {26, 9, 43, 4, 44}, {28, 17, 22, 1, 23}, {28, 2, 14, 19, 15}}, + {{28, 3, 113, 4, 114}, {26, 3, 44, 11, 45}, {26, 17, 21, 4, 22}, {26, 9, 13, 16, 14}}, + {{28, 3, 107, 5, 108}, {26, 3, 41, 13, 42}, {30, 15, 24, 5, 25}, {28, 15, 15, 10, 16}}, + {{28, 4, 116, 4, 117}, {26, 17, 42, 0, 0}, {28, 17, 22, 6, 23}, {30, 19, 16, 6, 17}}, + {{28, 2, 111, 7, 112}, {28, 17, 46, 0, 0}, {30, 7, 24, 16, 25}, {24, 34, 13, 0, 0}}, + {{30, 4, 121, 5, 122}, {28, 4, 47, 14, 48}, {30, 11, 24, 14, 25}, {30, 16, 15, 14, 16}}, + {{30, 6, 117, 4, 118}, {28, 6, 45, 14, 46}, {30, 11, 24, 16, 25}, {30, 30, 16, 2, 17}}, + {{26, 8, 106, 4, 107}, {28, 8, 47, 13, 48}, {30, 7, 24, 22, 25}, {30, 22, 15, 13, 16}}, + {{28, 10, 114, 2, 115}, {28, 19, 46, 4, 47}, {28, 28, 22, 6, 23}, {30, 33, 16, 4, 17}}, + {{30, 8, 122, 4, 123}, {28, 22, 45, 3, 46}, {30, 8, 23, 26, 24}, {30, 12, 15, 28, 16}}, + {{30, 3, 117, 10, 118}, {28, 3, 45, 23, 46}, {30, 4, 24, 31, 25}, {30, 11, 15, 31, 16}}, + {{30, 7, 116, 7, 117}, {28, 21, 45, 7, 46}, {30, 1, 23, 37, 24}, {30, 19, 15, 26, 16}}, + {{30, 5, 115, 10, 116}, {28, 19, 47, 10, 48}, {30, 15, 24, 25, 25}, {30, 23, 15, 25, 16}}, + {{30, 13, 115, 3, 116}, {28, 2, 46, 29, 47}, {30, 42, 24, 1, 25}, {30, 23, 15, 28, 16}}, + {{30, 17, 115, 0, 0}, {28, 10, 46, 23, 47}, {30, 10, 24, 35, 25}, {30, 19, 15, 35, 16}}, + {{30, 17, 115, 1, 116}, {28, 14, 46, 21, 47}, {30, 29, 24, 19, 25}, {30, 11, 15, 46, 16}}, + {{30, 13, 115, 6, 116}, {28, 14, 46, 23, 47}, {30, 44, 24, 7, 25}, {30, 59, 16, 1, 17}}, + {{30, 12, 121, 7, 122}, {28, 12, 47, 26, 48}, {30, 39, 24, 14, 25}, {30, 22, 15, 41, 16}}, + {{30, 6, 121, 14, 122}, {28, 6, 47, 34, 48}, {30, 46, 24, 10, 25}, {30, 2, 15, 64, 16}}, + {{30, 17, 122, 4, 123}, {28, 29, 46, 14, 47}, {30, 49, 24, 10, 25}, {30, 24, 15, 46, 16}}, + {{30, 4, 122, 18, 123}, {28, 13, 46, 32, 47}, {30, 48, 24, 14, 25}, {30, 42, 15, 32, 16}}, + {{30, 20, 117, 4, 118}, {28, 40, 47, 7, 48}, {30, 43, 24, 22, 25}, {30, 10, 15, 67, 16}}, + {{30, 19, 118, 6, 119}, {28, 18, 47, 31, 48}, {30, 34, 24, 34, 25}, {30, 20, 15, 61, 16}}, +} + +// QRMatrix is an encoded QR code: Modules[y][x] is true for dark. +type QRMatrix struct { + Version int + Level QRLevel + Mask int + Modules [][]bool +} + +// Size is the side of the symbol in modules, without the quiet zone. +func (m QRMatrix) Size() int { return len(m.Modules) } + +// EncodeQR encodes data in byte mode at the smallest version that fits, choosing the mask by penalty. +func EncodeQR(data []byte, level QRLevel) (QRMatrix, error) { + _ = level.String() + for v := 1; v <= 40; v++ { + cc := 8 + if v >= 10 { + cc = 16 + } + if len(data) >= 1<= 10 { + w.put(len(data), 16) + } else { + w.put(len(data), 8) + } + for _, d := range data { + w.put(int(d), 8) + } + capBits := 8 * b.data() + w.put(0, min(4, capBits-w.n)) + w.put(0, (8-w.n%8)%8) + for pad := 0xEC; w.n < capBits; pad ^= 0xEC ^ 0x11 { + w.put(pad, 8) + } + words := qrCodeInterleave(w.bytes, b) + g := newQRCodeGrid(v) + g.place(words) + best, bestPenalty := 0, -1 + for m := range 8 { + t := g.clone() + t.apply(m) + if p := t.penalty(); bestPenalty < 0 || p < bestPenalty { + best, bestPenalty = m, p + } + } + g.apply(best) + g.format(level.formatBits(), best) + g.version(true) + return QRMatrix{Version: v, Level: level, Mask: best, Modules: g.dark} +} + +// qrCodeBits appends bits most significant first. +type qrCodeBits struct { + bytes []byte + n int +} + +func (w *qrCodeBits) put(v, count int) { + for i := count - 1; i >= 0; i-- { + if w.n%8 == 0 { + w.bytes = append(w.bytes, 0) + } + if v>>i&1 == 1 { + w.bytes[w.n/8] |= 0x80 >> (w.n % 8) + } + w.n++ + } +} + +// qrCodeInterleave splits data into blocks, adds Reed-Solomon codewords and interleaves both. +func qrCodeInterleave(data []byte, b qrCodeBlocks) []byte { + var blocks, ecs [][]byte + gen := qrCodeGenerator(b.ec) + at := 0 + for i := range b.n1 + b.n2 { + n := b.d1 + if i >= b.n1 { + n = b.d2 + } + blk := data[at : at+n] + at += n + blocks = append(blocks, blk) + ecs = append(ecs, qrCodeRemainder(blk, gen)) + } + var out []byte + for i := range max(b.d1, b.d2) { + for _, blk := range blocks { + if i < len(blk) { + out = append(out, blk[i]) + } + } + } + for i := range b.ec { + for _, e := range ecs { + out = append(out, e[i]) + } + } + return out +} + +// qrCodeMul multiplies in GF(256) modulo x^8+x^4+x^3+x^2+1. +func qrCodeMul(x, y byte) byte { + var z byte + for i := 7; i >= 0; i-- { + hi := z & 0x80 + z <<= 1 + if hi != 0 { + z ^= 0x1D + } + if y>>i&1 == 1 { + z ^= x + } + } + return z +} + +// qrCodeGenerator returns the degree-n generator's coefficients, highest first, leading 1 dropped. +func qrCodeGenerator(n int) []byte { + g := make([]byte, n) + g[n-1] = 1 + root := byte(1) + for range n { + for j := range n { + g[j] = qrCodeMul(g[j], root) + if j+1 < n { + g[j] ^= g[j+1] + } + } + root = qrCodeMul(root, 2) + } + return g +} + +func qrCodeRemainder(data, gen []byte) []byte { + r := make([]byte, len(gen)) + for _, d := range data { + f := d ^ r[0] + copy(r, r[1:]) + r[len(r)-1] = 0 + for i := range r { + r[i] ^= qrCodeMul(gen[i], f) + } + } + return r +} + +// qrCodeAlignment returns the alignment pattern centers of version v. +func qrCodeAlignment(v int) []int { + if v == 1 { + return nil + } + n := v/7 + 2 + step := 26 + if v != 32 { + step = (v*4 + n*2 + 1) / (n*2 - 2) * 2 + } + pos := make([]int, n) + pos[0] = 6 + for i, p := n-1, v*4+10; i >= 1; i, p = i-1, p-step { + pos[i] = p + } + return pos +} diff --git a/qr_code_encoder_test.go b/qr_code_encoder_test.go new file mode 100644 index 0000000..49f0a26 --- /dev/null +++ b/qr_code_encoder_test.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "encoding/hex" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// testdata/qr: segno 1.6.6 byte-mode symbols, padding fixed to ISO 7.4.10. +func TestEncodeQRMatchesReference(t *testing.T) { + files, err := filepath.Glob("testdata/qr/*.txt") + if err != nil || len(files) < 6 { + t.Fatalf("reference files: %v %v", files, err) + } + levels := map[string]QRLevel{"L": QRLow, "M": QRMedium, "Q": QRQuartile, "H": QRHigh} + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + head := strings.Fields(lines[0]) + version, _ := strconv.Atoi(head[0]) + mask, _ := strconv.Atoi(head[2]) + data, err := hex.DecodeString(head[3]) + if err != nil { + t.Fatal(err) + } + m, err := EncodeQR(data, levels[head[1]]) + if err != nil { + t.Fatalf("%s: %v", f, err) + } + if m.Version != version || m.Mask != mask { + t.Errorf("%s: version %d mask %d, want %d %d", f, m.Version, m.Mask, version, mask) + continue + } + for y, row := range lines[1:] { + for x, ch := range row { + if m.Modules[y][x] != (ch == '1') { + t.Fatalf("%s: module (%d,%d) differs", f, x, y) + } + } + } + } +} + +func TestQRCodeAlignmentPositions(t *testing.T) { + for v, want := range map[int]string{1: "[]", 2: "[6 18]", 7: "[6 22 38]", 32: "[6 34 60 86 112 138]", 40: "[6 30 58 86 114 142 170]"} { + if got := fmtInts(qrCodeAlignment(v)); got != want { + t.Errorf("version %d: %s, want %s", v, got, want) + } + } +} + +func fmtInts(v []int) string { + s := make([]string, len(v)) + for i, n := range v { + s[i] = strconv.Itoa(n) + } + return "[" + strings.Join(s, " ") + "]" +} + +func TestEncodeQRLimits(t *testing.T) { + m, err := EncodeQR(make([]byte, 2953), QRLow) + if err != nil || m.Version != 40 || m.Size() != 177 { + t.Fatalf("largest L symbol: %v %v", m.Version, err) + } + if _, err := EncodeQR(make([]byte, 2954), QRLow); err == nil { + t.Fatal("2954 bytes fit at L") + } + if _, err := EncodeQR(make([]byte, 1274), QRHigh); err == nil { + t.Fatal("1274 bytes fit at H") + } + m, err = EncodeQR(nil, QRHigh) + if err != nil || m.Version != 1 { + t.Fatalf("empty data: %v %v", m.Version, err) + } +} diff --git a/qr_code_grid.go b/qr_code_grid.go new file mode 100644 index 0000000..39dbade --- /dev/null +++ b/qr_code_grid.go @@ -0,0 +1,242 @@ +package mujica + +// qrCodeGrid is a symbol under construction; fixed marks function modules. +type qrCodeGrid struct { + v int + dark, fixed [][]bool +} + +func newQRCodeGrid(v int) *qrCodeGrid { + n := 4*v + 17 + g := &qrCodeGrid{v: v, dark: make([][]bool, n), fixed: make([][]bool, n)} + for i := range n { + g.dark[i], g.fixed[i] = make([]bool, n), make([]bool, n) + } + for i := range n { + g.set(6, i, i%2 == 0) + g.set(i, 6, i%2 == 0) + } + g.finder(3, 3) + g.finder(n-4, 3) + g.finder(3, n-4) + al := qrCodeAlignment(v) + for i, x := range al { + for j, y := range al { + if i == 0 && j == 0 || i == 0 && j == len(al)-1 || i == len(al)-1 && j == 0 { + continue + } + for dy := -2; dy <= 2; dy++ { + for dx := -2; dx <= 2; dx++ { + g.set(x+dx, y+dy, max(qrCodeAbs(dx), qrCodeAbs(dy)) != 1) + } + } + } + } + // Reserve format and version areas; apply fills them. + g.format(-1, 0) + g.version(false) + return g +} + +func qrCodeAbs(v int) int { return max(v, -v) } + +func (g *qrCodeGrid) set(x, y int, dark bool) { + g.dark[y][x], g.fixed[y][x] = dark, true +} + +// finder draws a finder pattern and its separator centered at (cx, cy). +func (g *qrCodeGrid) finder(cx, cy int) { + n := len(g.dark) + for dy := -4; dy <= 4; dy++ { + for dx := -4; dx <= 4; dx++ { + x, y := cx+dx, cy+dy + if x < 0 || y < 0 || x >= n || y >= n { + continue + } + d := max(qrCodeAbs(dx), qrCodeAbs(dy)) + g.set(x, y, d != 2 && d != 4) + } + } +} + +// format writes the 15 format bits and the dark module; levelBits -1 leaves them light. +func (g *qrCodeGrid) format(levelBits, mask int) { + data := levelBits<<3 | mask + rem := data + for range 10 { + rem = rem<<1 ^ (rem>>9)*0x537 + } + b := (data<<10 | rem) ^ 0x5412 + bit := func(i int) bool { return levelBits >= 0 && b>>i&1 == 1 } + n := len(g.dark) + for i := 0; i <= 5; i++ { + g.set(8, i, bit(i)) + } + g.set(8, 7, bit(6)) + g.set(8, 8, bit(7)) + g.set(7, 8, bit(8)) + for i := 9; i < 15; i++ { + g.set(14-i, 8, bit(i)) + } + for i := range 8 { + g.set(n-1-i, 8, bit(i)) + } + for i := 8; i < 15; i++ { + g.set(8, n-15+i, bit(i)) + } + g.set(8, n-8, levelBits >= 0) +} + +// version writes the 18 version bits from version 7 on, or light ones. +func (g *qrCodeGrid) version(on bool) { + if g.v < 7 { + return + } + rem := g.v + for range 12 { + rem = rem<<1 ^ (rem>>11)*0x1F25 + } + b := g.v<<12 | rem + n := len(g.dark) + for i := range 18 { + dark := on && b>>i&1 == 1 + a, c := n-11+i%3, i/3 + g.set(a, c, dark) + g.set(c, a, dark) + } +} + +// place lays the codewords in the zigzag from the bottom right; remainder bits stay light. +func (g *qrCodeGrid) place(words []byte) { + n := len(g.dark) + i := 0 + for right := n - 1; right >= 1; right -= 2 { + if right == 6 { + right = 5 + } + for vert := range n { + for j := range 2 { + x := right - j + up := (right+1)&2 == 0 + y := vert + if up { + y = n - 1 - vert + } + if g.fixed[y][x] || i >= len(words)*8 { + continue + } + g.dark[y][x] = words[i/8]>>(7-i%8)&1 == 1 + i++ + } + } + } +} + +func (g *qrCodeGrid) clone() *qrCodeGrid { + t := &qrCodeGrid{v: g.v, dark: make([][]bool, len(g.dark)), fixed: g.fixed} + for i, row := range g.dark { + t.dark[i] = append([]bool(nil), row...) + } + return t +} + +// apply XORs mask over the data modules; the information areas stay light, as 7.8.3 evaluates them. +func (g *qrCodeGrid) apply(mask int) { + for y, row := range g.dark { + for x := range row { + if g.fixed[y][x] { + continue + } + var flip bool + switch mask { + case 0: + flip = (x+y)%2 == 0 + case 1: + flip = y%2 == 0 + case 2: + flip = x%3 == 0 + case 3: + flip = (x+y)%3 == 0 + case 4: + flip = (x/3+y/2)%2 == 0 + case 5: + flip = x*y%2+x*y%3 == 0 + case 6: + flip = (x*y%2+x*y%3)%2 == 0 + case 7: + flip = ((x+y)%2+x*y%3)%2 == 0 + } + row[x] = row[x] != flip + } + } +} + +// penalty scores the symbol by the four rules of ISO/IEC 18004 7.8.3. +func (g *qrCodeGrid) penalty() int { + n := len(g.dark) + p, dark := 0, 0 + row, col := make([]bool, n), make([]bool, n) + for i := range n { + for j := range n { + row[j], col[j] = g.dark[i][j], g.dark[j][i] + if row[j] { + dark++ + } + if i > 0 && j > 0 && row[j] == row[j-1] && row[j] == g.dark[i-1][j] && row[j] == g.dark[i-1][j-1] { + p += 3 + } + } + p += qrCodeRuns(row) + qrCodeFinders(row) + qrCodeRuns(col) + qrCodeFinders(col) + } + return p + 10*(qrCodeAbs(dark*100-50*n*n)/(5*n*n)) +} + +// qrCodeRuns scores runs of five or more equal modules. +func qrCodeRuns(seq []bool) int { + p, run := 0, 1 + for j := 1; j <= len(seq); j++ { + if j < len(seq) && seq[j] == seq[j-1] { + run++ + continue + } + if run >= 5 { + p += run - 2 + } + run = 1 + } + return p +} + +// qrCodeFinders scores 1:1:3:1:1 patterns with four light modules on a side. +func qrCodeFinders(seq []bool) int { + core := [7]bool{true, false, true, true, true, false, true} + n, p := len(seq), 0 + light := func(a, b int) bool { + for i := max(a, 0); i < min(b, n); i++ { + if seq[i] { + return false + } + } + return true + } + for idx := 0; idx+7 <= n; { + match := true + for i, d := range core { + if seq[idx+i] != d { + match = false + break + } + } + if !match { + idx++ + continue + } + if idx == 0 || idx == n-7 || light(idx-4, idx) || light(idx+7, idx+11) { + p += 40 + idx += 7 + } else { + idx += 4 + } + } + return p +} diff --git a/qr_code_test.go b/qr_code_test.go new file mode 100644 index 0000000..19bf83c --- /dev/null +++ b/qr_code_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestQRCodeDrawsModulesAndCaches(t *testing.T) { + text := "HELLO" + var box ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Dark}) + ui.Row(c).Children(func() { box = QRCode(c, text, QRCodeOptions{Level: QRLow, Size: 116}).Bounds() }) + }, 200, 200) + tt.Frame() + if box.W != 116 { + t.Fatalf("size %v", box) + } + m, _ := EncodeQR([]byte(text), QRLow) + // 21 modules plus 8 of quiet zone in 116 DIPs: 4 DIPs each. + px := func(x, y int) ui.Color { + r, g, b, _ := tt.Image().At(int(box.X)+(x+4)*4+2, int(box.Y)+(y+4)*4+2).RGBA() + return ui.RGB(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + } + for _, at := range [][2]int{{0, 0}, {3, 3}, {7, 7}, {10, 6}, {12, 12}} { + want := theme.Light().Surface + if m.Modules[at[1]][at[0]] { + want = theme.Light().Text + } + if got := px(at[0], at[1]); got != want { + t.Errorf("module %v is %v, want %v", at, got, want) + } + } + if _, ok := tt.Find("QR code"); !ok { + t.Fatal("no accessible name") + } +} + +func TestQRCodeTooLong(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + QRCode(c, strings.Repeat("x", 1300), QRCodeOptions{Level: QRHigh}) + }, 300, 300) + if !tt.HasText("Text too long for a QR code") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestQRCodeRejectsBadOptions(t *testing.T) { + for name, o := range map[string]QRCodeOptions{"level": {Level: 9}, "size": {Size: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); QRCode(c, "x", o) }, 200, 200) + }() + } +} + +func TestQRCodeSnapshot(t *testing.T) { + snapshot(t, "qr_code", 420, 200, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + QRCode(c, "https://example.com/mujica", QRCodeOptions{}) + QRCode(c, "MujicaUI", QRCodeOptions{Level: QRHigh, Size: 100}) + QRCode(c, strings.Repeat("x", 1300), QRCodeOptions{Level: QRHigh, Size: 100}) + }) + }) +} diff --git a/radio_group.go b/radio_group.go index 6a87ade..0cfca88 100644 --- a/radio_group.go +++ b/radio_group.go @@ -51,12 +51,7 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt wrap.Children(func() { r.Element = ui.RadioGroup(c, func() { for _, it := range items { - key := slices.Index(*known, it.Value) - if key < 0 { - key = len(*known) - *known = append(*known, it.Value) - } - if radioGroupItem(c, k, d, value, it, key) { + if radioGroupItem(c, k, d, value, it, stableKey(known, it.Value)) { r.changed = true slog.Debug("mujica: radio_group choose", "label", it.Label) } @@ -103,3 +98,13 @@ func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density }) return row.Changed() } + +// stableKey keys v by its first appearance in *known, so equal-looking values stay apart. +func stableKey[T comparable](known *[]T, v T) int { + i := slices.Index(*known, v) + if i < 0 { + i = len(*known) + *known = append(*known, v) + } + return i +} diff --git a/resizable_panel_group.go b/resizable_panel_group.go new file mode 100644 index 0000000..8e273b6 --- /dev/null +++ b/resizable_panel_group.go @@ -0,0 +1,256 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ResizablePanel is one panel of a ResizablePanelGroup. +type ResizablePanel struct { + // Label names the panel; dividers read it out. + Label string + // Min is the smallest size in DIPs; zero is 10 spacing units. + Min float32 + Collapsible bool + Build func() +} + +// ResizablePanelGroupOptions configure a ResizablePanelGroup. +type ResizablePanelGroupOptions struct { + // Vertical stacks the panels top to bottom. + Vertical bool + Disabled bool +} + +// ResizablePanelGroupResult reports a group's changes: Resized while moving, Committed when a change is done, to save. +type ResizablePanelGroupResult struct { + Element *ui.Element + Resized bool + Committed bool +} + +// resizablePanelState keeps drags and the sizes collapsed panels return to. +type resizablePanelState struct { + held int // divider being dragged, -1 for none + restored map[int]float32 +} + +// ResizablePanelGroup lays panels in a row with dividers; *sizes holds their fractions, summing to 1, for the caller to save. +func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePanel, opts ResizablePanelGroupOptions) ResizablePanelGroupResult { + if sizes == nil || len(panels) < 2 { + panic("mujica: ResizablePanelGroup needs sizes and two panels or more") + } + n := len(panels) + if len(*sizes) == 0 { + *sizes = make([]float32, n) + for i := range *sizes { + (*sizes)[i] = 1 / float32(n) + } + } + if len(*sizes) != n { + panic(fmt.Sprintf("mujica: ResizablePanelGroup has %d sizes for %d panels", len(*sizes), n)) + } + var sum float32 + for i, f := range *sizes { + if !(f >= 0) || f == 0 && !panels[i].Collapsible || panels[i].Label == "" || panels[i].Build == nil || panels[i].Min < 0 { + panic(fmt.Sprintf("mujica: ResizablePanelGroup panel %d is invalid", i)) + } + sum += f + } + if math.Abs(float64(sum-1)) > 1e-3 { + panic(fmt.Sprintf("mujica: ResizablePanelGroup sizes sum to %v, not 1", sum)) + } + u := Density(c).Unit() + k := Tokens(c) + var res ResizablePanelGroupResult + box := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) + if opts.Vertical { + box.Column() + } + disabled := box.IsDisabled() + st := *ui.Local(box, "resizable_panel_group", func() *resizablePanelState { return &resizablePanelState{held: -1, restored: map[int]float32{}} }) + was := box.Bounds() + internal.Remeasure(c, box, was.W, was.H) + total := was.W + if opts.Vertical { + total = was.H + } + avail := max(0, total-float32(n-1)*theme.BorderWidth) + mins := make([]float32, n) + for i, p := range panels { + mins[i] = cmp.Or(p.Min, 10*u) + } + along := func(e *ui.Element, v float32) *ui.Element { + if opts.Vertical { + return e.Height(v).FillWidth() + } + return e.Width(v).FillHeight() + } + box.Children(func() { + var grips []func() + defer func() { + // Grips go last, above the panels they overlap. + for _, g := range grips { + g() + } + }() + for i, p := range panels { + if i > 0 { + grips = append(grips, resizablePanelDivider(c, k, u, st, sizes, panels, mins, avail, i-1, opts.Vertical, disabled, &res)) + } + if (*sizes)[i] == 0 { + continue + } + pane := ui.Box(c).Key(i).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label) + if avail > 0 { + along(pane, (*sizes)[i]*avail) + } else { + pane.Grow((*sizes)[i]) + } + pane.Children(p.Build) + } + }) + if res.Resized || res.Committed { + c.Invalidate() + } + res.Element = box + return res +} + +// resizablePanelDivider builds the divider after panel i, stepped with arrows, Enter collapsing; it returns the grip's builder. +func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizablePanelState, sizes *[]float32, panels []ResizablePanel, mins []float32, avail float32, i int, vertical, disabled bool, res *ResizablePanelGroupResult) func() { + s := *sizes + div := ui.Box(c).Key(fmt.Sprint("div", i)).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter). + Label(fmt.Sprintf(msg(c, "resizable_panel_group.divider"), panels[i].Label, panels[i+1].Label)) + if vertical { + div.Height(theme.BorderWidth).FillWidth() + } else { + div.Width(theme.BorderWidth).FillHeight() + } + // Moving the boundary by d DIPs trades size between panels i and i+1 within their minimums. + move := func(d float32) bool { + if avail <= 0 { + return false + } + a, b := s[i]*avail, s[i+1]*avail + pair := a + b + na := max(0, min(pair, a+d)) + collapse := func(size, min float32, ok bool) bool { return ok && size < min/2 } + switch { + case collapse(na, mins[i], panels[i].Collapsible): + na = 0 + case collapse(pair-na, mins[i+1], panels[i+1].Collapsible): + na = pair + default: + na = max(min(mins[i], pair-mins[i+1]), min(na, pair-mins[i+1])) + } + if math.Abs(float64(na-a)) < 0.01 { + return false + } + s[i], s[i+1] = na/avail, (pair-na)/avail + return true + } + toggle := func() { + j := i + 1 + if !panels[j].Collapsible { + j = i + } + if !panels[j].Collapsible { + return + } + other := i + i + 1 - j + if s[j] > 0 { + st.restored[j] = s[j] + s[other] += s[j] + s[j] = 0 + } else { + back := min(cmp.Or(st.restored[j], mins[j]/max(avail, 1)), s[other]-mins[other]/max(avail, 1)) + s[j], s[other] = back, s[other]-back + } + res.Committed = true + slog.Debug("mujica: resizable_panel_group toggled", "panel", panels[j].Label, "size", s[j]) + } + if !disabled { + back, forth := ui.KeyLeft, ui.KeyRight + if vertical { + back, forth = ui.KeyUp, ui.KeyDown + } + switch { + case div.Shortcut(0, back): + res.Committed = move(-4 * u) + case div.Shortcut(0, forth): + res.Committed = move(4 * u) + case div.Shortcut(0, ui.KeyHome): + res.Committed = move(-s[i] * avail) + case div.Shortcut(0, ui.KeyEnd): + res.Committed = move(s[i+1] * avail) + case div.Shortcut(0, ui.KeyEnter): + toggle() + } + } + if s[i] > 0 && s[i+1] > 0 { + div.Description(fmt.Sprintf("%.0f%%", 100*s[i]/(s[i]+s[i+1]))) + } else { + div.Description(msg(c, "split_pane.collapsed")) + } + return func() { + grip := ui.Box(c).Key(fmt.Sprint("grip", i)).Absolute().Cursor(ui.CursorResizeEW) + if vertical { + grip.Cursor(ui.CursorResizeNS) + } + dx, dy, held := grip.Dragged() + held = held && !disabled + switch { + case held: + if st.held != i { + st.held = i + div.Focus() + } + d := dx + if vertical { + d = dy + } + if d != 0 && move(d) { + res.Resized = true + } + case st.held == i: + st.held = -1 + res.Committed = true + slog.Debug("mujica: resizable_panel_group resized", "divider", i, "sizes", s) + } + resizablePanelGrip(c, k, u, s, avail, i, vertical, grip, div, st.held == i) + } +} + +// resizablePanelGrip places the grip over divider i and draws the divider. +func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div *ui.Element, held bool) { + // The grip is wider than the 1 DIP line, centered on the boundary. + var at float32 + for _, f := range s[:i+1] { + at += f * avail + } + at += float32(i) * theme.BorderWidth + if vertical { + grip.Left(0).Right(0).Top(at - 1.5*u).Height(3 * u) + } else { + grip.Top(0).Bottom(0).Left(at - 1.5*u).Width(3 * u) + } + active := held || grip.Hovered() + div.Draw(func(p *ui.Painter, r ui.Rect) { + line := k.ControlBorder + if active { + line = k.AccentText + } + p.Fill(r, line, 0) + if div.FocusVisible() { + internal.FocusRing(p, r, 0, k.Focus) + } + }) +} diff --git a/resizable_panel_group_test.go b/resizable_panel_group_test.go new file mode 100644 index 0000000..6c56463 --- /dev/null +++ b/resizable_panel_group_test.go @@ -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) + }) + }) +} diff --git a/scrub_input.go b/scrub_input.go new file mode 100644 index 0000000..387bc67 --- /dev/null +++ b/scrub_input.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ScrubInputOptions configure a ScrubInput; the range and step rules are NumberInput's. +type ScrubInputOptions struct { + Min, Max float64 + Step float64 + Precision int + Disabled bool +} + +// scrubInputPixels is how far a drag moves for one step. +const scrubInputPixels float32 = 4 + +// ScrubInput edits *value by dragging its label sideways, one Step per 4 DIPs, or by typing in its field. +func ScrubInput(c *ui.Context, label string, value *float64, opts ScrubInputOptions) InputResult { + if label == "" || value == nil { + panic("mujica: ScrubInput needs a label and a value") + } + nopts := NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: opts.Step, Precision: opts.Precision, Label: label, Disabled: opts.Disabled} + s := numberInputResolve(nopts) + k := Tokens(c) + var r InputResult + row := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Disabled(opts.Disabled) + row.Children(func() { + h := ui.Box(c).Shrink(0).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Center().Cursor(ui.CursorResizeEW).Role(ui.RoleNone) + drag := ui.Local(h, "scrub_input.drag", func() float64 { return math.NaN() }) + dx, _, held := h.Dragged() + scrubbed := false + switch { + case !held || h.IsDisabled(): + *drag = math.NaN() + case math.IsNaN(*drag): + *drag = 0 + fallthrough + default: + start := *value + *drag += float64(dx) + if n := int(*drag / float64(scrubInputPixels)); n != 0 { + *drag -= float64(n) * float64(scrubInputPixels) + // One grid step at a time, so a long drag stops at the bound. + v := start + for range max(n, -n) { + v = s.stepBy(v, max(-1, min(1, n))) + } + if v != *value { + *value, scrubbed = v, true + } + } + } + fg := k.TextMuted + if held || h.Hovered() { + fg = k.AccentText + } + h.Children(func() { + ui.Text(c, label).TextColor(fg).Underline().DecorationColor(k.Border).SingleLine() + }) + r = NumberInput(c, value, nopts) + r.Element.Width(Density(c).Unit() * 36) + r.changed = r.changed || scrubbed + if scrubbed { + slog.Debug("mujica: scrub_input scrubbed", "label", label, "value", *value) + } + }) + r.Element = row + return r +} diff --git a/scrub_input_test.go b/scrub_input_test.go new file mode 100644 index 0000000..2e9502b --- /dev/null +++ b/scrub_input_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func scrubTester(v *float64, opts *ScrubInputOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if ScrubInput(c, "Opacity", v, *opts).Changed() { + *n++ + } + }) + }, 500, 200) +} + +func TestScrubInputDragAndRange(t *testing.T) { + v := 0.5 + opts := ScrubInputOptions{Min: 0, Max: 1, Step: 0.1} + n := 0 + tt := scrubTester(&v, &opts, &n) + r, _ := tt.Find("Opacity") + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + tt.Move(x+6, y) // one step, 2 DIPs carried + tt.Move(x+10, y) + tt.Release(x+10, y) + if v != 0.7 { + t.Fatalf("10 DIPs right: %v", v) + } + tt.Press(x, y) + tt.Move(x+400, y) + tt.Release(x+400, y) + if v != 1 { + t.Fatalf("past the max: %v", v) + } + tt.Press(x, y) + tt.Move(x-8, y) + tt.Release(x-8, y) + if v != 0.8 || n != 4 { + t.Fatalf("8 DIPs left: %v (%d)", v, n) + } +} + +func TestScrubInputTypingAndDisabled(t *testing.T) { + v := 2.0 + opts := ScrubInputOptions{Step: 1} + n := 0 + tt := scrubTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if v != 3 { + t.Fatalf("Up in the field: %v", v) + } + tt.Command("selectAll") + tt.Type("12") + if v != 12 { + t.Fatalf("typed: %v", v) + } + opts.Disabled = true + tt.Frame() + r, _ := tt.Find("Opacity") + tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W/2+40, r.Y+r.H/2) + tt.Release(r.X+r.W/2+40, r.Y+r.H/2) + if v != 12 { + t.Fatalf("disabled scrub: %v", v) + } +} + +func TestScrubInputSnapshot(t *testing.T) { + a, b := 0.6, 24.0 + snapshot(t, "scrub_input", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ScrubInput(c, "Opacity", &a, ScrubInputOptions{Max: 1, Step: 0.1}) + ScrubInput(c, "Size", &b, ScrubInputOptions{Disabled: true}) + }) + }) +} diff --git a/signature_pad.go b/signature_pad.go new file mode 100644 index 0000000..226162f --- /dev/null +++ b/signature_pad.go @@ -0,0 +1,153 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SignaturePoint is a point of a stroke, in DIPs from the pad's top left. +type SignaturePoint struct{ X, Y float32 } + +// SignaturePadOptions configure a SignaturePad. +type SignaturePadOptions struct { + // Height is the drawing area's height; zero is 160. The pad fills its parent's width. + Height float32 + // Label names the pad; empty uses "Signature". + Label string + Disabled bool +} + +// SignaturePath writes strokes as SVG path data: "M x y L x y …" per stroke. +func SignaturePath(strokes [][]SignaturePoint) string { + var b strings.Builder + num := func(v float32) string { return strconv.FormatFloat(float64(v), 'f', -1, 32) } + for _, st := range strokes { + for i, p := range st { + if b.Len() > 0 { + b.WriteByte(' ') + } + if i == 0 { + b.WriteString("M ") + } else { + b.WriteString("L ") + } + b.WriteString(num(p.X) + " " + num(p.Y)) + } + } + return b.String() +} + +// signaturePadDrawing is the stroke in progress. +type signaturePadDrawing struct { + active, ended bool +} + +// SignaturePad records pointer strokes into *strokes; Changed reports a finished stroke or a clear. +func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadOptions) ChoiceResult { + if strokes == nil { + panic("mujica: SignaturePad needs strokes") + } + if !(opts.Height >= 0) || math.IsInf(float64(opts.Height), 1) { + panic("mujica: SignaturePad Height must be finite and >= 0") + } + k := Tokens(c) + u := Density(c).Unit() + name := cmp.Or(opts.Label, msg(c, "signature_pad.label")) + col := ui.Column(c).FillWidth().Gap(u * 2).Disabled(opts.Disabled) + disabled := col.IsDisabled() + changed := false + col.Children(func() { + pad := ui.Box(c).FillWidth().Height(cmp.Or(opts.Height, 160)).Focusable().Radius(theme.CardRadius).Clip(). + Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Cursor(ui.CursorCrosshair). + Role(ui.RoleImage).Label(name).Description(msg(c, "signature_pad.hint")) + internal.Ring(pad, k.Focus, theme.CardRadius) + d := ui.Local(pad, "signature_pad", func() *signaturePadDrawing { return &signaturePadDrawing{} }) + s := *d + pad.HandleInput(func(ev ui.InputEvent) bool { + if pad.IsDisabled() { + return false + } + pt := SignaturePoint{ev.X, ev.Y} + switch { + case ev.Kind == ui.InputPointerDown && ev.Button == 0: + *strokes = append(*strokes, []SignaturePoint{pt}) + s.active = true + pad.Focus() + case ev.Kind == ui.InputPointerMove && s.active && len(*strokes) > 0: + last := &(*strokes)[len(*strokes)-1] + if q := (*last)[len(*last)-1]; math.Hypot(float64(pt.X-q.X), float64(pt.Y-q.Y)) >= 1 { + *last = append(*last, pt) + } + case ev.Kind == ui.InputPointerUp && s.active: + s.active, s.ended = false, true + default: + return false + } + return true + }) + if s.ended { + s.ended, changed = false, true + slog.Debug("mujica: signature_pad stroke", "strokes", len(*strokes)) + } + clear := func() { + if len(*strokes) > 0 { + *strokes, changed = nil, true + slog.Debug("mujica: signature_pad clear") + } + } + if !disabled && (pad.Shortcut(0, ui.KeyDelete) || pad.Shortcut(0, ui.KeyBackspace)) { + clear() + } + ink := signaturePadClone(*strokes) + empty := len(ink) == 0 + size := FontSize(c, theme.CaptionSize) + pad.Draw(func(p *ui.Painter, r ui.Rect) { + line := r.Y + r.H - 28 + 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 +} diff --git a/signature_pad_test.go b/signature_pad_test.go new file mode 100644 index 0000000..5aaf1c8 --- /dev/null +++ b/signature_pad_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSignaturePath(t *testing.T) { + got := SignaturePath([][]SignaturePoint{{{1, 2}, {3.5, 4}}, {{10, 10}}}) + if want := "M 1 2 L 3.5 4 M 10 10"; got != want { + t.Fatalf("path %q, want %q", got, want) + } + if SignaturePath(nil) != "" { + t.Fatal("empty strokes made a path") + } +} + +func TestSignaturePadStrokesAndClear(t *testing.T) { + var strokes [][]SignaturePoint + dis := false + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(400).Children(func() { + if SignaturePad(c, &strokes, SignaturePadOptions{Disabled: dis}).Changed() { + n++ + } + }) + }, 500, 300) + tt.Press(50, 60) + tt.Move(80, 70) + tt.Move(80.5, 70) // under a DIP: not kept + tt.Move(120, 90) + tt.Release(120, 90) + if len(strokes) != 1 || len(strokes[0]) != 3 || strokes[0][0] != (SignaturePoint{30, 40}) || n != 1 { + t.Fatalf("stroke %+v, changes %d", strokes, n) + } + tt.Press(200, 60) + tt.Release(200, 60) + if len(strokes) != 2 || n != 2 { + t.Fatalf("dot: %+v", strokes) + } + tt.Key(0, ui.KeyDelete) // drawing focused the pad + if len(strokes) != 0 || n != 3 { + t.Fatalf("Delete kept %d strokes", len(strokes)) + } + tt.Press(50, 60) + tt.Release(50, 60) + if err := tt.Click("Clear"); err != nil || len(strokes) != 0 { + t.Fatalf("Clear button: %v %d", err, len(strokes)) + } + dis = true + tt.Frame() + tt.Press(50, 60) + tt.Move(90, 60) + tt.Release(90, 60) + if len(strokes) != 0 { + t.Fatal("disabled pad drew") + } +} + +func TestSignaturePadSnapshot(t *testing.T) { + strokes := [][]SignaturePoint{{{30, 90}, {50, 60}, {60, 95}, {80, 55}, {95, 100}, {120, 70}, {150, 85}}, {{170, 60}, {175, 100}}} + var empty [][]SignaturePoint + snapshot(t, "signature_pad", 560, 260, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ui.Column(c).Width(250).Children(func() { SignaturePad(c, &strokes, SignaturePadOptions{Height: 180}) }) + ui.Column(c).Width(250).Children(func() { SignaturePad(c, &empty, SignaturePadOptions{Height: 180}) }) + }) + }) +} diff --git a/split_button.go b/split_button.go new file mode 100644 index 0000000..200d60a --- /dev/null +++ b/split_button.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SplitButtonOptions configure a SplitButton. +type SplitButtonOptions struct { + // Variant is Primary (the zero value) or Secondary. + Variant ButtonVariant + Icon *ui.SVG + // MenuLabel names the menu half; empty uses "More options". + MenuLabel string + Disabled bool +} + +// SplitButton runs its main action from the left half and opens build's menu from the right; each half takes focus. +func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build func(m *PopupMenu)) ChoiceResult { + if label == "" || build == nil { + panic("mujica: SplitButton needs a label and a menu") + } + if opts.Variant != Primary && opts.Variant != Secondary { + panic(fmt.Sprintf("mujica: SplitButton Variant %d must be Primary or Secondary", opts.Variant)) + } + k := Tokens(c) + d := Density(c) + menuName := opts.MenuLabel + if menuName == "" { + menuName = msg(c, "split_button.more") + } + group := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) + disabled := group.IsDisabled() + open := ui.Local(group, "split_button.open", func() bool { return false }) + if disabled { + *open = false + } + var clicked bool + group.Children(func() { + main := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*4).Gap(d.Unit()*2). + Radius(theme.ControlRadius, 0, 0, theme.ControlRadius) + splitButtonLook(c, k, main, opts.Variant, false) + main.Children(func() { + if opts.Icon != nil { + ui.Icon(c, opts.Icon).Size(16, 16) + } + ui.Text(c, label).SingleLine() + }) + clicked = main.Clicked() && !disabled + if opts.Variant == Primary { + ui.Box(c).Width(theme.BorderWidth).Background(k.OnAccent.Alpha(0.45)).Role(ui.RoleNone) + } + more := ui.ButtonBase(c).Role(ui.RoleMenuButton).Label(menuName).MinHeight(d.ControlHeight()).Width(theme.MinHitSize+d.Unit()). + Radius(0, theme.ControlRadius, theme.ControlRadius, 0).Tooltip(menuName) + splitButtonLook(c, k, more, opts.Variant, *open) + if opts.Variant == Secondary { + more.BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 0) + } + more.Children(func() { ui.Icon(c, icons.Must("chevron-down")).Size(14, 14) }) + if !disabled && (more.Clicked() || more.Shortcut(0, ui.KeyDown)) { + *open = true + slog.Debug("mujica: split_button open") + } + popupMenuOpen(c, group, open, build) + }) + if clicked { + slog.Debug("mujica: split_button action", "label", label) + } + return ChoiceResult{group, clicked} +} + +// splitButtonLook colors one half as a Button of variant v would be. +func splitButtonLook(c *ui.Context, k theme.Tokens, b *ui.Element, v ButtonVariant, open bool) { + internal.Ring(b, k.Focus, theme.ControlRadius) + bg, fg, border := buttonColors(k, v, b.Hovered() && !b.IsDisabled(), b.Pressed() || open) + b.Background(bg).TextColor(fg).Transition(colorFade) + if border.A > 0 { + b.Border(theme.BorderWidth, border) + } +} diff --git a/split_button_test.go b/split_button_test.go new file mode 100644 index 0000000..166d2c1 --- /dev/null +++ b/split_button_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func splitButtonTester(dis *bool, runs *int, chosen *string) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + r := SplitButton(c, "Save", SplitButtonOptions{Disabled: *dis}, func(m *PopupMenu) { + for _, s := range []string{"Save as…", "Save all"} { + if m.Item(s, PopupMenuItemOptions{}) { + *chosen = s + } + } + }) + if r.Changed() { + *runs++ + } + }) + }, 500, 300) +} + +func TestSplitButtonMainAndMenu(t *testing.T) { + dis, runs, chosen := false, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + if err := tt.Click("Save"); err != nil { + t.Fatal(err) + } + if runs != 1 || tt.HasText("Save all") { + t.Fatalf("main click: runs %d, menu open %v", runs, tt.HasText("Save all")) + } + if err := tt.Click("More options"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Save all"); err != nil { + t.Fatal(err) + } + if chosen != "Save all" || runs != 1 || tt.HasText("Save as…") { + t.Fatalf("menu choice %q runs %d", chosen, runs) + } +} + +func TestSplitButtonKeyboardHalves(t *testing.T) { + dis, runs, chosen := false, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Save") { + t.Fatal("first Tab misses the main half") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + if !tt.Focused("More options") { + t.Fatal("second Tab misses the menu half") + } + tt.Key(0, ui.KeyDown) + if runs != 1 || !tt.HasText("Save as…") { + t.Fatalf("runs %d, menu %v", runs, tt.HasText("Save as…")) + } + tt.Key(0, ui.KeyEnter) + if chosen != "Save as…" { + t.Fatalf("Enter chose %q", chosen) + } +} + +func TestSplitButtonDisabled(t *testing.T) { + dis, runs, chosen := true, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + _ = tt.Click("Save") + _ = tt.Click("More options") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if runs != 0 || tt.HasText("Save all") { + t.Fatalf("disabled: runs %d menu %v", runs, tt.HasText("Save all")) + } + defer func() { + if recover() == nil { + t.Fatal("Danger variant accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SplitButton(c, "x", SplitButtonOptions{Variant: Danger}, func(*PopupMenu) {}) + }, 200, 100) +} + +func TestSplitButtonSnapshot(t *testing.T) { + snapshot(t, "split_button", 460, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + menu := func(m *PopupMenu) { m.Item("Other", PopupMenuItemOptions{}) } + SplitButton(c, "Publish", SplitButtonOptions{}, menu) + SplitButton(c, "Export", SplitButtonOptions{Variant: Secondary}, menu) + SplitButton(c, "Merge", SplitButtonOptions{Disabled: true}, menu) + }) + }) +} diff --git a/stable_keys_test.go b/stable_keys_test.go new file mode 100644 index 0000000..8cc1d50 --- /dev/null +++ b/stable_keys_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFormFocusFollowsFieldAfterReorder(t *testing.T) { + var st FormState + first, second := "", "" + swapped := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Form(c, &st, FormOptions{}, func(f *FormScope) { + fields := []func(){ + func() { + FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() *ui.Element { + return TextInput(c, &first, TextInputOptions{Label: "First"}).Input + }) + }, + func() { + FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() *ui.Element { + return TextInput(c, &second, TextInputOptions{Label: "Second"}).Input + }) + }, + } + if swapped { + slices.Reverse(fields) + } + for _, fn := range fields { + fn() + } + }) + }, 500, 400) + if err := tt.Click("First"); err != nil { + t.Fatal(err) + } + tt.Type("a") + swapped = true + tt.Frame() + tt.Type("b") + if first != "ab" || second != "" { + t.Fatalf("first %q second %q, want typing to stay in First", first, second) + } +} + +func TestToggleGroupFocusFollowsValueAfterReorder(t *testing.T) { + var on []string + items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToggleGroup(c, &on, items, ToggleGroupOptions{Multiple: true, Label: "Align"}) + }, 400, 200) + tt.Key(0, ui.KeyTab) + slices.Reverse(items) + tt.Frame() + tt.Key(0, ui.KeySpace) + if !slices.Equal(on, []string{"left"}) { + t.Fatalf("pressed %v, want [left]", on) + } +} diff --git a/testdata/qr/hello-l.txt b/testdata/qr/hello-l.txt new file mode 100644 index 0000000..2e671dd --- /dev/null +++ b/testdata/qr/hello-l.txt @@ -0,0 +1,22 @@ +1 L 4 48454c4c4f +111111101111101111111 +100000101010101000001 +101110101000001011101 +101110101111001011101 +101110100000101011101 +100000101101001000001 +111111101010101111111 +000000000111100000000 +110011100101000101111 +101001000101100101111 +000101101111001110010 +100000011000011010000 +111011110100111000110 +000000001000111001011 +111111100100110001010 +100000101101100100010 +101110101011001110101 +101110100111100001011 +101110100011001111000 +100000101110011000000 +111111101110111110101 diff --git a/testdata/qr/long-h.txt b/testdata/qr/long-h.txt new file mode 100644 index 0000000..3b7ee86 --- /dev/null +++ b/testdata/qr/long-h.txt @@ -0,0 +1,90 @@ +18 H 7 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff4d756a69636155494d756a69636155494d756a69636155494d756a69636155494d756a69636155494d756a6963615549 +11111110100011001001111011010000010111101000100000010111000011001100110010001011101111111 +10000010101011101101011100110101101000101010010110111010110010101001011001101101001000001 +10111010001001001111010000100101011110100001011011110100001001100001010111110100001011101 +10111010100100010101001110100110011011010101000010101110111111000100010001010110101011101 +10111010101010111100111110011111101001100110111101011011111000110110110110110101001011101 +10000010100000010011011110101000101011101010001101001110001110001010000111101101001000001 +11111110101010101010101010101010101010101010101010101010101010101010101010101010101111111 +00000000000100111011011011011000110111111010011111101010001010001100011010010101100000000 +00010010010011001101111101111111111101110010000000011011111001110001110101100000100111011 +10101101110010101001001010110100000011101100111000001100111100100010001000100111101011101 +11001010010011001011011100010001110110101010100100000101101110010000110111110101111010110 +11110000111100111011111111011011111000000010010001001001010111000011111111011010100110100 +00011111010010111001101001111011001001101010011011011010101110011101110011001100010010110 +01010100101111110011101011110110000011001001011100111101010000110000101111010000010100010 +01100111001111011000101010110001101010011010000001010011101000110001100001010111010001011 +00110100101010000011010010100001100101100111010011111011111111010100110111000100100111000 +11011011110100011110000001001001101110100100010111110100001101111001001001001000111110010 +00001100000011111111011101111010100110111111011001010010111101011111001010011111000000001 +10100111011010111101110011011000101010111111110010101010001110011101100111111011110000110 +10100100110000110010110111110010110101000001001111000001111110111111110110101111010110010 +10111010001101001111110100110101001000001010110010111101110011011010000110101101010100000 +11110101111110111001001000100111101000100001111110010100100111101110100010001001010101111 +10111011110011100011110101100100000100100101001011011011100100010100111111010111011111011 +00011101001011101011111010111010110101001001011011100101101101110100010011100000001011101 +01001111001100011011100000101110000000111101111011010101100100110010011001100110100101100 +11111100110100101110101001000110011010110001000101011011001110100111100011100010101100101 +10111010100111110001100011100100001100111000010000110111010010010001110000110010111111010 +10111000100010011100000000100111110110011010000110101101111000101001101001110110111111010 +10101111110101100000000101001111111100101100111100010111111110100111110010101111111111011 +10101000111001101001101111101000110111110111001000100010001111110001110000110101100011010 +11111010101011010010001001011010101111101110010101010110101110101100110011001000101010110 +11001000110100110111101011001000110000111000001000111010001001000100111000011101100010001 +00111111101010100101011100011111111110110101110010110011111000000110100100000111111111110 +01110100010100001010101100111001010010001000010110100110000011010111011100100010111100001 +11110110001111001101001101001010010111001000110111010011010101101011011000000001101110001 +00000001010110101011111100100111010111011000110011011110100001011110100000001011100110111 +10001111111111010000000001001000010010010011000100101001010111001010100000000011100110110 +01110101010101111111001101111110011111001011110010010100010100001101010000101000001001000 +01110110111011101011001011010001010010000111111010100011101111001110010111100000111010100 +01111000101111011100100000111101100111010010100001111100011101011100001100111000000110001 +11010110010110100001001100010011001010110011111110101101111101001101000001101101001110111 +01000100000000100000011110001100101110100110000000011000001001000000111011010111010010111 +01110010001101101110101110100100100011000011100001110101011001100010001010001100001111100 +00100001110101101010001100100110001001111010010000111100100010101110011110010011011001011 +01101011010011011011010111100011000001100010011100011001010110101100110100100001001010101 +11111001101110000110011010000001000010100111010011110011111001011101110111111001101010000 +00010011101111111111011101111010101100100011100000001110100011000111100011000101001111101 +11101000011100000110100100101011111100001100000111000110111111100001110011111010110000100 +11111010100111100010000011110011101010001010011001000000110001100110000000100011001111001 +00100000100100111111110000110100011101001000111111100010010000110111111100100110000100011 +01001011001011100101100110001011100000010100000000110111011100100010001000110101101101100 +01111001101101011101001100001111010101100101010000110010011111110111111011101110110011111 +00010011000010000100010100100110011001000101011101001010101010101110110001010110110011100 +00101100110100111101000011010110110011100000110100110010001011101000010011110010011101101 +01111111110111000100011111101111100010101010010000110111111011001001111110101100111110011 +11111000101000010111101000001000110010001111100011000010001110011101111010000001100010100 +01111010110010101100110011001010100110001001001010001110101100000110101101010111101011001 +10111000111100000111101000001000110101000111101110000110001111110111111111010110100011011 +00001111101000100111001010111111101101111100011011010111111000010001110001101101111110110 +10111100001110011110110000000110001111001011001000011011001101011010011111011000001011010 +10001010001110011001101000000010001011111111011100000011001010111010101110111111101000011 +00001100001000010000010101111001111110111011001101111100111011000101011000001101110100001 +01001010111011000100111111011101011101101011010010101100011110011001000111001111100100011 +01100101010011110111101001010100001111110111011001100000100000001000100010011100111111100 +11001010010110110101001000111110001111000011101001100010010000001101011100011010000110010 +11111101110001011011011010100011010100111111100001101110000110010011111101000001001000010 +01101110111110000010110111001000101010000010011111101000111010111101000001100011100101011 +10101101100011110111111001100111111111011101100110101000011111100011100111101010001111110 +00101110100111110111010110110100110111011001000101101011011011100001010011111000011110011 +11110101001100110111010001110100000111001001000001000111111010000101110111011101110100101 +10111111000011010010111111011010101101110111010010011111010101101101101010100010001111000 +00100100001100000000110101000001000101110100100010100001000010011110001010000111110110001 +10000011001111010110111100101101011110001000001101110001110000000001000100010000011010111 +01111000111000001101011100100000001001001010100100101010111101111010110110110111001101110 +01110110010010000011101111101110110110011000110001011011011111000110110100100011011111001 +11111000001000010011001111011111010010110010101101101111111110001001100000010000001100110 +11101110100111111001110001010110111000011000100001100110001001010000101111010001110100101 +01100100001010000001110010110010001101100111000000011010100010101100010111101000111100000 +10010010110001100010101010000100111011100101110010110111001011101010110010001111011110101 +11001101101000010100111000110111011001110010100001011110011111100010100001111011010011101 +10010010110011101100111101111111100101001010101100000111111101010011111001010010111110100 +00000000100111100111001010011000111111010100011101010010001100101010101011101110100011110 +11111110010000000110100100001010110101000111000011110010101101001111000000001001101010000 +10000010001100100100001000101000101111110111101100011110001110111100100000101101100010001 +10111010010110011100100111011111111101010101001011011111111111111111111111101111111111000 +10111010100111001001001111001011011111111000010101000010010101011000001100011100001110111 +10111010011110001100111110000001010110010101100011110110001010111011001011111101010010101 +10000010010100100000000110110110011001110010010011111000011010111010001100101011000000010 +11111110010101101000100101001010110001111100100010001101100101111010000101101011010111010 diff --git a/testdata/qr/text-q.txt b/testdata/qr/text-q.txt new file mode 100644 index 0000000..ac74755 --- /dev/null +++ b/testdata/qr/text-q.txt @@ -0,0 +1,50 @@ +8 Q 7 436f75727420676f7468696320636f6d706f6e656e747320666f72204d79476f2e20436f75727420676f7468696320636f6d706f6e656e747320666f72204d79476f2e20436f75727420676f7468696320636f6d706f6e656e747320666f72204d79476f2e20 +1111111010000000011000011010001101101100101111111 +1000001001110010010000011101111111110111101000001 +1011101011011000111001101101000010110001101011101 +1011101010000101101110110110100000100001001011101 +1011101001110100001101111110111000001000001011101 +1000001010110100011000100010111111101010001000001 +1111111010101010101010101010101010101010101111111 +0000000010011100110000100010110101011110000000000 +0101011111100110100111111110111001111110011101101 +1110000001011111100110101101000000001000010011011 +0010101010110111110001110101001110010000000110111 +1111100010101110100010010101011101001101010100010 +0100011010111010111010100000010111011010010011011 +0011110101011111010100001100000000110100100001101 +1001101100011110010011010110110000011010110101001 +0011110000100101001011000100101110101011001011001 +1000111010110011111011101000100110111010010010110 +1100100110001000010010011000111101010010010101001 +1010101010000100010001101010101100010100110101011 +1001000100000000010001111110101011011101110110101 +1001101000011010111000111011100101101110010100000 +1000110000100011001110111101000010000000000000110 +1000111111110000110000111110000000100100111110111 +0001100010111001101001100010011100101011100010001 +0100101011100111010110101010100111101001101010011 +1100100010011010100001100010110110111101100010101 +1011111111111000100110111110110101011010111111101 +0000000000101101101110111010110111011000101000010 +0110001000011100110101101111001010011110011101111 +1011110100110010010001101101110110000010100010111 +1100101000111000111101011101000010011000111000011 +1111010100011101111100110011110000111000001100100 +0001001010010000010001100101000111001010011011001 +1100010101101101011000100111100110001100110000011 +0100011111111010101101011101100000111100011110011 +1000100110011000000010111001111000101100001111001 +0001111011111011100011110001100111001110001100010 +0110100001010111011011010110100100101100110101111 +0100011010111000011100101111110110011010101000101 +0111000110000100010100010100110111010110011000001 +1110001100001010111101111111011110011010111110101 +0000000011011100100111100010100001000011100010101 +1111111011100101010110101011011100101010101011011 +1000001011111010001011100011001011111110100010110 +1011101000010000100010111111101101001110111111011 +1011101011100000000001100000100011010001101010011 +1011101000001111011101101011110110100000010011011 +1000001010111010110010001001000111111001110100000 +1111111001000101010110100001101111111100110010011 diff --git a/testdata/qr/url-m.txt b/testdata/qr/url-m.txt new file mode 100644 index 0000000..02002b2 --- /dev/null +++ b/testdata/qr/url-m.txt @@ -0,0 +1,26 @@ +2 M 1 68747470733a2f2f6578616d706c652e636f6d2f6d756a696361 +1111111011100010001111111 +1000001000110110101000001 +1011101010101100101011101 +1011101001100110001011101 +1011101000011010001011101 +1000001011100010001000001 +1111111010101010101111111 +0000000001110011100000000 +1010001100110010100100101 +1110110100110101011101011 +0100101101010001010011101 +1110100110110010100101000 +0001101111101001101100001 +0011110111000101101100011 +1110101011010101111001101 +0011010110000010110111000 +1100001100111000111110010 +0000000010111010100010001 +1111111011010100101010001 +1000001000110011100010001 +1011101000111001111110010 +1011101001011100010010110 +1011101011010011110111011 +1000001001100001111110000 +1111111011101000101001001 diff --git a/testdata/qr/v8-l.txt b/testdata/qr/v8-l.txt new file mode 100644 index 0000000..52d312b --- /dev/null +++ b/testdata/qr/v8-l.txt @@ -0,0 +1,50 @@ +8 L 2 78787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878 +1111111001110110010100001101011000001000101111111 +1000001010011111010000100111110010100011101000001 +1011101000010011100011001010100111110001101011101 +1011101011110110010100010000001101011101001011101 +1011101000011001001001111111011000001100001011101 +1000001011100010000101100011110010100110001000001 +1111111010101010101010101010101010101010101111111 +0000000000010001001001100010100111110011100000000 +1111101110010100111110111110001101011000110101010 +1110100111010110010101100101011000001100011001110 +1001111000111001001001010111110010100111010011011 +0001010000110101111010010010100111110011100110001 +0010111111110001001000111000001101011000101100100 +0101100101101100111110101101011000001100011001110 +1111101000100011100011100111110010100111010011011 +0010010010001000101110101010100111110011100110001 +1010101000110100111111001000001101011000101100100 +1101000111111110010101101101011000001100011001110 +0011001000000111010001000111110010100111010011011 +0101110000001011100010010010100111110011100110001 +0011011000100110010100111000001101011000101100100 +1100010101001001001001001101011000001100011001110 +1111111110111010000101111111110010100111111111011 +0001100011111001001000100010100111110011100010001 +0101101010100100111111101010001101011001101010100 +1011100010100110010101100011011000001100100011110 +0001111111100001001001111111110010100110111111011 +0100000011111101111010100110100111110010110010001 +1011001101110001001001010010001101011001000110100 +1000010010110100111111011001011000001101001101110 +1111111010101011100010101101110010100110111001011 +1000110011000000101110100110100111110010110010001 +1010101100010100111110110010001101011001000110100 +0111100001011110010100011001011000001101001101110 +1111101111101111010001101101110010100110111001011 +1000000111101011100010000110100111110010110010001 +1100001010111110010101010010001101011001000110100 +1110100110001001001001011001011000001101001101110 +0100011101111010000100101101110010100110111001011 +0111000000101001001001100110100111110010110010001 +1110001011110100111111111110001101011001111110101 +0000000010011110010101100011011000001101100011110 +1111111011101001001000101011110010100111101011011 +1000001000100101111011100010100111110011100010001 +1011101011101001001001111110001101011000111110100 +1011101010110100111110010011011000001100100111100 +1011101011100011100011111001110010100110001101000 +1000001011110000101111001100100111110011011000001 +1111111010110100111110000110001101011001110010111 diff --git a/testdata/qr/zh-m.txt b/testdata/qr/zh-m.txt new file mode 100644 index 0000000..563da6d --- /dev/null +++ b/testdata/qr/zh-m.txt @@ -0,0 +1,26 @@ +2 M 2 e5aeabe5bbb7e593a5e789b9e7bb84e4bbb6e5ba93 +1111111001000010001111111 +1000001000001110101000001 +1011101010111100001011101 +1011101010010110101011101 +1011101011011000001011101 +1000001010100010001000001 +1111111010101010101111111 +0000000011111111100000000 +1011111000001101001111100 +0010110001110110100111100 +0001011110011100011100100 +0101010111001101110100100 +0000101110001001000011110 +1000010101000011001010000 +1011011110111100100110000 +1001110100110010011001111 +1010001111010000111110111 +0000000011001100100011100 +1111111000100110101010110 +1000001011101101100011111 +1011101011001010111110111 +1011101010000000100100011 +1011101010111000010010101 +1000001001110011100100101 +1111111011010000111101111 diff --git a/testdata/snapshots/darwin/activity_feed-dark.png b/testdata/snapshots/darwin/activity_feed-dark.png new file mode 100644 index 0000000..b6a09e5 Binary files /dev/null and b/testdata/snapshots/darwin/activity_feed-dark.png differ diff --git a/testdata/snapshots/darwin/activity_feed-light.png b/testdata/snapshots/darwin/activity_feed-light.png new file mode 100644 index 0000000..9e25971 Binary files /dev/null and b/testdata/snapshots/darwin/activity_feed-light.png differ diff --git a/testdata/snapshots/darwin/banner-dark.png b/testdata/snapshots/darwin/banner-dark.png new file mode 100644 index 0000000..9ec220a Binary files /dev/null and b/testdata/snapshots/darwin/banner-dark.png differ diff --git a/testdata/snapshots/darwin/banner-light.png b/testdata/snapshots/darwin/banner-light.png new file mode 100644 index 0000000..c465661 Binary files /dev/null and b/testdata/snapshots/darwin/banner-light.png differ diff --git a/testdata/snapshots/darwin/bulk_action_bar-dark.png b/testdata/snapshots/darwin/bulk_action_bar-dark.png new file mode 100644 index 0000000..adb3146 Binary files /dev/null and b/testdata/snapshots/darwin/bulk_action_bar-dark.png differ diff --git a/testdata/snapshots/darwin/bulk_action_bar-light.png b/testdata/snapshots/darwin/bulk_action_bar-light.png new file mode 100644 index 0000000..0a8f21b Binary files /dev/null and b/testdata/snapshots/darwin/bulk_action_bar-light.png differ diff --git a/testdata/snapshots/darwin/choice_chips-dark.png b/testdata/snapshots/darwin/choice_chips-dark.png new file mode 100644 index 0000000..3a10fcf Binary files /dev/null and b/testdata/snapshots/darwin/choice_chips-dark.png differ diff --git a/testdata/snapshots/darwin/choice_chips-light.png b/testdata/snapshots/darwin/choice_chips-light.png new file mode 100644 index 0000000..473b5a3 Binary files /dev/null and b/testdata/snapshots/darwin/choice_chips-light.png differ diff --git a/testdata/snapshots/darwin/color_palette-dark.png b/testdata/snapshots/darwin/color_palette-dark.png new file mode 100644 index 0000000..8c4106e Binary files /dev/null and b/testdata/snapshots/darwin/color_palette-dark.png differ diff --git a/testdata/snapshots/darwin/color_palette-light.png b/testdata/snapshots/darwin/color_palette-light.png new file mode 100644 index 0000000..65fe9ad Binary files /dev/null and b/testdata/snapshots/darwin/color_palette-light.png differ diff --git a/testdata/snapshots/darwin/copy_button-dark.png b/testdata/snapshots/darwin/copy_button-dark.png new file mode 100644 index 0000000..b059394 Binary files /dev/null and b/testdata/snapshots/darwin/copy_button-dark.png differ diff --git a/testdata/snapshots/darwin/copy_button-light.png b/testdata/snapshots/darwin/copy_button-light.png new file mode 100644 index 0000000..f5668c3 Binary files /dev/null and b/testdata/snapshots/darwin/copy_button-light.png differ diff --git a/testdata/snapshots/darwin/currency_input-dark.png b/testdata/snapshots/darwin/currency_input-dark.png new file mode 100644 index 0000000..07aaac8 Binary files /dev/null and b/testdata/snapshots/darwin/currency_input-dark.png differ diff --git a/testdata/snapshots/darwin/currency_input-light.png b/testdata/snapshots/darwin/currency_input-light.png new file mode 100644 index 0000000..b4a5df3 Binary files /dev/null and b/testdata/snapshots/darwin/currency_input-light.png differ diff --git a/testdata/snapshots/darwin/dock_panel-dark.png b/testdata/snapshots/darwin/dock_panel-dark.png new file mode 100644 index 0000000..cf54f97 Binary files /dev/null and b/testdata/snapshots/darwin/dock_panel-dark.png differ diff --git a/testdata/snapshots/darwin/dock_panel-light.png b/testdata/snapshots/darwin/dock_panel-light.png new file mode 100644 index 0000000..0cab0c0 Binary files /dev/null and b/testdata/snapshots/darwin/dock_panel-light.png differ diff --git a/testdata/snapshots/darwin/hold_to_confirm-dark.png b/testdata/snapshots/darwin/hold_to_confirm-dark.png new file mode 100644 index 0000000..519d86f Binary files /dev/null and b/testdata/snapshots/darwin/hold_to_confirm-dark.png differ diff --git a/testdata/snapshots/darwin/hold_to_confirm-light.png b/testdata/snapshots/darwin/hold_to_confirm-light.png new file mode 100644 index 0000000..30625a2 Binary files /dev/null and b/testdata/snapshots/darwin/hold_to_confirm-light.png differ diff --git a/testdata/snapshots/darwin/knob-dark.png b/testdata/snapshots/darwin/knob-dark.png new file mode 100644 index 0000000..6ab48fb Binary files /dev/null and b/testdata/snapshots/darwin/knob-dark.png differ diff --git a/testdata/snapshots/darwin/knob-light.png b/testdata/snapshots/darwin/knob-light.png new file mode 100644 index 0000000..c8a6952 Binary files /dev/null and b/testdata/snapshots/darwin/knob-light.png differ diff --git a/testdata/snapshots/darwin/masked_input-dark.png b/testdata/snapshots/darwin/masked_input-dark.png new file mode 100644 index 0000000..4914745 Binary files /dev/null and b/testdata/snapshots/darwin/masked_input-dark.png differ diff --git a/testdata/snapshots/darwin/masked_input-light.png b/testdata/snapshots/darwin/masked_input-light.png new file mode 100644 index 0000000..a2a0375 Binary files /dev/null and b/testdata/snapshots/darwin/masked_input-light.png differ diff --git a/testdata/snapshots/darwin/masonry-dark.png b/testdata/snapshots/darwin/masonry-dark.png new file mode 100644 index 0000000..4c4ee52 Binary files /dev/null and b/testdata/snapshots/darwin/masonry-dark.png differ diff --git a/testdata/snapshots/darwin/masonry-light.png b/testdata/snapshots/darwin/masonry-light.png new file mode 100644 index 0000000..44e6eb2 Binary files /dev/null and b/testdata/snapshots/darwin/masonry-light.png differ diff --git a/testdata/snapshots/darwin/mention_input-dark.png b/testdata/snapshots/darwin/mention_input-dark.png new file mode 100644 index 0000000..1588c52 Binary files /dev/null and b/testdata/snapshots/darwin/mention_input-dark.png differ diff --git a/testdata/snapshots/darwin/mention_input-light.png b/testdata/snapshots/darwin/mention_input-light.png new file mode 100644 index 0000000..cba77f7 Binary files /dev/null and b/testdata/snapshots/darwin/mention_input-light.png differ diff --git a/testdata/snapshots/darwin/qr_code-dark.png b/testdata/snapshots/darwin/qr_code-dark.png new file mode 100644 index 0000000..a6481aa Binary files /dev/null and b/testdata/snapshots/darwin/qr_code-dark.png differ diff --git a/testdata/snapshots/darwin/qr_code-light.png b/testdata/snapshots/darwin/qr_code-light.png new file mode 100644 index 0000000..0c46034 Binary files /dev/null and b/testdata/snapshots/darwin/qr_code-light.png differ diff --git a/testdata/snapshots/darwin/resizable_panel_group-dark.png b/testdata/snapshots/darwin/resizable_panel_group-dark.png new file mode 100644 index 0000000..a7a9b63 Binary files /dev/null and b/testdata/snapshots/darwin/resizable_panel_group-dark.png differ diff --git a/testdata/snapshots/darwin/resizable_panel_group-light.png b/testdata/snapshots/darwin/resizable_panel_group-light.png new file mode 100644 index 0000000..70099f4 Binary files /dev/null and b/testdata/snapshots/darwin/resizable_panel_group-light.png differ diff --git a/testdata/snapshots/darwin/scrub_input-dark.png b/testdata/snapshots/darwin/scrub_input-dark.png new file mode 100644 index 0000000..f5a4d87 Binary files /dev/null and b/testdata/snapshots/darwin/scrub_input-dark.png differ diff --git a/testdata/snapshots/darwin/scrub_input-light.png b/testdata/snapshots/darwin/scrub_input-light.png new file mode 100644 index 0000000..4d7f243 Binary files /dev/null and b/testdata/snapshots/darwin/scrub_input-light.png differ diff --git a/testdata/snapshots/darwin/signature_pad-dark.png b/testdata/snapshots/darwin/signature_pad-dark.png new file mode 100644 index 0000000..aec2569 Binary files /dev/null and b/testdata/snapshots/darwin/signature_pad-dark.png differ diff --git a/testdata/snapshots/darwin/signature_pad-light.png b/testdata/snapshots/darwin/signature_pad-light.png new file mode 100644 index 0000000..ac20d18 Binary files /dev/null and b/testdata/snapshots/darwin/signature_pad-light.png differ diff --git a/testdata/snapshots/darwin/split_button-dark.png b/testdata/snapshots/darwin/split_button-dark.png new file mode 100644 index 0000000..a198882 Binary files /dev/null and b/testdata/snapshots/darwin/split_button-dark.png differ diff --git a/testdata/snapshots/darwin/split_button-light.png b/testdata/snapshots/darwin/split_button-light.png new file mode 100644 index 0000000..892ce9e Binary files /dev/null and b/testdata/snapshots/darwin/split_button-light.png differ diff --git a/testdata/snapshots/darwin/undo_toast-dark.png b/testdata/snapshots/darwin/undo_toast-dark.png new file mode 100644 index 0000000..61e51a2 Binary files /dev/null and b/testdata/snapshots/darwin/undo_toast-dark.png differ diff --git a/testdata/snapshots/darwin/undo_toast-light.png b/testdata/snapshots/darwin/undo_toast-light.png new file mode 100644 index 0000000..26b855e Binary files /dev/null and b/testdata/snapshots/darwin/undo_toast-light.png differ diff --git a/testdata/snapshots/darwin/vertical_slider-dark.png b/testdata/snapshots/darwin/vertical_slider-dark.png new file mode 100644 index 0000000..be3302a Binary files /dev/null and b/testdata/snapshots/darwin/vertical_slider-dark.png differ diff --git a/testdata/snapshots/darwin/vertical_slider-light.png b/testdata/snapshots/darwin/vertical_slider-light.png new file mode 100644 index 0000000..98cab76 Binary files /dev/null and b/testdata/snapshots/darwin/vertical_slider-light.png differ diff --git a/toggle_group.go b/toggle_group.go index e8617ec..1c87cc3 100644 --- a/toggle_group.go +++ b/toggle_group.go @@ -59,17 +59,21 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } g.Dividers(theme.BorderWidth, k.ControlBorder) changed := false + known := ui.Local(g, "values", func() []T { return nil }) g.Children(func() { for _, it := range items { on := slices.Contains(*value, it.Value) pressed := on - b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) - // The base took input before Disabled applied: drop a disabled item's press. - if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { - changed = true - on = pressed - } - toggleGroupLook(c, k, b, on, it) + // Keyed by value so focus follows the item, not its position. + ui.Box(c).Key(stableKey(known, it.Value)).Children(func() { + b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) + // The base took input before Disabled applied: drop a disabled item's press. + if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { + changed = true + on = pressed + } + toggleGroupLook(c, k, b, on, it) + }) } }) if changed { diff --git a/undo_toast.go b/undo_toast.go new file mode 100644 index 0000000..bbbe138 --- /dev/null +++ b/undo_toast.go @@ -0,0 +1,136 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// UndoToastOptions configure an UndoToast. +type UndoToastOptions struct { + // OnUndo is required; it runs when the user undoes. + OnUndo func() + // Duration is the countdown; zero is ToastDuration. + Duration time.Duration + // Action labels the button; empty uses "Undo". + Action string +} + +// UndoToastResult reports how an UndoToast ended this frame, if it did. +type UndoToastResult struct { + Element *ui.Element + Undone bool + // Expired: the countdown ran out, or the user closed it; the action stands. + Expired bool +} + +// undoToastClock counts the time shown, stopped while hovered or focused. +type undoToastClock struct { + message string + spent time.Duration + runFrom time.Time +} + +// UndoToast shows message at the bottom of the window with a countdown while *open; it never takes the focus. +func UndoToast(c *ui.Context, open *bool, message string, opts UndoToastOptions) UndoToastResult { + if open == nil || message == "" || opts.OnUndo == nil { + panic("mujica: UndoToast needs open, a message and OnUndo") + } + if opts.Duration < 0 { + panic("mujica: negative UndoToast Duration") + } + clock := ui.Local(c.Root(), "undo_toast", func() *undoToastClock { return &undoToastClock{} }) + s := *clock + var res UndoToastResult + if !*open { + *s = undoToastClock{} + return res + } + now := c.Now() + life := cmp.Or(opts.Duration, ToastDuration) + if s.message != message { + *s = undoToastClock{message: message, runFrom: now} + c.Announce(message) + } + spent := s.spent + if !s.runFrom.IsZero() { + spent += now.Sub(s.runFrom) + } + if spent >= life { + *open, res.Expired = false, true + *s = undoToastClock{} + slog.Debug("mujica: undo_toast expired") + return res + } + k := Tokens(c) + u := Density(c).Unit() + ww, _ := c.Size() + left := life - spent + ui.Overlay(c, func() { + dock := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 4).Justify(ui.Center).PassThrough() + dock.Children(func() { + card := ui.Column(c).Width(min(420, ww-8*u)).Radius(theme.PopupRadius).Background(k.Surface). + Border(theme.BorderWidth, k.ControlBorder).Role(ui.RoleStatus).Label(message).Clip() + card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14)) + card.Transition(ui.ElementTransition{Duration: theme.PopupDuration, Enter: &ui.Motion{Y: 8}, Exit: &ui.Motion{}}) + res.Element = card + card.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).Children(func() { + ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1) + secs := int(math.Ceil(left.Seconds())) + label := fmt.Sprintf("%s (%d)", cmp.Or(opts.Action, msg(c, "undo_toast.undo")), secs) + if Button(c, label, ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw")}).Clicked() { + res.Undone = true + } + if iconAction(c, icons.Must("x"), msg(c, "toast.close")).Clicked() { + res.Expired = true + } + }) + still := c.Preferences().ReduceMotion + ui.Box(c).FillWidth().Height(2).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + if still { + return + } + rest := left + if !s.runFrom.IsZero() { + rest = life - s.spent - p.Now().Sub(s.runFrom) + p.AnimationFrame() + } + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * max(0, float32(rest)/float32(life)), H: r.H}, k.AccentText, 0) + }) + }) + if card.Shortcut(0, ui.KeyEscape) { + res.Expired = true + } + // Hover or focus holds the countdown. + switch held := card.Hovered() || card.FocusWithin(); { + case held && !s.runFrom.IsZero(): + s.spent, s.runFrom = spent, time.Time{} + case !held && s.runFrom.IsZero(): + s.runFrom = now + } + }) + }) + switch { + case res.Undone: + *open, res.Expired = false, false + *s = undoToastClock{} + slog.Debug("mujica: undo_toast undo") + opts.OnUndo() + case res.Expired: + *open = false + *s = undoToastClock{} + slog.Debug("mujica: undo_toast closed") + case !s.runFrom.IsZero(): + // Wake for the next whole second of the count, and for the end. + c.After(min(left, left-left.Truncate(time.Second)+time.Millisecond)) + } + return res +} diff --git a/undo_toast_test.go b/undo_toast_test.go new file mode 100644 index 0000000..4c687d7 --- /dev/null +++ b/undo_toast_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func undoTester(open *bool, life time.Duration, undos *int, last *UndoToastResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Delete", ButtonOptions{}).Clicked() { + *open = true + } + if r := UndoToast(c, open, "File deleted", UndoToastOptions{Duration: life, OnUndo: func() { *undos++ }}); r.Undone || r.Expired { + *last = r + } + }, 600, 400) +} + +func TestUndoToastUndoAndFocus(t *testing.T) { + open, undos := false, 0 + var last UndoToastResult + tt := undoTester(&open, 0, &undos, &last) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("File deleted") || !tt.HasText("Undo (5)") || !tt.Focused("Delete") { + t.Fatalf("shown %q, focus kept %v", tt.Texts(), tt.Focused("Delete")) + } + if !slices.Contains(tt.Announcements(), "File deleted") { + t.Fatal("not announced") + } + if err := tt.Click("Undo (5)"); err != nil { + t.Fatal(err) + } + if undos != 1 || open || !last.Undone || tt.HasText("File deleted") { + t.Fatalf("undo: %d %v %+v", undos, open, last) + } +} + +func TestUndoToastCountdownPauseAndClose(t *testing.T) { + open, undos := false, 0 + var last UndoToastResult + tt := undoTester(&open, 60*time.Millisecond, &undos, &last) + _ = tt.Click("Delete") + r, _ := tt.Find("File deleted") + tt.Move(r.X+r.W/2, r.Y+r.H/2) + time.Sleep(90 * time.Millisecond) + tt.Frame() + if !open { + t.Fatal("hovered countdown ran out") + } + tt.Move(5, 5) + time.Sleep(90 * time.Millisecond) + tt.Frame() + if open || !last.Expired || undos != 0 { + t.Fatalf("expiry: %v %+v %d", open, last, undos) + } + last = UndoToastResult{} + _ = tt.Click("Delete") + _ = tt.Click("Dismiss notification") + if open || !last.Expired || undos != 0 { + t.Fatalf("close: %v %+v", open, last) + } +} + +func TestUndoToastRejects(t *testing.T) { + open := true + for name, f := range map[string]func(c *ui.Context){ + "no undo": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{}) }, + "negative": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{OnUndo: func() {}, Duration: -1}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestUndoToastSnapshot(t *testing.T) { + open := true + snapshot(t, "undo_toast", 520, 160, func(c *ui.Context) { + UndoToast(c, &open, "3 messages archived", UndoToastOptions{OnUndo: func() {}, Duration: time.Hour}) + }) +} diff --git a/vertical_slider.go b/vertical_slider.go new file mode 100644 index 0000000..7e25cb9 --- /dev/null +++ b/vertical_slider.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// VerticalSliderOptions configure a VerticalSlider. Min and Max both zero mean 0 to 100. +type VerticalSliderOptions struct { + Min, Max float64 + // Step snaps the value; zero is continuous. + Step float64 + Marks []SliderMark + // Height is the track length; zero is 160. + Height float32 + // ShowValue shows the value beside the thumb while hovered, dragged or focused. + ShowValue bool + Format func(float64) string + Disabled bool + Label string +} + +// VerticalSlider is a Slider standing up: max at the top, Up and Right raise it. +func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) ChoiceResult { + if value == nil { + panic("mujica: VerticalSlider needs a value") + } + if !(opts.Height >= 0) || math.IsInf(float64(opts.Height), 1) { + panic("mujica: VerticalSlider Height must be finite and >= 0") + } + g, _ := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, false, c) + format := sliderFormat(opts.Format, g) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + h := cmp.Or(opts.Height, 160) + old := g.snap(*value) + *value = old + var s *ui.Element + var next float64 + var held bool + wrap := ui.Column(c).Disabled(opts.Disabled) + wrap.Children(func() { s, next, held = sliderControl(c, old, g, g.lo, g.hi) }) + // The base maps the pointer across; here the height decides. + y := func(r ui.Rect, v float64) float32 { + return r.Y + r.H - sliderThumb/2 - (r.H-sliderThumb)*float32((v-g.lo)/(g.hi-g.lo)) + } + if held { + if _, py, _ := s.PointerPosition(); s.Bounds().H > sliderThumb { + f := 1 - float64((py-sliderThumb/2)/(s.Bounds().H-sliderThumb)) + next = g.snap(g.lo + max(0, min(1, f))*(g.hi-g.lo)) + } + } + w := Density(c).ControlHeight() + for _, m := range opts.Marks { + if m.Label != "" { + w += 40 + break + } + } + s.Size(w, h).PaddingY(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer) + if opts.Label != "" { + s.Label(opts.Label) + } + if !wrap.IsDisabled() { + *value = next + } + changed := *value != old + if changed { + slog.Debug("mujica: vertical_slider changed", "label", opts.Label, "value", *value) + } + hot := s.Hovered() || held + v := *value + s.Draw(func(p *ui.Painter, r ui.Rect) { + x := r.X + Density(c).ControlHeight()/2 + top, bot := y(r, g.hi), y(r, g.lo) + p.Fill(ui.Rect{X: x - sliderTrack/2, Y: top, W: sliderTrack, H: bot - top}, k.Border, 0) + p.Stroke(ui.Rect{X: x - sliderTrack/2, Y: top, W: sliderTrack, H: bot - top}, k.ControlBorder, 0, 1) + p.Fill(ui.Rect{X: x - sliderTrack/2, Y: y(r, v), W: sliderTrack, H: bot - y(r, v)}, k.AccentText, 0) + for _, m := range opts.Marks { + my := y(r, m.Value) + p.Fill(ui.Rect{X: x + sliderTrack/2 + 2, Y: my - 0.5, W: 5, H: 1}, k.TextMuted, 0) + if m.Label != "" { + _, th := p.MeasureText(1000, ui.Span{Text: m.Label, Size: size}) + p.Text(x+sliderTrack/2+10, my-th/2, m.Label, size, k.TextMuted) + } + } + sliderThumbPaint(p, k, x, y(r, v), hot, s.FocusVisible()) + if opts.ShowValue && (hot || s.Focused()) { + sliderBubblePaint(p, k, x, y(r, v), format(v), size) + } + }) + return ChoiceResult{wrap, changed} +} diff --git a/vertical_slider_test.go b/vertical_slider_test.go new file mode 100644 index 0000000..45d0347 --- /dev/null +++ b/vertical_slider_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func verticalTester(v *float64, opts *VerticalSliderOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if VerticalSlider(c, v, *opts).Changed() { + *n++ + } + }) + }, 300, 300) +} + +func TestVerticalSliderKeysAndPointer(t *testing.T) { + v := 0.0 + opts := VerticalSliderOptions{Step: 10, Label: "Level", Height: 216} + n := 0 + tt := verticalTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyRight) + if v != 20 || n != 2 { + t.Fatalf("Up, Right: %v %d", v, n) + } + tt.Key(0, ui.KeyDown) + if v != 10 { + t.Fatalf("Down: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 100 { + t.Fatalf("End: %v", v) + } + r, _ := tt.Find("Level") + // Track runs from y+8 (100) to y+208 (0): a quarter up is 25, snapped to 30 or 20. + tt.Press(r.X+r.W/2, r.Y+8+150) + tt.Release(r.X+r.W/2, r.Y+8+150) + if v != 30 && v != 20 { + t.Fatalf("press at a quarter height: %v", v) + } + tt.Press(r.X+r.W/2, r.Y+8+150) + tt.Move(r.X+r.W/2, r.Y) + tt.Release(r.X+r.W/2, r.Y) + if v != 100 { + t.Fatalf("drag to the top: %v", v) + } + opts.Disabled = true + tt.Frame() + tt.Press(r.X+r.W/2, r.Y+200) + tt.Release(r.X+r.W/2, r.Y+200) + if v != 100 { + t.Fatalf("disabled slider moved: %v", v) + } +} + +func TestVerticalSliderRejects(t *testing.T) { + v := 0.0 + for name, o := range map[string]VerticalSliderOptions{"height": {Height: -1}, "step": {Step: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); VerticalSlider(c, &v, o) }, 200, 200) + }() + } +} + +func TestVerticalSliderSnapshot(t *testing.T) { + a, b := 40.0, 70.0 + snapshot(t, "vertical_slider", 260, 220, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + VerticalSlider(c, &a, VerticalSliderOptions{Marks: []SliderMark{{Value: 0, Label: "0"}, {Value: 50, Label: "50"}, {Value: 100, Label: "100"}}}) + VerticalSlider(c, &b, VerticalSliderOptions{Disabled: true}) + }) + }) +}