Data: components 069-080
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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 内边距。
|
||||
@@ -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` 等滚动位置读取接口。
|
||||
@@ -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)。
|
||||
@@ -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` 回到界面线程。
|
||||
@@ -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`;表头只能显示文字。
|
||||
- 排序只作用于同级节点,层级关系保持不变。
|
||||
@@ -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 内内容仍在界面树中。
|
||||
@@ -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 内请求帧。
|
||||
@@ -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`(错误图标 + “错误”)。 |
|
||||
|
||||
## 状态
|
||||
|
||||
仅展示:默认、完成、进行中、警告、错误。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 仅纵向排列;内容为纯文本。
|
||||
- 不虚拟化,适合几十条以内的事件。
|
||||
@@ -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 剪贴板接口,只写入纯文本。
|
||||
@@ -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` 提供。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 079 ImageViewer · 图片查看器
|
||||
|
||||
## 用途
|
||||
|
||||
在窗口上方的模态层中查看图片:放大、缩小、平移、适应窗口、上一张/下一张,以及键盘操作与关闭。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
picture := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#AD9164"/><path d="M120 20 L200 80 L120 140 L40 80 Z" fill="#7D2034"/></svg>`))
|
||||
open, index := true, 0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ImageViewer(c, &open, &index, []mujica.ViewerImage{{Source: picture, Alt: "Rose window"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) ImageViewerView`。`ViewerImage` 字段:`Source`(`*ui.Bitmap` 或 `*ui.SVG`,必填)、`Alt`(说明文字与无障碍名称)。关闭时返回的 `Element` 为 nil。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `open` | `*bool` | 必填 | 为 true 时显示;Escape、点击遮罩或关闭按钮设为 false。 |
|
||||
| `index` | `*int` | 必填 | 当前图片下标;越界 panic。 |
|
||||
| `images` | `[]ViewerImage` | 必填 | 至少一张。 |
|
||||
|
||||
## 状态
|
||||
|
||||
缩放 25%–800%(相对适应窗口),按钮在边界处禁用;适应窗口时“适应”按钮禁用;首张/末张时上一张/下一张禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`ImageViewerView.Changed()`:切换了图片,`*index` 已更新。关闭通过 `*open` 变为 false 观察。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- + / -:放大、缩小;0:适应窗口。
|
||||
- ←/→:上一张、下一张;Shift+方向键:平移。
|
||||
- 滚轮:缩放;拖动图片:平移。
|
||||
- Escape:关闭;Tab 在工具按钮与图片之间移动。
|
||||
|
||||
## 限制
|
||||
|
||||
- 基于 MyGo `ui.DialogBase`,焦点限制与关闭后的焦点恢复由 MyGo 处理。
|
||||
- 图片解码由调用方完成(`ui.DecodeBitmap`),组件不读取文件或网络。
|
||||
- 不支持旋转与触控板捏合手势。
|
||||
@@ -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 日志),例如条目数减少后。
|
||||
@@ -0,0 +1,182 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleCard() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Card(c, mujica.CardOptions{Title: "Archive", Decorated: true, Footer: func() {
|
||||
mujica.Button(c, "Open", mujica.ButtonOptions{})
|
||||
}}, func() {
|
||||
ui.Text(c, "Letters of the court, sealed in wax.")
|
||||
}).Width(280)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleList() {
|
||||
names := []string{"Matins", "Lauds", "Prime", "Terce"}
|
||||
var state mujica.ListState[string]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
v := mujica.List(c, &state, len(names), mujica.ListOptions[string]{
|
||||
Key: func(i int) string { return names[i] },
|
||||
Label: func(i int) string { return names[i] },
|
||||
}, func(i int) { ui.Text(c, names[i]) })
|
||||
v.Size(240, 200)
|
||||
if v.Changed() {
|
||||
key, _ := state.Selected()
|
||||
fmt.Println("chose", key)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDataTable() {
|
||||
type relic struct {
|
||||
ID int
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
rows := []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}}
|
||||
var state mujica.DataTableState[int]
|
||||
opts := mujica.DataTableOptions[relic, int]{
|
||||
Key: func(r relic) int { return r.ID },
|
||||
Columns: []mujica.DataColumn[relic]{
|
||||
{ID: "name", Title: "Name", Sortable: true,
|
||||
Text: func(r relic) string { return r.Name },
|
||||
Compare: func(a, b relic) int { return cmp.Compare(a.Name, b.Name) }},
|
||||
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true,
|
||||
Text: func(r relic) string { return strconv.Itoa(r.Age) },
|
||||
Compare: func(a, b relic) int { return cmp.Compare(a.Age, b.Age) }},
|
||||
},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DataTable(c, &state, rows, opts).Size(360, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTree() {
|
||||
kids := map[string][]string{"Nave": {"Pulpit", "Pews"}}
|
||||
var state mujica.TreeState[string]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Tree(c, &state, mujica.TreeOptions[string]{
|
||||
Roots: []string{"Nave", "Sacristy"},
|
||||
Children: func(n string) []string { return kids[n] },
|
||||
Label: func(n string) string { return n },
|
||||
}).Size(280, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTreeTable() {
|
||||
kids := map[string][]string{"Vestry": {"Stole", "Alb"}}
|
||||
size := map[string]int{"Vestry": 9, "Stole": 2, "Alb": 5, "Organ": 7}
|
||||
var state mujica.TreeTableState[string]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TreeTable(c, &state, mujica.TreeTableOptions[string]{
|
||||
Roots: []string{"Vestry", "Organ"},
|
||||
Children: func(n string) []string { return kids[n] },
|
||||
Label: func(n string) string { return n },
|
||||
Columns: []mujica.TreeColumn[string]{
|
||||
{ID: "name", Title: "Name", Cell: func(n string) { ui.Text(c, n) },
|
||||
Compare: cmp.Compare[string]},
|
||||
{ID: "size", Title: "Size", Width: 80, Align: ui.End,
|
||||
Cell: func(n string) { ui.Text(c, strconv.Itoa(size[n])) }},
|
||||
},
|
||||
}).Size(360, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAccordion() {
|
||||
open := []string{"choir"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Accordion(c, &open, []mujica.AccordionItem{
|
||||
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices.") }},
|
||||
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Black oak pews.") }},
|
||||
}, mujica.AccordionOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCollapsible() {
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Collapsible(c, &open, mujica.CollapsibleOptions{Title: "Details"}, func() {
|
||||
ui.Text(c, "Candles are lit at the seventh bell.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTimeline() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Timeline(c, []mujica.TimelineItem{
|
||||
{Time: "06:00", Title: "Matins sung", Status: mujica.TimelineDone},
|
||||
{Time: "09:00", Title: "Relics moved", Content: "From the crypt to the altar.", Status: mujica.TimelineActive},
|
||||
{Time: "18:00", Title: "Vespers"},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDescriptionList() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DescriptionList(c, []mujica.DescriptionItem{
|
||||
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
|
||||
{Label: "Shelf", Value: "North vault, row 7"},
|
||||
{Label: "Seal", Value: "Wine-red wax", Span: -1},
|
||||
}, mujica.DescriptionListOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStatistic() {
|
||||
change := 12.5
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Statistic(c, 12840, mujica.StatisticOptions{
|
||||
Title: "Pilgrims", Unit: "souls", Precision: 0,
|
||||
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast",
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleImageViewer() {
|
||||
picture := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#AD9164"/><path d="M120 20 L200 80 L120 140 L40 80 Z" fill="#7D2034"/></svg>`))
|
||||
open, index := true, 0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ImageViewer(c, &open, &index, []mujica.ViewerImage{{Source: picture, Alt: "Rose window"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCarousel() {
|
||||
index := 0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Carousel(c, &index, 3, mujica.CarouselOptions{Label: "Windows", Autoplay: true}, func(i int) {
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", i+1)).Padding(24)
|
||||
}).Size(360, 220)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
// Snippets are the bodies of the Example functions in example_data_test.go.
|
||||
|
||||
const dataCardCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Card(c, mujica.CardOptions{Title: "Archive", Decorated: true, Footer: func() {
|
||||
mujica.Button(c, "Open", mujica.ButtonOptions{})
|
||||
}}, func() {
|
||||
ui.Text(c, "Letters of the court, sealed in wax.")
|
||||
}).Width(280)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataListCode = `names := []string{"Matins", "Lauds", "Prime", "Terce"}
|
||||
var state mujica.ListState[string]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
v := mujica.List(c, &state, len(names), mujica.ListOptions[string]{
|
||||
Key: func(i int) string { return names[i] },
|
||||
Label: func(i int) string { return names[i] },
|
||||
}, func(i int) { ui.Text(c, names[i]) })
|
||||
v.Size(240, 200)
|
||||
if v.Changed() {
|
||||
key, _ := state.Selected()
|
||||
fmt.Println("chose", key)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataTableCode = `type relic struct {
|
||||
ID int
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
rows := []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}}
|
||||
var state mujica.DataTableState[int]
|
||||
opts := mujica.DataTableOptions[relic, int]{
|
||||
Key: func(r relic) int { return r.ID },
|
||||
Columns: []mujica.DataColumn[relic]{
|
||||
{ID: "name", Title: "Name", Sortable: true,
|
||||
Text: func(r relic) string { return r.Name },
|
||||
Compare: func(a, b relic) int { return cmp.Compare(a.Name, b.Name) }},
|
||||
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true,
|
||||
Text: func(r relic) string { return strconv.Itoa(r.Age) },
|
||||
Compare: func(a, b relic) int { return cmp.Compare(a.Age, b.Age) }},
|
||||
},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DataTable(c, &state, rows, opts).Size(360, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataTreeCode = `kids := map[string][]string{"Nave": {"Pulpit", "Pews"}}
|
||||
var state mujica.TreeState[string]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Tree(c, &state, mujica.TreeOptions[string]{
|
||||
Roots: []string{"Nave", "Sacristy"},
|
||||
Children: func(n string) []string { return kids[n] },
|
||||
Label: func(n string) string { return n },
|
||||
}).Size(280, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataTreeTableCode = `kids := map[string][]string{"Vestry": {"Stole", "Alb"}}
|
||||
size := map[string]int{"Vestry": 9, "Stole": 2, "Alb": 5, "Organ": 7}
|
||||
var state mujica.TreeTableState[string]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TreeTable(c, &state, mujica.TreeTableOptions[string]{
|
||||
Roots: []string{"Vestry", "Organ"},
|
||||
Children: func(n string) []string { return kids[n] },
|
||||
Label: func(n string) string { return n },
|
||||
Columns: []mujica.TreeColumn[string]{
|
||||
{ID: "name", Title: "Name", Cell: func(n string) { ui.Text(c, n) },
|
||||
Compare: cmp.Compare[string]},
|
||||
{ID: "size", Title: "Size", Width: 80, Align: ui.End,
|
||||
Cell: func(n string) { ui.Text(c, strconv.Itoa(size[n])) }},
|
||||
},
|
||||
}).Size(360, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataAccordionCode = `open := []string{"choir"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Accordion(c, &open, []mujica.AccordionItem{
|
||||
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices.") }},
|
||||
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Black oak pews.") }},
|
||||
}, mujica.AccordionOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataCollapsibleCode = `open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Collapsible(c, &open, mujica.CollapsibleOptions{Title: "Details"}, func() {
|
||||
ui.Text(c, "Candles are lit at the seventh bell.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataTimelineCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Timeline(c, []mujica.TimelineItem{
|
||||
{Time: "06:00", Title: "Matins sung", Status: mujica.TimelineDone},
|
||||
{Time: "09:00", Title: "Relics moved", Content: "From the crypt to the altar.", Status: mujica.TimelineActive},
|
||||
{Time: "18:00", Title: "Vespers"},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataDescriptionCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DescriptionList(c, []mujica.DescriptionItem{
|
||||
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
|
||||
{Label: "Shelf", Value: "North vault, row 7"},
|
||||
{Label: "Seal", Value: "Wine-red wax", Span: -1},
|
||||
}, mujica.DescriptionListOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataStatisticCode = `change := 12.5
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Statistic(c, 12840, mujica.StatisticOptions{
|
||||
Title: "Pilgrims", Unit: "souls", Precision: 0,
|
||||
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast",
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const dataViewerCode = "picture := ui.MustParseSVG([]byte(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"240\" height=\"160\"><rect width=\"240\" height=\"160\" fill=\"#AD9164\"/><path d=\"M120 20 L200 80 L120 140 L40 80 Z\" fill=\"#7D2034\"/></svg>`))\nopen, index := true, 0\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.ImageViewer(c, &open, &index, []mujica.ViewerImage{{Source: picture, Alt: \"Rose window\"}})\n}\nui.Render(view, 400, 300, 1)"
|
||||
|
||||
const dataCarouselCode = `index := 0
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Carousel(c, &index, 3, mujica.CarouselOptions{Label: "Windows", Autoplay: true}, func(i int) {
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", i+1)).Padding(24)
|
||||
}).Size(360, 220)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
@@ -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()))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func dataCardDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
decorated, footer bool
|
||||
title string
|
||||
}
|
||||
s := ui.Local(c.Root(), "069", func() state { return state{footer: true, title: "Archive"} })
|
||||
dataSection(c, "Card", func() {
|
||||
dataControls(c, func() {
|
||||
ui.TextInput(c, &s.title).Width(180).Placeholder("Title")
|
||||
ui.Checkbox(c, &s.decorated, "Decorated")
|
||||
ui.Checkbox(c, &s.footer, "Footer")
|
||||
})
|
||||
card := func(decorated bool) {
|
||||
opts := mujica.CardOptions{Title: s.title, Decorated: decorated}
|
||||
if s.footer {
|
||||
opts.Footer = func() {
|
||||
if mujica.Button(c, "Open", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
log(fmt.Sprintf("open clicked (decorated=%v)", decorated))
|
||||
}
|
||||
}
|
||||
}
|
||||
mujica.Card(c, opts, func() {
|
||||
ui.Text(c, "Letters of the court, sealed in wine-red wax and kept in the north vault.")
|
||||
}).Width(280)
|
||||
}
|
||||
dataStates(c, func(state func(string, func())) {
|
||||
state("Options above", func() { card(s.decorated) })
|
||||
state("Plain", func() { card(false) })
|
||||
state("Decorated", func() { card(true) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func dataAccordionDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
multiple, keepOne bool
|
||||
open []string
|
||||
}
|
||||
s := ui.Local(c.Root(), "074", func() state { return state{open: []string{"choir"}} })
|
||||
dataSection(c, "Accordion", func() {
|
||||
dataControls(c, func() {
|
||||
ui.Checkbox(c, &s.multiple, "Multiple")
|
||||
ui.Checkbox(c, &s.keepOne, "Keep one open")
|
||||
if mujica.Button(c, "Close all", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.open = nil
|
||||
log("closed all from outside")
|
||||
}
|
||||
})
|
||||
if mujica.Accordion(c, &s.open, []mujica.AccordionItem{
|
||||
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices, two at the altar.") }},
|
||||
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Pews of black oak, polished for the feast.") }},
|
||||
{ID: "crypt", Title: "Crypt (disabled)", Disabled: true, Content: func() {}},
|
||||
{ID: "bells", Title: "Bells", Content: func() {
|
||||
mujica.Button(c, "Ring", mujica.ButtonOptions{})
|
||||
}},
|
||||
}, mujica.AccordionOptions{Multiple: s.multiple, KeepOne: s.keepOne}).Width(420).Changed() {
|
||||
log(fmt.Sprintf("open %v", s.open))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func dataCollapsibleDemo(c *ui.Context, log func(string)) {
|
||||
type state struct{ open, disabled bool }
|
||||
s := ui.Local(c.Root(), "075", func() state { return state{} })
|
||||
dataSection(c, "Collapsible", func() {
|
||||
dataControls(c, func() {
|
||||
if mujica.Button(c, "Open from outside", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.open = true
|
||||
log("opened from outside")
|
||||
}
|
||||
ui.Checkbox(c, &s.disabled, "Disabled")
|
||||
})
|
||||
if mujica.Collapsible(c, &s.open, mujica.CollapsibleOptions{Title: "Rites of the evening", Disabled: s.disabled}, func() {
|
||||
ui.Text(c, "Candles are lit at the seventh bell; the choir enters from the north door.")
|
||||
mujica.Button(c, "Light candles", mujica.ButtonOptions{Variant: mujica.Outline})
|
||||
}).Changed() {
|
||||
log(fmt.Sprintf("open=%v", s.open))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func dataTimelineDemo(c *ui.Context, log func(string)) {
|
||||
type state struct{ items []mujica.TimelineItem }
|
||||
s := ui.Local(c.Root(), "076", func() state {
|
||||
return state{items: []mujica.TimelineItem{
|
||||
{Time: "06:00", Title: "Matins sung", Content: "The choir gathered before dawn.", Status: mujica.TimelineDone},
|
||||
{Time: "09:00", Title: "Relics moved", Status: mujica.TimelineActive,
|
||||
Content: "The reliquaries travel from the crypt to the high altar, carried in procession by four acolytes in black and wine."},
|
||||
{Time: "12:00", Title: "Bell cracked", Status: mujica.TimelineError},
|
||||
{Time: "15:00", Title: "Storm warning", Status: mujica.TimelineWarning},
|
||||
{Time: "18:00", Title: "Vespers"},
|
||||
}}
|
||||
})
|
||||
dataSection(c, "Timeline", func() {
|
||||
dataControls(c, func() {
|
||||
if mujica.Button(c, "Add event", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.items = append(s.items, mujica.TimelineItem{Time: fmt.Sprintf("%02d:00", 19+len(s.items)%5), Title: "Compline"})
|
||||
log(fmt.Sprintf("%d events", len(s.items)))
|
||||
}
|
||||
})
|
||||
ui.Column(c).Width(420).Children(func() { mujica.Timeline(c, s.items) })
|
||||
})
|
||||
}
|
||||
|
||||
func dataDescriptionDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
columns string
|
||||
width float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "077", func() state { return state{columns: "2", width: 560} })
|
||||
dataSection(c, "DescriptionList", func() {
|
||||
dataControls(c, func() {
|
||||
ui.Select(c, &s.columns, []string{"1", "2", "3"})
|
||||
ui.Text(c, "Width")
|
||||
ui.Slider(c, &s.width, 240, 720).Width(200)
|
||||
})
|
||||
cols, _ := strconv.Atoi(s.columns)
|
||||
ui.Column(c).Width(float32(s.width)).Children(func() {
|
||||
if i, ok := mujica.DescriptionList(c, []mujica.DescriptionItem{
|
||||
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
|
||||
{Label: "Shelf", Value: "North vault, row 7"},
|
||||
{Label: "Catalogue", Value: "MS-1347-B", Copyable: true},
|
||||
{Label: "Seal", Value: "Wine-red wax, cracked at the rim", Span: -1},
|
||||
}, mujica.DescriptionListOptions{Columns: cols}).Copied(); ok {
|
||||
log(fmt.Sprintf("copied item %d", i))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func dataStatisticDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
value, change float64
|
||||
precision string
|
||||
}
|
||||
s := ui.Local(c.Root(), "078", func() state { return state{value: 12840, change: 12.5, precision: "0"} })
|
||||
dataSection(c, "Statistic", func() {
|
||||
dataControls(c, func() {
|
||||
ui.Text(c, "Value")
|
||||
if ui.Slider(c, &s.value, 0, 100000).Width(160).Changed() {
|
||||
log(fmt.Sprintf("value %.0f", s.value))
|
||||
}
|
||||
ui.Text(c, "Change")
|
||||
ui.Slider(c, &s.change, -50, 50).Width(160)
|
||||
ui.Select(c, &s.precision, []string{"0", "1", "2"})
|
||||
})
|
||||
p, _ := strconv.Atoi(s.precision)
|
||||
change := s.change
|
||||
dataStates(c, func(state func(string, func())) {
|
||||
state("With change", func() {
|
||||
mujica.Statistic(c, s.value, mujica.StatisticOptions{Title: "Pilgrims", Unit: "souls", Precision: p,
|
||||
Change: &change, ChangeUnit: "%", ChangeNote: "vs last feast"})
|
||||
})
|
||||
state("Prefix", func() {
|
||||
mujica.Statistic(c, s.value/1000, mujica.StatisticOptions{Title: "Tithe", Prefix: "£", Suffix: "k", Precision: p})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var dataPictures = func() []mujica.ViewerImage {
|
||||
art := func(bg, fg string, shape string) *ui.SVG {
|
||||
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320"><rect width="480" height="320" fill="` + bg + `"/>` +
|
||||
`<path d="` + shape + `" fill="` + fg + `"/></svg>`))
|
||||
}
|
||||
return []mujica.ViewerImage{
|
||||
{Source: art("#AD9164", "#7D2034", "M240 40 L380 160 L240 280 L100 160 Z"), Alt: "Rose window"},
|
||||
{Source: art("#211C1D", "#BDA172", "M80 280 L80 120 L240 40 L400 120 L400 280 Z"), Alt: "Chapel arch"},
|
||||
{Source: art("#F2EBDD", "#651729", "M60 60 H420 V100 H60 Z M60 140 H420 V180 H60 Z M60 220 H420 V260 H60 Z"), Alt: "Choir stalls"},
|
||||
}
|
||||
}()
|
||||
|
||||
func dataViewerDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
open bool
|
||||
index int
|
||||
}
|
||||
s := ui.Local(c.Root(), "079", func() state { return state{} })
|
||||
dataSection(c, "ImageViewer", func() {
|
||||
k := mujica.Tokens(c)
|
||||
ui.Text(c, "Choose a picture to open the viewer.").TextColor(k.TextMuted)
|
||||
ui.Row(c).Gap(12).Children(func() {
|
||||
for i, p := range dataPictures {
|
||||
thumb := ui.ButtonBase(c).Label("Open "+p.Alt).Radius(4).Border(1, k.Border).Clip()
|
||||
thumb.Children(func() { ui.Image(c, p.Source).Size(144, 96) })
|
||||
if thumb.Clicked() {
|
||||
s.open, s.index = true, i
|
||||
log("open " + p.Alt)
|
||||
}
|
||||
}
|
||||
})
|
||||
wasOpen := s.open
|
||||
if mujica.ImageViewer(c, &s.open, &s.index, dataPictures).Changed() {
|
||||
log(fmt.Sprintf("image %d", s.index+1))
|
||||
}
|
||||
if wasOpen && !s.open {
|
||||
log("closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func dataCarouselDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
index int
|
||||
autoplay, loop bool
|
||||
interval string
|
||||
}
|
||||
s := ui.Local(c.Root(), "080", func() state { return state{interval: "3s"} })
|
||||
dataSection(c, "Carousel", func() {
|
||||
dataControls(c, func() {
|
||||
ui.Checkbox(c, &s.autoplay, "Autoplay")
|
||||
ui.Checkbox(c, &s.loop, "Loop")
|
||||
ui.Select(c, &s.interval, []string{"2s", "3s", "5s"})
|
||||
})
|
||||
d, err := time.ParseDuration(s.interval)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
v := mujica.Carousel(c, &s.index, len(dataPictures), mujica.CarouselOptions{Label: "Windows", Autoplay: s.autoplay, Loop: s.loop, Interval: d}, func(i int) {
|
||||
ui.Image(c, dataPictures[i].Source).Grow(1).Label(dataPictures[i].Alt)
|
||||
})
|
||||
v.Size(520, 300)
|
||||
if v.Changed() {
|
||||
log(fmt.Sprintf("slide %d", s.index+1))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "下一张"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||