diff --git a/accordion.go b/accordion.go
new file mode 100644
index 0000000..a13e865
--- /dev/null
+++ b/accordion.go
@@ -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
+}
diff --git a/accordion_test.go b/accordion_test.go
new file mode 100644
index 0000000..a0b0778
--- /dev/null
+++ b/accordion_test.go
@@ -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) })
+ }
+}
diff --git a/card.go b/card.go
new file mode 100644
index 0000000..6f06830
--- /dev/null
+++ b/card.go
@@ -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)
+ }
+}
diff --git a/card_test.go b/card_test.go
new file mode 100644
index 0000000..3d44603
--- /dev/null
+++ b/card_test.go
@@ -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)
+ })
+ }
+}
diff --git a/carousel.go b/carousel.go
new file mode 100644
index 0000000..81078e3
--- /dev/null
+++ b/carousel.go
@@ -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)
+ })
+}
diff --git a/carousel_test.go b/carousel_test.go
new file mode 100644
index 0000000..b30e2e4
--- /dev/null
+++ b/carousel_test.go
@@ -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)
+ })
+ })
+}
diff --git a/collapsible.go b/collapsible.go
new file mode 100644
index 0000000..7e90fa4
--- /dev/null
+++ b/collapsible.go
@@ -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
+}
diff --git a/collapsible_test.go b/collapsible_test.go
new file mode 100644
index 0000000..90e5b28
--- /dev/null
+++ b/collapsible_test.go
@@ -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() {})
+ })
+ })
+}
diff --git a/data_table.go b/data_table.go
new file mode 100644
index 0000000..eafc719
--- /dev/null
+++ b/data_table.go
@@ -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
+}
diff --git a/data_table_columns.go b/data_table_columns.go
new file mode 100644
index 0000000..430b8e1
--- /dev/null
+++ b/data_table_columns.go
@@ -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)
+ }
+}
diff --git a/data_table_test.go b/data_table_test.go
new file mode 100644
index 0000000..b327fee
--- /dev/null
+++ b/data_table_test.go
@@ -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)
+ })
+ })
+}
diff --git a/description_list.go b/description_list.go
new file mode 100644
index 0000000..3be4d4b
--- /dev/null
+++ b/description_list.go
@@ -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
+}
diff --git a/description_list_test.go b/description_list_test.go
new file mode 100644
index 0000000..ba7a710
--- /dev/null
+++ b/description_list_test.go
@@ -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{}) })
+ })
+}
diff --git a/docs/components/069-card.md b/docs/components/069-card.md
new file mode 100644
index 0000000..e959146
--- /dev/null
+++ b/docs/components/069-card.md
@@ -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 内边距。
diff --git a/docs/components/070-list.md b/docs/components/070-list.md
new file mode 100644
index 0000000..48dc71e
--- /dev/null
+++ b/docs/components/070-list.md
@@ -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` 等滚动位置读取接口。
diff --git a/docs/components/071-data-table.md b/docs/components/071-data-table.md
new file mode 100644
index 0000000..dce0755
--- /dev/null
+++ b/docs/components/071-data-table.md
@@ -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)。
diff --git a/docs/components/072-tree.md b/docs/components/072-tree.md
new file mode 100644
index 0000000..5e37cb8
--- /dev/null
+++ b/docs/components/072-tree.md
@@ -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` 回到界面线程。
diff --git a/docs/components/073-tree-table.md b/docs/components/073-tree-table.md
new file mode 100644
index 0000000..60d8799
--- /dev/null
+++ b/docs/components/073-tree-table.md
@@ -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`;表头只能显示文字。
+- 排序只作用于同级节点,层级关系保持不变。
diff --git a/docs/components/074-accordion.md b/docs/components/074-accordion.md
new file mode 100644
index 0000000..5d02cdc
--- /dev/null
+++ b/docs/components/074-accordion.md
@@ -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 内内容仍在界面树中。
diff --git a/docs/components/075-collapsible.md b/docs/components/075-collapsible.md
new file mode 100644
index 0000000..12b3cbe
--- /dev/null
+++ b/docs/components/075-collapsible.md
@@ -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 内请求帧。
diff --git a/docs/components/076-timeline.md b/docs/components/076-timeline.md
new file mode 100644
index 0000000..5512437
--- /dev/null
+++ b/docs/components/076-timeline.md
@@ -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`(错误图标 + “错误”)。 |
+
+## 状态
+
+仅展示:默认、完成、进行中、警告、错误。
+
+## 事件
+
+无。
+
+## 键盘操作
+
+不获取焦点。
+
+## 限制
+
+- 仅纵向排列;内容为纯文本。
+- 不虚拟化,适合几十条以内的事件。
diff --git a/docs/components/077-description-list.md b/docs/components/077-description-list.md
new file mode 100644
index 0000000..5fce41d
--- /dev/null
+++ b/docs/components/077-description-list.md
@@ -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 剪贴板接口,只写入纯文本。
diff --git a/docs/components/078-statistic.md b/docs/components/078-statistic.md
new file mode 100644
index 0000000..d04bcc0
--- /dev/null
+++ b/docs/components/078-statistic.md
@@ -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` 提供。
diff --git a/docs/components/079-image-viewer.md b/docs/components/079-image-viewer.md
new file mode 100644
index 0000000..c2a038c
--- /dev/null
+++ b/docs/components/079-image-viewer.md
@@ -0,0 +1,48 @@
+# 079 ImageViewer · 图片查看器
+
+## 用途
+
+在窗口上方的模态层中查看图片:放大、缩小、平移、适应窗口、上一张/下一张,以及键盘操作与关闭。
+
+## 最小示例
+
+```go
+picture := ui.MustParseSVG([]byte(``))
+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`),组件不读取文件或网络。
+- 不支持旋转与触控板捏合手势。
diff --git a/docs/components/080-carousel.md b/docs/components/080-carousel.md
new file mode 100644
index 0000000..51cc923
--- /dev/null
+++ b/docs/components/080-carousel.md
@@ -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 日志),例如条目数减少后。
diff --git a/example_data_test.go b/example_data_test.go
new file mode 100644
index 0000000..a79859c
--- /dev/null
+++ b/example_data_test.go
@@ -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(``))
+ 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)
+}
diff --git a/examples/gallery/pages_data.go b/examples/gallery/pages_data.go
index 00b222e..7e7a5df 100644
--- a/examples/gallery/pages_data.go
+++ b/examples/gallery/pages_data.go
@@ -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)
+}
diff --git a/examples/gallery/pages_data_code.go b/examples/gallery/pages_data_code.go
new file mode 100644
index 0000000..2cde508
--- /dev/null
+++ b/examples/gallery/pages_data_code.go
@@ -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(``))\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)`
diff --git a/examples/gallery/pages_data_collections.go b/examples/gallery/pages_data_collections.go
new file mode 100644
index 0000000..bfc442c
--- /dev/null
+++ b/examples/gallery/pages_data_collections.go
@@ -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()))
+ }
+ })
+}
diff --git a/examples/gallery/pages_data_content.go b/examples/gallery/pages_data_content.go
new file mode 100644
index 0000000..57a4612
--- /dev/null
+++ b/examples/gallery/pages_data_content.go
@@ -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(``))
+ }
+ 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))
+ }
+ })
+}
diff --git a/image_viewer.go b/image_viewer.go
new file mode 100644
index 0000000..30a10e2
--- /dev/null
+++ b/image_viewer.go
@@ -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
+}
diff --git a/image_viewer_test.go b/image_viewer_test.go
new file mode 100644
index 0000000..3b42018
--- /dev/null
+++ b/image_viewer_test.go
@@ -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"}})
+ })
+}
diff --git a/list.go b/list.go
new file mode 100644
index 0000000..bedea8d
--- /dev/null
+++ b/list.go
@@ -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
+}
diff --git a/list_test.go b/list_test.go
new file mode 100644
index 0000000..d146136
--- /dev/null
+++ b/list_test.go
@@ -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)
+ })
+ })
+}
diff --git a/messages_data.go b/messages_data.go
new file mode 100644
index 0000000..d5fe967
--- /dev/null
+++ b/messages_data.go
@@ -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", "下一张"},
+ })
+}
diff --git a/statistic.go b/statistic.go
new file mode 100644
index 0000000..2530b5c
--- /dev/null
+++ b/statistic.go
@@ -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
+}
diff --git a/statistic_test.go b/statistic_test.go
new file mode 100644
index 0000000..d72c92b
--- /dev/null
+++ b/statistic_test.go
@@ -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"})
+ })
+ })
+}
diff --git a/testdata/snapshots/darwin/accordion-dark.png b/testdata/snapshots/darwin/accordion-dark.png
new file mode 100644
index 0000000..4c96e34
Binary files /dev/null and b/testdata/snapshots/darwin/accordion-dark.png differ
diff --git a/testdata/snapshots/darwin/accordion-light.png b/testdata/snapshots/darwin/accordion-light.png
new file mode 100644
index 0000000..0ded971
Binary files /dev/null and b/testdata/snapshots/darwin/accordion-light.png differ
diff --git a/testdata/snapshots/darwin/card-comfortable-dark.png b/testdata/snapshots/darwin/card-comfortable-dark.png
new file mode 100644
index 0000000..10d4d65
Binary files /dev/null and b/testdata/snapshots/darwin/card-comfortable-dark.png differ
diff --git a/testdata/snapshots/darwin/card-comfortable-light.png b/testdata/snapshots/darwin/card-comfortable-light.png
new file mode 100644
index 0000000..c4168ec
Binary files /dev/null and b/testdata/snapshots/darwin/card-comfortable-light.png differ
diff --git a/testdata/snapshots/darwin/card-compact-dark.png b/testdata/snapshots/darwin/card-compact-dark.png
new file mode 100644
index 0000000..dd1317f
Binary files /dev/null and b/testdata/snapshots/darwin/card-compact-dark.png differ
diff --git a/testdata/snapshots/darwin/card-compact-light.png b/testdata/snapshots/darwin/card-compact-light.png
new file mode 100644
index 0000000..bafc0a0
Binary files /dev/null and b/testdata/snapshots/darwin/card-compact-light.png differ
diff --git a/testdata/snapshots/darwin/card-dark.png b/testdata/snapshots/darwin/card-dark.png
new file mode 100644
index 0000000..5c1aca2
Binary files /dev/null and b/testdata/snapshots/darwin/card-dark.png differ
diff --git a/testdata/snapshots/darwin/card-light.png b/testdata/snapshots/darwin/card-light.png
new file mode 100644
index 0000000..227f964
Binary files /dev/null and b/testdata/snapshots/darwin/card-light.png differ
diff --git a/testdata/snapshots/darwin/carousel-dark.png b/testdata/snapshots/darwin/carousel-dark.png
new file mode 100644
index 0000000..5b767a6
Binary files /dev/null and b/testdata/snapshots/darwin/carousel-dark.png differ
diff --git a/testdata/snapshots/darwin/carousel-light.png b/testdata/snapshots/darwin/carousel-light.png
new file mode 100644
index 0000000..21726d2
Binary files /dev/null and b/testdata/snapshots/darwin/carousel-light.png differ
diff --git a/testdata/snapshots/darwin/collapsible-dark.png b/testdata/snapshots/darwin/collapsible-dark.png
new file mode 100644
index 0000000..a2867f8
Binary files /dev/null and b/testdata/snapshots/darwin/collapsible-dark.png differ
diff --git a/testdata/snapshots/darwin/collapsible-light.png b/testdata/snapshots/darwin/collapsible-light.png
new file mode 100644
index 0000000..9598e51
Binary files /dev/null and b/testdata/snapshots/darwin/collapsible-light.png differ
diff --git a/testdata/snapshots/darwin/data_table-dark.png b/testdata/snapshots/darwin/data_table-dark.png
new file mode 100644
index 0000000..df9f8da
Binary files /dev/null and b/testdata/snapshots/darwin/data_table-dark.png differ
diff --git a/testdata/snapshots/darwin/data_table-light.png b/testdata/snapshots/darwin/data_table-light.png
new file mode 100644
index 0000000..49c7d33
Binary files /dev/null and b/testdata/snapshots/darwin/data_table-light.png differ
diff --git a/testdata/snapshots/darwin/description_list-dark.png b/testdata/snapshots/darwin/description_list-dark.png
new file mode 100644
index 0000000..24ae14a
Binary files /dev/null and b/testdata/snapshots/darwin/description_list-dark.png differ
diff --git a/testdata/snapshots/darwin/description_list-light.png b/testdata/snapshots/darwin/description_list-light.png
new file mode 100644
index 0000000..0b48698
Binary files /dev/null and b/testdata/snapshots/darwin/description_list-light.png differ
diff --git a/testdata/snapshots/darwin/image_viewer-dark.png b/testdata/snapshots/darwin/image_viewer-dark.png
new file mode 100644
index 0000000..cdd19c9
Binary files /dev/null and b/testdata/snapshots/darwin/image_viewer-dark.png differ
diff --git a/testdata/snapshots/darwin/image_viewer-light.png b/testdata/snapshots/darwin/image_viewer-light.png
new file mode 100644
index 0000000..4763542
Binary files /dev/null and b/testdata/snapshots/darwin/image_viewer-light.png differ
diff --git a/testdata/snapshots/darwin/list-dark.png b/testdata/snapshots/darwin/list-dark.png
new file mode 100644
index 0000000..c55bbe6
Binary files /dev/null and b/testdata/snapshots/darwin/list-dark.png differ
diff --git a/testdata/snapshots/darwin/list-light.png b/testdata/snapshots/darwin/list-light.png
new file mode 100644
index 0000000..baa6c87
Binary files /dev/null and b/testdata/snapshots/darwin/list-light.png differ
diff --git a/testdata/snapshots/darwin/statistic-dark.png b/testdata/snapshots/darwin/statistic-dark.png
new file mode 100644
index 0000000..b5f8668
Binary files /dev/null and b/testdata/snapshots/darwin/statistic-dark.png differ
diff --git a/testdata/snapshots/darwin/statistic-light.png b/testdata/snapshots/darwin/statistic-light.png
new file mode 100644
index 0000000..89d0eff
Binary files /dev/null and b/testdata/snapshots/darwin/statistic-light.png differ
diff --git a/testdata/snapshots/darwin/timeline-dark.png b/testdata/snapshots/darwin/timeline-dark.png
new file mode 100644
index 0000000..6746ee2
Binary files /dev/null and b/testdata/snapshots/darwin/timeline-dark.png differ
diff --git a/testdata/snapshots/darwin/timeline-light.png b/testdata/snapshots/darwin/timeline-light.png
new file mode 100644
index 0000000..c81e718
Binary files /dev/null and b/testdata/snapshots/darwin/timeline-light.png differ
diff --git a/testdata/snapshots/darwin/tree-dark.png b/testdata/snapshots/darwin/tree-dark.png
new file mode 100644
index 0000000..896dd19
Binary files /dev/null and b/testdata/snapshots/darwin/tree-dark.png differ
diff --git a/testdata/snapshots/darwin/tree-light.png b/testdata/snapshots/darwin/tree-light.png
new file mode 100644
index 0000000..159f119
Binary files /dev/null and b/testdata/snapshots/darwin/tree-light.png differ
diff --git a/testdata/snapshots/darwin/tree_table-dark.png b/testdata/snapshots/darwin/tree_table-dark.png
new file mode 100644
index 0000000..e2eb09e
Binary files /dev/null and b/testdata/snapshots/darwin/tree_table-dark.png differ
diff --git a/testdata/snapshots/darwin/tree_table-light.png b/testdata/snapshots/darwin/tree_table-light.png
new file mode 100644
index 0000000..8d71e7b
Binary files /dev/null and b/testdata/snapshots/darwin/tree_table-light.png differ
diff --git a/timeline.go b/timeline.go
new file mode 100644
index 0000000..115f077
--- /dev/null
+++ b/timeline.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/timeline_test.go b/timeline_test.go
new file mode 100644
index 0000000..e7a6a92
--- /dev/null
+++ b/timeline_test.go
@@ -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) })
+ })
+}
diff --git a/tree.go b/tree.go
new file mode 100644
index 0000000..d659ceb
--- /dev/null
+++ b/tree.go
@@ -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
+}
diff --git a/tree_table.go b/tree_table.go
new file mode 100644
index 0000000..43313bb
--- /dev/null
+++ b/tree_table.go
@@ -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
+ }
+}
diff --git a/tree_table_test.go b/tree_table_test.go
new file mode 100644
index 0000000..1f4f02a
--- /dev/null
+++ b/tree_table_test.go
@@ -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)
+ })
+ })
+}
diff --git a/tree_test.go b/tree_test.go
new file mode 100644
index 0000000..d258570
--- /dev/null
+++ b/tree_test.go
@@ -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)
+ })
+ })
+}