Data: components 069-080

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:27:55 -04:00
1 parent 2a232930dd
commit 16533b8f02
71 files changed
+5400 -1

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
package mujica
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AccordionItem is one section of an Accordion.
type AccordionItem struct {
// ID identifies the section in the open list; required, unique.
ID string
Title string
Content func()
Disabled bool
}
// AccordionOptions configure an Accordion.
type AccordionOptions struct {
// Multiple lets several sections stay open; otherwise opening one closes the rest.
Multiple bool
// KeepOne stops the last open section from closing.
KeepOne bool
}
// AccordionView is a built Accordion and what happened to it in this frame.
type AccordionView struct {
*ui.Element
changed bool
}
// Changed reports that a section opened or closed.
func (v AccordionView) Changed() bool { return v.changed }
// Accordion shows sections whose open IDs live in *open, which the caller controls.
func Accordion(c *ui.Context, open *[]string, items []AccordionItem, opts AccordionOptions) AccordionView {
seen := map[string]bool{}
for _, it := range items {
if it.ID == "" || seen[it.ID] || it.Content == nil {
panic("mujica: Accordion item needs a unique ID and Content: " + it.ID)
}
seen[it.ID] = true
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
var v AccordionView
v.Element = ui.Column(c).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius)
focusTo := ui.Local(v.Element, "focus", func() int { return -1 })
v.Children(func() {
for i, it := range items {
ui.Column(c).Key(it.ID).Children(func() {
if i > 0 {
ui.Box(c).Height(1).FillWidth().Background(k.Border)
}
isOpen := slices.Contains(*open, it.ID)
was := isOpen
p := ui.CollapsibleBase(c, &isOpen)
tr := p.Trigger.Disabled(it.Disabled).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()+u*2).
Padding(0, u*4).Label(it.Title)
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() && !it.Disabled {
tr.Background(k.SurfaceHover)
}
if *focusTo == i {
tr.Focus()
*focusTo = -1
}
if j := accordionKey(tr, items, i); j >= 0 {
*focusTo = j
}
if isOpen != was && !it.Disabled && accordionSet(open, it.ID, isOpen, opts) {
v.changed = true
}
isOpen = slices.Contains(*open, it.ID)
prog := tr.Animate("mujica.open", collapsibleTarget(isOpen), Motion(c, theme.StateDuration))
tr.Children(func() {
ui.Text(c, it.Title).FontWeight(600).Grow(1).MinWidth(0)
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).Rotate(180 * prog).TextColor(k.TextMuted).Shrink(0)
})
if panel := collapsiblePanel(c, isOpen, prog, it.Content); panel != nil {
panel.Padding(0, u*4, u*4).Gap(u * 2)
}
})
}
})
return v
}
// accordionKey returns the header the arrows, Home or End move to from header i, or -1.
func accordionKey(tr *ui.Element, items []AccordionItem, i int) int {
step, from := 0, i
switch {
case tr.Shortcut(0, ui.KeyDown):
step = 1
case tr.Shortcut(0, ui.KeyUp):
step = -1
case tr.Shortcut(0, ui.KeyHome):
step, from = 1, -1
case tr.Shortcut(0, ui.KeyEnd):
step, from = -1, len(items)
default:
return -1
}
for j := from + step; j >= 0 && j < len(items); j += step {
if !items[j].Disabled {
return j
}
}
return -1
}
// accordionSet applies a header toggle under the options and reports a change.
func accordionSet(open *[]string, id string, on bool, opts AccordionOptions) bool {
switch {
case on && opts.Multiple:
*open = append(slices.Clone(*open), id)
case on:
*open = []string{id}
case opts.KeepOne && len(*open) == 1:
return false
default:
*open = slices.DeleteFunc(slices.Clone(*open), func(s string) bool { return s == id })
}
slog.Debug("mujica: accordion toggle", "id", id, "open", on)
return true
}
+128
View File
@@ -0,0 +1,128 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func accordionHarness(open *[]string, opts *AccordionOptions, changes *int) func(c *ui.Context) {
return func(c *ui.Context) {
Use(c, Settings{})
items := []AccordionItem{
{ID: "a", Title: "Choir", Content: func() { ui.Text(c, "Choir body") }},
{ID: "b", Title: "Nave", Content: func() { ui.Text(c, "Nave body") }},
{ID: "x", Title: "Locked", Disabled: true, Content: func() { ui.Text(c, "Locked body") }},
{ID: "c", Title: "Crypt", Content: func() { ui.Text(c, "Crypt body") }},
}
ui.Column(c).Padding(10).Children(func() {
if Accordion(c, open, items, *opts).Changed() {
*changes++
}
})
}
}
func TestAccordionSingle(t *testing.T) {
var open []string
var opts AccordionOptions
changes := 0
tt := ui.NewTester(accordionHarness(&open, &opts, &changes), 300, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
_ = tt.Click("Choir")
_ = tt.Click("Nave")
if fmt.Sprint(open) != "[b]" || changes != 2 || tt.HasText("Choir body") || !tt.HasText("Nave body") {
t.Fatalf("open %v changes %d", open, changes)
}
_ = tt.Click("Nave")
if len(open) != 0 {
t.Fatalf("could not close all: %v", open)
}
_ = tt.Click("Locked")
if len(open) != 0 || changes != 3 {
t.Fatalf("disabled opened: %v", open)
}
}
func TestAccordionMultipleKeepOne(t *testing.T) {
open := []string{"a"}
opts := AccordionOptions{Multiple: true, KeepOne: true}
changes := 0
tt := ui.NewTester(accordionHarness(&open, &opts, &changes), 300, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
_ = tt.Click("Crypt")
if fmt.Sprint(open) != "[a c]" {
t.Fatalf("multiple: %v", open)
}
_ = tt.Click("Choir")
_ = tt.Click("Crypt")
if fmt.Sprint(open) != "[c]" || changes != 2 {
t.Fatalf("keep one: %v changes %d", open, changes)
}
}
func TestAccordionKeyboard(t *testing.T) {
var open []string
var opts AccordionOptions
changes := 0
tt := ui.NewTester(accordionHarness(&open, &opts, &changes), 300, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Key(0, ui.KeyTab)
if !tt.Focused("Choir") {
t.Fatal("Tab missed the first header")
}
tt.Key(0, ui.KeyDown)
if !tt.Focused("Nave") {
t.Fatal("Down missed Nave")
}
tt.Key(0, ui.KeyDown) // skips the disabled header
if !tt.Focused("Crypt") {
t.Fatal("Down did not skip the disabled header")
}
tt.Key(0, ui.KeyHome)
if !tt.Focused("Choir") {
t.Fatal("Home missed")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if fmt.Sprint(open) != "[c]" {
t.Fatalf("Enter on End opened %v", open)
}
tt.Key(0, ui.KeyUp)
if !tt.Focused("Nave") {
t.Fatal("Up missed Nave")
}
}
func TestAccordionBadItems(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic on duplicate IDs")
}
}()
var open []string
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Accordion(c, &open, []AccordionItem{{ID: "a", Content: func() {}}, {ID: "a", Content: func() {}}}, AccordionOptions{})
}, 100, 100)
}
func TestAccordionSnapshot(t *testing.T) {
var opts AccordionOptions
changes := 0
open := []string{"b"}
snapshot(t, "accordion", 400, 300, accordionHarnessNoUse(&open, &opts, &changes))
}
func accordionHarnessNoUse(open *[]string, opts *AccordionOptions, changes *int) func(c *ui.Context) {
return func(c *ui.Context) {
items := []AccordionItem{
{ID: "a", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices, two at the altar.") }},
{ID: "b", Title: "Nave", Content: func() { ui.Text(c, "Pews of black oak, polished for the feast.") }},
{ID: "x", Title: "Locked", Disabled: true, Content: func() {}},
{ID: "c", Title: "Crypt", Content: func() {}},
}
ui.Column(c).Padding(16).Children(func() { Accordion(c, open, items, *opts) })
}
}
+70
View File
@@ -0,0 +1,70 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CardOptions configure a Card.
type CardOptions struct {
// Title heads the card in the serif face; empty shows no header.
Title string
// Footer builds the bottom slot, below a divider; nil shows none.
Footer func()
// Decorated draws a double border and corner ornaments in Ornament.
Decorated bool
}
// cardInset is how far the inner line of a decorated card sits inside.
const cardInset float32 = 4
// Card groups content on a surface with an outline. It adds no click behavior.
func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
pad := u * 4
if opts.Decorated {
pad += cardInset + 2
}
card := ui.Column(c).Background(k.Surface).Radius(theme.CardRadius).Gap(u * 3).Padding(pad).Role(ui.RoleGroup)
if opts.Title != "" {
card.Label(opts.Title)
}
if opts.Decorated {
card.Border(theme.BorderWidth, k.Ornament).DrawOver(func(p *ui.Painter, r ui.Rect) {
cardOrnament(p, r, k.Ornament)
})
} else {
card.Border(theme.BorderWidth, k.Border)
}
card.Children(func() {
if opts.Title != "" {
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).LineHeight(1.3)
if opts.Decorated {
ui.Box(c).Height(7).FillWidth().Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
cy := r.Y + r.H/2
p.Line(r.X, cy, r.X+r.W/2-6, cy, 1, k.Ornament)
p.Line(r.X+r.W/2+6, cy, r.X+r.W, cy, 1, k.Ornament)
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, cy), 7), k.Ornament)
})
}
}
ui.Column(c).Gap(u * 2).Children(body)
if opts.Footer != nil {
ui.Box(c).Height(1).FillWidth().Shrink(0).Background(k.Border)
ui.Row(c).Gap(u * 2).Justify(ui.End).AlignItems(ui.Center).Wrap().Children(opts.Footer)
}
})
return card
}
// cardOrnament draws the inner line and the four corner diamonds.
func cardOrnament(p *ui.Painter, r ui.Rect, col ui.Color) {
in := ui.Rect{X: r.X + cardInset, Y: r.Y + cardInset, W: r.W - 2*cardInset, H: r.H - 2*cardInset}
p.Stroke(in, col, 1, 1)
for _, pt := range [4][2]float32{{in.X, in.Y}, {in.X + in.W, in.Y}, {in.X, in.Y + in.H}, {in.X + in.W, in.Y + in.H}} {
p.FillPath(internal.Diamond(internal.Snap(p, pt[0]), internal.Snap(p, pt[1]), 9), col)
}
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"image/color"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func cardView(decorated bool) func(c *ui.Context) {
return func(c *ui.Context) {
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
Card(c, CardOptions{Title: "Archive", Decorated: decorated, Footer: func() {
Button(c, "Open", ButtonOptions{})
}}, func() {
ui.Text(c, "Letters of the court")
}).Width(260)
})
}
}
func TestCardSlots(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
cardView(false)(c)
}, 400, 300)
for _, s := range []string{"Archive", "Letters of the court", "Open"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
title, _ := tt.Find("Archive")
body, _ := tt.Find("Letters of the court")
foot, _ := tt.Find("Open")
if !(title.Y < body.Y && body.Y < foot.Y) {
t.Fatalf("slot order: title %v body %v footer %v", title.Y, body.Y, foot.Y)
}
}
func TestCardNoTitleNoFooter(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Card(c, CardOptions{}, func() { ui.Text(c, "Only body") })
}, 300, 200)
if got := tt.Texts(); len(got) != 1 || got[0] != "Only body" {
t.Fatalf("texts %q", got)
}
}
func TestCardDecorationColor(t *testing.T) {
for _, dec := range []bool{false, true} {
var bounds ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
bounds = Card(c, CardOptions{Title: "T", Decorated: dec}, func() {}).Width(200).Bounds()
})
}, 300, 200)
tt.Frame()
img := tt.Image()
// The inner corner diamond sits at the inset corner.
px := img.At(int(bounds.X+cardInset), int(bounds.Y+cardInset))
want := toRGBA(theme.Light().Ornament)
if got := color.RGBAModel.Convert(px).(color.RGBA); (got == want) != dec {
t.Fatalf("decorated=%v: corner pixel %v, ornament %v", dec, got, want)
}
}
}
func toRGBA(c ui.Color) color.RGBA { return color.RGBA{c.R, c.G, c.B, c.A} }
func TestCardSnapshot(t *testing.T) {
snapshot(t, "card", 640, 240, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(20).AlignItems(ui.Start).Children(func() {
Card(c, CardOptions{Title: "Archive", Footer: func() {
Button(c, "Open", ButtonOptions{Variant: Secondary})
}}, func() { ui.Text(c, "Letters of the court, sealed in wine-red wax.") }).Width(280)
Card(c, CardOptions{Title: "Chapel", Decorated: true, Footer: func() {
Button(c, "Enter", ButtonOptions{})
}}, func() { ui.Text(c, "Vespers at seven; candles by the north door.") }).Width(280)
})
})
for _, d := range []theme.Density{theme.Compact, theme.Comfortable} {
snapshot(t, "card-"+strings.ToLower(d.String()), 640, 260, func(c *ui.Context) {
mode := Light
if Tokens(c).Dark {
mode = Dark
}
Use(c, Settings{Mode: mode, Density: d})
cardView(true)(c)
})
}
}
+175
View File
@@ -0,0 +1,175 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CarouselOptions configure a Carousel.
type CarouselOptions struct {
// Label names the carousel for assistive technology.
Label string
// Autoplay advances slides every Interval; it pauses on hover, focus or the pause button.
Autoplay bool
// Interval defaults to 5 seconds.
Interval time.Duration
// Loop wraps from the last slide to the first and back; Autoplay always wraps.
Loop bool
}
// CarouselView is a built Carousel and what happened to it in this frame.
type CarouselView struct {
*ui.Element
changed bool
}
// Changed reports a new slide.
func (v CarouselView) Changed() bool { return v.changed }
type carouselState struct {
paused bool
next time.Time
}
// Carousel shows slide *index of n with previous, next and position controls.
func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func(i int)) CarouselView {
if n < 0 || opts.Interval < 0 {
panic("mujica: Carousel with negative count or Interval")
}
interval := opts.Interval
if interval == 0 {
interval = 5 * time.Second
}
k := Tokens(c)
u := Density(c).Unit()
var v CarouselView
v.Element = ui.Column(c).Gap(u * 2).Label(opts.Label)
st := ui.Local(v.Element, "state", func() carouselState { return carouselState{} })
if n > 0 && (*index < 0 || *index >= n) {
slog.Debug("mujica: carousel index clamped", "index", *index, "n", n)
*index = max(0, min(*index, n-1))
}
wrap := opts.Loop || opts.Autoplay
goTo := func(i int) {
if wrap {
i = (i%n + n) % n
}
if n == 0 || i < 0 || i >= n || i == *index {
return
}
*index = i
v.changed = true
c.Announce(fmt.Sprintf(msg(c, "carousel.position"), i+1, n))
slog.Debug("mujica: carousel slide", "index", i)
}
now := c.Now()
playing := opts.Autoplay && n > 1 && !st.paused && !v.Hovered() && !v.FocusWithin()
if !playing {
st.next = time.Time{}
} else if st.next.IsZero() {
st.next = now.Add(interval)
} else if !now.Before(st.next) {
goTo(*index + 1)
st.next = now.Add(interval)
}
if playing {
c.After(st.next.Sub(now))
}
switch {
case v.Shortcut(0, ui.KeyLeft):
goTo(*index - 1)
case v.Shortcut(0, ui.KeyRight):
goTo(*index + 1)
case v.Shortcut(0, ui.KeyHome):
goTo(0)
case v.Shortcut(0, ui.KeyEnd):
goTo(n - 1)
}
v.Children(func() {
ui.Row(c).Grow(1).MinHeight(120).AlignItems(ui.Center).Gap(u * 2).Children(func() {
prev := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("chevron-left"), Label: msg(c, "carousel.prev"), Disabled: n < 2 || !wrap && *index == 0})
ui.Column(c).Grow(1).MinWidth(0).FillHeight().Clip().Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "carousel.position"), *index+1, n)).Children(func() {
if n > 0 {
ui.Column(c).Key(*index).Grow(1).Children(func() { slide(*index) })
}
})
next := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("chevron-right"), Label: msg(c, "carousel.next"), Disabled: n < 2 || !wrap && *index == n-1})
if prev.Clicked() {
goTo(*index - 1)
}
if next.Clicked() {
goTo(*index + 1)
}
})
ui.Row(c).Justify(ui.Center).AlignItems(ui.Center).Gap(u * 2).Children(func() {
ui.Row(c).Gap(u).FocusGroup(ui.Horizontal).Children(func() {
for i := range n {
if carouselDot(c, i, n, i == *index).Clicked() {
goTo(i)
}
}
})
if opts.Autoplay && carouselPause(c, st.paused).Clicked() {
st.paused = !st.paused
slog.Debug("mujica: carousel autoplay", "paused", st.paused)
}
})
})
return v
}
// carouselDot is a position button: a filled diamond for the current slide.
func carouselDot(c *ui.Context, i, n int, on bool) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).
Label(fmt.Sprintf(msg(c, "carousel.position"), i+1, n)).Role(ui.RoleTab).Selected(on)
internal.Ring(b, k.Focus, theme.ControlRadius)
hover := b.Hovered()
b.Background(ui.Transparent).Draw(func(p *ui.Painter, r ui.Rect) {
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
switch {
case on:
p.FillPath(internal.Diamond(cx, cy, 12), k.AccentText)
case hover:
p.FillPath(internal.Diamond(cx, cy, 8), k.TextMuted)
default:
p.StrokePath(internal.Diamond(cx, cy, 8), 1.25, k.TextMuted)
}
})
return b
}
// carouselPause is the pause button: two bars, or a play triangle while paused.
func carouselPause(c *ui.Context, paused bool) *ui.Element {
k := Tokens(c)
label := msg(c, "carousel.pause")
if paused {
label = msg(c, "carousel.play")
}
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Pressed() {
b.Background(k.Selection)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
return b.Draw(func(p *ui.Painter, r ui.Rect) {
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
if !paused {
p.Fill(ui.Rect{X: cx - 4, Y: cy - 5, W: 3, H: 10}, k.AccentText, 0)
p.Fill(ui.Rect{X: cx + 1, Y: cy - 5, W: 3, H: 10}, k.AccentText, 0)
return
}
var tri ui.Path
tri.MoveTo(cx-3, cy-5).LineTo(cx+5, cy).LineTo(cx-3, cy+5).Close()
p.FillPath(&tri, k.AccentText)
})
}
+132
View File
@@ -0,0 +1,132 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type carouselHarness struct {
index int
opts CarouselOptions
changes int
}
func (h *carouselHarness) view(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Size(400, 260).Children(func() {
v := Carousel(c, &h.index, 4, h.opts, func(i int) {
ui.Text(c, fmt.Sprintf("Window %d", i+1)).Padding(20)
})
v.Grow(1)
if v.Changed() {
h.changes++
}
})
}
func TestCarouselButtonsAndDots(t *testing.T) {
h := &carouselHarness{}
tt := ui.NewTester(h.view, 420, 280)
_ = tt.Click("Previous slide")
if h.index != 0 || h.changes != 0 {
t.Fatal("previous moved past the first slide")
}
_ = tt.Click("Next slide")
if h.index != 1 || !tt.HasText("Window 2") || h.changes != 1 {
t.Fatalf("next: %d changes %d %q", h.index, h.changes, tt.Texts())
}
_ = tt.Click("Slide 4 of 4")
if h.index != 3 {
t.Fatalf("dot: %d", h.index)
}
_ = tt.Click("Next slide")
if h.index != 3 {
t.Fatal("next moved past the last slide without Loop")
}
h.opts.Loop = true
tt.Frame()
_ = tt.Click("Next slide")
if h.index != 0 {
t.Fatalf("loop: %d", h.index)
}
}
func TestCarouselKeyboard(t *testing.T) {
h := &carouselHarness{}
tt := ui.NewTester(h.view, 420, 280)
tt.Key(0, ui.KeyTab) // previous is disabled: focus lands on next
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if h.index != 2 {
t.Fatalf("Right×2: %d", h.index)
}
tt.Key(0, ui.KeyEnd)
if h.index != 3 {
t.Fatalf("End: %d", h.index)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyLeft)
if h.index != 0 {
t.Fatalf("Home, Left: %d", h.index)
}
if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Slide 1 of 4" {
t.Fatalf("announcements %q", got)
}
}
func TestCarouselNoAutoplayByDefault(t *testing.T) {
h := &carouselHarness{opts: CarouselOptions{Interval: 10 * time.Millisecond}}
tt := ui.NewTester(h.view, 420, 280)
time.Sleep(30 * time.Millisecond)
tt.Frame()
if h.index != 0 || tt.HasText("Pause autoplay") {
t.Fatalf("advanced without Autoplay: %d", h.index)
}
}
func TestCarouselAutoplayPause(t *testing.T) {
h := &carouselHarness{opts: CarouselOptions{Autoplay: true, Interval: 20 * time.Millisecond}}
tt := ui.NewTester(h.view, 420, 280)
time.Sleep(30 * time.Millisecond)
tt.Frame()
if h.index != 1 {
t.Fatalf("autoplay did not advance: %d", h.index)
}
_ = tt.Click("Pause autoplay")
tt.Move(1, 1) // away from the carousel
tt.SetFocused(false)
tt.SetFocused(true)
at := h.index
time.Sleep(50 * time.Millisecond)
tt.Frame()
if h.index != at || !tt.HasText("") {
t.Fatalf("paused carousel advanced: %d -> %d", at, h.index)
}
if _, ok := tt.Find("Resume autoplay"); !ok {
t.Fatal("no resume button")
}
r, _ := tt.Find("Window " + fmt.Sprint(at+1))
_ = tt.Click("Resume autoplay")
tt.Move(r.X+2, r.Y+2) // hover pauses too
time.Sleep(50 * time.Millisecond)
tt.Frame()
if h.index != at {
t.Fatalf("hovered carousel advanced: %d -> %d", at, h.index)
}
}
func TestCarouselSnapshot(t *testing.T) {
snapshot(t, "carousel", 440, 240, func(c *ui.Context) {
i := ui.Local(c.Root(), "i", func() *int { v := 1; return &v })
ui.Column(c).Padding(16).Size(440, 240).Children(func() {
Carousel(c, *i, 4, CarouselOptions{Autoplay: true, Label: "Windows"}, func(n int) {
ui.Column(c).Grow(1).Center().Children(func() {
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(18)
})
}).Grow(1)
})
})
}
+99
View File
@@ -0,0 +1,99 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CollapsibleOptions configure a Collapsible.
type CollapsibleOptions struct {
// Title is the trigger's text; required unless Trigger is set.
Title string
// Trigger builds custom trigger content after the arrow.
Trigger func()
Disabled bool
}
// CollapsibleView is a built Collapsible and what happened to it in this frame.
type CollapsibleView struct {
*ui.Element
changed bool
}
// Changed reports that the trigger opened or closed the content.
func (v CollapsibleView) Changed() bool { return v.changed }
// Collapsible shows content below a trigger while *open; closed content is not built.
func Collapsible(c *ui.Context, open *bool, opts CollapsibleOptions, content func()) CollapsibleView {
if opts.Title == "" && opts.Trigger == nil {
panic("mujica: Collapsible needs Title or Trigger")
}
k := Tokens(c)
u := Density(c).Unit()
var v CollapsibleView
v.Element = ui.Column(c).Shrink(0)
v.Children(func() {
was := *open
p := ui.CollapsibleBase(c, open)
tr := p.Trigger
if opts.Disabled {
*open = was
}
v.changed = *open != was
tr.Disabled(opts.Disabled).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*2).
MinHeight(Density(c).ControlHeight()).Padding(0, u*3, 0, u*2).Radius(theme.ControlRadius)
if opts.Title != "" {
tr.Label(opts.Title)
}
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() && !opts.Disabled {
tr.Background(k.SurfaceHover)
}
prog := tr.Animate("mujica.open", collapsibleTarget(*open), Motion(c, theme.StateDuration))
tr.Children(func() {
ui.Icon(c, icons.Must("chevron-right")).Size(16, 16).Rotate(90 * prog).TextColor(k.TextMuted)
if opts.Trigger != nil {
opts.Trigger()
} else {
ui.Text(c, opts.Title).FontWeight(600).SingleLine()
}
})
if panel := collapsiblePanel(c, *open, prog, content); panel != nil {
panel.Padding(u*2, u*2, u*2, u*8).Gap(u * 2)
}
})
if v.changed {
slog.Debug("mujica: collapsible toggle", "open", *open)
}
return v
}
func collapsibleTarget(open bool) float32 {
if open {
return 1
}
return 0
}
// collapsiblePanel builds fn while open or animating, clipped to its progress.
func collapsiblePanel(c *ui.Context, open bool, prog float32, fn func()) *ui.Element {
if !open && prog == 0 {
return nil
}
clip := ui.Column(c).Shrink(0)
var inner *ui.Element
clip.Children(func() {
inner = ui.Column(c).Shrink(0)
inner.Children(fn)
})
if prog < 1 {
// The content's height from the last frame, grown by progress.
clip.ClipY().Height(prog * inner.Bounds().H)
}
return inner
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestCollapsibleToggleAndFocus(t *testing.T) {
open, changes, clicks := false, 0, 0
disabled := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Children(func() {
if Collapsible(c, &open, CollapsibleOptions{Title: "Details", Disabled: disabled}, func() {
if Button(c, "Inner", ButtonOptions{}).Clicked() {
clicks++
}
}).Changed() {
changes++
}
Button(c, "After", ButtonOptions{})
})
}, 300, 300)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if tt.HasText("Inner") {
t.Fatal("closed content built")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if !tt.Focused("After") {
t.Fatal("Tab entered hidden content")
}
if err := tt.Click("Details"); err != nil {
t.Fatal(err)
}
if !open || changes != 1 || !tt.HasText("Inner") {
t.Fatalf("open %v changes %d", open, changes)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Inner") {
t.Fatal("Tab skipped open content")
}
tt.Key(ui.Shift, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if open || changes != 2 {
t.Fatalf("Space did not close: open %v changes %d", open, changes)
}
open = true // controlled from outside
tt.Frame()
if !tt.HasText("Inner") {
t.Fatal("external open ignored")
}
disabled = true
tt.Frame()
_ = tt.Click("Details")
if !open || changes != 2 {
t.Fatal("disabled trigger toggled")
}
}
func TestCollapsibleNeedsTitle(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
open := false
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Collapsible(c, &open, CollapsibleOptions{}, func() {})
}, 100, 100)
}
func TestCollapsibleSnapshot(t *testing.T) {
snapshot(t, "collapsible", 400, 200, func(c *ui.Context) {
a := ui.Local(c.Root(), "a", func() *bool { b := true; return &b })
b := ui.Local(c.Root(), "b", func() *bool { b := false; return &b })
ui.Column(c).Padding(16).Gap(8).Children(func() {
Collapsible(c, *a, CollapsibleOptions{Title: "Rites of the evening"}, func() {
ui.Text(c, "Candles are lit at the seventh bell.")
})
Collapsible(c, *b, CollapsibleOptions{Title: "Sealed records", Disabled: true}, func() {})
})
})
}
+332
View File
@@ -0,0 +1,332 @@
package mujica
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// DataColumn is a column of a DataTable showing rows of type R.
type DataColumn[R any] struct {
// ID identifies the column in sort, layout and visibility; required, unique.
ID string
Title string
// Width is in DIPs; 0 shares the room left. MinWidth bounds resizing.
Width, MinWidth float32
Align ui.Align
// Sortable lets a header click sort by the column; local mode needs Compare.
Sortable bool
// Compare orders two rows, as cmp.Compare does.
Compare func(a, b R) int
// Text is the cell's plain text, for filtering and the default cell; required.
Text func(r R) string
// Cell builds a custom cell; nil shows Text.
Cell func(r R)
}
// DataTableOptions configure a DataTable.
type DataTableOptions[R any, K comparable] struct {
Columns []DataColumn[R]
// Key returns a row's stable ID; required.
Key func(r R) K
Multiple bool
// Controlled shows rows as given: the caller sorts and filters on SortChanged.
Controlled bool
// Version must change when rows change in place; a new slice is noticed.
Version int
// Empty replaces the built-in "No items" text.
Empty string
Status DataStatus
Error string
}
// DataTableState holds a DataTable's sort, filter, layout and selection.
type DataTableState[K comparable] struct {
Sort ui.SortOrder
// Filter keeps rows whose cells contain it, ignoring case, in local mode.
Filter string
// Hidden names the columns not shown, by ID.
Hidden map[string]bool
// Layout is the column order and widths the user set.
Layout ui.TableLayout
// Selection holds the chosen row IDs of a Multiple table.
Selection ui.Selection[K]
rows ui.ListState
cursor int
started bool
sel K
has bool
cache dataTableCache[K]
}
// dataTableCache is the sorted, filtered view, rebuilt only when its inputs change.
type dataTableCache[K comparable] struct {
valid bool
first any
n int
version int
sort ui.SortOrder
filter string
view []int
at map[K]int
}
// Selected returns the chosen row's ID, or the last chosen of a Multiple table.
func (s *DataTableState[K]) Selected() (K, bool) { return s.sel, s.has }
// Rows returns how many rows the table shows after filtering.
func (s *DataTableState[K]) Rows() int { return len(s.cache.view) }
// DataTableView is a built DataTable and what happened to it in this frame.
type DataTableView struct {
*ui.Element
changed, submitted, sorted, retry bool
}
// Changed reports a new selection.
func (v DataTableView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on a row.
func (v DataTableView) Submitted() bool { return v.submitted }
// SortChanged reports a header click that changed State.Sort.
func (v DataTableView) SortChanged() bool { return v.sorted }
// Retried reports a click on the retry action of a failed load.
func (v DataTableView) Retried() bool { return v.retry }
const dataTableMarkID = "mujica.select"
// DataTable shows rows in columns, building only those in view.
func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView {
dataTableCheck(opts)
if !s.started {
s.started, s.cursor = true, -1
}
dataTableRefresh(s, rows, opts)
if s.has {
if i, ok := s.cache.at[s.sel]; ok {
s.cursor = i
} else {
s.cursor = -1
}
}
k := Tokens(c)
d := Density(c)
var v DataTableView
v.Element = ui.Column(c).MinHeight(0)
v.Children(func() {
view := s.cache.view
if len(view) == 0 {
empty := opts.Empty
if len(rows) > 0 {
empty = msg(c, "data_table.noMatch")
}
v.retry = dataPlaceholder(c, opts.Status, opts.Error, empty, msg(c, "list.empty"))
return
}
vis, cols := dataTableColumns(c, s, opts)
s.rows.Key = func(i int) any { return opts.Key(rows[view[i]]) }
s.rows.Label = func(i int) string { return opts.Columns[vis[0]].Text(rows[view[i]]) }
s.rows.Selected, s.rows.Sort, s.rows.Columns = &s.cursor, &s.Sort, s.Layout
s.rows.Selection = nil
if opts.Multiple {
s.rows.Selection = &s.Selection
}
before := s.Sort
// Rows reach the data-row height of the density.
saved := c.Theme()
t := *saved
t.Spacing = d.ControlHeight() / 8
c.SetTheme(&t)
e := ui.Table(c, &s.rows, cols, len(view), func(row, col int) {
r := rows[view[row]]
if col == 0 {
dataTableMark(c, s, opts, opts.Key(r), row)
return
}
dc := opts.Columns[vis[col-1]]
if dc.Cell != nil {
dc.Cell(r)
} else {
ui.Text(c, dc.Text(r)).SingleLine()
}
})
c.SetTheme(saved)
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
s.Layout = s.rows.Columns
v.sorted = s.Sort != before
v.changed = e.Changed() && !v.sorted
v.submitted = e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
if s.cursor >= 0 && s.cursor < len(view) {
s.sel, s.has = opts.Key(rows[view[s.cursor]]), true
}
})
if v.sorted {
slog.Debug("mujica: data_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
}
if v.changed {
slog.Debug("mujica: data_table select", "count", s.Selection.Len())
}
return v
}
func dataTableCheck[R any, K comparable](opts DataTableOptions[R, K]) {
if opts.Key == nil || len(opts.Columns) == 0 {
panic("mujica: DataTable needs Key and Columns")
}
seen := map[string]bool{}
for _, col := range opts.Columns {
if col.ID == "" || seen[col.ID] || col.ID == dataTableMarkID || col.Text == nil {
panic("mujica: DataTable column needs a unique ID and Text: " + col.ID)
}
if col.Sortable && !opts.Controlled && col.Compare == nil {
panic("mujica: sortable DataTable column needs Compare: " + col.ID)
}
seen[col.ID] = true
}
}
// dataTableRefresh rebuilds the view only when rows, sort or filter changed.
func dataTableRefresh[R any, K comparable](s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) {
cc := &s.cache
var first any
if len(rows) > 0 {
first = &rows[0]
}
sort, filter := s.Sort, s.Filter
if opts.Controlled {
sort, filter = ui.SortOrder{}, ""
}
data := !cc.valid || cc.first != first || cc.n != len(rows) || cc.version != opts.Version
if !data && cc.sort == sort && cc.filter == filter {
return
}
var view []int
if opts.Controlled {
view = make([]int, len(rows))
for i := range view {
view[i] = i
}
} else {
view = DataTableRows(rows, opts.Columns, sort, filter)
}
at := make(map[K]int, len(view))
for i, j := range view {
at[opts.Key(rows[j])] = i
}
if len(at) != len(view) {
panic("mujica: DataTable rows with duplicate keys")
}
*cc = dataTableCache[K]{valid: true, first: first, n: len(rows), version: opts.Version, sort: sort, filter: filter, view: view, at: at}
if data {
dataTablePrune(s, rows, opts)
}
slog.Debug("mujica: data_table view", "rows", len(view), "of", len(rows))
}
// dataTablePrune drops chosen IDs whose rows left the data.
func dataTablePrune[R any, K comparable](s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) {
all := s.cache.at
if len(s.cache.view) != len(rows) {
all = make(map[K]int, len(rows))
for i, r := range rows {
all[opts.Key(r)] = i
}
}
for key := range s.Selection.All() {
if _, ok := all[key]; !ok {
s.Selection.Remove(key)
}
}
if _, ok := all[s.sel]; s.has && !ok {
s.has = false
}
}
func dataTableColumns[R any, K comparable](c *ui.Context, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) {
w := Density(c).ControlHeight()
cols := []ui.TableColumn{{ID: dataTableMarkID, Width: w, Fixed: true, Align: ui.Center}}
var vis []int
for i, col := range opts.Columns {
if s.Hidden[col.ID] {
continue
}
vis = append(vis, i)
cols = append(cols, ui.TableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Align: col.Align, Sortable: col.Sortable})
}
if len(vis) == 0 {
panic("mujica: DataTable with every column hidden")
}
return vis, cols
}
// dataTableMark draws the selection column: a check box, or a diamond on the chosen row.
func dataTableMark[R any, K comparable](c *ui.Context, s *DataTableState[K], opts DataTableOptions[R, K], key K, row int) {
k := Tokens(c)
if !opts.Multiple {
if row == s.cursor {
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.OnAccent)
})
}
return
}
on := s.Selection.Has(key)
box := ui.Box(c).Size(16, 16).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleCheckBox).Label(msg(c, "data_table.selectRow"))
if on {
box.Border(theme.BorderWidth, k.OnAccent).TextColor(k.OnAccent).Children(func() {
ui.Icon(c, icons.Must("check")).Size(14, 14)
}).Center()
} else {
box.Border(theme.BorderWidth, k.ControlBorder)
}
if box.Clicked() {
if on {
s.Selection.Remove(key)
} else {
s.Selection.Add(key)
}
slog.Debug("mujica: data_table toggle", "on", !on)
}
}
// DataTableRows sorts and filters rows as a local DataTable does; it returns indices.
func DataTableRows[R any](rows []R, cols []DataColumn[R], sort ui.SortOrder, filter string) []int {
view := make([]int, 0, len(rows))
q := strings.ToLower(filter)
for i, r := range rows {
if q == "" || dataTableMatch(r, cols, q) {
view = append(view, i)
}
}
for _, col := range cols {
if col.ID == sort.Column && col.Compare != nil {
cmp := col.Compare
slices.SortStableFunc(view, func(a, b int) int {
if sort.Descending {
return cmp(rows[b], rows[a])
}
return cmp(rows[a], rows[b])
})
}
}
return view
}
func dataTableMatch[R any](r R, cols []DataColumn[R], q string) bool {
for _, col := range cols {
if strings.Contains(strings.ToLower(col.Text(r)), q) {
return true
}
}
return false
}
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// DataTableColumnPicker is a Columns button whose popover shows and hides columns.
// The last visible column cannot be hidden.
func DataTableColumnPicker[R any, K comparable](c *ui.Context, s *DataTableState[K], cols []DataColumn[R]) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("eye")})
open := ui.Local(b, "open", func() bool { return false })
if b.Clicked() {
*open = !*open
}
visible := 0
for _, col := range cols {
if !s.Hidden[col.ID] {
visible++
}
}
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
panel.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.PopupRadius).
Padding(u * 2).Gap(u).MinWidth(200).MarginY(u)
for _, col := range cols {
ui.Column(c).Key(col.ID).Children(func() { dataTablePickerItem(c, s, col.ID, col.Title, visible) })
}
})
return b
}
// dataTablePickerItem is one column's check box in the picker.
func dataTablePickerItem[K comparable](c *ui.Context, s *DataTableState[K], id, title string, visible int) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
on := !s.Hidden[id]
last := on && visible == 1
cb := ui.CheckboxBase(c, &on).Disabled(last).MinHeight(d.ControlHeight()).
Padding(0, u*2).Gap(u * 2).AlignItems(ui.Center).Radius(theme.ControlRadius)
internal.Ring(cb, k.Focus, theme.ControlRadius)
if cb.Hovered() && !last {
cb.Background(k.SurfaceHover)
}
if cb.Changed() && !last {
if s.Hidden == nil {
s.Hidden = map[string]bool{}
}
s.Hidden[id] = !on
slog.Debug("mujica: data_table column", "id", id, "visible", on)
}
cb.Children(func() {
dataTableCheckBox(c, on)
ui.Text(c, title).SingleLine()
})
}
// dataTableCheckBox draws a 16 DIP check box: OnAccent check on Accent when on.
func dataTableCheckBox(c *ui.Context, on bool) {
k := Tokens(c)
box := ui.Box(c).Size(16, 16).Shrink(0).Radius(theme.ControlRadius).Center()
if on {
box.Background(k.Accent).TextColor(k.OnAccent).Children(func() {
ui.Icon(c, icons.Must("check")).Size(14, 14)
})
} else {
box.Border(theme.BorderWidth, k.ControlBorder).Background(k.Surface)
}
}
+337
View File
@@ -0,0 +1,337 @@
package mujica
import (
"cmp"
"fmt"
"strconv"
"testing"
"github.com/egoist/mygo/ui"
)
type relic struct {
ID int
Name string
Age int
}
type tableHarness struct {
rows []relic
st DataTableState[int]
opts DataTableOptions[relic, int]
compares int
changes int
sorts int
picker bool
c *ui.Context
seen map[string]int
}
func newTableHarness(rows []relic) *tableHarness {
h := &tableHarness{rows: rows}
h.opts = DataTableOptions[relic, int]{
Key: func(r relic) int { return r.ID },
Columns: []DataColumn[relic]{
{ID: "name", Title: "Name", Sortable: true, Text: func(r relic) string { return r.Name },
Compare: func(a, b relic) int { h.compares++; return cmp.Compare(a.Name, b.Name) }},
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true, Text: func(r relic) string { return strconv.Itoa(r.Age) },
Compare: func(a, b relic) int { h.compares++; return cmp.Compare(a.Age, b.Age) }},
},
}
return h
}
func (h *tableHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
h.c, h.seen = c, map[string]int{}
ui.Column(c).Padding(10).Gap(8).Children(func() {
if h.picker {
DataTableColumnPicker(c, &h.st, h.opts.Columns)
}
cols := h.opts
for i := range cols.Columns {
text := cols.Columns[i].Text
cols.Columns[i].Text = func(r relic) string { h.seen[r.Name]++; return text(r) }
}
v := DataTable(c, &h.st, h.rows, cols)
v.Size(360, 300)
if v.Changed() {
h.changes++
}
if v.SortChanged() {
h.sorts++
}
})
}
func relics() []relic {
return []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}, {4, "Diptych", 450}}
}
func (h *tableHarness) order(tt *ui.Tester) []string {
var names []string
for _, s := range tt.Texts() {
for _, r := range h.rows {
// Rows are labeled with their name too: skip the repeat.
if r.Name == s && (len(names) == 0 || names[len(names)-1] != s) {
names = append(names, s)
}
}
}
return names
}
func TestDataTableSortsOnce(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 400, 360)
if got := fmt.Sprint(h.order(tt)); got != "[Censer Altar Bell Diptych]" {
t.Fatalf("initial order %s", got)
}
if err := tt.Click("Name"); err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(h.order(tt)); got != "[Altar Bell Censer Diptych]" || h.st.Sort != (ui.SortOrder{Column: "name"}) || h.sorts != 1 {
t.Fatalf("ascending %s sort %+v sorts %d", got, h.st.Sort, h.sorts)
}
n := h.compares
tt.Frame()
tt.Frame()
if h.compares != n {
t.Fatalf("sorted again without a change: %d compares", h.compares-n)
}
if err := tt.Click("Age"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Age"); err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(h.order(tt)); got != "[Diptych Censer Altar Bell]" || !h.st.Sort.Descending {
t.Fatalf("age descending %s", got)
}
}
func TestDataTableSelectionFollowsRow(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 400, 360)
if err := tt.Click("Bell"); err != nil {
t.Fatal(err)
}
if k, ok := h.st.Selected(); !ok || k != 3 || h.changes != 1 {
t.Fatalf("selected %d %v changes %d", k, ok, h.changes)
}
_ = tt.Click("Name")
_ = tt.Click("Name")
if k, _ := h.st.Selected(); k != 3 || h.rows[h.st.cache.view[h.st.cursor]].Name != "Bell" {
t.Fatalf("after sort selected %d at row %d", k, h.st.cursor)
}
tt.Key(0, ui.KeyDown)
if k, _ := h.st.Selected(); k != 2 { // descending: Diptych Censer Bell Altar
t.Fatalf("Down chose %d", k)
}
h.st.Filter = "ce"
tt.Frame()
if h.st.Rows() != 1 || h.st.cursor != -1 {
t.Fatalf("filter rows %d cursor %d", h.st.Rows(), h.st.cursor)
}
h.st.Filter = ""
tt.Frame()
if k, ok := h.st.Selected(); !ok || k != 2 || h.rows[h.st.cache.view[h.st.cursor]].Name != "Altar" {
t.Fatalf("selection lost through filter: %d %v", k, ok)
}
h.rows = []relic{{1, "Censer", 300}}
tt.Frame()
if _, ok := h.st.Selected(); ok {
t.Fatal("removed row stays selected")
}
}
func TestDataTableMultipleMarks(t *testing.T) {
h := newTableHarness(relics())
h.opts.Multiple = true
tt := ui.NewTester(h.view, 400, 360)
table, _ := tt.Find("Name")
for _, name := range []string{"Altar", "Diptych"} {
r, _ := tt.Find(name)
tt.ClickAt(table.X-28, r.Y+r.H/2) // the mark column sits before Name
}
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) {
t.Fatalf("marks chose %d", h.st.Selection.Len())
}
_ = tt.Click("Age")
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(4) {
t.Fatal("selection changed by sorting")
}
h.rows = h.rows[:2] // Censer, Altar
tt.Frame()
if h.st.Selection.Len() != 1 || !h.st.Selection.Has(2) {
t.Fatalf("prune kept %d", h.st.Selection.Len())
}
}
func TestDataTableColumns(t *testing.T) {
h := newTableHarness(relics())
h.picker = true
tt := ui.NewTester(h.view, 400, 400)
h.st.Layout.Order = []string{"age", "name"}
tt.Frame()
age, _ := tt.Find("Age")
name, _ := tt.Find("Name")
if age.X >= name.X {
t.Fatalf("order not applied: age %v name %v", age.X, name.X)
}
if err := tt.Click("Columns"); err != nil {
t.Fatal(err)
}
ages := 0
for _, s := range tt.Texts() {
if s == "Age" {
ages++
}
}
if ages != 2 { // header and picker
t.Fatalf("picker missing: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !h.st.Hidden["name"] || tt.HasText("Censer") {
t.Fatalf("name not hidden: %v %q", h.st.Hidden, tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if h.st.Hidden["age"] {
t.Fatal("hid the last visible column")
}
}
func TestDataTableCustomCellAndControlled(t *testing.T) {
h := newTableHarness(relics())
h.opts.Controlled = true
h.opts.Columns[0].Cell = func(r relic) { ui.Text(h.c, "◆ "+r.Name) }
tt := ui.NewTester(h.view, 400, 360)
if !tt.HasText("◆ Altar") || tt.HasText("Altar") && !tt.HasText("◆ Altar") {
t.Fatalf("custom cell missing: %q", tt.Texts())
}
_ = tt.Click("Name")
first := ""
for _, s := range tt.Texts() {
if len(s) > 4 && s[:4] == "◆ " {
first = s
break
}
}
if h.sorts != 1 || h.compares != 0 || first != "◆ Censer" {
t.Fatalf("controlled sorted locally: sorts %d compares %d first %q", h.sorts, h.compares, first)
}
}
func TestDataTableBadOptions(t *testing.T) {
for name, mutate := range map[string]func(o *DataTableOptions[relic, int]){
"no key": func(o *DataTableOptions[relic, int]) { o.Key = nil },
"no compare": func(o *DataTableOptions[relic, int]) { o.Columns[0].Compare = nil },
"duplicate id": func(o *DataTableOptions[relic, int]) { o.Columns[1].ID = "name" },
} {
h := newTableHarness(relics())
mutate(&h.opts)
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(h.view, 300, 300)
}()
}
h := newTableHarness([]relic{{1, "A", 1}, {1, "B", 2}})
defer func() {
if recover() == nil {
t.Error("duplicate row keys: no panic")
}
}()
ui.NewTester(h.view, 300, 300)
}
func TestDataTableStatuses(t *testing.T) {
h := newTableHarness(nil)
tt := ui.NewTester(h.view, 400, 360)
if !tt.HasText("No items") {
t.Fatalf("empty: %q", tt.Texts())
}
h.rows = relics()
h.st.Filter = "zzz"
tt.Frame()
if !tt.HasText("No matching rows") {
t.Fatalf("no match: %q", tt.Texts())
}
h.st.Filter = ""
h.opts.Status = DataFailed
tt.Frame()
if !tt.HasText("Could not load") || !tt.HasText("Retry") || !tt.HasText("Bell") {
t.Fatalf("failed append: %q", tt.Texts())
}
}
func TestDataTableLargeIsVirtualized(t *testing.T) {
rows := make([]relic, 100_000)
for i := range rows {
rows[i] = relic{ID: i, Name: fmt.Sprintf("Relic %06d", (i*7919)%100_000), Age: i % 997}
}
h := newTableHarness(rows)
for i := 3; i <= 10; i++ {
id := fmt.Sprintf("c%d", i)
h.opts.Columns = append(h.opts.Columns, DataColumn[relic]{ID: id, Title: id, Width: 60, Text: func(r relic) string { return id }})
}
tt := ui.NewTester(h.view, 400, 360)
// Rows whose cells ran in the last frame: 300 DIPs show about 7 rows of 36 DIPs, plus overscan.
const bound = 16
if len(h.seen) == 0 || len(h.seen) > bound {
t.Fatalf("built %d rows, bound %d", len(h.seen), bound)
}
if err := tt.Click("Name"); err != nil {
t.Fatal(err)
}
if len(h.seen) > bound || !tt.HasText("Relic 000000") || !tt.HasText("Relic 000001") {
t.Fatalf("after sort built %d rows, texts %q", len(h.seen), tt.Texts())
}
if err := tt.Click("Relic 000001"); err != nil {
t.Fatal(err)
}
n := h.compares
_ = tt.Click("Name") // descending: the row moves 99,998 places
tt.Frame()
if k, _ := h.st.Selected(); k != rowWithName(rows, "Relic 000001") {
t.Fatalf("selection lost: %d", k)
}
m := h.compares
if m == n {
t.Fatal("descending did not sort")
}
tt.Frame()
tt.Frame()
if h.compares != m {
t.Fatal("re-sorted each frame")
}
}
func rowWithName(rows []relic, name string) int {
for _, r := range rows {
if r.Name == name {
return r.ID
}
}
panic(name)
}
func TestDataTableSnapshot(t *testing.T) {
snapshot(t, "data_table", 640, 300, func(c *ui.Context) {
h := ui.Local(c.Root(), "h", func() *tableHarness {
h := newTableHarness(relics())
h.opts.Multiple = true
h.st.Sort = ui.SortOrder{Column: "name"}
h.st.Selection.Add(3)
return h
})
ui.Column(c).Padding(16).Children(func() {
DataTable(c, &(*h).st, (*h).rows, (*h).opts).Size(600, 260)
})
})
}
+113
View File
@@ -0,0 +1,113 @@
package mujica
import (
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// DescriptionItem is a name and its value.
type DescriptionItem struct {
Label string
Value string
// Span is how many columns the item takes; negative spans the whole row.
Span int
// Copyable adds a button copying Value to the clipboard.
Copyable bool
}
// DescriptionListOptions configure a DescriptionList.
type DescriptionListOptions struct {
// Columns is the most columns shown; 0 means 2.
Columns int
// MinColumnWidth drops columns when narrower; 0 means 220 DIPs.
MinColumnWidth float32
}
// DescriptionListView is a built DescriptionList and what happened to it in this frame.
type DescriptionListView struct {
*ui.Element
copied int
}
// Copied returns the index of the item whose value was copied in this frame.
func (v DescriptionListView) Copied() (int, bool) { return v.copied, v.copied >= 0 }
// descriptionCopied shows the copied mark for this long.
const descriptionCopied = 1500 * time.Millisecond
type descriptionState struct {
item int
until time.Time
}
// DescriptionList shows names and values in columns, one column when narrow.
func DescriptionList(c *ui.Context, items []DescriptionItem, opts DescriptionListOptions) DescriptionListView {
if opts.Columns < 0 || opts.MinColumnWidth < 0 {
panic("mujica: DescriptionList with negative Columns or MinColumnWidth")
}
most, minW := opts.Columns, opts.MinColumnWidth
if most == 0 {
most = 2
}
if minW == 0 {
minW = 220
}
k := Tokens(c)
u := Density(c).Unit()
gap := u * 6
v := DescriptionListView{Element: ui.Grid(c).GapX(gap).GapY(u * 4).FillWidth(), copied: -1}
st := ui.Local(v.Element, "state", func() descriptionState { return descriptionState{item: -1} })
fit := func(w float32) int {
if w <= 0 {
return most
}
return max(1, min(most, int(math.Floor(float64((w+gap)/(minW+gap))))))
}
cols := fit(v.Bounds().W)
v.Draw(func(p *ui.Painter, r ui.Rect) {
if fit(r.W) != cols {
// The new width needs other columns: build again.
c.Invalidate()
}
})
now := c.Now()
v.Columns(cols).Children(func() {
for i, it := range items {
cell := ui.Column(c).Gap(u).MinWidth(0)
if it.Span < 0 || it.Span > cols {
cell.ColumnSpan(-1)
} else if it.Span > 1 {
cell.ColumnSpan(it.Span)
}
cell.Children(func() {
ui.Text(c, it.Label).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Row(c).Gap(u).MinHeight(theme.MinHitSize).AlignItems(ui.Center).Children(func() {
ui.Text(c, it.Value).Shrink(1).MinWidth(0)
if !it.Copyable {
return
}
done := st.item == i && now.Before(st.until)
icon, label := icons.Must("copy"), msg(c, "description_list.copy")+" "+it.Label
if done {
icon, label = icons.Must("check"), msg(c, "description_list.copied")
c.After(st.until.Sub(now))
}
if Button(c, "", ButtonOptions{Variant: Ghost, Icon: icon, Label: label}).MinHeight(theme.MinHitSize).Padding(0, u).Clicked() {
c.WriteClipboard(it.Value)
c.Announce(msg(c, "description_list.copied"))
*st = descriptionState{item: i, until: now.Add(descriptionCopied)}
v.copied = i
slog.Debug("mujica: description_list copy", "item", i)
}
})
})
}
})
return v
}
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var descItems = []DescriptionItem{
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
{Label: "Shelf", Value: "North vault, row 7"},
{Label: "Seal", Value: "Wine-red wax, cracked", Span: -1},
}
func TestDescriptionListColumnsAndCopy(t *testing.T) {
width := float32(560)
copied := -1
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(width).Children(func() {
if i, ok := DescriptionList(c, descItems, DescriptionListOptions{}).Copied(); ok {
copied = i
}
})
}, 700, 400)
a, _ := tt.Find("Archivist")
b, _ := tt.Find("Shelf")
seal, _ := tt.Find("Seal")
if a.Y != b.Y || b.X <= a.X || seal.Y <= a.Y {
t.Fatalf("two columns expected: %v %v %v", a, b, seal)
}
if err := tt.Click("Copy Archivist"); err != nil {
t.Fatal(err)
}
if tt.Clipboard() != "Sister Agnès" || copied != 0 {
t.Fatalf("clipboard %q copied %d", tt.Clipboard(), copied)
}
if _, ok := tt.Find("Copied"); !ok {
t.Fatal("copied mark missing")
}
width = 300
tt.Frame()
a, _ = tt.Find("Archivist")
b, _ = tt.Find("Shelf")
if b.Y <= a.Y || b.X != a.X {
t.Fatalf("narrow width kept columns: %v %v", a, b)
}
}
func TestDescriptionListSpan(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(900).Children(func() {
DescriptionList(c, []DescriptionItem{{Label: "A", Value: "a", Span: 2}, {Label: "B", Value: "b"}, {Label: "C", Value: "c"}}, DescriptionListOptions{Columns: 3})
})
}, 950, 300)
a, _ := tt.Find("a")
b, _ := tt.Find("b")
cc, _ := tt.Find("c")
if b.Y != a.Y || cc.Y <= a.Y {
t.Fatalf("span 2 of 3: a %v b %v c %v", a, b, cc)
}
}
func TestDescriptionListBadOptions(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
DescriptionList(c, nil, DescriptionListOptions{Columns: -1})
}, 100, 100)
}
func TestDescriptionListSnapshot(t *testing.T) {
snapshot(t, "description_list", 560, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() { DescriptionList(c, descItems, DescriptionListOptions{}) })
})
}
+46
View File
@@ -0,0 +1,46 @@
# 069 Card · 卡片
## 用途
把一组相关内容放在带描边的表面上:标题、内容与页脚三个插槽。默认不增加点击行为;可选 `Decorated` 绘制宫廷风格的双线边框与四角菱形角饰。Card 是视觉回归基准之一,快照覆盖浅深主题与三种密度。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Card(c, mujica.CardOptions{Title: "Archive", Decorated: true, Footer: func() {
mujica.Button(c, "Open", mujica.ButtonOptions{})
}}, func() {
ui.Text(c, "Letters of the court, sealed in wax.")
}).Width(280)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element`,`body` 构建内容插槽。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | 空 | 衬线标题;为空时不显示标题区。同时作为分组的无障碍名称。 |
| `Footer` | `func()` | nil | 页脚插槽,位于分隔线下方并右对齐,常放操作按钮。 |
| `Decorated` | `bool` | false | 外框与内框双线、四角菱形与标题下菱形分隔线,均使用 Ornament 色。 |
## 状态
默认(Surface 表面 + Border 描边,4 DIP 圆角)、装饰(Ornament 双线与角饰)。卡片本身无悬停、按下、选中或禁用状态;内部控件保留各自状态。
## 事件
无。卡片不产生事件,交互来自内部控件。
## 键盘操作
卡片不获取焦点;Tab 按顺序进入内部控件。装饰只做绘制,不占焦点、不遮挡内容、不缩小点击区域。
## 限制
- 卡片不提供整卡点击;需要时由调用方在内容中放置按钮或链接。
- 角饰为矢量绘制,位于 4 DIP 内框的四角;装饰模式会额外增加 6 DIP 内边距。
+62
View File
@@ -0,0 +1,62 @@
# 070 List · 列表
## 用途
展示任意数量的条目,只构建可见行(加预取两行),10 万条记录也能流畅滚动、选择。支持列表与网格两种模式、单选与多选、稳定条目键、按标签输入定位,以及首次加载、追加加载、失败与空状态。
## 最小示例
```go
names := []string{"Matins", "Lauds", "Prime", "Terce"}
var state mujica.ListState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.List(c, &state, len(names), mujica.ListOptions[string]{
Key: func(i int) string { return names[i] },
Label: func(i int) string { return names[i] },
}, func(i int) { ui.Text(c, names[i]) })
v.Size(240, 200)
if v.Changed() {
key, _ := state.Selected()
fmt.Println("chose", key)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) ListView`。`ListView` 嵌入 `*ui.Element`;`ListState` 由调用方持有,零值表示未选择。`state.Selected()` 返回所选键,多选时 `state.Selection` 保存全部键;`state.ScrollTo(i)` 滚动到第 i 项。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Key` | `func(i int) K` | 必填 | 条目的稳定键;选择与行状态跟随键,而不是下标。 |
| `Label` | `func(i int) string` | 必填 | 条目文字:无障碍名称与输入定位。 |
| `Multiple` | `bool` | false | 多选:Cmd/Ctrl 点击增减、Shift 连选、Cmd/Ctrl+A 全选。 |
| `Grid` | `bool` | false | 网格模式:按 `ItemWidth` 自动分列。 |
| `ItemWidth`, `ItemHeight` | `float32` | 160, 120 | 网格条目最小宽度与高度(DIP)。 |
| `Status` | `DataStatus` | `DataReady` | 加载状态:`DataLoading` 首次加载(无行时显示加载标记)、`DataLoadingMore` 追加加载(行下方显示“正在加载更多”)、`DataFailed` 失败(显示错误与“重试”)。 |
| `Error` | `string` | 内置“加载失败” | `DataFailed` 时显示的具体错误。 |
| `Empty` | `string` | 内置“暂无数据” | 无数据时的说明文字。 |
## 状态
默认、悬停(SurfaceHover)、选中(Accent 填充 + OnAccent 文字 + 勾选标记)、聚焦(列表外 2 DIP 焦点环)、网格选中(AccentText 边框 + 勾选)、加载、追加加载、失败、空。
## 事件
`ListView.Changed()`:选择变化;`Submitted()`:Enter 或双击;`Retried()`:点击失败状态的“重试”。
## 键盘操作
- ↑/↓、Home/End,Linux 与 Windows 上 PageUp/PageDown:移动选择;网格模式下方向键二维移动。
- 输入字母:按 `Label` 定位。
- Enter:提交。
- 多选:Shift+方向键连选;Cmd/Ctrl+A 全选;Linux/Windows 上 Ctrl+方向键只移动、Ctrl+Space 切换。
## 限制
- 虚拟化由 MyGo `ui.List`/`ui.GridView` 提供:每帧构建的元素数由可见区域和预取范围决定;程序跳转(`ScrollTo`)时目标两侧各构建一屏。测试以 10 万条记录统计“最后一帧调用条目构建函数的次数”验证上限(list_test.go)。
- MyGo 单选跟随键时只在原位置附近 1000 行内查找(ui/list.go:30 `listSearch`);插入或删除大量上方行后,单选可能被清除。
- Shift 连选的锚点由 MyGo 决定(ui/list.go `extend`),与平台习惯一致。
- 网格模式没有 `ListState.Visible` 等滚动位置读取接口。
+73
View File
@@ -0,0 +1,73 @@
# 071 DataTable · 数据表格
## 用途
按列展示数据行,只构建可见行。提供排序、筛选、多选、列宽、列顺序、列显隐与自定义单元格。行与列都以稳定 ID 保存状态:排序、筛选或调整列之后,选择仍指向同一条数据。默认使用本地排序与筛选(仅在数据、排序或筛选条件变化时计算一次并缓存);`Controlled` 模式下按调用方顺序显示,调用方在 `SortChanged()` 时自行查询。
## 最小示例
```go
type relic struct {
ID int
Name string
Age int
}
rows := []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}}
var state mujica.DataTableState[int]
opts := mujica.DataTableOptions[relic, int]{
Key: func(r relic) int { return r.ID },
Columns: []mujica.DataColumn[relic]{
{ID: "name", Title: "Name", Sortable: true,
Text: func(r relic) string { return r.Name },
Compare: func(a, b relic) int { return cmp.Compare(a.Name, b.Name) }},
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true,
Text: func(r relic) string { return strconv.Itoa(r.Age) },
Compare: func(a, b relic) int { return cmp.Compare(a.Age, b.Age) }},
},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DataTable(c, &state, rows, opts).Size(360, 240)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView`。另有 `DataTableColumnPicker(c, s, cols)`(“列”按钮与勾选弹层,控制显隐)和纯函数 `DataTableRows(rows, cols, sort, filter) []int`(本地排序筛选辅助,返回行下标)。
`DataTableState` 字段:`Sort ui.SortOrder`、`Filter string`(不区分大小写包含匹配全部列的 `Text`)、`Hidden map[string]bool`(按列 ID 隐藏)、`Layout ui.TableLayout`(用户拖动得到的列顺序与列宽,可保存恢复)、`Selection ui.Selection[K]`(多选)。方法:`Selected()`、`Rows()`(筛选后行数)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Columns` | `[]DataColumn[R]` | 必填 | 列定义:`ID`(必填、唯一)、`Title`、`Width`(0 表示分配剩余宽度)、`MinWidth`、`Align`、`Sortable`、`Compare`(本地排序比较函数)、`Text`(必填,纯文本,用于筛选与默认单元格)、`Cell`(自定义单元格)。 |
| `Key` | `func(R) K` | 必填 | 行的稳定 ID;重复时 panic。 |
| `Multiple` | `bool` | false | 多选:首列显示勾选框,点击勾选框增减,Cmd/Ctrl、Shift 点击与 Cmd/Ctrl+A。 |
| `Controlled` | `bool` | false | 受控模式:不在本地排序筛选。 |
| `Version` | `int` | 0 | 原地修改 `rows` 后递增,通知缓存失效;换新切片无需递增。 |
| `Status` | `DataStatus` | `DataReady` | 加载状态:`DataLoading` 首次加载(无行时显示加载标记)、`DataLoadingMore` 追加加载(行下方显示“正在加载更多”)、`DataFailed` 失败(显示错误与“重试”)。 |
| `Error` | `string` | 内置“加载失败” | `DataFailed` 时显示的具体错误。 |
| `Empty` | `string` | 内置“暂无数据” | 无数据时的说明文字。 |
## 状态
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Accent 填充 + OnAccent 文字;单选首列菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行高度不低于当前密度的数据行高(28/36/44 DIP)。
## 事件
`Changed()`:选择变化;`Submitted()`:Enter 或双击;`SortChanged()`:点击表头改变了 `State.Sort`;`Retried()`:点击“重试”。
## 键盘操作
- ↑/↓、Home/End:移动选择;Enter:提交;输入字母按首个可见列定位。
- 多选:Shift/Cmd(Ctrl) 配合点击或方向键;Cmd/Ctrl+A 全选。
- 表头:点击排序、再次点击反向;拖动表头边缘调整列宽,双击适配内容;拖动表头移动列。
- 列选择器:Tab 进入勾选项,Space 切换;Escape 关闭。
## 限制
- MyGo `ui.Table` 只按调用方给出的顺序显示(docs/ui/table.md “Sorting”),因此本组件在本地完成排序,或在受控模式下交给调用方。
- 表头内容只能是 `Title` 文字(ui/table.go `table` 构建表头),无法放置全选勾选框;全选使用 Cmd/Ctrl+A。
- 单选的跟随由组件按键重新定位,跨越任意距离都有效;多选的键盘锚点在大规模重排后由 MyGo 重置(ui/list.go:30)。
- 删除的数据行对应的选择会在数据变化时清除;被筛选隐藏的行保留选择。
- 10 万行 × 10 列:测试统计“最后一帧构建过单元格的行数”不超过 16,并断言重复帧不再调用比较函数(data_table_test.go)。
+57
View File
@@ -0,0 +1,57 @@
# 072 Tree · 树
## 用途
展示层级数据:节点展开、选择、键盘导航、外部加载子项与空状态。只构建可见行,10 万节点同样流畅;折叠的子树不创建任何界面元素。
## 最小示例
```go
kids := map[string][]string{"Nave": {"Pulpit", "Pews"}}
var state mujica.TreeState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Tree(c, &state, mujica.TreeOptions[string]{
Roots: []string{"Nave", "Sacristy"},
Children: func(n string) []string { return kids[n] },
Label: func(n string) string { return n },
}).Size(280, 240)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Tree[K comparable](c *ui.Context, s *TreeState[K], opts TreeOptions[K]) TreeView`。节点值本身即稳定键。`TreeState` 方法:`Selected()`、`SetOpen(item, open)`、`IsOpen(item)`;字段 `Selection ui.Selection[K]` 保存多选。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Roots` | `[]K` | 空 | 顶层节点;为空时显示空状态。 |
| `Children` | `func(K) []K` | 必填 | 子节点:nil 表示叶子;未加载的节点返回空的非 nil 切片。 |
| `Label` | `func(K) string` | 必填 | 节点文字。 |
| `ItemStatus` | `func(K) DataStatus` | nil | 子项加载状态:`DataUnloaded`、`DataLoading`(行尾加载标记)、`DataFailed`(行尾错误与重试);nil 表示全部已加载。 |
| `Load` | `func(K)` | nil | 节点以 `DataUnloaded` 状态展开时调用一次,点击重试时再次调用。 |
| `Multiple` | `bool` | false | 多选。 |
| `Status` | `DataStatus` | `DataReady` | 加载状态:`DataLoading` 首次加载(无行时显示加载标记)、`DataLoadingMore` 追加加载(行下方显示“正在加载更多”)、`DataFailed` 失败(显示错误与“重试”)。 |
| `Error` | `string` | 内置“加载失败” | `DataFailed` 时显示的具体错误。 |
| `Empty` | `string` | 内置“暂无数据” | 无数据时的说明文字。 |
## 状态
默认、悬停、选中(Accent 填充 + 勾选标记)、聚焦、展开/折叠(箭头旋转)、子项加载中、子项加载失败、空、首次加载与失败。
## 事件
`Changed()`:选择变化;`Submitted()`:Enter 或双击;`Retried()`:整树失败时点击重试。子项重试直接调用 `Load`。
## 键盘操作
- ↑/↓:移动;→:展开或进入第一个子节点;←:折叠或回到父节点;macOS 上 Option、其他平台 Shift 配合 ←/→ 展开或折叠全部子孙。
- 输入字母按 `Label` 定位;Enter 提交。
## 限制
- 基于 MyGo `ui.Outline` 而不是 `ui.Tree`:`ui.Tree` 会构建全部已展开节点(docs/ui/tree.md “A tree builds every item it shows”),不满足大量节点虚拟化要求。
- `Outline` 每帧遍历已展开的节点以计算行(ui/outline.go `flatten`),元素数受可见区域限制,但遍历成本随展开节点数增长。
- 单选跟随节点的搜索范围为 1000 行(ui/list.go:30)。
- 异步加载由调用方在后台完成并通过 `Window.Update` 回到界面线程。
+61
View File
@@ -0,0 +1,61 @@
# 073 TreeTable · 树形表格
## 用途
把树形层级与表格列结合:首列显示展开箭头与缩进,其余列显示属性。支持展开、选择、列宽与列顺序、排序状态(同级节点在本地排序,结果按父节点缓存,只在子项或排序变化时重新计算),以及外部加载子项。只构建可见行。
## 最小示例
```go
kids := map[string][]string{"Vestry": {"Stole", "Alb"}}
size := map[string]int{"Vestry": 9, "Stole": 2, "Alb": 5, "Organ": 7}
var state mujica.TreeTableState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TreeTable(c, &state, mujica.TreeTableOptions[string]{
Roots: []string{"Vestry", "Organ"},
Children: func(n string) []string { return kids[n] },
Label: func(n string) string { return n },
Columns: []mujica.TreeColumn[string]{
{ID: "name", Title: "Name", Cell: func(n string) { ui.Text(c, n) },
Compare: cmp.Compare[string]},
{ID: "size", Title: "Size", Width: 80, Align: ui.End,
Cell: func(n string) { ui.Text(c, strconv.Itoa(size[n])) }},
},
}).Size(360, 240)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTableOptions[K]) DataTableView`。`TreeTableState` 字段:`Sort`、`Layout`、`Selection`;方法:`Selected()`、`SetOpen`、`IsOpen`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Columns` | `[]TreeColumn[K]` | 必填 | 列:`ID`(必填、唯一)、`Title`、`Width`、`MinWidth`、`Align`、`Compare`(设置后可点击排序同级节点)、`Cell`(必填)。首列在箭头后显示 `Cell`。 |
| `Roots` | `[]K` | 空 | 顶层节点。 |
| `Children` | `func(K) []K` | 必填 | 子节点:nil 为叶子,空切片为未加载。子项变化时请返回新切片(排序缓存按切片识别)。 |
| `Label` | `func(K) string` | 必填 | 无障碍名称与输入定位。 |
| `ItemStatus`, `Load` | | nil | 与 Tree 相同的外部加载协议。 |
| `Multiple` | `bool` | false | 多选。 |
| `Status` | `DataStatus` | `DataReady` | 加载状态:`DataLoading` 首次加载(无行时显示加载标记)、`DataLoadingMore` 追加加载(行下方显示“正在加载更多”)、`DataFailed` 失败(显示错误与“重试”)。 |
| `Error` | `string` | 内置“加载失败” | `DataFailed` 时显示的具体错误。 |
| `Empty` | `string` | 内置“暂无数据” | 无数据时的说明文字。 |
## 状态
默认、悬停、选中(Accent 填充 + 勾选标记)、聚焦、展开/折叠、排序箭头、子项加载中/失败、空。
## 事件
返回 `DataTableView`:`Changed()`、`Submitted()`、`SortChanged()`、`Retried()`。
## 键盘操作
- 与 Tree 相同的方向键与展开键;点击表头排序同级节点,再次点击反向;拖动表头边缘或表头调整列宽与顺序。
## 限制
- 基于 MyGo `ui.OutlineTable`;表头只能显示文字。
- 排序只作用于同级节点,层级关系保持不变。
+47
View File
@@ -0,0 +1,47 @@
# 074 Accordion · 手风琴
## 用途
多个可展开的区段放在一个带边框的盒子中。支持单项或多项展开、是否允许全部关闭,展开状态由调用方持有(受控)。
## 最小示例
```go
open := []string{"choir"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Accordion(c, &open, []mujica.AccordionItem{
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices.") }},
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Black oak pews.") }},
}, mujica.AccordionOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Accordion(c *ui.Context, open *[]string, items []AccordionItem, opts AccordionOptions) AccordionView`。`*open` 是展开区段的 ID 列表;`AccordionItem` 字段:`ID`(必填、唯一)、`Title`、`Content`(必填)、`Disabled`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Multiple` | `bool` | false | 允许同时展开多项;否则展开一项会关闭其他项。 |
| `KeepOne` | `bool` | false | 最后一个展开项不能被关闭。 |
## 状态
默认、悬停、聚焦、展开(箭头翻转,内容 120 ms 展开;减少动画时直接切换)、禁用(不展开,退出 Tab 序列)。
## 事件
`AccordionView.Changed()`:某区段因用户操作而展开或关闭,`*open` 已更新。外部直接修改 `*open` 不触发事件。
## 键盘操作
- Tab:到达标题;标题打开时 Tab 进入其内容。
- ↑/↓:上/下一个标题(跳过禁用项);Home/End:第一个/最后一个。
- Enter/Space:展开或关闭。
## 限制
- 基于 MyGo `ui.CollapsibleBase`;MyGo 的 `ui.Accordion` 外观固定且用内部状态移动焦点(ui/collapsible.go `accordionKeys`),因此键盘移动在组件内重新实现:向上移动时焦点在下一帧到达。
- 关闭动画进行的 120 ms 内内容仍在界面树中。
+45
View File
@@ -0,0 +1,45 @@
# 075 Collapsible · 折叠面板
## 用途
一个触发区控制下方内容的显示。展开状态由调用方的 `*bool` 控制,可在外部打开或关闭;关闭后的内容不构建,因此退出焦点序列。
## 最小示例
```go
open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Collapsible(c, &open, mujica.CollapsibleOptions{Title: "Details"}, func() {
ui.Text(c, "Candles are lit at the seventh bell.")
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Collapsible(c *ui.Context, open *bool, opts CollapsibleOptions, content func()) CollapsibleView`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | 空 | 触发区文字;与 `Trigger` 至少设置一个。 |
| `Trigger` | `func()` | nil | 自定义触发区内容(箭头之后)。 |
| `Disabled` | `bool` | false | 禁止展开或关闭,退出 Tab 序列。 |
## 状态
默认、悬停、按下、聚焦、展开(箭头旋转 90°,120 ms)、禁用。
## 事件
`CollapsibleView.Changed()`:用户点击或按键切换了展开状态。
## 键盘操作
- Enter/Space:展开或关闭。
- 关闭后内容不在 Tab 序列中。
## 限制
- MyGo `CollapsibleBase` 自带 200 ms 动画计时(ui/collapsible.go),组件使用自己的 120 ms 进度绘制,但 MyGo 的计时仍会在 200 ms 内请求帧。
+44
View File
@@ -0,0 +1,44 @@
# 076 Timeline · 时间线
## 用途
纵向列出事件:时间、标题、内容与状态。标记之间以细线连接,长内容自动换行。状态同时用形状、图标和文字表示,不只依赖颜色。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Timeline(c, []mujica.TimelineItem{
{Time: "06:00", Title: "Matins sung", Status: mujica.TimelineDone},
{Time: "09:00", Title: "Relics moved", Content: "From the crypt to the altar.", Status: mujica.TimelineActive},
{Time: "18:00", Title: "Vespers"},
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Timeline(c *ui.Context, items []TimelineItem) *ui.Element`。`TimelineItem` 字段:`Time`、`Title`、`Content`、`Status`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Status` | `TimelineStatus` | `TimelineDefault` | `TimelineDefault`(空心菱形)、`TimelineDone`(勾选图标 + “已完成”)、`TimelineActive`(实心菱形 + “进行中”)、`TimelineWarning`(警告图标 + “警告”)、`TimelineError`(错误图标 + “错误”)。 |
## 状态
仅展示:默认、完成、进行中、警告、错误。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 仅纵向排列;内容为纯文本。
- 不虚拟化,适合几十条以内的事件。
+45
View File
@@ -0,0 +1,45 @@
# 077 DescriptionList · 描述列表
## 用途
以名称与值的形式展示详情,支持多列、跨列与复制值。容器宽度不足时自动减少列数,最窄时为单列。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DescriptionList(c, []mujica.DescriptionItem{
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
{Label: "Shelf", Value: "North vault, row 7"},
{Label: "Seal", Value: "Wine-red wax", Span: -1},
}, mujica.DescriptionListOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DescriptionList(c *ui.Context, items []DescriptionItem, opts DescriptionListOptions) DescriptionListView`。`DescriptionItem` 字段:`Label`、`Value`、`Span`(跨列数,负数跨整行)、`Copyable`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Columns` | `int` | 2 | 最多列数。 |
| `MinColumnWidth` | `float32` | 220 | 每列最小宽度(DIP);容器放不下时减少列数。 |
## 状态
默认、复制按钮悬停与聚焦、已复制(勾选图标 1.5 秒,并向读屏软件播报“已复制”)。
## 事件
`DescriptionListView.Copied()` 返回本帧被复制项的下标。
## 键盘操作
- Tab 到达每个复制按钮(名称为“复制 + 标签”),Enter/Space 复制。
## 限制
- 列数根据上一帧的宽度计算,宽度变化后在下一帧重新排列(组件会立即请求该帧)。
- 复制使用 MyGo 剪贴板接口,只写入纯文本。
+50
View File
@@ -0,0 +1,50 @@
# 078 Statistic · 统计数值
## 用途
突出显示一个数值,附带标题、单位、前后缀、精度与变化值说明。组件只格式化调用方给出的数值,不计算业务指标。
## 最小示例
```go
change := 12.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Statistic(c, 12840, mujica.StatisticOptions{
Title: "Pilgrims", Unit: "souls", Precision: 0,
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast",
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | 空 | 数值上方的标题。 |
| `Precision` | `int` | 0 | 小数位数;负数 panic。 |
| `Prefix`, `Suffix` | `string` | 空 | 紧贴数字的前后缀,如 `£`、`k`。 |
| `Unit` | `string` | 空 | 数字后的较小单位文字。 |
| `Format` | `func(float64) string` | 千分位分组 | 自定义数字格式。 |
| `Change` | `*float64` | nil | 变化值:正数显示上箭头与 Success 色,负数下箭头与 Danger 色,零为持平;同时带正负号。 |
| `ChangeUnit` | `string` | 空 | 变化值单位,如 `%`。 |
| `ChangeNote` | `string` | 空 | 变化值说明,如“较上次”。 |
## 状态
仅展示:上升、下降、持平(图标、符号与颜色共同表示)。
## 事件
无。
## 键盘操作
不获取焦点;分组名称把标题、数值、单位与变化一起读出。
## 限制
- 默认格式使用逗号分组与点号小数;其他地区格式通过 `Format` 提供。
+48
View File
@@ -0,0 +1,48 @@
# 079 ImageViewer · 图片查看器
## 用途
在窗口上方的模态层中查看图片:放大、缩小、平移、适应窗口、上一张/下一张,以及键盘操作与关闭。
## 最小示例
```go
picture := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#AD9164"/><path d="M120 20 L200 80 L120 140 L40 80 Z" fill="#7D2034"/></svg>`))
open, index := true, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ImageViewer(c, &open, &index, []mujica.ViewerImage{{Source: picture, Alt: "Rose window"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) ImageViewerView`。`ViewerImage` 字段:`Source`(`*ui.Bitmap` 或 `*ui.SVG`,必填)、`Alt`(说明文字与无障碍名称)。关闭时返回的 `Element` 为 nil。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `open` | `*bool` | 必填 | 为 true 时显示;Escape、点击遮罩或关闭按钮设为 false。 |
| `index` | `*int` | 必填 | 当前图片下标;越界 panic。 |
| `images` | `[]ViewerImage` | 必填 | 至少一张。 |
## 状态
缩放 25%–800%(相对适应窗口),按钮在边界处禁用;适应窗口时“适应”按钮禁用;首张/末张时上一张/下一张禁用。
## 事件
`ImageViewerView.Changed()`:切换了图片,`*index` 已更新。关闭通过 `*open` 变为 false 观察。
## 键盘操作
- + / -:放大、缩小;0:适应窗口。
- ←/→:上一张、下一张;Shift+方向键:平移。
- 滚轮:缩放;拖动图片:平移。
- Escape:关闭;Tab 在工具按钮与图片之间移动。
## 限制
- 基于 MyGo `ui.DialogBase`,焦点限制与关闭后的焦点恢复由 MyGo 处理。
- 图片解码由调用方完成(`ui.DecodeBitmap`),组件不读取文件或网络。
- 不支持旋转与触控板捏合手势。
+49
View File
@@ -0,0 +1,49 @@
# 080 Carousel · 走马灯
## 用途
逐张展示内容,提供上一项、下一项与位置指示。默认不自动播放;开启自动播放后提供暂停按钮,并在指针悬停或焦点位于组件内时暂停。
## 最小示例
```go
index := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Carousel(c, &index, 3, mujica.CarouselOptions{Label: "Windows", Autoplay: true}, func(i int) {
ui.Text(c, fmt.Sprintf("Rose window %d", i+1)).Padding(24)
}).Size(360, 220)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func(i int)) CarouselView`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | 空 | 无障碍名称。 |
| `Autoplay` | `bool` | false | 每隔 `Interval` 前进一项(循环)。 |
| `Interval` | `time.Duration` | 5s | 自动播放间隔;负数 panic。 |
| `Loop` | `bool` | false | 首尾相接;自动播放总是循环。 |
## 状态
默认、按钮悬停/按下/聚焦、首尾禁用(未循环时)、当前位置(实心菱形)、自动播放中、已暂停(显示继续按钮)。
## 事件
`CarouselView.Changed()`:当前项变化,`*index` 已更新;同时向读屏软件播报“第 N 项,共 M 项”。
## 键盘操作
- 焦点位于组件内时:←/→ 上一项、下一项;Home/End 第一项、最后一项。
- 位置指示为一个焦点组:方向键在指示点间移动,Enter/Space 跳转。
- Tab 依次到达上一项、下一项、位置指示与暂停按钮。
## 限制
- 切换不做滑动动画,直接显示新的一项。
- 不自动播放时没有计时器;自动播放只在可见帧中通过 `Context.After` 预约下一帧。
- `*index` 超出范围时会被夹到有效范围(记录 debug 日志),例如条目数减少后。
+182
View File
@@ -0,0 +1,182 @@
package mujica_test
import (
"cmp"
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleCard() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Card(c, mujica.CardOptions{Title: "Archive", Decorated: true, Footer: func() {
mujica.Button(c, "Open", mujica.ButtonOptions{})
}}, func() {
ui.Text(c, "Letters of the court, sealed in wax.")
}).Width(280)
}
ui.Render(view, 400, 300, 1)
}
func ExampleList() {
names := []string{"Matins", "Lauds", "Prime", "Terce"}
var state mujica.ListState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.List(c, &state, len(names), mujica.ListOptions[string]{
Key: func(i int) string { return names[i] },
Label: func(i int) string { return names[i] },
}, func(i int) { ui.Text(c, names[i]) })
v.Size(240, 200)
if v.Changed() {
key, _ := state.Selected()
fmt.Println("chose", key)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDataTable() {
type relic struct {
ID int
Name string
Age int
}
rows := []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}}
var state mujica.DataTableState[int]
opts := mujica.DataTableOptions[relic, int]{
Key: func(r relic) int { return r.ID },
Columns: []mujica.DataColumn[relic]{
{ID: "name", Title: "Name", Sortable: true,
Text: func(r relic) string { return r.Name },
Compare: func(a, b relic) int { return cmp.Compare(a.Name, b.Name) }},
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true,
Text: func(r relic) string { return strconv.Itoa(r.Age) },
Compare: func(a, b relic) int { return cmp.Compare(a.Age, b.Age) }},
},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DataTable(c, &state, rows, opts).Size(360, 240)
}
ui.Render(view, 400, 300, 1)
}
func ExampleTree() {
kids := map[string][]string{"Nave": {"Pulpit", "Pews"}}
var state mujica.TreeState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Tree(c, &state, mujica.TreeOptions[string]{
Roots: []string{"Nave", "Sacristy"},
Children: func(n string) []string { return kids[n] },
Label: func(n string) string { return n },
}).Size(280, 240)
}
ui.Render(view, 400, 300, 1)
}
func ExampleTreeTable() {
kids := map[string][]string{"Vestry": {"Stole", "Alb"}}
size := map[string]int{"Vestry": 9, "Stole": 2, "Alb": 5, "Organ": 7}
var state mujica.TreeTableState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TreeTable(c, &state, mujica.TreeTableOptions[string]{
Roots: []string{"Vestry", "Organ"},
Children: func(n string) []string { return kids[n] },
Label: func(n string) string { return n },
Columns: []mujica.TreeColumn[string]{
{ID: "name", Title: "Name", Cell: func(n string) { ui.Text(c, n) },
Compare: cmp.Compare[string]},
{ID: "size", Title: "Size", Width: 80, Align: ui.End,
Cell: func(n string) { ui.Text(c, strconv.Itoa(size[n])) }},
},
}).Size(360, 240)
}
ui.Render(view, 400, 300, 1)
}
func ExampleAccordion() {
open := []string{"choir"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Accordion(c, &open, []mujica.AccordionItem{
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices.") }},
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Black oak pews.") }},
}, mujica.AccordionOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCollapsible() {
open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Collapsible(c, &open, mujica.CollapsibleOptions{Title: "Details"}, func() {
ui.Text(c, "Candles are lit at the seventh bell.")
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimeline() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Timeline(c, []mujica.TimelineItem{
{Time: "06:00", Title: "Matins sung", Status: mujica.TimelineDone},
{Time: "09:00", Title: "Relics moved", Content: "From the crypt to the altar.", Status: mujica.TimelineActive},
{Time: "18:00", Title: "Vespers"},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleDescriptionList() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DescriptionList(c, []mujica.DescriptionItem{
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
{Label: "Shelf", Value: "North vault, row 7"},
{Label: "Seal", Value: "Wine-red wax", Span: -1},
}, mujica.DescriptionListOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStatistic() {
change := 12.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Statistic(c, 12840, mujica.StatisticOptions{
Title: "Pilgrims", Unit: "souls", Precision: 0,
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast",
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleImageViewer() {
picture := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#AD9164"/><path d="M120 20 L200 80 L120 140 L40 80 Z" fill="#7D2034"/></svg>`))
open, index := true, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ImageViewer(c, &open, &index, []mujica.ViewerImage{{Source: picture, Alt: "Rose window"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCarousel() {
index := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Carousel(c, &index, 3, mujica.CarouselOptions{Label: "Windows", Autoplay: true}, func(i int) {
ui.Text(c, fmt.Sprintf("Rose window %d", i+1)).Padding(24)
}).Size(360, 220)
}
ui.Render(view, 400, 300, 1)
}
+79 -1
View File
@@ -1,3 +1,81 @@
package main
var dataPages = []page{}
import (
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/theme"
)
const dataCategory = "Data"
var dataPages = []page{
{ID: "069", Name: "Card", ZhName: "卡片", Category: dataCategory, Demo: dataCardDemo, Code: dataCardCode,
Keys: "A card takes no focus itself; Tab moves through the controls inside it."},
{ID: "070", Name: "List", ZhName: "列表", Category: dataCategory, Demo: dataListDemo, Code: dataListCode,
Keys: "Up/Down (arrows in grid mode), Home/End and PageUp/PageDown move the choice; typing jumps by label; Enter submits; Cmd/Ctrl-click and Shift-click choose several; Cmd/Ctrl+A chooses all."},
{ID: "071", Name: "DataTable", ZhName: "数据表格", Category: dataCategory, Demo: dataTableDemo, Code: dataTableCode,
Keys: "Up/Down, Home/End move the choice; Enter submits; Shift and Cmd/Ctrl extend it; click a header to sort, again to reverse; drag header edges to resize and headers to move columns."},
{ID: "072", Name: "Tree", ZhName: "树", Category: dataCategory, Demo: dataTreeDemo, Code: dataTreeCode,
Keys: "Up/Down move; Right opens or goes to the first child; Left closes or goes to the parent; Option/Shift with Right/Left opens or closes all inside; typing jumps by label."},
{ID: "073", Name: "TreeTable", ZhName: "树形表格", Category: dataCategory, Demo: dataTreeTableDemo, Code: dataTreeTableCode,
Keys: "As Tree for rows; click a header to sort siblings; drag header edges to resize and headers to move columns."},
{ID: "074", Name: "Accordion", ZhName: "手风琴", Category: dataCategory, Demo: dataAccordionDemo, Code: dataAccordionCode,
Keys: "Tab reaches a header; Up/Down, Home/End move between headers, skipping disabled ones; Enter or Space opens and closes."},
{ID: "075", Name: "Collapsible", ZhName: "折叠面板", Category: dataCategory, Demo: dataCollapsibleDemo, Code: dataCollapsibleCode,
Keys: "Enter or Space on the trigger opens and closes; closed content leaves the Tab order."},
{ID: "076", Name: "Timeline", ZhName: "时间线", Category: dataCategory, Demo: dataTimelineDemo, Code: dataTimelineCode,
Keys: "Display only: no keys."},
{ID: "077", Name: "DescriptionList", ZhName: "描述列表", Category: dataCategory, Demo: dataDescriptionDemo, Code: dataDescriptionCode,
Keys: "Tab reaches each copy button; Enter or Space copies its value."},
{ID: "078", Name: "Statistic", ZhName: "统计数值", Category: dataCategory, Demo: dataStatisticDemo, Code: dataStatisticCode,
Keys: "Display only: no keys."},
{ID: "079", Name: "ImageViewer", ZhName: "图片查看器", Category: dataCategory, Demo: dataViewerDemo, Code: dataViewerCode,
Keys: "+ and - zoom, 0 fits; Left/Right switch images; Shift+arrows pan; the wheel zooms; drag pans; Escape closes."},
{ID: "080", Name: "Carousel", ZhName: "走马灯", Category: dataCategory, Demo: dataCarouselDemo, Code: dataCarouselCode,
Keys: "With focus inside: Left/Right step, Home/End jump; arrows move among the position dots; Enter or Space activates buttons."},
}
// dataSection lays out a titled block of a demo.
func dataSection(c *ui.Context, title string, fn func()) {
ui.Column(c).Gap(12).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size)
fn()
})
}
// dataStates lays labeled variants side by side.
func dataStates(c *ui.Context, fn func(state func(name string, body func()))) {
k := mujica.Tokens(c)
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
fn(func(name string, body func()) {
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
body()
})
})
})
}
func dataControls(c *ui.Context, fn func()) {
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn)
}
var dataStatuses = []string{"Ready", "Loading", "Loading more", "Failed", "Empty"}
// dataStatus maps a status choice to the status and whether rows show.
func dataStatus(s string) (mujica.DataStatus, bool) {
switch s {
case "Ready":
return mujica.DataReady, true
case "Loading":
return mujica.DataLoading, false
case "Loading more":
return mujica.DataLoadingMore, true
case "Failed":
return mujica.DataFailed, true
case "Empty":
return mujica.DataReady, false
}
panic("gallery: unknown status " + s)
}
+144
View File
@@ -0,0 +1,144 @@
package main
// Snippets are the bodies of the Example functions in example_data_test.go.
const dataCardCode = `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Card(c, mujica.CardOptions{Title: "Archive", Decorated: true, Footer: func() {
mujica.Button(c, "Open", mujica.ButtonOptions{})
}}, func() {
ui.Text(c, "Letters of the court, sealed in wax.")
}).Width(280)
}
ui.Render(view, 400, 300, 1)`
const dataListCode = `names := []string{"Matins", "Lauds", "Prime", "Terce"}
var state mujica.ListState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.List(c, &state, len(names), mujica.ListOptions[string]{
Key: func(i int) string { return names[i] },
Label: func(i int) string { return names[i] },
}, func(i int) { ui.Text(c, names[i]) })
v.Size(240, 200)
if v.Changed() {
key, _ := state.Selected()
fmt.Println("chose", key)
}
}
ui.Render(view, 400, 300, 1)`
const dataTableCode = `type relic struct {
ID int
Name string
Age int
}
rows := []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}}
var state mujica.DataTableState[int]
opts := mujica.DataTableOptions[relic, int]{
Key: func(r relic) int { return r.ID },
Columns: []mujica.DataColumn[relic]{
{ID: "name", Title: "Name", Sortable: true,
Text: func(r relic) string { return r.Name },
Compare: func(a, b relic) int { return cmp.Compare(a.Name, b.Name) }},
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true,
Text: func(r relic) string { return strconv.Itoa(r.Age) },
Compare: func(a, b relic) int { return cmp.Compare(a.Age, b.Age) }},
},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DataTable(c, &state, rows, opts).Size(360, 240)
}
ui.Render(view, 400, 300, 1)`
const dataTreeCode = `kids := map[string][]string{"Nave": {"Pulpit", "Pews"}}
var state mujica.TreeState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Tree(c, &state, mujica.TreeOptions[string]{
Roots: []string{"Nave", "Sacristy"},
Children: func(n string) []string { return kids[n] },
Label: func(n string) string { return n },
}).Size(280, 240)
}
ui.Render(view, 400, 300, 1)`
const dataTreeTableCode = `kids := map[string][]string{"Vestry": {"Stole", "Alb"}}
size := map[string]int{"Vestry": 9, "Stole": 2, "Alb": 5, "Organ": 7}
var state mujica.TreeTableState[string]
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TreeTable(c, &state, mujica.TreeTableOptions[string]{
Roots: []string{"Vestry", "Organ"},
Children: func(n string) []string { return kids[n] },
Label: func(n string) string { return n },
Columns: []mujica.TreeColumn[string]{
{ID: "name", Title: "Name", Cell: func(n string) { ui.Text(c, n) },
Compare: cmp.Compare[string]},
{ID: "size", Title: "Size", Width: 80, Align: ui.End,
Cell: func(n string) { ui.Text(c, strconv.Itoa(size[n])) }},
},
}).Size(360, 240)
}
ui.Render(view, 400, 300, 1)`
const dataAccordionCode = `open := []string{"choir"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Accordion(c, &open, []mujica.AccordionItem{
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices.") }},
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Black oak pews.") }},
}, mujica.AccordionOptions{})
}
ui.Render(view, 400, 300, 1)`
const dataCollapsibleCode = `open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Collapsible(c, &open, mujica.CollapsibleOptions{Title: "Details"}, func() {
ui.Text(c, "Candles are lit at the seventh bell.")
})
}
ui.Render(view, 400, 300, 1)`
const dataTimelineCode = `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Timeline(c, []mujica.TimelineItem{
{Time: "06:00", Title: "Matins sung", Status: mujica.TimelineDone},
{Time: "09:00", Title: "Relics moved", Content: "From the crypt to the altar.", Status: mujica.TimelineActive},
{Time: "18:00", Title: "Vespers"},
})
}
ui.Render(view, 400, 300, 1)`
const dataDescriptionCode = `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DescriptionList(c, []mujica.DescriptionItem{
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
{Label: "Shelf", Value: "North vault, row 7"},
{Label: "Seal", Value: "Wine-red wax", Span: -1},
}, mujica.DescriptionListOptions{})
}
ui.Render(view, 400, 300, 1)`
const dataStatisticCode = `change := 12.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Statistic(c, 12840, mujica.StatisticOptions{
Title: "Pilgrims", Unit: "souls", Precision: 0,
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast",
})
}
ui.Render(view, 400, 300, 1)`
const dataViewerCode = "picture := ui.MustParseSVG([]byte(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"240\" height=\"160\"><rect width=\"240\" height=\"160\" fill=\"#AD9164\"/><path d=\"M120 20 L200 80 L120 140 L40 80 Z\" fill=\"#7D2034\"/></svg>`))\nopen, index := true, 0\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.ImageViewer(c, &open, &index, []mujica.ViewerImage{{Source: picture, Alt: \"Rose window\"}})\n}\nui.Render(view, 400, 300, 1)"
const dataCarouselCode = `index := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Carousel(c, &index, 3, mujica.CarouselOptions{Label: "Windows", Autoplay: true}, func(i int) {
ui.Text(c, fmt.Sprintf("Rose window %d", i+1)).Padding(24)
}).Size(360, 220)
}
ui.Render(view, 400, 300, 1)`
+303
View File
@@ -0,0 +1,303 @@
package main
import (
"cmp"
"fmt"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var dataOffices = []string{"Matins", "Lauds", "Prime", "Terce", "Sext", "None", "Vespers", "Compline"}
func dataListDemo(c *ui.Context, log func(string)) {
type state struct {
big, grid, multiple bool
status, query string
list mujica.ListState[int]
}
s := ui.Local(c.Root(), "070", func() state { return state{status: "Ready"} })
n := 24
if s.big {
n = 100_000
}
label := func(i int) string { return fmt.Sprintf("%s %d", dataOffices[i%len(dataOffices)], i+1) }
st, show := dataStatus(s.status)
if !show {
n = 0
}
dataSection(c, "List", func() {
dataControls(c, func() {
ui.Checkbox(c, &s.big, "100,000 items")
ui.Checkbox(c, &s.grid, "Grid")
ui.Checkbox(c, &s.multiple, "Multiple")
ui.Select(c, &s.status, dataStatuses)
})
dataControls(c, func() {
ui.TextInput(c, &s.query).Width(200).Placeholder("Find by label")
if mujica.Button(c, "Find", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for i := range n {
if strings.Contains(strings.ToLower(label(i)), strings.ToLower(s.query)) {
s.list.ScrollTo(i)
log(fmt.Sprintf("found %q at %d", s.query, i))
break
}
}
}
})
v := mujica.List(c, &s.list, n, mujica.ListOptions[int]{
Key: func(i int) int { return i }, Label: label, Grid: s.grid, Multiple: s.multiple,
ItemWidth: 140, ItemHeight: 72, Status: st,
}, func(i int) {
ui.Text(c, label(i)).SingleLine()
if s.grid {
ui.Text(c, "Office").TextColor(mujica.Tokens(c).TextMuted)
}
})
v.Size(480, 320)
if v.Changed() {
k, _ := s.list.Selected()
log(fmt.Sprintf("selected %d, %d chosen", k, s.list.Selection.Len()))
}
if v.Submitted() {
k, _ := s.list.Selected()
log(fmt.Sprintf("submitted %d", k))
}
if v.Retried() {
log("retry")
}
})
}
type dataRelic struct {
ID int
Name string
Office string
Year int
Sealed bool
}
func dataRelics(n int) []dataRelic {
rows := make([]dataRelic, n)
for i := range rows {
rows[i] = dataRelic{ID: i + 1, Name: fmt.Sprintf("Relic %05d", (i*7919)%n+1), Office: dataOffices[i%len(dataOffices)],
Year: 1200 + (i*37)%600, Sealed: i%3 == 0}
}
return rows
}
func dataTableDemo(c *ui.Context, log func(string)) {
type state struct {
big, multiple bool
status string
small, large []dataRelic
table mujica.DataTableState[int]
}
s := ui.Local(c.Root(), "071", func() state { return state{status: "Ready", small: dataRelics(60)} })
rows := s.small
if s.big {
if s.large == nil {
s.large = dataRelics(100_000)
}
rows = s.large
}
st, show := dataStatus(s.status)
if !show {
rows = nil
}
k := mujica.Tokens(c)
cols := []mujica.DataColumn[dataRelic]{
{ID: "id", Title: "ID", Width: 80, Align: ui.End, Sortable: true, Text: func(r dataRelic) string { return strconv.Itoa(r.ID) },
Compare: func(a, b dataRelic) int { return cmp.Compare(a.ID, b.ID) }},
{ID: "name", Title: "Name", MinWidth: 120, Sortable: true, Text: func(r dataRelic) string { return r.Name },
Compare: func(a, b dataRelic) int { return cmp.Compare(a.Name, b.Name) }},
{ID: "office", Title: "Office", Width: 110, Sortable: true, Text: func(r dataRelic) string { return r.Office },
Compare: func(a, b dataRelic) int { return cmp.Compare(a.Office, b.Office) }},
{ID: "year", Title: "Year", Width: 80, Align: ui.End, Sortable: true, Text: func(r dataRelic) string { return strconv.Itoa(r.Year) },
Compare: func(a, b dataRelic) int { return cmp.Compare(a.Year, b.Year) }},
{ID: "seal", Title: "Seal", Width: 100, Text: func(r dataRelic) string { return map[bool]string{true: "Sealed", false: "Open"}[r.Sealed] },
Cell: func(r dataRelic) {
color := k.Success
if r.Sealed {
color = k.Warning
}
ui.Text(c, map[bool]string{true: "◆ Sealed", false: "◇ Open"}[r.Sealed]).TextColor(color).SingleLine()
}},
}
dataSection(c, "DataTable", func() {
dataControls(c, func() {
ui.Checkbox(c, &s.big, "100,000 rows")
ui.Checkbox(c, &s.multiple, "Multiple")
ui.Select(c, &s.status, dataStatuses)
ui.TextInput(c, &s.table.Filter).Width(180).Placeholder("Filter")
mujica.DataTableColumnPicker(c, &s.table, cols)
})
v := mujica.DataTable(c, &s.table, rows, mujica.DataTableOptions[dataRelic, int]{
Columns: cols, Key: func(r dataRelic) int { return r.ID }, Multiple: s.multiple, Status: st,
})
v.Size(620, 360)
ui.Text(c, fmt.Sprintf("%d of %d rows shown", s.table.Rows(), len(rows))).TextColor(k.TextMuted)
if v.SortChanged() {
log(fmt.Sprintf("sort %s descending=%v", s.table.Sort.Column, s.table.Sort.Descending))
}
if v.Changed() {
id, _ := s.table.Selected()
log(fmt.Sprintf("selected row %d, %d chosen", id, s.table.Selection.Len()))
}
if v.Submitted() {
id, _ := s.table.Selected()
log(fmt.Sprintf("opened row %d", id))
}
if v.Retried() {
log("retry")
}
})
}
// dataLazy simulates children that arrive 600 ms after they are asked for.
type dataLazy struct {
kids map[string][]string
status map[string]mujica.DataStatus
due map[string]time.Time
failed map[string]bool
}
func newDataLazy() *dataLazy {
return &dataLazy{
kids: map[string][]string{"Nave": {"Pulpit", "Pews", "Font"}, "Crypt": {}, "Archive": {},
"Pulpit": nil, "Pews": nil, "Font": nil, "Sacristy": nil},
status: map[string]mujica.DataStatus{"Crypt": mujica.DataUnloaded, "Archive": mujica.DataUnloaded},
due: map[string]time.Time{}, failed: map[string]bool{},
}
}
func (d *dataLazy) load(c *ui.Context, log func(string)) func(string) {
return func(n string) {
d.status[n] = mujica.DataLoading
d.due[n] = c.Now().Add(600 * time.Millisecond)
log("load " + n)
}
}
// tick finishes due loads; the Archive fails once to show retry.
func (d *dataLazy) tick(c *ui.Context) {
for n, at := range d.due {
if c.Now().Before(at) {
c.After(at.Sub(c.Now()))
continue
}
delete(d.due, n)
if n == "Archive" && !d.failed[n] {
d.failed[n], d.status[n] = true, mujica.DataFailed
continue
}
d.status[n] = mujica.DataReady
d.kids[n] = []string{n + " north", n + " south"}
}
}
func dataTreeDemo(c *ui.Context, log func(string)) {
type state struct {
big, multiple bool
lazy *dataLazy
tree mujica.TreeState[string]
bigTree mujica.TreeState[string]
}
s := ui.Local(c.Root(), "072", func() state { return state{lazy: newDataLazy()} })
s.lazy.tick(c)
opts := mujica.TreeOptions[string]{
Roots: []string{"Nave", "Crypt", "Archive", "Sacristy"}, Children: func(n string) []string { return s.lazy.kids[n] },
Label: func(n string) string { return n }, ItemStatus: func(n string) mujica.DataStatus { return s.lazy.status[n] },
Load: s.lazy.load(c, log), Multiple: s.multiple,
}
ts := &s.tree
if s.big {
opts = dataBigTree(s.multiple)
ts = &s.bigTree
}
dataSection(c, "Tree", func() {
dataControls(c, func() {
ui.Checkbox(c, &s.big, "100,000 nodes")
ui.Checkbox(c, &s.multiple, "Multiple")
})
v := mujica.Tree(c, ts, opts)
v.Size(420, 340)
if v.Changed() {
n, _ := ts.Selected()
log(fmt.Sprintf("selected %s, %d chosen", n, ts.Selection.Len()))
}
if v.Submitted() {
n, _ := ts.Selected()
log("submitted " + n)
}
})
}
// dataBigTree has 100 roots of 999 children: 100,000 nodes, built as they open.
func dataBigTree(multiple bool) mujica.TreeOptions[string] {
roots := make([]string, 100)
for i := range roots {
roots[i] = fmt.Sprintf("Wing %d", i+1)
}
return mujica.TreeOptions[string]{
Roots: roots, Label: func(n string) string { return n }, Multiple: multiple,
Children: func(n string) []string {
if !strings.HasPrefix(n, "Wing ") || strings.Contains(n, "·") {
return nil
}
return dataWing(n)
},
}
}
var dataWings = map[string][]string{}
func dataWing(n string) []string {
if kids, ok := dataWings[n]; ok {
return kids
}
kids := make([]string, 999)
for i := range kids {
kids[i] = fmt.Sprintf("%s · cell %d", n, i+1)
}
dataWings[n] = kids
return kids
}
func dataTreeTableDemo(c *ui.Context, log func(string)) {
type state struct {
multiple bool
lazy *dataLazy
table mujica.TreeTableState[string]
}
s := ui.Local(c.Root(), "073", func() state { return state{lazy: newDataLazy()} })
s.lazy.tick(c)
size := func(n string) int { return len(n)*7 + len(s.lazy.kids[n])*13 }
text := func(n string) { ui.Text(c, n).SingleLine() }
dataSection(c, "TreeTable", func() {
dataControls(c, func() { ui.Checkbox(c, &s.multiple, "Multiple") })
v := mujica.TreeTable(c, &s.table, mujica.TreeTableOptions[string]{
Roots: []string{"Nave", "Crypt", "Archive", "Sacristy"}, Children: func(n string) []string { return s.lazy.kids[n] },
Label: func(n string) string { return n }, ItemStatus: func(n string) mujica.DataStatus { return s.lazy.status[n] },
Load: s.lazy.load(c, log), Multiple: s.multiple,
Columns: []mujica.TreeColumn[string]{
{ID: "name", Title: "Name", MinWidth: 160, Cell: text, Compare: cmp.Compare[string]},
{ID: "size", Title: "Size", Width: 90, Align: ui.End, Cell: func(n string) { ui.Text(c, fmt.Sprintf("%d m²", size(n))) },
Compare: func(a, b string) int { return cmp.Compare(size(a), size(b)) }},
{ID: "kids", Title: "Rooms", Width: 80, Align: ui.End, Cell: func(n string) { ui.Text(c, strconv.Itoa(len(s.lazy.kids[n]))) }},
},
})
v.Size(520, 320)
if v.SortChanged() {
log(fmt.Sprintf("sort %s descending=%v", s.table.Sort.Column, s.table.Sort.Descending))
}
if v.Changed() {
n, _ := s.table.Selected()
log(fmt.Sprintf("selected %s, %d chosen", n, s.table.Selection.Len()))
}
})
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"fmt"
"strconv"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func dataCardDemo(c *ui.Context, log func(string)) {
type state struct {
decorated, footer bool
title string
}
s := ui.Local(c.Root(), "069", func() state { return state{footer: true, title: "Archive"} })
dataSection(c, "Card", func() {
dataControls(c, func() {
ui.TextInput(c, &s.title).Width(180).Placeholder("Title")
ui.Checkbox(c, &s.decorated, "Decorated")
ui.Checkbox(c, &s.footer, "Footer")
})
card := func(decorated bool) {
opts := mujica.CardOptions{Title: s.title, Decorated: decorated}
if s.footer {
opts.Footer = func() {
if mujica.Button(c, "Open", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
log(fmt.Sprintf("open clicked (decorated=%v)", decorated))
}
}
}
mujica.Card(c, opts, func() {
ui.Text(c, "Letters of the court, sealed in wine-red wax and kept in the north vault.")
}).Width(280)
}
dataStates(c, func(state func(string, func())) {
state("Options above", func() { card(s.decorated) })
state("Plain", func() { card(false) })
state("Decorated", func() { card(true) })
})
})
}
func dataAccordionDemo(c *ui.Context, log func(string)) {
type state struct {
multiple, keepOne bool
open []string
}
s := ui.Local(c.Root(), "074", func() state { return state{open: []string{"choir"}} })
dataSection(c, "Accordion", func() {
dataControls(c, func() {
ui.Checkbox(c, &s.multiple, "Multiple")
ui.Checkbox(c, &s.keepOne, "Keep one open")
if mujica.Button(c, "Close all", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
s.open = nil
log("closed all from outside")
}
})
if mujica.Accordion(c, &s.open, []mujica.AccordionItem{
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices, two at the altar.") }},
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Pews of black oak, polished for the feast.") }},
{ID: "crypt", Title: "Crypt (disabled)", Disabled: true, Content: func() {}},
{ID: "bells", Title: "Bells", Content: func() {
mujica.Button(c, "Ring", mujica.ButtonOptions{})
}},
}, mujica.AccordionOptions{Multiple: s.multiple, KeepOne: s.keepOne}).Width(420).Changed() {
log(fmt.Sprintf("open %v", s.open))
}
})
}
func dataCollapsibleDemo(c *ui.Context, log func(string)) {
type state struct{ open, disabled bool }
s := ui.Local(c.Root(), "075", func() state { return state{} })
dataSection(c, "Collapsible", func() {
dataControls(c, func() {
if mujica.Button(c, "Open from outside", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.open = true
log("opened from outside")
}
ui.Checkbox(c, &s.disabled, "Disabled")
})
if mujica.Collapsible(c, &s.open, mujica.CollapsibleOptions{Title: "Rites of the evening", Disabled: s.disabled}, func() {
ui.Text(c, "Candles are lit at the seventh bell; the choir enters from the north door.")
mujica.Button(c, "Light candles", mujica.ButtonOptions{Variant: mujica.Outline})
}).Changed() {
log(fmt.Sprintf("open=%v", s.open))
}
})
}
func dataTimelineDemo(c *ui.Context, log func(string)) {
type state struct{ items []mujica.TimelineItem }
s := ui.Local(c.Root(), "076", func() state {
return state{items: []mujica.TimelineItem{
{Time: "06:00", Title: "Matins sung", Content: "The choir gathered before dawn.", Status: mujica.TimelineDone},
{Time: "09:00", Title: "Relics moved", Status: mujica.TimelineActive,
Content: "The reliquaries travel from the crypt to the high altar, carried in procession by four acolytes in black and wine."},
{Time: "12:00", Title: "Bell cracked", Status: mujica.TimelineError},
{Time: "15:00", Title: "Storm warning", Status: mujica.TimelineWarning},
{Time: "18:00", Title: "Vespers"},
}}
})
dataSection(c, "Timeline", func() {
dataControls(c, func() {
if mujica.Button(c, "Add event", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.items = append(s.items, mujica.TimelineItem{Time: fmt.Sprintf("%02d:00", 19+len(s.items)%5), Title: "Compline"})
log(fmt.Sprintf("%d events", len(s.items)))
}
})
ui.Column(c).Width(420).Children(func() { mujica.Timeline(c, s.items) })
})
}
func dataDescriptionDemo(c *ui.Context, log func(string)) {
type state struct {
columns string
width float64
}
s := ui.Local(c.Root(), "077", func() state { return state{columns: "2", width: 560} })
dataSection(c, "DescriptionList", func() {
dataControls(c, func() {
ui.Select(c, &s.columns, []string{"1", "2", "3"})
ui.Text(c, "Width")
ui.Slider(c, &s.width, 240, 720).Width(200)
})
cols, _ := strconv.Atoi(s.columns)
ui.Column(c).Width(float32(s.width)).Children(func() {
if i, ok := mujica.DescriptionList(c, []mujica.DescriptionItem{
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
{Label: "Shelf", Value: "North vault, row 7"},
{Label: "Catalogue", Value: "MS-1347-B", Copyable: true},
{Label: "Seal", Value: "Wine-red wax, cracked at the rim", Span: -1},
}, mujica.DescriptionListOptions{Columns: cols}).Copied(); ok {
log(fmt.Sprintf("copied item %d", i))
}
})
})
}
func dataStatisticDemo(c *ui.Context, log func(string)) {
type state struct {
value, change float64
precision string
}
s := ui.Local(c.Root(), "078", func() state { return state{value: 12840, change: 12.5, precision: "0"} })
dataSection(c, "Statistic", func() {
dataControls(c, func() {
ui.Text(c, "Value")
if ui.Slider(c, &s.value, 0, 100000).Width(160).Changed() {
log(fmt.Sprintf("value %.0f", s.value))
}
ui.Text(c, "Change")
ui.Slider(c, &s.change, -50, 50).Width(160)
ui.Select(c, &s.precision, []string{"0", "1", "2"})
})
p, _ := strconv.Atoi(s.precision)
change := s.change
dataStates(c, func(state func(string, func())) {
state("With change", func() {
mujica.Statistic(c, s.value, mujica.StatisticOptions{Title: "Pilgrims", Unit: "souls", Precision: p,
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast"})
})
state("Prefix", func() {
mujica.Statistic(c, s.value/1000, mujica.StatisticOptions{Title: "Tithe", Prefix: "£", Suffix: "k", Precision: p})
})
})
})
}
var dataPictures = func() []mujica.ViewerImage {
art := func(bg, fg string, shape string) *ui.SVG {
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320"><rect width="480" height="320" fill="` + bg + `"/>` +
`<path d="` + shape + `" fill="` + fg + `"/></svg>`))
}
return []mujica.ViewerImage{
{Source: art("#AD9164", "#7D2034", "M240 40 L380 160 L240 280 L100 160 Z"), Alt: "Rose window"},
{Source: art("#211C1D", "#BDA172", "M80 280 L80 120 L240 40 L400 120 L400 280 Z"), Alt: "Chapel arch"},
{Source: art("#F2EBDD", "#651729", "M60 60 H420 V100 H60 Z M60 140 H420 V180 H60 Z M60 220 H420 V260 H60 Z"), Alt: "Choir stalls"},
}
}()
func dataViewerDemo(c *ui.Context, log func(string)) {
type state struct {
open bool
index int
}
s := ui.Local(c.Root(), "079", func() state { return state{} })
dataSection(c, "ImageViewer", func() {
k := mujica.Tokens(c)
ui.Text(c, "Choose a picture to open the viewer.").TextColor(k.TextMuted)
ui.Row(c).Gap(12).Children(func() {
for i, p := range dataPictures {
thumb := ui.ButtonBase(c).Label("Open "+p.Alt).Radius(4).Border(1, k.Border).Clip()
thumb.Children(func() { ui.Image(c, p.Source).Size(144, 96) })
if thumb.Clicked() {
s.open, s.index = true, i
log("open " + p.Alt)
}
}
})
wasOpen := s.open
if mujica.ImageViewer(c, &s.open, &s.index, dataPictures).Changed() {
log(fmt.Sprintf("image %d", s.index+1))
}
if wasOpen && !s.open {
log("closed")
}
})
}
func dataCarouselDemo(c *ui.Context, log func(string)) {
type state struct {
index int
autoplay, loop bool
interval string
}
s := ui.Local(c.Root(), "080", func() state { return state{interval: "3s"} })
dataSection(c, "Carousel", func() {
dataControls(c, func() {
ui.Checkbox(c, &s.autoplay, "Autoplay")
ui.Checkbox(c, &s.loop, "Loop")
ui.Select(c, &s.interval, []string{"2s", "3s", "5s"})
})
d, err := time.ParseDuration(s.interval)
if err != nil {
panic(err)
}
v := mujica.Carousel(c, &s.index, len(dataPictures), mujica.CarouselOptions{Label: "Windows", Autoplay: s.autoplay, Loop: s.loop, Interval: d}, func(i int) {
ui.Image(c, dataPictures[i].Source).Grow(1).Label(dataPictures[i].Alt)
})
v.Size(520, 300)
if v.Changed() {
log(fmt.Sprintf("slide %d", s.index+1))
}
})
}
+193
View File
@@ -0,0 +1,193 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ViewerImage is one picture of an ImageViewer.
type ViewerImage struct {
// Source is a *ui.Bitmap or *ui.SVG; required.
Source ui.ImageSource
// Alt describes the picture for assistive technology and the caption.
Alt string
}
// ImageViewerView is a built ImageViewer and what happened to it in this frame.
// Element is nil while the viewer is closed.
type ImageViewerView struct {
*ui.Element
changed bool
}
// Changed reports that another image was chosen.
func (v ImageViewerView) Changed() bool { return v.changed }
// Zoom limits, as multiples of the fitted size.
const (
viewerMinZoom = 0.25
viewerMaxZoom = 8
viewerZoomStep = 1.25
viewerPanStep = 48
)
type viewerState struct {
image int
zoom, x, y float32
}
// ImageViewer shows images[*index] over the window while *open, with zoom, pan and switching.
func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) ImageViewerView {
var v ImageViewerView
if !*open {
return v
}
if len(images) == 0 || *index < 0 || *index >= len(images) {
panic("mujica: ImageViewer index out of range")
}
k := Tokens(c)
u := Density(c).Unit()
w, h := c.Size()
v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
back.Background(ui.RGBA(0, 0, 0, 0.55))
panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius).
Size(w-u*12, h-u*12).Padding(u * 3).Gap(u * 3).Label(msg(c, "image_viewer.title"))
st := ui.Local(panel, "view", func() viewerState { return viewerState{image: -1} })
if st.image != *index {
*st = viewerState{image: *index, zoom: 1}
}
goTo := func(i int) {
if i < 0 || i >= len(images) || i == *index {
return
}
*index, v.changed = i, true
*st = viewerState{image: i, zoom: 1}
c.Announce(fmt.Sprintf(msg(c, "image_viewer.position"), i+1, len(images)))
slog.Debug("mujica: image_viewer image", "index", i)
}
zoom := func(f float32) {
st.zoom = float32(math.Max(viewerMinZoom, math.Min(viewerMaxZoom, float64(st.zoom*f))))
}
viewerKeys(panel, st, zoom, goTo, *index)
img := images[*index]
tool := func(icon, label string, disabled bool) bool {
return Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must(icon), Label: msg(c, label), Disabled: disabled}).Clicked()
}
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "image_viewer.position"), *index+1, len(images))).TextColor(k.TextMuted).Grow(1)
if tool("zoom-out", "image_viewer.zoomOut", st.zoom <= viewerMinZoom) {
zoom(1 / viewerZoomStep)
}
ui.Text(c, fmt.Sprintf("%d%%", int(math.Round(float64(st.zoom*100))))).Width(48).TextAlign(ui.Center).SingleLine()
if tool("zoom-in", "image_viewer.zoomIn", st.zoom >= viewerMaxZoom) {
zoom(viewerZoomStep)
}
if tool("maximize", "image_viewer.fit", st.zoom == 1 && st.x == 0 && st.y == 0) {
*st = viewerState{image: *index, zoom: 1}
}
if tool("x", "image_viewer.close", false) {
*open = false
}
})
ui.Row(c).Grow(1).MinHeight(0).Gap(u * 2).AlignItems(ui.Center).Children(func() {
if tool("chevron-left", "image_viewer.prev", *index == 0) {
goTo(*index - 1)
}
stage := ui.Box(c).Grow(1).FillHeight().MinWidth(0).Focusable().AutoFocus().Clip().
Radius(theme.ControlRadius).Background(k.Background).Role(ui.RoleImage).Label(img.Alt).Cursor(ui.CursorGrab)
internal.Ring(stage, k.Focus, theme.ControlRadius)
if dx, dy, ok := stage.Dragged(); ok {
st.x, st.y = st.x+dx, st.y+dy
}
stage.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputScroll || ev.DY == 0 {
return false
}
if ev.DY < 0 {
zoom(viewerZoomStep)
} else {
zoom(1 / viewerZoomStep)
}
return true
})
iw, ih := viewerSize(img.Source)
place := func(r ui.Rect) (dw, dh, mx, my float32) {
fit := float32(math.Min(float64(r.W/iw), float64(r.H/ih)))
dw, dh = iw*fit*st.zoom, ih*fit*st.zoom
// The picture cannot be dragged past its edges.
return dw, dh, max(0, (dw-r.W)/2), max(0, (dh-r.H)/2)
}
if b := stage.Bounds(); b.W > 0 {
_, _, mx, my := place(b)
st.x, st.y = min(mx, max(-mx, st.x)), min(my, max(-my, st.y))
}
x, y := st.x, st.y
stage.Draw(func(p *ui.Painter, r ui.Rect) {
dw, dh, mx, my := place(r)
ox, oy := min(mx, max(-mx, x)), min(my, max(-my, y))
p.Clip(r, theme.ControlRadius, func() {
p.Image(img.Source, ui.Rect{X: r.X + (r.W-dw)/2 + ox, Y: r.Y + (r.H-dh)/2 + oy, W: dw, H: dh}, ui.FillBox)
})
})
if tool("chevron-right", "image_viewer.next", *index == len(images)-1) {
goTo(*index + 1)
}
})
if img.Alt != "" {
ui.Text(c, img.Alt).TextColor(k.TextMuted).TextAlign(ui.Center).FillWidth()
}
})
if !*open {
slog.Debug("mujica: image_viewer close")
}
return v
}
// viewerKeys, anywhere in the panel, zooms with + - 0, switches with Left and Right, and pans with Shift+arrows.
func viewerKeys(panel *ui.Element, st *viewerState, zoom func(float32), goTo func(int), at int) {
switch {
case panel.Shortcut(0, ui.KeyEqual), panel.Shortcut(ui.Shift, ui.KeyEqual):
zoom(viewerZoomStep)
case panel.Shortcut(0, ui.KeyMinus):
zoom(1 / viewerZoomStep)
case panel.Shortcut(0, ui.Key0):
*st = viewerState{image: st.image, zoom: 1}
case panel.Shortcut(0, ui.KeyLeft):
goTo(at - 1)
case panel.Shortcut(0, ui.KeyRight):
goTo(at + 1)
case panel.Shortcut(ui.Shift, ui.KeyLeft):
st.x += viewerPanStep
case panel.Shortcut(ui.Shift, ui.KeyRight):
st.x -= viewerPanStep
case panel.Shortcut(ui.Shift, ui.KeyUp):
st.y += viewerPanStep
case panel.Shortcut(ui.Shift, ui.KeyDown):
st.y -= viewerPanStep
}
}
// viewerSize returns a picture's own size in DIPs.
func viewerSize(src ui.ImageSource) (float32, float32) {
var w, h float32
switch s := src.(type) {
case *ui.Bitmap:
iw, ih := s.Size()
w, h = float32(iw), float32(ih)
case *ui.SVG:
w, h = s.Size()
default:
panic(fmt.Sprintf("mujica: ImageViewer source %T is not a *ui.Bitmap or *ui.SVG", src))
}
if w <= 0 || h <= 0 {
panic("mujica: ImageViewer source without a size")
}
return w, h
}
+157
View File
@@ -0,0 +1,157 @@
package mujica
import (
"image"
"image/color"
"testing"
"github.com/egoist/mygo/ui"
)
// viewerPicture is red on the left half and blue on the right.
func viewerPicture() *ui.Bitmap {
img := image.NewRGBA(image.Rect(0, 0, 200, 100))
for y := range 100 {
for x := range 200 {
c := color.RGBA{200, 30, 30, 255}
if x >= 100 {
c = color.RGBA{30, 30, 200, 255}
}
img.SetRGBA(x, y, c)
}
}
return ui.NewBitmap(img)
}
type viewerHarness struct {
open bool
index int
images []ViewerImage
changes int
stage ui.Rect
}
func newViewerHarness() *viewerHarness {
pic := viewerPicture()
return &viewerHarness{open: true, images: []ViewerImage{{pic, "Rose window"}, {pic, "Choir stalls"}, {pic, "Crypt door"}}}
}
func (h *viewerHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
if ImageViewer(c, &h.open, &h.index, h.images).Changed() {
h.changes++
}
}
func centerPixel(tt *ui.Tester) color.RGBA {
r, _ := tt.Find("Rose window") // the stage, by its Label
return color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W/2), int(r.Y+r.H/2))).(color.RGBA)
}
func TestImageViewerSwitchAndClose(t *testing.T) {
h := newViewerHarness()
tt := ui.NewTester(h.view, 640, 480)
if !tt.HasText("Image 1 of 3") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Previous image")
if h.index != 0 {
t.Fatal("previous moved before the first image")
}
_ = tt.Click("Next image")
if h.index != 1 || h.changes != 1 || !tt.HasText("Choir stalls") {
t.Fatalf("next: index %d changes %d", h.index, h.changes)
}
tt.Key(0, ui.KeyRight)
if h.index != 2 {
t.Fatalf("Right: %d", h.index)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyEscape)
if h.open || h.index != 1 {
t.Fatalf("Escape: open %v index %d", h.open, h.index)
}
h.open = true
tt.Frame()
_ = tt.Click("Close")
if h.open {
t.Fatal("close button left it open")
}
}
func TestImageViewerZoomAndPan(t *testing.T) {
h := newViewerHarness()
tt := ui.NewTester(h.view, 640, 480)
tt.Key(0, ui.KeyEqual)
if !tt.HasText("125%") {
t.Fatalf("+ did not zoom: %q", tt.Texts())
}
_ = tt.Click("Zoom in")
_ = tt.Click("Zoom in")
if !tt.HasText("195%") {
t.Fatalf("zoom in: %q", tt.Texts())
}
for range 10 {
tt.Key(ui.Shift, ui.KeyLeft) // the picture moves right: its left half shows
}
if p := centerPixel(tt); p.R < 150 || p.B > 100 {
t.Fatalf("pan right shows %v, want red", p)
}
for range 20 {
tt.Key(ui.Shift, ui.KeyRight)
}
if p := centerPixel(tt); p.B < 150 || p.R > 100 {
t.Fatalf("pan left shows %v, want blue", p)
}
tt.Key(0, ui.Key0)
if !tt.HasText("100%") {
t.Fatalf("0 did not fit: %q", tt.Texts())
}
for range 20 {
tt.Key(0, ui.KeyMinus)
}
if !tt.HasText("25%") {
t.Fatalf("zoom floor: %q", tt.Texts())
}
}
func TestImageViewerClosedAndBad(t *testing.T) {
h := newViewerHarness()
h.open = false
tt := ui.NewTester(h.view, 300, 200)
if len(tt.Texts()) != 0 {
t.Fatalf("closed viewer shows %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("no panic on bad index")
}
}()
h.open, h.index = true, 9
tt.Frame()
}
// viewerArt is a wine diamond on old gold, for the snapshot.
func viewerArt() *ui.Bitmap {
img := image.NewRGBA(image.Rect(0, 0, 240, 160))
for y := range 160 {
for x := range 240 {
c := color.RGBA{173, 145, 100, 255}
if viewerAbs(x-120)*2/3+viewerAbs(y-80) < 60 {
c = color.RGBA{125, 32, 52, 255}
}
img.SetRGBA(x, y, c)
}
}
return ui.NewBitmap(img)
}
func viewerAbs(v int) int { return max(v, -v) }
func TestImageViewerSnapshot(t *testing.T) {
pic := viewerArt()
snapshot(t, "image_viewer", 560, 360, func(c *ui.Context) {
open, i := true, 0
ImageViewer(c, &open, &i, []ViewerImage{{pic, "Rose window"}, {pic, "Choir stalls"}})
})
}
+235
View File
@@ -0,0 +1,235 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// DataStatus is the load state of a data view's rows.
type DataStatus int
const (
DataReady DataStatus = iota
// DataLoading is the first load, before any row came.
DataLoading
// DataLoadingMore appends rows below those shown.
DataLoadingMore
// DataFailed shows the error with a retry action.
DataFailed
// DataUnloaded marks a tree item whose children were not asked for yet.
DataUnloaded
)
// ListOptions configure a List of items keyed by K.
type ListOptions[K comparable] struct {
// Key returns item i's stable identity; required.
Key func(i int) K
// Label returns item i's text, read out and matched by typing; required.
Label func(i int) string
Multiple bool
// Grid lays items out in columns at least ItemWidth wide, ItemHeight high.
Grid bool
ItemWidth, ItemHeight float32
// Empty replaces the built-in "No items" text.
Empty string
Status DataStatus
// Error describes a DataFailed status; empty uses the built-in text.
Error string
}
// ListState is a List's selection and place. Its zero value selects nothing.
type ListState[K comparable] struct {
// Selection holds the chosen keys of a Multiple list.
Selection ui.Selection[K]
rows ui.ListState
grid ui.GridState
cursor int
started bool
key K
has bool
}
// Selected returns the chosen item's key, or the last chosen of a Multiple list.
func (s *ListState[K]) Selected() (K, bool) { return s.key, s.has }
// ScrollTo brings item i to the top of the list.
func (s *ListState[K]) ScrollTo(i int) {
s.rows.ScrollTo(i, ui.Start)
s.grid.ScrollTo(i, ui.Start)
}
// ListView is a built List and what happened to it in this frame.
type ListView struct {
*ui.Element
changed, submitted, retry bool
}
// Changed reports a new selection.
func (v ListView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on an item.
func (v ListView) Submitted() bool { return v.submitted }
// Retried reports a click on the retry action of a failed load.
func (v ListView) Retried() bool { return v.retry }
// List shows n items, building only those in view, as rows or a grid.
func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) ListView {
if opts.Key == nil || opts.Label == nil {
panic("mujica: List needs Key and Label")
}
if n < 0 {
panic("mujica: List with negative item count")
}
if !s.started {
s.started, s.cursor = true, -1
}
k := Tokens(c)
var v ListView
v.Element = ui.Column(c).MinHeight(0)
v.Children(func() {
if n == 0 {
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
return
}
var e *ui.Element
if opts.Grid {
e = listGrid(c, s, n, opts, item)
} else {
e = listRows(c, s, n, opts, item)
}
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
v.changed, v.submitted = e.Changed(), e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
})
s.has = s.cursor >= 0 && s.cursor < n
if s.has {
s.key = opts.Key(s.cursor)
}
if v.changed {
slog.Debug("mujica: list changed", "selected", s.cursor, "count", s.Selection.Len())
}
return v
}
func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element {
d := Density(c)
s.rows.Key = func(i int) any { return opts.Key(i) }
s.rows.Label = opts.Label
s.rows.Selected = &s.cursor
if opts.Multiple {
s.rows.Selection = &s.Selection
} else {
s.rows.Selection = nil
}
return ui.List(c, &s.rows, n, func(i int) {
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center).Gap(d.Unit() * 2).Children(func() { item(i) })
if listChosen(s, opts, i) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
}
})
}).Padding(d.Unit())
}
func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element {
d := Density(c)
w, h := opts.ItemWidth, opts.ItemHeight
if w <= 0 {
w = 160
}
if h <= 0 {
h = 120
}
s.grid.Key = func(i int) any { return opts.Key(i) }
s.grid.Label = opts.Label
s.grid.Selected = &s.cursor
if opts.Multiple {
s.grid.Selection = &s.Selection
} else {
s.grid.Selection = nil
}
// Chosen items take AccentText borders, legible on both surfaces.
saved := c.Theme()
t := *saved
t.Accent = Tokens(c).AccentText
c.SetTheme(&t)
e := ui.GridView(c, &s.grid, n, w, h, func(i int) {
ui.Column(c).Grow(1).Padding(d.Unit() * 2).Gap(d.Unit()).Children(func() {
item(i)
if listChosen(s, opts, i) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Absolute().Top(d.Unit()).Right(d.Unit())
}
})
}).Padding(d.Unit() * 2)
c.SetTheme(saved)
return e
}
func listChosen[K comparable](s *ListState[K], opts ListOptions[K], i int) bool {
if opts.Multiple {
return s.Selection.Has(opts.Key(i))
}
return s.cursor == i
}
// dataPlaceholder fills an empty view with its status and reports a retry.
func dataPlaceholder(c *ui.Context, st DataStatus, errText, empty, emptyDefault string) bool {
k := Tokens(c)
u := Density(c).Unit()
retry := false
ui.Column(c).Grow(1).MinHeight(u * 30).Center().Gap(u * 3).Padding(u * 6).Children(func() {
switch st {
case DataLoading, DataLoadingMore, DataUnloaded:
Spinner(c, SpinnerOptions{Label: msg(c, "list.loading"), ShowLabel: true})
case DataFailed:
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(20, 20)
ui.Text(c, dataError(c, errText)).Role(ui.RoleStatus)
})
retry = Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked()
default:
if empty == "" {
empty = emptyDefault
}
ui.Icon(c, icons.Must("inbox")).Size(24, 24).TextColor(k.TextMuted)
ui.Text(c, empty).TextColor(k.TextMuted)
}
})
return retry
}
// dataFooter shows appending and failed loads below rows and reports a retry.
func dataFooter(c *ui.Context, st DataStatus, errText string) bool {
k := Tokens(c)
u := Density(c).Unit()
retry := false
switch st {
case DataLoadingMore:
ui.Row(c).Padding(u*2, u*3).Shrink(0).Children(func() {
Spinner(c, SpinnerOptions{Label: msg(c, "list.loadingMore"), ShowLabel: true})
})
case DataFailed:
ui.Row(c).Padding(u*2, u*3).Gap(u * 3).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Grow(1).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
ui.Text(c, dataError(c, errText)).Role(ui.RoleStatus)
})
retry = Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw")}).Clicked()
})
}
return retry
}
func dataError(c *ui.Context, s string) string {
if s == "" {
return msg(c, "list.failed")
}
return s
}
+215
View File
@@ -0,0 +1,215 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
type listHarness struct {
ids []int
st ListState[int]
opts ListOptions[int]
built int
changes int
submits int
retries int
listSize [2]float32
}
func newListHarness(n int) *listHarness {
h := &listHarness{listSize: [2]float32{300, 360}}
for i := range n {
h.ids = append(h.ids, i)
}
h.opts.Key = func(i int) int { return h.ids[i] }
h.opts.Label = func(i int) string { return fmt.Sprintf("Item %d", h.ids[i]) }
return h
}
func (h *listHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
h.built = 0
ui.Column(c).Padding(10).Children(func() {
v := List(c, &h.st, len(h.ids), h.opts, func(i int) {
h.built++
ui.Text(c, h.opts.Label(i)).SingleLine()
})
v.Size(h.listSize[0], h.listSize[1])
if v.Changed() {
h.changes++
}
if v.Submitted() {
h.submits++
}
if v.Retried() {
h.retries++
}
})
}
func TestListSingleSelect(t *testing.T) {
h := newListHarness(20)
tt := ui.NewTester(h.view, 400, 400)
if _, ok := h.st.Selected(); ok {
t.Fatal("zero state has a selection")
}
if err := tt.Click("Item 3"); err != nil {
t.Fatal(err)
}
if k, ok := h.st.Selected(); !ok || k != 3 || h.changes != 1 {
t.Fatalf("selected %v %v, changes %d", k, ok, h.changes)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if k, _ := h.st.Selected(); k != 5 {
t.Fatalf("after Down×2 selected %d, want 5", k)
}
tt.Key(0, ui.KeyEnd)
if k, _ := h.st.Selected(); k != 19 {
t.Fatalf("End selected %d", k)
}
tt.Key(0, ui.KeyEnter)
if h.submits != 1 {
t.Fatalf("submits %d", h.submits)
}
}
func TestListKeyFollowsInsert(t *testing.T) {
h := newListHarness(10)
tt := ui.NewTester(h.view, 400, 400)
if err := tt.Click("Item 4"); err != nil {
t.Fatal(err)
}
h.ids = append([]int{100, 101}, h.ids...)
tt.Frame()
if k, _ := h.st.Selected(); k != 4 {
t.Fatalf("selection moved to %d after insert", k)
}
h.ids = append(h.ids[:5], h.ids[7:]...) // drops items 3 and 4
tt.Frame()
if k, ok := h.st.Selected(); ok {
t.Fatalf("removed item still selected: %d", k)
}
}
func TestListMultiple(t *testing.T) {
h := newListHarness(10)
h.opts.Multiple = true
tt := ui.NewTester(h.view, 400, 400)
if err := tt.Click("Item 1"); err != nil {
t.Fatal(err)
}
if err := tt.ClickWith(ui.Cmd, "Item 4"); err != nil {
t.Fatal(err)
}
if err := tt.ClickWith(ui.Shift, "Item 6"); err != nil {
t.Fatal(err)
}
// Shift extends from the anchor click (MyGo keeps the pivot at item 1).
for id := 1; id <= 6; id++ {
if !h.st.Selection.Has(id) {
t.Fatalf("item %d not chosen; %d chosen", id, h.st.Selection.Len())
}
}
if h.st.Selection.Len() != 6 {
t.Fatalf("chosen %d, want 6", h.st.Selection.Len())
}
tt.Key(ui.Cmd, ui.KeyA)
if h.st.Selection.Len() != 10 {
t.Fatalf("select all chose %d", h.st.Selection.Len())
}
}
func TestListGridKeyboard(t *testing.T) {
h := newListHarness(12)
h.opts.Grid = true
h.opts.ItemWidth, h.opts.ItemHeight = 80, 60
h.listSize = [2]float32{380, 300}
tt := ui.NewTester(h.view, 420, 340)
if err := tt.Click("Item 0"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyRight)
if k, _ := h.st.Selected(); k != 1 {
t.Fatalf("Right chose %d", k)
}
tt.Key(0, ui.KeyDown)
if k, _ := h.st.Selected(); k <= 1 {
t.Fatalf("Down chose %d, want a later row", k)
}
}
func TestListStatuses(t *testing.T) {
h := newListHarness(0)
tt := ui.NewTester(h.view, 400, 400)
if !tt.HasText("No items") {
t.Fatalf("empty: %q", tt.Texts())
}
h.opts.Empty = "Nothing in the vault"
tt.Frame()
if !tt.HasText("Nothing in the vault") {
t.Fatalf("custom empty: %q", tt.Texts())
}
h.opts.Status = DataLoading
tt.Frame()
if !tt.HasText("Loading") || tt.HasText("Nothing") {
t.Fatalf("loading: %q", tt.Texts())
}
h.opts.Status, h.opts.Error = DataFailed, "Vault sealed"
tt.Frame()
if err := tt.Click("Retry"); err != nil || h.retries != 1 || !tt.HasText("Vault sealed") {
t.Fatalf("failed: %v retries %d texts %q", err, h.retries, tt.Texts())
}
h.ids = []int{1, 2}
h.opts.Status = DataLoadingMore
tt.Frame()
if !tt.HasText("Item 2") || !tt.HasText("Loading more") {
t.Fatalf("loading more: %q", tt.Texts())
}
}
func TestListLargeIsVirtualized(t *testing.T) {
h := newListHarness(100_000)
tt := ui.NewTester(h.view, 400, 400)
// Rows are at least 36 DIPs: 360 DIPs show 10, plus overscan.
const bound = 24
if h.built == 0 || h.built > bound {
t.Fatalf("first frame built %d items, bound %d", h.built, bound)
}
h.st.ScrollTo(73_000)
tt.Frame()
if h.built > bound || !tt.HasText("Item 73000") {
t.Fatalf("after scroll built %d, texts %q", h.built, tt.Texts())
}
if err := tt.Click("Item 73001"); err != nil {
t.Fatal(err)
}
if k, _ := h.st.Selected(); k != 73001 {
t.Fatalf("selected %d", k)
}
}
func TestListSnapshot(t *testing.T) {
names := []string{"Matins", "Lauds", "Prime", "Terce", "Sext", "None"}
snapshot(t, "list", 640, 260, func(c *ui.Context) {
st := ui.Local(c.Root(), "st", func() *ListState[int] {
s := &ListState[int]{}
s.Selection.Add(1)
s.Selection.Add(2)
return s
})
g := ui.Local(c.Root(), "g", func() *ListState[int] { return &ListState[int]{started: true, cursor: 4} })
opts := ListOptions[int]{Key: func(i int) int { return i }, Label: func(i int) string { return names[i] }, Multiple: true}
ui.Row(c).Padding(16).Gap(16).Children(func() {
List(c, *st, len(names), opts, func(i int) { ui.Text(c, names[i]) }).Size(220, 228)
gopts := opts
gopts.Grid, gopts.Multiple, gopts.ItemWidth, gopts.ItemHeight = true, false, 100, 64
List(c, *g, len(names), gopts, func(i int) {
ui.Text(c, names[i]).FontWeight(600)
ui.Text(c, "Office").TextColor(Tokens(c).TextMuted)
}).Size(360, 228)
})
})
}
+36
View File
@@ -0,0 +1,36 @@
package mujica
func init() {
addMessages(map[string][2]string{
"list.empty": {"No items", "暂无数据"},
"list.loading": {"Loading", "加载中"},
"list.loadingMore": {"Loading more", "正在加载更多"},
"list.failed": {"Could not load", "加载失败"},
"list.retry": {"Retry", "重试"},
"data_table.noMatch": {"No matching rows", "没有匹配的行"},
"data_table.selectRow": {"Select row", "选择行"},
"data_table.columns": {"Columns", "列"},
"timeline.done": {"Done", "已完成"},
"timeline.active": {"Current", "进行中"},
"timeline.warning": {"Warning", "警告"},
"timeline.error": {"Error", "错误"},
"description_list.copy": {"Copy", "复制"},
"description_list.copied": {"Copied", "已复制"},
"statistic.up": {"up", "上升"},
"statistic.down": {"down", "下降"},
"statistic.flat": {"unchanged", "持平"},
"carousel.prev": {"Previous slide", "上一项"},
"carousel.next": {"Next slide", "下一项"},
"carousel.pause": {"Pause autoplay", "暂停自动播放"},
"carousel.play": {"Resume autoplay", "继续自动播放"},
"carousel.position": {"Slide %d of %d", "第 %d 项,共 %d 项"},
"image_viewer.title": {"Image viewer", "图片查看器"},
"image_viewer.position": {"Image %d of %d", "第 %d 张,共 %d 张"},
"image_viewer.zoomIn": {"Zoom in", "放大"},
"image_viewer.zoomOut": {"Zoom out", "缩小"},
"image_viewer.fit": {"Fit to window", "适应窗口"},
"image_viewer.close": {"Close", "关闭"},
"image_viewer.prev": {"Previous image", "上一张"},
"image_viewer.next": {"Next image", "下一张"},
})
}
+113
View File
@@ -0,0 +1,113 @@
package mujica
import (
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// StatisticOptions configure a Statistic. It formats values; it computes none.
type StatisticOptions struct {
Title string
// Precision is the number of decimals, 0 or more.
Precision int
// Prefix and Suffix wrap the number, as "$" or "×"; Unit follows it, smaller.
Prefix, Suffix, Unit string
// Format replaces the built-in grouped decimal formatting.
Format func(v float64) string
// Change, when set, shows the caller's change with a sign and an arrow.
Change *float64
ChangeUnit string
// ChangeNote explains the change, as "vs last week".
ChangeNote string
}
// Statistic shows a headline number with its title, unit and change.
func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element {
if opts.Precision < 0 {
panic("mujica: Statistic with negative Precision")
}
k := Tokens(c)
u := Density(c).Unit()
format := opts.Format
if format == nil {
format = func(v float64) string { return statisticFormat(v, opts.Precision) }
}
num := opts.Prefix + format(value) + opts.Suffix
spoken := strings.TrimSpace(opts.Title + " " + num + " " + opts.Unit)
e := ui.Column(c).Gap(u).Role(ui.RoleGroup)
e.Children(func() {
if opts.Title != "" {
ui.Text(c, opts.Title).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
}
ui.Row(c).Gap(u).AlignItems(ui.End).Children(func() {
ui.Text(c, num).Font(theme.SerifFont).FontSize(theme.H1Size).FontWeight(600).LineHeight(1.2).SingleLine()
if opts.Unit != "" {
ui.Text(c, opts.Unit).TextColor(k.TextMuted).Padding(0, 0, u, 0)
}
})
if opts.Change == nil {
return
}
ch := *opts.Change
col, icon, word := k.TextMuted, "minus", msg(c, "statistic.flat")
switch {
case ch > 0:
col, icon, word = k.Success, "arrow-up", msg(c, "statistic.up")
case ch < 0:
col, icon, word = k.Danger, "arrow-down", msg(c, "statistic.down")
}
text := statisticSigned(ch, opts.Precision) + opts.ChangeUnit
spoken += ", " + word + " " + text
ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Children(func() {
ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, text).FontWeight(600).SingleLine()
})
if opts.ChangeNote != "" {
ui.Text(c, opts.ChangeNote).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
}
})
})
return e.Label(spoken)
}
// statisticFormat writes v with p decimals and comma-grouped thousands.
func statisticFormat(v float64, p int) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return strconv.FormatFloat(v, 'f', -1, 64)
}
s := strconv.FormatFloat(math.Abs(v), 'f', p, 64)
whole, frac, _ := strings.Cut(s, ".")
var b strings.Builder
if v < 0 && strings.Trim(s, "0.") != "" {
b.WriteString("−")
}
for i, r := range whole {
if i > 0 && (len(whole)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(r)
}
if frac != "" {
b.WriteString("." + frac)
}
return b.String()
}
// statisticSigned writes a change with an explicit sign.
func statisticSigned(v float64, p int) string {
s := statisticFormat(math.Abs(v), p)
switch {
case v > 0:
return "+" + s
case v < 0:
return "−" + s
}
return s
}
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestStatisticFormat(t *testing.T) {
for _, tc := range []struct {
v float64
p int
want string
}{
{1234567.891, 2, "1,234,567.89"},
{999, 0, "999"},
{1000, 0, "1,000"},
{-42.5, 1, "−42.5"},
{-0.001, 1, "0.0"},
{0, 3, "0.000"},
{math.Inf(1), 0, "+Inf"},
} {
if got := statisticFormat(tc.v, tc.p); got != tc.want {
t.Errorf("format(%v, %d) = %q, want %q", tc.v, tc.p, got, tc.want)
}
}
}
func TestStatisticChange(t *testing.T) {
for _, tc := range []struct {
change float64
text string
word string
}{{3.26, "+3.3%", "up"}, {-1.5, "−1.5%", "down"}, {0, "0.0%", "unchanged"}} {
ch := tc.change
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Statistic(c, 12840, StatisticOptions{Title: "Pilgrims", Prefix: "≈", Unit: "souls", Precision: 1, Change: &ch, ChangeUnit: "%", ChangeNote: "vs last feast"})
}, 400, 200)
for _, s := range []string{"Pilgrims", "≈12,840.0", "souls", tc.text, "vs last feast"} {
if !tt.HasText(s) {
t.Fatalf("%v: missing %q in %q", tc.change, s, tt.Texts())
}
}
want := "Pilgrims ≈12,840.0 souls, " + tc.word + " " + tc.text
if _, ok := tt.Find(want); !ok {
t.Fatalf("spoken label %q missing", want)
}
}
}
func TestStatisticCustomFormatAndBadPrecision(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Statistic(c, 0.5, StatisticOptions{Format: func(v float64) string { return "half" }})
}, 200, 100)
if !tt.HasText("half") {
t.Fatalf("custom format: %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Statistic(c, 1, StatisticOptions{Precision: -1})
}, 100, 100)
}
func TestStatisticSnapshot(t *testing.T) {
up, down := 12.5, -3.0
snapshot(t, "statistic", 560, 140, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(40).AlignItems(ui.Start).Children(func() {
Statistic(c, 12840, StatisticOptions{Title: "Pilgrims", Unit: "souls", Change: &up, ChangeUnit: "%", Precision: 1, ChangeNote: "vs last feast"})
Statistic(c, 3.75, StatisticOptions{Title: "Tithe", Prefix: "£", Precision: 2, Change: &down, ChangeUnit: "%"})
Statistic(c, 7, StatisticOptions{Title: "Bells"})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+115
View File
@@ -0,0 +1,115 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TimelineStatus marks an event's state with a shape and a word, not only color.
type TimelineStatus int
const (
TimelineDefault TimelineStatus = iota
TimelineDone
TimelineActive
TimelineWarning
TimelineError
)
// TimelineItem is one event of a Timeline.
type TimelineItem struct {
Time string
Title string
Content string
Status TimelineStatus
}
// Timeline lists events top to bottom with markers joined by a line; long text wraps.
func Timeline(c *ui.Context, items []TimelineItem) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return ui.Column(c).Role(ui.RoleList).Children(func() {
for i, it := range items {
last := i == len(items)-1
ui.Row(c).Gap(u * 3).AlignItems(ui.Stretch).Role(ui.RoleListItem).Children(func() {
ui.Column(c).Width(20).Shrink(0).AlignItems(ui.Center).Children(func() {
timelineMarker(c, it.Status)
if !last {
ui.Box(c).Width(1).Grow(1).MinHeight(u * 3).Background(k.Border)
}
})
ui.Column(c).Grow(1).MinWidth(0).Gap(u).Padding(0, 0, u*5, 0).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() {
if it.Time != "" {
ui.Text(c, it.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
}
if w := timelineWord(c, it.Status); w != "" {
ui.Text(c, w).FontSize(theme.CaptionSize).TextColor(timelineColor(k, it.Status)).FontWeight(600)
}
})
if it.Title != "" {
ui.Text(c, it.Title).FontWeight(600)
}
if it.Content != "" {
ui.Text(c, it.Content).TextColor(k.TextMuted)
}
})
})
}
})
}
func timelineColor(k theme.Tokens, s TimelineStatus) ui.Color {
switch s {
case TimelineDone:
return k.Success
case TimelineActive:
return k.AccentText
case TimelineWarning:
return k.Warning
case TimelineError:
return k.Danger
}
return k.TextMuted
}
func timelineWord(c *ui.Context, s TimelineStatus) string {
switch s {
case TimelineDone:
return msg(c, "timeline.done")
case TimelineActive:
return msg(c, "timeline.active")
case TimelineWarning:
return msg(c, "timeline.warning")
case TimelineError:
return msg(c, "timeline.error")
}
return ""
}
// timelineMarker draws a hollow diamond, or a status icon.
func timelineMarker(c *ui.Context, s TimelineStatus) {
k := Tokens(c)
col := timelineColor(k, s)
box := ui.Box(c).Size(20, 20).Shrink(0).Center().TextColor(col)
switch s {
case TimelineDone:
box.Children(func() { ui.Icon(c, icons.Must("circle-check")).Size(18, 18) })
case TimelineWarning:
box.Children(func() { ui.Icon(c, icons.Must("triangle-alert")).Size(18, 18) })
case TimelineError:
box.Children(func() { ui.Icon(c, icons.Must("circle-x")).Size(18, 18) })
default:
box.Draw(func(p *ui.Painter, r ui.Rect) {
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
if s == TimelineActive {
p.FillPath(internal.Diamond(cx, cy, 12), col)
} else {
p.StrokePath(internal.Diamond(cx, cy, 11), 1.5, col)
}
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var timelineItems = []TimelineItem{
{Time: "06:00", Title: "Matins sung", Content: "The choir gathered before dawn.", Status: TimelineDone},
{Time: "09:00", Title: "Relics moved", Status: TimelineActive,
Content: "The reliquaries travel from the crypt to the high altar, carried in procession by four acolytes in black and wine."},
{Time: "12:00", Title: "Bell cracked", Status: TimelineError},
{Time: "18:00", Title: "Vespers"},
}
func TestTimelineOrderAndStatus(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(260).Children(func() { Timeline(c, timelineItems) })
}, 300, 600)
var ys []float32
for _, title := range []string{"Matins sung", "Relics moved", "Bell cracked", "Vespers"} {
r, ok := tt.Find(title)
if !ok {
t.Fatalf("missing %q", title)
}
ys = append(ys, r.Y)
}
for i := 1; i < len(ys); i++ {
if ys[i] <= ys[i-1] {
t.Fatalf("not top to bottom: %v", ys)
}
}
for _, w := range []string{"Done", "Current", "Error"} {
if !tt.HasText(w) {
t.Fatalf("status word %q missing", w)
}
}
long, _ := tt.Find(timelineItems[1].Content)
if long.H < 3*14 || long.W > 260 {
t.Fatalf("long content did not wrap: %v", long)
}
}
func TestTimelineSnapshot(t *testing.T) {
snapshot(t, "timeline", 420, 380, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() { Timeline(c, timelineItems) })
})
}
+183
View File
@@ -0,0 +1,183 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TreeOptions configure a Tree of items of type K, which are also their keys.
type TreeOptions[K comparable] struct {
Roots []K
// Children returns an item's children: nil for a leaf, empty while unloaded; required.
Children func(item K) []K
// Label returns an item's text, read out and matched by typing; required.
Label func(item K) string
// ItemStatus returns the load state of an item's children; nil means loaded.
ItemStatus func(item K) DataStatus
// Load asks for an item's children when it opens DataUnloaded, and on retry.
Load func(item K)
Multiple bool
// Empty replaces the built-in "No items" text.
Empty string
Status DataStatus
Error string
}
// TreeState is a Tree's open items, selection and place.
type TreeState[K comparable] struct {
// Selection holds the chosen items of a Multiple tree.
Selection ui.Selection[K]
tree treeCore[K]
}
// Selected returns the chosen item, or the last chosen of a Multiple tree.
func (s *TreeState[K]) Selected() (K, bool) { return s.tree.sel, s.tree.has }
// SetOpen opens or closes an item.
func (s *TreeState[K]) SetOpen(item K, open bool) { s.tree.setOpen(item, open) }
// IsOpen reports whether an item shows its children.
func (s *TreeState[K]) IsOpen(item K) bool { return s.tree.outline.Open.Has(item) }
// treeCore is the outline state shared by Tree and TreeTable.
type treeCore[K comparable] struct {
outline ui.OutlineState[K]
cursor int
started bool
sel K
has bool
// asked holds the open items whose children Load was asked for.
asked map[K]bool
}
func (t *treeCore[K]) setOpen(item K, open bool) {
if open {
t.outline.Open.Add(item)
} else {
t.outline.Open.Remove(item)
}
}
// begin wires the outline's selection before it is built.
func (t *treeCore[K]) begin(label func(K) string, sel *ui.Selection[K], multiple bool) {
if !t.started {
t.started, t.cursor, t.asked = true, -1, map[K]bool{}
}
t.outline.List.Selected = &t.cursor
t.outline.List.Label = func(i int) string { return label(t.outline.Item(i)) }
t.outline.List.Selection = nil
if multiple {
t.outline.List.Selection = sel
}
}
// end reads the chosen item after the outline was built.
func (t *treeCore[K]) end() {
t.has = t.cursor >= 0 && t.cursor < t.outline.Rows()
if t.has {
t.sel = t.outline.Item(t.cursor)
}
}
// chosen reports whether item shows as chosen.
func (t *treeCore[K]) chosen(item K, sel *ui.Selection[K], multiple bool) bool {
if multiple {
return sel.Has(item)
}
return t.cursor >= 0 && t.cursor < t.outline.Rows() && t.outline.Item(t.cursor) == item
}
// itemStatus asks for unloaded children of an open item and shows loading or failure.
func (t *treeCore[K]) itemStatus(c *ui.Context, item K, status func(K) DataStatus, load func(K)) {
if status == nil {
return
}
st := status(item)
open := t.outline.Open.Has(item)
if st != DataUnloaded || !open {
delete(t.asked, item)
}
if st == DataUnloaded && open && !t.asked[item] {
if load == nil {
panic("mujica: tree item unloaded without Load")
}
t.asked[item] = true
slog.Debug("mujica: tree load", "item", item)
load(item)
}
k := Tokens(c)
u := Density(c).Unit()
switch st {
case DataLoading:
Spinner(c, SpinnerOptions{Size: 14, Label: msg(c, "list.loading"), ShowLabel: true})
case DataFailed:
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(14, 14)
ui.Text(c, msg(c, "list.failed")).FontSize(theme.CaptionSize).SingleLine()
})
if Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Ghost}).MinHeight(theme.MinHitSize).Clicked() {
slog.Debug("mujica: tree retry", "item", item)
load(item)
}
}
}
// TreeView is a built Tree and what happened to it in this frame.
type TreeView struct {
*ui.Element
changed, submitted, retry bool
}
// Changed reports a new selection.
func (v TreeView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on an item.
func (v TreeView) Submitted() bool { return v.submitted }
// Retried reports a click on the retry action of a failed first load.
func (v TreeView) Retried() bool { return v.retry }
// Tree shows items in a hierarchy, building only the rows in view.
func Tree[K comparable](c *ui.Context, s *TreeState[K], opts TreeOptions[K]) TreeView {
if opts.Children == nil || opts.Label == nil {
panic("mujica: Tree needs Children and Label")
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
t := &s.tree
t.begin(opts.Label, &s.Selection, opts.Multiple)
var v TreeView
v.Element = ui.Column(c).MinHeight(0)
v.Children(func() {
if len(opts.Roots) == 0 {
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
return
}
// Outline rows pad by one unit above and below.
h := d.ControlHeight() - 2*c.Theme().Space(1)
e := ui.Outline(c, &t.outline, opts.Roots, opts.Children, func(item K) {
ui.Row(c).Grow(1).MinWidth(0).MinHeight(h).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, opts.Label(item)).SingleLine().Grow(1).MinWidth(0)
t.itemStatus(c, item, opts.ItemStatus, opts.Load)
if t.chosen(item, &s.Selection, opts.Multiple) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
}
})
})
e.Grow(1).MinHeight(0).Padding(u).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
v.changed, v.submitted = e.Changed(), e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
})
t.end()
if v.changed {
slog.Debug("mujica: tree changed", "row", t.cursor, "count", s.Selection.Len())
}
return v
}
+177
View File
@@ -0,0 +1,177 @@
package mujica
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TreeColumn is a column of a TreeTable; the first shows the arrows.
type TreeColumn[K comparable] struct {
// ID identifies the column in sort and layout; required, unique.
ID string
Title string
// Width is in DIPs; 0 shares the room left. MinWidth bounds resizing.
Width, MinWidth float32
Align ui.Align
// Compare orders siblings, as cmp.Compare does; nil makes the column unsortable.
Compare func(a, b K) int
// Cell builds the item's cell in this column; required.
Cell func(item K)
}
// TreeTableOptions configure a TreeTable of items of type K.
type TreeTableOptions[K comparable] struct {
Columns []TreeColumn[K]
Roots []K
// Children returns an item's children: nil for a leaf, empty while unloaded; required.
// Return a new slice when they change: sorted copies are cached by slice.
Children func(item K) []K
// Label returns an item's text, read out and matched by typing; required.
Label func(item K) string
// ItemStatus returns the load state of an item's children; nil means loaded.
ItemStatus func(item K) DataStatus
// Load asks for an item's children when it opens DataUnloaded, and on retry.
Load func(item K)
Multiple bool
Empty string
Status DataStatus
Error string
}
// TreeTableState holds a TreeTable's sort, layout, open items and selection.
type TreeTableState[K comparable] struct {
// Sort orders siblings by a column; the header shows it.
Sort ui.SortOrder
// Layout is the column order and widths the user set.
Layout ui.TableLayout
// Selection holds the chosen items of a Multiple table.
Selection ui.Selection[K]
tree treeCore[K]
sorted map[K]treeSorted[K]
roots treeSorted[K]
}
// treeSorted caches one sibling list sorted, until its source or the sort changes.
type treeSorted[K comparable] struct {
src *K
n int
sort ui.SortOrder
out []K
}
// Selected returns the chosen item, or the last chosen of a Multiple table.
func (s *TreeTableState[K]) Selected() (K, bool) { return s.tree.sel, s.tree.has }
// SetOpen opens or closes an item.
func (s *TreeTableState[K]) SetOpen(item K, open bool) { s.tree.setOpen(item, open) }
// IsOpen reports whether an item shows its children.
func (s *TreeTableState[K]) IsOpen(item K) bool { return s.tree.outline.Open.Has(item) }
// TreeTable shows a hierarchy in sortable, resizable columns, building only rows in view.
func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTableOptions[K]) DataTableView {
treeTableCheck(opts)
k := Tokens(c)
d := Density(c)
t := &s.tree
t.begin(opts.Label, &s.Selection, opts.Multiple)
if s.sorted == nil {
s.sorted = map[K]treeSorted[K]{}
}
var cmp func(a, b K) int
cols := make([]ui.TableColumn, len(opts.Columns))
for i, col := range opts.Columns {
cols[i] = ui.TableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Align: col.Align, Sortable: col.Compare != nil}
if col.ID == s.Sort.Column && col.Compare != nil {
cmp = col.Compare
}
}
order := s.Sort
sorted := func(src []K, cache *treeSorted[K]) []K {
if cmp == nil || len(src) < 2 {
return src
}
if cache.src == &src[0] && cache.n == len(src) && cache.sort == order {
return cache.out
}
out := slices.Clone(src)
slices.SortStableFunc(out, func(a, b K) int {
if order.Descending {
return cmp(b, a)
}
return cmp(a, b)
})
*cache = treeSorted[K]{src: &src[0], n: len(src), sort: order, out: out}
return out
}
children := func(item K) []K {
kids := opts.Children(item)
if len(kids) < 2 || cmp == nil {
return kids
}
cache := s.sorted[item]
out := sorted(kids, &cache)
s.sorted[item] = cache
return out
}
var v DataTableView
v.Element = ui.Column(c).MinHeight(0)
v.Children(func() {
if len(opts.Roots) == 0 {
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
return
}
roots := sorted(opts.Roots, &s.roots)
before := s.Sort
t.outline.List.Sort, t.outline.List.Columns = &s.Sort, s.Layout
saved := c.Theme()
th := *saved
th.Spacing = d.ControlHeight() / 8
c.SetTheme(&th)
e := ui.OutlineTable(c, &t.outline, cols, roots, children, func(item K, col int) {
if col > 0 {
opts.Columns[col].Cell(item)
return
}
ui.Row(c).Grow(1).MinWidth(0).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center).Children(func() { opts.Columns[0].Cell(item) })
t.itemStatus(c, item, opts.ItemStatus, opts.Load)
if t.chosen(item, &s.Selection, opts.Multiple) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
}
})
})
c.SetTheme(saved)
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
s.Layout = t.outline.List.Columns
v.sorted = s.Sort != before
v.changed = e.Changed() && !v.sorted
v.submitted = e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
})
t.end()
if v.sorted {
slog.Debug("mujica: tree_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
}
return v
}
func treeTableCheck[K comparable](opts TreeTableOptions[K]) {
if opts.Children == nil || opts.Label == nil || len(opts.Columns) == 0 {
panic("mujica: TreeTable needs Columns, Children and Label")
}
seen := map[string]bool{}
for _, col := range opts.Columns {
if col.ID == "" || seen[col.ID] || col.Cell == nil {
panic("mujica: TreeTable column needs a unique ID and Cell: " + col.ID)
}
seen[col.ID] = true
}
}
+192
View File
@@ -0,0 +1,192 @@
package mujica
import (
"cmp"
"fmt"
"strconv"
"testing"
"github.com/egoist/mygo/ui"
)
type ttHarness struct {
kids map[string][]string
size map[string]int
status map[string]DataStatus
loads int
st TreeTableState[string]
opts TreeTableOptions[string]
compares int
sorts int
c *ui.Context
}
func newTTHarness() *ttHarness {
h := &ttHarness{
kids: map[string][]string{"Vestry": {"Stole", "Alb", "Cope"}, "Belfry": {}},
size: map[string]int{"Vestry": 9, "Belfry": 4, "Stole": 2, "Alb": 5, "Cope": 1, "Organ": 7},
status: map[string]DataStatus{"Belfry": DataUnloaded},
}
text := func(n string) { ui.Text(h.c, n).SingleLine() }
h.opts = TreeTableOptions[string]{
Roots: []string{"Vestry", "Belfry", "Organ"},
Children: func(n string) []string { return h.kids[n] },
Label: func(n string) string { return n },
ItemStatus: func(n string) DataStatus { return h.status[n] },
Load: func(n string) { h.loads++; h.status[n] = DataLoading },
Columns: []TreeColumn[string]{
{ID: "name", Title: "Name", Cell: text,
Compare: func(a, b string) int { h.compares++; return cmp.Compare(a, b) }},
{ID: "size", Title: "Size", Width: 80, Align: ui.End,
Cell: func(n string) { ui.Text(h.c, strconv.Itoa(h.size[n])) },
Compare: func(a, b string) int { h.compares++; return cmp.Compare(h.size[a], h.size[b]) }},
},
}
return h
}
func (h *ttHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
h.c = c
ui.Column(c).Padding(10).Children(func() {
v := TreeTable(c, &h.st, h.opts)
v.Size(360, 320)
if v.SortChanged() {
h.sorts++
}
})
}
func (h *ttHarness) order(tt *ui.Tester) string {
var out []string
for _, s := range tt.Texts() {
if _, ok := h.size[s]; ok && (len(out) == 0 || out[len(out)-1] != s) {
out = append(out, s)
}
}
return fmt.Sprint(out)
}
func TestTreeTableSortsSiblings(t *testing.T) {
h := newTTHarness()
tt := ui.NewTester(h.view, 400, 360)
h.st.SetOpen("Vestry", true)
tt.Frame()
if got := h.order(tt); got != "[Vestry Stole Alb Cope Belfry Organ]" {
t.Fatalf("unsorted %s", got)
}
if err := tt.Click("Alb"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Size"); err != nil {
t.Fatal(err)
}
if got := h.order(tt); got != "[Belfry Organ Vestry Cope Stole Alb]" || h.sorts != 1 {
t.Fatalf("by size %s sorts %d", got, h.sorts)
}
if k, _ := h.st.Selected(); k != "Alb" {
t.Fatalf("selection moved to %q", k)
}
n := h.compares
tt.Frame()
tt.Frame()
if h.compares != n {
t.Fatalf("re-sorted without a change: %d", h.compares-n)
}
_ = tt.Click("Size")
if got := h.order(tt); got != "[Vestry Alb Stole Cope Organ Belfry]" {
t.Fatalf("descending %s", got)
}
}
func TestTreeTableLazyAndKeys(t *testing.T) {
h := newTTHarness()
tt := ui.NewTester(h.view, 400, 360)
_ = tt.Click("Belfry")
tt.Key(0, ui.KeyRight)
if !h.st.IsOpen("Belfry") || h.loads != 1 || !tt.HasText("Loading") {
t.Fatalf("open %v loads %d texts %q", h.st.IsOpen("Belfry"), h.loads, tt.Texts())
}
h.kids["Belfry"] = []string{"Rope"}
h.size["Rope"] = 3
h.status["Belfry"] = DataReady
tt.Key(0, ui.KeyRight)
if k, _ := h.st.Selected(); k != "Rope" {
t.Fatalf("Right chose %q", k)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
if h.st.IsOpen("Belfry") || tt.HasText("Rope") {
t.Fatal("Left did not close")
}
}
func TestTreeTableLayout(t *testing.T) {
h := newTTHarness()
h.st.Layout = ui.TableLayout{Order: []string{"size", "name"}, Widths: map[string]float32{"size": 120}}
tt := ui.NewTester(h.view, 400, 360)
size, _ := tt.Find("Size")
name, _ := tt.Find("Name")
if size.X >= name.X || h.st.Layout.Widths["size"] != 120 {
t.Fatalf("layout lost: size %v name %v %v", size.X, name.X, h.st.Layout)
}
}
func TestTreeTableBadOptions(t *testing.T) {
h := newTTHarness()
h.opts.Columns[1].Cell = nil
defer func() {
if recover() == nil {
t.Fatal("no panic without Cell")
}
}()
ui.NewTester(h.view, 300, 300)
}
func TestTreeTableLargeIsVirtualized(t *testing.T) {
roots, kids := bigTree()
var st TreeTableState[int]
rows := map[int]bool{}
cell := func(c *ui.Context) func(int) {
return func(n int) { rows[n] = true; ui.Text(c, strconv.Itoa(n)) }
}
view := func(c *ui.Context) {
Use(c, Settings{})
clear(rows)
TreeTable(c, &st, TreeTableOptions[int]{Roots: roots, Children: kids, Label: strconv.Itoa,
Columns: []TreeColumn[int]{{ID: "n", Title: "N", Cell: cell(c), Compare: cmp.Compare[int]}, {ID: "m", Title: "M", Cell: cell(c)}}}).Size(320, 360)
}
tt := ui.NewTester(view, 400, 400)
const bound = 26 // see TestTreeLargeIsVirtualized
if len(rows) == 0 || len(rows) > bound || rows[1] {
t.Fatalf("collapsed: %d rows", len(rows))
}
for _, r := range roots {
st.SetOpen(r, true)
}
tt.Frame()
if len(rows) > bound || st.tree.outline.Rows() != 100_000 {
t.Fatalf("open: %d rows of %d", len(rows), st.tree.outline.Rows())
}
st.Sort = ui.SortOrder{Column: "n", Descending: true}
tt.Frame()
if len(rows) > bound || st.tree.outline.Item(0) != 99_000 || st.tree.outline.Item(1) != 99_999 {
t.Fatalf("sorted: %d rows, first %d %d", len(rows), st.tree.outline.Item(0), st.tree.outline.Item(1))
}
}
func TestTreeTableSnapshot(t *testing.T) {
snapshot(t, "tree_table", 420, 240, func(c *ui.Context) {
h := ui.Local(c.Root(), "h", func() *ttHarness {
h := newTTHarness()
h.st.SetOpen("Vestry", true)
h.st.Sort = ui.SortOrder{Column: "name"}
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 2, map[string]bool{}
return h
})
(*h).c = c
ui.Column(c).Padding(16).Children(func() {
TreeTable(c, &(*h).st, (*h).opts).Size(388, 208)
})
})
}
+222
View File
@@ -0,0 +1,222 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
type treeHarness struct {
kids map[string][]string
status map[string]DataStatus
loads []string
st TreeState[string]
opts TreeOptions[string]
built map[string]bool
changes int
}
func newTreeHarness() *treeHarness {
h := &treeHarness{
kids: map[string][]string{
"Nave": {"Pulpit", "Pews"},
"Crypt": {}, // loaded lazily
"Pulpit": nil,
"Pews": nil,
"Sacristy": nil,
},
status: map[string]DataStatus{"Crypt": DataUnloaded},
}
h.opts = TreeOptions[string]{
Roots: []string{"Nave", "Crypt", "Sacristy"},
Children: func(n string) []string { return h.kids[n] },
Label: func(n string) string { return n },
ItemStatus: func(n string) DataStatus { return h.status[n] },
Load: func(n string) {
h.loads = append(h.loads, n)
h.status[n] = DataLoading
},
}
return h
}
func (h *treeHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
h.built = map[string]bool{}
opts := h.opts
label := opts.Label
opts.Label = func(n string) string { h.built[n] = true; return label(n) }
ui.Column(c).Padding(10).Children(func() {
v := Tree(c, &h.st, opts)
v.Size(320, 360)
if v.Changed() {
h.changes++
}
})
}
func TestTreeExpandAndKeyboard(t *testing.T) {
h := newTreeHarness()
tt := ui.NewTester(h.view, 400, 400)
if tt.HasText("Pulpit") || h.built["Pulpit"] {
t.Fatal("closed subtree built")
}
if err := tt.Click("Nave"); err != nil {
t.Fatal(err)
}
if k, _ := h.st.Selected(); k != "Nave" || h.changes != 1 {
t.Fatalf("selected %q changes %d", k, h.changes)
}
tt.Key(0, ui.KeyRight)
if !h.st.IsOpen("Nave") || !tt.HasText("Pulpit") {
t.Fatalf("Right did not open: %q", tt.Texts())
}
tt.Key(0, ui.KeyRight)
if k, _ := h.st.Selected(); k != "Pulpit" {
t.Fatalf("Right to first child chose %q", k)
}
tt.Key(0, ui.KeyDown)
if k, _ := h.st.Selected(); k != "Pews" {
t.Fatalf("Down chose %q", k)
}
tt.Key(0, ui.KeyLeft)
if k, _ := h.st.Selected(); k != "Nave" {
t.Fatalf("Left to parent chose %q", k)
}
tt.Key(0, ui.KeyLeft)
if h.st.IsOpen("Nave") || tt.HasText("Pews") {
t.Fatal("Left did not close")
}
}
func TestTreeLazyLoad(t *testing.T) {
h := newTreeHarness()
tt := ui.NewTester(h.view, 400, 400)
if len(h.loads) != 0 {
t.Fatalf("loaded before opening: %v", h.loads)
}
h.st.SetOpen("Crypt", true)
tt.Frame()
if fmt.Sprint(h.loads) != "[Crypt]" || !tt.HasText("Loading") {
t.Fatalf("loads %v texts %q", h.loads, tt.Texts())
}
tt.Frame()
if len(h.loads) != 1 {
t.Fatalf("asked twice: %v", h.loads)
}
h.status["Crypt"] = DataFailed
tt.Frame()
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
if len(h.loads) != 2 {
t.Fatalf("retry did not load: %v", h.loads)
}
h.kids["Crypt"] = []string{"Ossuary"}
h.status["Crypt"] = DataReady
tt.Frame()
if !tt.HasText("Ossuary") || tt.HasText("Loading") {
t.Fatalf("children missing: %q", tt.Texts())
}
}
func TestTreeMultipleAndEmpty(t *testing.T) {
h := newTreeHarness()
h.opts.Multiple = true
tt := ui.NewTester(h.view, 400, 400)
_ = tt.Click("Nave")
_ = tt.ClickWith(ui.Cmd, "Sacristy")
if h.st.Selection.Len() != 2 || !h.st.Selection.Has("Sacristy") {
t.Fatalf("chose %d", h.st.Selection.Len())
}
h.opts.Roots = nil
tt.Frame()
if !tt.HasText("No items") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestTreeBadOptions(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without Children")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
var s TreeState[int]
Tree(c, &s, TreeOptions[int]{Roots: []int{1}})
}, 100, 100)
}
// bigTree has 100 roots of 999 children each: 100,000 nodes.
func bigTree() (roots []int, kids func(int) []int) {
for r := range 100 {
roots = append(roots, r*1000)
}
cache := map[int][]int{}
return roots, func(n int) []int {
if n%1000 != 0 {
return nil
}
if k, ok := cache[n]; ok {
return k
}
var k []int
for i := 1; i < 1000; i++ {
k = append(k, n+i)
}
cache[n] = k
return k
}
}
func TestTreeLargeIsVirtualized(t *testing.T) {
roots, kids := bigTree()
var st TreeState[int]
built := 0
view := func(c *ui.Context) {
Use(c, Settings{})
built = 0
Tree(c, &st, TreeOptions[int]{Roots: roots, Children: kids, Label: func(n int) string {
built++
return fmt.Sprintf("Node %d", n)
}}).Size(320, 360)
}
tt := ui.NewTester(view, 400, 400)
// Label runs twice per built row (row name and text). A jump builds a
// view on each side of its target: 2×10 rows of 36 DIPs, plus 2×2 overscan and 2 edges.
const bound = 2 * 26
if built == 0 || built > bound {
t.Fatalf("collapsed: label calls %d, bound %d", built, bound)
}
for _, r := range roots {
st.SetOpen(r, true)
}
tt.Frame()
if built > bound || st.tree.outline.Rows() != 100_000 {
t.Fatalf("open: label calls %d rows %d", built, st.tree.outline.Rows())
}
st.tree.outline.List.ScrollTo(64_000, ui.Start)
tt.Frame()
if built > bound || !tt.HasText(fmt.Sprintf("Node %d", st.tree.outline.Item(64_000))) {
t.Fatalf("scrolled: label calls %d texts %q", built, tt.Texts())
}
}
func TestTreeSnapshot(t *testing.T) {
snapshot(t, "tree", 400, 260, func(c *ui.Context) {
h := ui.Local(c.Root(), "h", func() *treeHarness {
h := newTreeHarness()
h.st.SetOpen("Nave", true)
h.st.SetOpen("Crypt", true)
h.status["Crypt"] = DataLoading
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 1, map[string]bool{}
return h
})
ui.Column(c).Padding(16).Children(func() {
Tree(c, &(*h).st, (*h).opts).Size(360, 228)
})
})
}