Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,124 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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
|
||||
}
|
||||
|
||||
// Accordion shows sections whose open IDs live in *open, which the caller controls.
|
||||
func Accordion(c *ui.Context, open *[]string, items []AccordionItem, opts AccordionOptions) core.ChoiceResult {
|
||||
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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
var v core.ChoiceResult
|
||||
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.Element.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 = core.NewChoiceResult(v.Element, true)
|
||||
}
|
||||
isOpen = slices.Contains(*open, it.ID)
|
||||
prog := tr.Animate("mujica.open", collapsibleTarget(isOpen), core.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,130 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func accordionHarness(open *[]string, opts *AccordionOptions, changes *int) func(c *ui.Context) {
|
||||
return func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,59 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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) {
|
||||
internal.Frame(p, r, cardInset, 9, k.Ornament)
|
||||
})
|
||||
} else {
|
||||
card.Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
card.Children(func() {
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, 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) {
|
||||
internal.RuleDiamond(p, r, 6, 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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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() {
|
||||
input.Button(c, "Open", input.ButtonOptions{})
|
||||
}}, func() {
|
||||
ui.Text(c, "Letters of the court")
|
||||
}).Width(260)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardSlots(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Mode: core.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() {
|
||||
input.Button(c, "Open", input.ButtonOptions{Variant: input.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() {
|
||||
input.Button(c, "Enter", input.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 := core.Light
|
||||
if core.Tokens(c).Dark {
|
||||
mode = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: mode, Density: d})
|
||||
cardView(true)(c)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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
|
||||
}
|
||||
|
||||
type carouselState struct {
|
||||
paused bool
|
||||
next time.Time
|
||||
// dotFocus leaves the arrows to the dots' focus group.
|
||||
dotFocus bool
|
||||
}
|
||||
|
||||
// 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)) core.ChoiceResult {
|
||||
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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var v core.ChoiceResult
|
||||
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 n == 0 {
|
||||
return
|
||||
}
|
||||
if wrap {
|
||||
i = (i%n + n) % n
|
||||
}
|
||||
if i < 0 || i >= n || i == *index {
|
||||
return
|
||||
}
|
||||
*index = i
|
||||
v = core.NewChoiceResult(v.Element, true)
|
||||
c.Announce(fmt.Sprintf(core.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.Element.Hovered() && !v.Element.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 st.dotFocus:
|
||||
case v.Element.Shortcut(0, ui.KeyLeft):
|
||||
goTo(*index - 1)
|
||||
case v.Element.Shortcut(0, ui.KeyRight):
|
||||
goTo(*index + 1)
|
||||
case v.Element.Shortcut(0, ui.KeyHome):
|
||||
goTo(0)
|
||||
case v.Element.Shortcut(0, ui.KeyEnd):
|
||||
goTo(n - 1)
|
||||
}
|
||||
v.Element.Children(func() {
|
||||
ui.Row(c).Grow(1).MinHeight(120).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
prev := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("chevron-left"), Label: core.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(core.Msg(c, "carousel.position"), *index+1, n)).Children(func() {
|
||||
if n > 0 {
|
||||
ui.Column(c).Key(*index).Grow(1).Children(func() { slide(*index) })
|
||||
}
|
||||
})
|
||||
next := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("chevron-right"), Label: core.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() {
|
||||
st.dotFocus = false
|
||||
ui.Row(c).Gap(u).FocusGroup(ui.Horizontal).Children(func() {
|
||||
for i := range n {
|
||||
dot := carouselDot(c, i, n, i == *index)
|
||||
if dot.Focused() {
|
||||
st.dotFocus = true
|
||||
}
|
||||
// The arrows move the focus among the dots; the slide follows it.
|
||||
if dot.Clicked() || dot.Focused() && i != *index {
|
||||
goTo(i)
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
})
|
||||
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 := core.Tokens(c)
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).
|
||||
Label(fmt.Sprintf(core.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 := core.Tokens(c)
|
||||
label := core.Msg(c, "carousel.pause")
|
||||
if paused {
|
||||
label = core.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,176 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type carouselHarness struct {
|
||||
index int
|
||||
opts CarouselOptions
|
||||
changes int
|
||||
}
|
||||
|
||||
func (h *carouselHarness) view(c *ui.Context) {
|
||||
core.Use(c, core.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.Element.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(core.FontSize(c, 18))
|
||||
})
|
||||
}).Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarouselEmptyAutoplayKeys(t *testing.T) {
|
||||
index := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Carousel(c, &index, 0, CarouselOptions{Autoplay: true}, func(int) {}).Element.Size(300, 200)
|
||||
}, 320, 240)
|
||||
for range 4 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
}
|
||||
if index != 0 {
|
||||
t.Fatalf("index %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarouselDotsFollowArrows(t *testing.T) {
|
||||
h := &carouselHarness{}
|
||||
tt := ui.NewTester(h.view, 420, 280)
|
||||
for range 10 {
|
||||
if tt.Focused("Slide 1 of 4") {
|
||||
break
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if !tt.Focused("Slide 1 of 4") {
|
||||
t.Fatal("Tab never reached the first dot")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.index != 1 || !tt.Focused("Slide 2 of 4") {
|
||||
t.Fatalf("Right: index %d, focus on dot 2 %v", h.index, tt.Focused("Slide 2 of 4"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.index != 1 {
|
||||
t.Fatalf("Enter on the focused dot moved to %d", h.index)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.index != 3 || !tt.Focused("Slide 4 of 4") {
|
||||
t.Fatalf("End: index %d", h.index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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
|
||||
}
|
||||
|
||||
// Collapsible shows content below a trigger while *open; closed content is not built.
|
||||
func Collapsible(c *ui.Context, open *bool, opts CollapsibleOptions, content func()) core.ChoiceResult {
|
||||
if opts.Title == "" && opts.Trigger == nil {
|
||||
panic("mujica: Collapsible needs Title or Trigger")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var v core.ChoiceResult
|
||||
v.Element = ui.Column(c).Shrink(0)
|
||||
v.Element.Children(func() {
|
||||
was := *open
|
||||
p := ui.CollapsibleBase(c, open)
|
||||
tr := p.Trigger
|
||||
if opts.Disabled {
|
||||
*open = was
|
||||
}
|
||||
v = core.NewChoiceResult(v.Element, *open != was)
|
||||
tr.Disabled(opts.Disabled).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*2).
|
||||
MinHeight(core.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), core.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
|
||||
}
|
||||
// Closing content takes no input or focus while it shrinks away.
|
||||
clip := ui.Column(c).Shrink(0).Disabled(!open)
|
||||
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,111 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func TestCollapsibleToggleAndFocus(t *testing.T) {
|
||||
open, changes, clicks := false, 0, 0
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
if Collapsible(c, &open, CollapsibleOptions{Title: "Details", Disabled: disabled}, func() {
|
||||
if input.Button(c, "Inner", input.ButtonOptions{}).Clicked() {
|
||||
clicks++
|
||||
}
|
||||
}).Changed() {
|
||||
changes++
|
||||
}
|
||||
input.Button(c, "After", input.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) {
|
||||
core.Use(c, core.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() {})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollapsibleClosingContentLeavesTabOrder(t *testing.T) {
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
Collapsible(c, &open, CollapsibleOptions{Title: "Details"}, func() {
|
||||
input.Button(c, "Inner", input.ButtonOptions{})
|
||||
})
|
||||
input.Button(c, "After", input.ButtonOptions{})
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace) // closes; the content shrinks for 120 ms
|
||||
if open || !tt.HasText("Inner") {
|
||||
t.Fatalf("expected a closing animation: open %v", open)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("After") {
|
||||
t.Fatal("Tab entered closing content")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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
|
||||
toggled 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) }
|
||||
|
||||
// VisibleRows returns the range of view rows built this frame.
|
||||
func (s *DataTableState[K]) VisibleRows() (first, last int) { return s.rows.Visible() }
|
||||
|
||||
// DataTableView is a built DataTable and what happened to it in this frame.
|
||||
type DataTableView struct {
|
||||
Element *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 or menu choice that changed State.Sort.
|
||||
func (v DataTableView) SortChanged() bool { return v.sorted }
|
||||
|
||||
// Retried reports a click on a failed load's retry.
|
||||
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
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
before := s.Sort
|
||||
var v DataTableView
|
||||
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
|
||||
v.Element.Children(func() {
|
||||
if len(rows) > 0 {
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
tableColumnMenu(c, dataTableMenu(opts), &s.Sort, &s.Layout, &s.Hidden)
|
||||
})
|
||||
}
|
||||
dataTableRefresh(s, rows, opts)
|
||||
if s.has {
|
||||
if i, ok := s.cache.at[s.sel]; ok {
|
||||
s.cursor = i
|
||||
} else {
|
||||
s.cursor = -1
|
||||
}
|
||||
}
|
||||
view := s.cache.view
|
||||
if len(view) == 0 {
|
||||
empty := opts.Empty
|
||||
if len(rows) > 0 {
|
||||
empty = core.Msg(c, "data_table.noMatch")
|
||||
}
|
||||
v.retry = DataPlaceholder(c, opts.Status, opts.Error, empty, core.Msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
vis, cols := dataTableColumns(dataRowUnit(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
|
||||
}
|
||||
// Rows reach the data-row height of the density.
|
||||
unit := dataRowUnit(c)
|
||||
restore := DataSelectTheme(c, unit)
|
||||
head := s.Sort
|
||||
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()
|
||||
}
|
||||
})
|
||||
restore()
|
||||
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.changed = e.Changed() && s.Sort == head || s.toggled
|
||||
s.toggled = false
|
||||
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
|
||||
}
|
||||
})
|
||||
v.sorted = s.Sort != before
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// dataTableMenu lists the columns for the column menu.
|
||||
func dataTableMenu[R any, K comparable](opts DataTableOptions[R, K]) []tableColumn {
|
||||
cols := make([]tableColumn, len(opts.Columns))
|
||||
for i, col := range opts.Columns {
|
||||
cols[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Sortable}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// dataRowUnit is the table spacing unit: rows reach the density's height and fit scaled text.
|
||||
func dataRowUnit(c *ui.Context) float32 {
|
||||
// MyGo rows and the header are 8 units high with 1.5 units of padding above and below.
|
||||
return max(core.Density(c).ControlHeight()/8, core.FontSize(c, theme.BodySize)*theme.LineHeight/5)
|
||||
}
|
||||
|
||||
func dataTableColumns[R any, K comparable](unit float32, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) {
|
||||
// The check box keeps its full hit width inside MyGo's 2.5-unit cell padding.
|
||||
w := theme.MinHitSize + 5*unit
|
||||
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 := core.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.AccentText)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
on := s.Selection.Has(key)
|
||||
cb := ui.CheckboxBase(c, &on).Label(core.Msg(c, "data_table.selectRow")).Size(theme.MinHitSize, theme.MinHitSize).
|
||||
Justify(ui.Center).AlignItems(ui.Center).Radius(theme.ControlRadius)
|
||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||
cb.Children(func() { dataTableCheckBox(c, on) })
|
||||
if cb.Changed() {
|
||||
if on {
|
||||
s.Selection.Add(key)
|
||||
} else {
|
||||
s.Selection.Remove(key)
|
||||
}
|
||||
s.toggled = true
|
||||
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,179 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// tableColumn is what the column menu knows of a column.
|
||||
type tableColumn struct {
|
||||
ID, Title string
|
||||
Width, MinWidth float32
|
||||
Sortable bool
|
||||
}
|
||||
|
||||
// tableWidthStep is how far one Narrower or Wider press resizes a column.
|
||||
const tableWidthStep float32 = 16
|
||||
|
||||
// tableColumnMenu is the keyboard path to sort, move, resize and, with hidden, show columns.
|
||||
func tableColumnMenu(c *ui.Context, cols []tableColumn, sort *ui.SortOrder, layout *ui.TableLayout, hidden *map[string]bool) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
b := input.Button(c, core.Msg(c, "data_table.columns"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("settings")})
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
if b.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
order := tableOrder(cols, *layout)
|
||||
visible := len(cols)
|
||||
if hidden != nil {
|
||||
for _, col := range cols {
|
||||
if (*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(240).MarginY(u)
|
||||
for at, id := range order {
|
||||
col := cols[slices.IndexFunc(cols, func(col tableColumn) bool { return col.ID == id })]
|
||||
ui.Row(c).Key(id).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
if hidden != nil {
|
||||
tableVisibility(c, col, hidden, visible)
|
||||
} else {
|
||||
ui.Text(c, col.Title).SingleLine().Grow(1).Padding(0, u*2)
|
||||
}
|
||||
tableColumnActions(c, col, at, order, sort, layout)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// tableColumnActions are one column's sort, move and width buttons.
|
||||
func tableColumnActions(c *ui.Context, col tableColumn, at int, order []string, sort *ui.SortOrder, layout *ui.TableLayout) {
|
||||
act := func(icon, key string, disabled bool) bool {
|
||||
return input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must(icon), Label: fmt.Sprintf(core.Msg(c, key), col.Title), Disabled: disabled}).Clicked()
|
||||
}
|
||||
if col.Sortable {
|
||||
for _, desc := range []bool{false, true} {
|
||||
icon, key := "arrow-up", "data_table.sortAsc"
|
||||
if desc {
|
||||
icon, key = "arrow-down", "data_table.sortDesc"
|
||||
}
|
||||
if act(icon, key, *sort == ui.SortOrder{Column: col.ID, Descending: desc}) {
|
||||
*sort = ui.SortOrder{Column: col.ID, Descending: desc}
|
||||
slog.Debug("mujica: table sort", "column", col.ID, "descending", desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, step := range []int{-1, 1} {
|
||||
icon, key := "chevron-left", "data_table.moveLeft"
|
||||
if step > 0 {
|
||||
icon, key = "chevron-right", "data_table.moveRight"
|
||||
}
|
||||
to := at + step
|
||||
if act(icon, key, to < 0 || to >= len(order)) {
|
||||
ids := slices.Clone(order)
|
||||
ids[at], ids[to] = ids[to], ids[at]
|
||||
layout.Order = ids
|
||||
slog.Debug("mujica: table move", "column", col.ID, "to", to)
|
||||
}
|
||||
}
|
||||
w := tableWidth(col, *layout)
|
||||
least := max(col.MinWidth, tableWidthStep)
|
||||
if act("minus", "data_table.narrower", w <= least) {
|
||||
tableSetWidth(layout, col.ID, max(least, w-tableWidthStep))
|
||||
}
|
||||
if act("plus", "data_table.wider", false) {
|
||||
tableSetWidth(layout, col.ID, w+tableWidthStep)
|
||||
}
|
||||
}
|
||||
|
||||
// tableVisibility is a column's show/hide check box; the last visible one cannot hide.
|
||||
func tableVisibility(c *ui.Context, col tableColumn, hidden *map[string]bool, visible int) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
on := !(*hidden)[col.ID]
|
||||
last := on && visible == 1
|
||||
cb := ui.CheckboxBase(c, &on).Label(col.Title).Disabled(last).MinHeight(d.ControlHeight()).Grow(1).
|
||||
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 {
|
||||
m := maps.Clone(*hidden)
|
||||
if m == nil {
|
||||
m = map[string]bool{}
|
||||
}
|
||||
m[col.ID] = !on
|
||||
*hidden = m
|
||||
slog.Debug("mujica: table column", "id", col.ID, "visible", on)
|
||||
}
|
||||
cb.Children(func() {
|
||||
dataTableCheckBox(c, on)
|
||||
ui.Text(c, col.Title).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
// tableOrder lists column IDs as the layout orders them, the rest after.
|
||||
func tableOrder(cols []tableColumn, l ui.TableLayout) []string {
|
||||
var ids []string
|
||||
for _, id := range l.Order {
|
||||
if slices.ContainsFunc(cols, func(col tableColumn) bool { return col.ID == id }) && !slices.Contains(ids, id) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
for _, col := range cols {
|
||||
if !slices.Contains(ids, col.ID) {
|
||||
ids = append(ids, col.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// tableWidth is a column's set width; a sharing column starts from its least, at least 120.
|
||||
func tableWidth(col tableColumn, l ui.TableLayout) float32 {
|
||||
if w, ok := l.Widths[col.ID]; ok {
|
||||
return w
|
||||
}
|
||||
if col.Width > 0 {
|
||||
return col.Width
|
||||
}
|
||||
return max(col.MinWidth, 120)
|
||||
}
|
||||
|
||||
func tableSetWidth(l *ui.TableLayout, id string, w float32) {
|
||||
m := maps.Clone(l.Widths)
|
||||
if m == nil {
|
||||
m = map[string]float32{}
|
||||
}
|
||||
m[id] = w
|
||||
l.Widths = m
|
||||
slog.Debug("mujica: table width", "column", id, "width", w)
|
||||
}
|
||||
|
||||
// dataTableCheckBox draws a 16 DIP check box: OnAccent check on Accent when on.
|
||||
func dataTableCheckBox(c *ui.Context, on bool) {
|
||||
k := core.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,404 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
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
|
||||
c *ui.Context
|
||||
density theme.Density
|
||||
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) {
|
||||
core.Use(c, core.Settings{Mode: core.Light, Density: h.density})
|
||||
h.c, h.seen = c, map[string]int{}
|
||||
ui.Column(c).Padding(10).Gap(8).Children(func() {
|
||||
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.Element.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) || h.changes != 2 {
|
||||
t.Fatalf("marks chose %d, changes %d", h.st.Selection.Len(), h.changes)
|
||||
}
|
||||
if _, ok := tt.Find("Select row"); !ok {
|
||||
t.Fatal("row check box has no name")
|
||||
}
|
||||
_ = 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())
|
||||
}
|
||||
}
|
||||
|
||||
// tabTo presses Tab until the element labeled label has the focus.
|
||||
func tabTo(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
for range 60 {
|
||||
if tt.Focused(label) {
|
||||
return
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
t.Fatalf("Tab never reached %q", label)
|
||||
}
|
||||
|
||||
func TestDataTableColumnMenuKeyboard(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
tt := ui.NewTester(h.view, 420, 480)
|
||||
tabTo(t, tt, "Columns")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Sort Age descending")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Sort != (ui.SortOrder{Column: "age", Descending: true}) || h.sorts != 1 {
|
||||
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
|
||||
}
|
||||
tabTo(t, tt, "Move Age left")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if fmt.Sprint(h.st.Layout.Order) != "[age name]" {
|
||||
t.Fatalf("order %v", h.st.Layout.Order)
|
||||
}
|
||||
tabTo(t, tt, "Make Age wider")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Layout.Widths["age"] != 96 {
|
||||
t.Fatalf("widths %v", h.st.Layout.Widths)
|
||||
}
|
||||
tabTo(t, tt, "Name")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !h.st.Hidden["name"] {
|
||||
t.Fatalf("name not hidden: %v", h.st.Hidden)
|
||||
}
|
||||
tabTo(t, tt, "Age")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if h.st.Hidden["age"] {
|
||||
t.Fatal("hid the last visible column")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Censer") || !tt.HasText("Age") {
|
||||
t.Fatalf("table did not follow the menu: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
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).Element.Size(600, 260)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDataTableSelectionColors(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
if err := tt.Click("Bell"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Bell")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableCompactLargeText(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
h.opts.Multiple = true
|
||||
h.density = theme.Compact
|
||||
tt := ui.NewTester(h.view, 600, 700)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
head, _ := tt.Find("Name")
|
||||
row, _ := tt.Find("Censer") // the first row, labeled by its first column
|
||||
if head.Y+head.H > row.Y-1 {
|
||||
t.Fatalf("header text %v runs past the header into row %v", head, row)
|
||||
}
|
||||
box, ok := tt.Find("Select row")
|
||||
if !ok || box.W < theme.MinHitSize || box.H < theme.MinHitSize || box.X < row.X {
|
||||
t.Fatalf("check box hit area %v in row %v", box, row)
|
||||
}
|
||||
if name, _ := tt.Find("Name"); box.X+box.W > name.X {
|
||||
t.Fatalf("check box %v overlaps the Name column at %v", box, name.X)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableCheckBoxHitWidth(t *testing.T) {
|
||||
for _, d := range []theme.Density{theme.Compact, theme.Default} {
|
||||
h := newTableHarness(relics())
|
||||
h.opts.Multiple = true
|
||||
h.density = d
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
box, _ := tt.Find("Select row")
|
||||
if box.W < theme.MinHitSize || box.H < theme.MinHitSize {
|
||||
t.Fatalf("%v: check box hit area %v", d, box)
|
||||
}
|
||||
tt.ClickAt(box.X+1, box.Y+box.H/2) // its left edge still toggles
|
||||
if h.st.Selection.Len() != 1 {
|
||||
t.Fatalf("%v: left edge of the hit area missed", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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 {
|
||||
Element *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 := core.Tokens(c)
|
||||
u := core.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.Element.Bounds().W)
|
||||
v.Element.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.Element.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(core.FontSize(c, 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"), core.Msg(c, "description_list.copy")+" "+it.Label
|
||||
if done {
|
||||
icon, label = icons.Must("check"), core.Msg(c, "description_list.copied")
|
||||
c.After(st.until.Sub(now))
|
||||
}
|
||||
if input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icon, Label: label}).MinHeight(theme.MinHitSize).Padding(0, u).Clicked() {
|
||||
c.WriteClipboard(it.Value)
|
||||
c.Announce(core.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,83 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,2 @@
|
||||
// Package data holds MujicaUI lists, tables, trees, cards and other content and data views.
|
||||
package data
|
||||
@@ -0,0 +1,184 @@
|
||||
package data_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func ExampleCard() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Card(c, data.CardOptions{Title: "Archive", Decorated: true, Footer: func() {
|
||||
input.Button(c, "Open", input.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 data.ListState[string]
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
v := data.List(c, &state, len(names), data.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.Element.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 data.DataTableState[int]
|
||||
opts := data.DataTableOptions[relic, int]{
|
||||
Key: func(r relic) int { return r.ID },
|
||||
Columns: []data.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.DataTable(c, &state, rows, opts).Element.Size(360, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTree() {
|
||||
kids := map[string][]string{"Nave": {"Pulpit", "Pews"}}
|
||||
var state data.TreeState[string]
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Tree(c, &state, data.TreeOptions[string]{
|
||||
Roots: []string{"Nave", "Sacristy"},
|
||||
Children: func(n string) []string { return kids[n] },
|
||||
Label: func(n string) string { return n },
|
||||
}).Element.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 data.TreeTableState[string]
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.TreeTable(c, &state, data.TreeTableOptions[string]{
|
||||
Roots: []string{"Vestry", "Organ"},
|
||||
Children: func(n string) []string { return kids[n] },
|
||||
Label: func(n string) string { return n },
|
||||
Columns: []data.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])) }},
|
||||
},
|
||||
}).Element.Size(360, 240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAccordion() {
|
||||
open := []string{"choir"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Accordion(c, &open, []data.AccordionItem{
|
||||
{ID: "choir", Title: "Choir", Content: func() { ui.Text(c, "Twelve voices.") }},
|
||||
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Black oak pews.") }},
|
||||
}, data.AccordionOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCollapsible() {
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Collapsible(c, &open, data.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Timeline(c, []data.TimelineItem{
|
||||
{Time: "06:00", Title: "Matins sung", Status: data.TimelineDone},
|
||||
{Time: "09:00", Title: "Relics moved", Content: "From the crypt to the altar.", Status: data.TimelineActive},
|
||||
{Time: "18:00", Title: "Vespers"},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDescriptionList() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.DescriptionList(c, []data.DescriptionItem{
|
||||
{Label: "Archivist", Value: "Sister Agnès", Copyable: true},
|
||||
{Label: "Shelf", Value: "North vault, row 7"},
|
||||
{Label: "Seal", Value: "Wine-red wax", Span: -1},
|
||||
}, data.DescriptionListOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStatistic() {
|
||||
change := 12.5
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Statistic(c, 12840, data.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.ImageViewer(c, &open, &index, []data.ViewerImage{{Source: picture, Alt: "Rose window"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCarousel() {
|
||||
index := 0
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
data.Carousel(c, &index, 3, data.CarouselOptions{Label: "Windows", Autoplay: true}, func(i int) {
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", i+1)).Padding(24)
|
||||
}).Element.Size(360, 220)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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
|
||||
}
|
||||
|
||||
// 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; Element is nil while closed.
|
||||
func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) core.ChoiceResult {
|
||||
var v core.ChoiceResult
|
||||
if !*open {
|
||||
return v
|
||||
}
|
||||
if len(images) == 0 || *index < 0 || *index >= len(images) {
|
||||
panic("mujica: ImageViewer index out of range")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.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(core.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 = i, core.NewChoiceResult(v.Element, true)
|
||||
*st = viewerState{image: i, zoom: 1}
|
||||
c.Announce(fmt.Sprintf(core.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 input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must(icon), Label: core.Msg(c, label), Disabled: disabled}).Clicked()
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.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,158 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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) {
|
||||
core.Use(c, core.Settings{Mode: core.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"}})
|
||||
})
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 {
|
||||
Element *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 a failed load's retry.
|
||||
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
|
||||
}
|
||||
listLocate(s, n, opts.Key)
|
||||
k := core.Tokens(c)
|
||||
var v ListView
|
||||
v.Element = ui.Column(c).MinHeight(0)
|
||||
v.Element.Children(func() {
|
||||
if n == 0 {
|
||||
v.retry = DataPlaceholder(c, opts.Status, opts.Error, opts.Empty, core.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
|
||||
}
|
||||
|
||||
// listLocate finds the chosen key again when items moved or came and went.
|
||||
func listLocate[K comparable](s *ListState[K], n int, key func(int) K) {
|
||||
if !s.has || s.cursor >= 0 && s.cursor < n && key(s.cursor) == s.key {
|
||||
return
|
||||
}
|
||||
s.cursor = -1
|
||||
for i := range n {
|
||||
if key(i) == s.key {
|
||||
s.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
slog.Debug("mujica: list selection moved", "row", s.cursor)
|
||||
}
|
||||
|
||||
func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element {
|
||||
d := core.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
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
restore := DataSelectTheme(c, 0)
|
||||
defer restore()
|
||||
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).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
}).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 := core.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 an AccentText ring over a Selection face.
|
||||
k := core.Tokens(c)
|
||||
saved := c.Theme()
|
||||
t := *saved
|
||||
t.Accent = k.AccentText
|
||||
c.SetTheme(&t)
|
||||
e := ui.GridView(c, &s.Grid, n, w, h, func(i int) {
|
||||
chosen := listChosen(s, opts, i)
|
||||
face := ui.Column(c).Grow(1).Padding(d.Unit() * 2).Gap(d.Unit()).Radius(theme.ControlRadius)
|
||||
if chosen {
|
||||
face.Background(k.Selection)
|
||||
}
|
||||
face.Children(func() {
|
||||
item(i)
|
||||
if chosen {
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Absolute().Top(d.Unit()).Right(d.Unit()).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
}).Padding(d.Unit() * 2)
|
||||
c.SetTheme(saved)
|
||||
return e
|
||||
}
|
||||
|
||||
// DataSelectTheme shows MyGo's chosen rows as Selection with Text; spacing > 0 sets the unit.
|
||||
func DataSelectTheme(c *ui.Context, spacing float32) func() {
|
||||
k := core.Tokens(c)
|
||||
saved := c.Theme()
|
||||
t := *saved
|
||||
t.Accent, t.AccentText = k.Selection, k.Text
|
||||
if spacing > 0 {
|
||||
t.Spacing = spacing
|
||||
}
|
||||
c.SetTheme(&t)
|
||||
return func() { c.SetTheme(saved) }
|
||||
}
|
||||
|
||||
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 := core.Tokens(c)
|
||||
u := core.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:
|
||||
core.Spinner(c, core.SpinnerOptions{Label: core.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 = input.Button(c, core.Msg(c, "list.retry"), input.ButtonOptions{Variant: input.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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
retry := false
|
||||
switch st {
|
||||
case DataLoadingMore:
|
||||
ui.Row(c).Padding(u*2, u*3).Shrink(0).Children(func() {
|
||||
core.Spinner(c, core.SpinnerOptions{Label: core.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 = input.Button(c, core.Msg(c, "list.retry"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("refresh-cw")}).Clicked()
|
||||
})
|
||||
}
|
||||
return retry
|
||||
}
|
||||
|
||||
func DataError(c *ui.Context, s string) string {
|
||||
if s == "" {
|
||||
return core.Msg(c, "list.failed")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.Settings{Mode: core.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.Element.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]) }).Element.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(core.Tokens(c).TextMuted)
|
||||
}).Element.Size(360, 228)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestListKeyFollowsLargeReorder(t *testing.T) {
|
||||
h := newListHarness(5000)
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Item 3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Reverse(h.ids)
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); !ok || k != 3 || h.st.cursor != 4996 {
|
||||
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, h.st.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGridKeyFollowsInsert(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 2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ids = append([]int{100}, h.ids...)
|
||||
tt.Frame()
|
||||
if k, _ := h.st.Selected(); k != 2 || h.st.cursor != 3 {
|
||||
t.Fatalf("after insert: selected %d at %d", k, h.st.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSelectionColors(t *testing.T) {
|
||||
h := newListHarness(5)
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Item 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Item 1")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.End).Children(func() {
|
||||
ui.Text(c, num).Font(theme.SerifFont).FontSize(core.FontSize(c, 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", core.Msg(c, "statistic.flat")
|
||||
switch {
|
||||
case ch > 0:
|
||||
col, icon, word = k.Success, "arrow-up", core.Msg(c, "statistic.up")
|
||||
case ch < 0:
|
||||
col, icon, word = k.Danger, "arrow-down", core.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(core.FontSize(c, 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,98 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatisticTextScale(t *testing.T) {
|
||||
var h float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() { h = Statistic(c, 7, StatisticOptions{}).Bounds().H })
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
base := h
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame()
|
||||
if h < base*1.8 {
|
||||
t.Fatalf("height %v at 200%%, %v at 100%%", h, base)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
if w := timelineWord(c, it.Status); w != "" {
|
||||
ui.Text(c, w).FontSize(core.FontSize(c, 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 core.Msg(c, "timeline.done")
|
||||
case TimelineActive:
|
||||
return core.Msg(c, "timeline.active")
|
||||
case TimelineWarning:
|
||||
return core.Msg(c, "timeline.warning")
|
||||
case TimelineError:
|
||||
return core.Msg(c, "timeline.error")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// timelineMarker draws a hollow diamond, or a status icon.
|
||||
func timelineMarker(c *ui.Context, s TimelineStatus) {
|
||||
k := core.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,52 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) })
|
||||
})
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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
|
||||
}
|
||||
}
|
||||
|
||||
// clear drops the choice and row state when the tree has no items.
|
||||
func (t *treeCore[K]) clear(sel *ui.Selection[K]) {
|
||||
var zero K
|
||||
t.cursor, t.Sel, t.Has = -1, zero, false
|
||||
sel.Clear()
|
||||
clear(t.asked)
|
||||
t.Outline = ui.OutlineState[K]{Open: t.Outline.Open}
|
||||
}
|
||||
|
||||
// end reads a new choice, or finds the chosen item again after rows moved.
|
||||
func (t *treeCore[K]) end(c *ui.Context, changed bool) {
|
||||
rows := t.Outline.Rows()
|
||||
at := t.cursor >= 0 && t.cursor < rows
|
||||
if changed || !t.Has {
|
||||
t.Has = at
|
||||
if at {
|
||||
t.Sel = t.Outline.Item(t.cursor)
|
||||
}
|
||||
return
|
||||
}
|
||||
if at && t.Outline.Item(t.cursor) == t.Sel {
|
||||
return
|
||||
}
|
||||
t.cursor = -1
|
||||
for i := range rows {
|
||||
if t.Outline.Item(i) == t.Sel {
|
||||
t.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if t.cursor < 0 {
|
||||
t.Has = false
|
||||
return
|
||||
}
|
||||
// The rows showed the old place: build them again at the new one.
|
||||
c.Invalidate()
|
||||
slog.Debug("mujica: tree selection moved", "row", 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.Has && t.Sel == 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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
switch st {
|
||||
case DataLoading:
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 14, Label: core.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, core.Msg(c, "list.failed")).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
if input.Button(c, core.Msg(c, "list.retry"), input.ButtonOptions{Variant: input.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 {
|
||||
Element *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 := core.Tokens(c)
|
||||
d := core.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.Element.Children(func() {
|
||||
if len(opts.Roots) == 0 {
|
||||
t.clear(&s.Selection)
|
||||
v.retry = DataPlaceholder(c, opts.Status, opts.Error, opts.Empty, core.Msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
// Outline rows pad by one unit above and below.
|
||||
h := d.ControlHeight() - 2*c.Theme().Space(1)
|
||||
restore := DataSelectTheme(c, 0)
|
||||
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).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
})
|
||||
restore()
|
||||
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)
|
||||
})
|
||||
if len(opts.Roots) > 0 {
|
||||
t.end(c, v.changed)
|
||||
}
|
||||
if v.changed {
|
||||
slog.Debug("mujica: tree changed", "row", t.cursor, "count", s.Selection.Len())
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 kids (nil leaf, empty unloaded); required; return a new slice on change.
|
||||
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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
t := &s.Tree
|
||||
t.begin(opts.Label, &s.Selection, opts.Multiple)
|
||||
if s.sorted == nil {
|
||||
s.sorted = map[K]treeSorted[K]{}
|
||||
}
|
||||
cols := make([]ui.TableColumn, len(opts.Columns))
|
||||
menu := make([]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}
|
||||
menu[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Compare != nil}
|
||||
}
|
||||
// The sort is read after the column menu, which may change it.
|
||||
var cmp func(a, b K) int
|
||||
var order ui.SortOrder
|
||||
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
|
||||
}
|
||||
before := s.Sort
|
||||
var v DataTableView
|
||||
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
|
||||
v.Element.Children(func() {
|
||||
if len(opts.Roots) == 0 {
|
||||
t.clear(&s.Selection)
|
||||
v.retry = DataPlaceholder(c, opts.Status, opts.Error, opts.Empty, core.Msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
ui.Row(c).Justify(ui.End).Children(func() { tableColumnMenu(c, menu, &s.Sort, &s.Layout, nil) })
|
||||
order = s.Sort
|
||||
for _, col := range opts.Columns {
|
||||
if col.ID == order.Column {
|
||||
cmp = col.Compare
|
||||
}
|
||||
}
|
||||
roots := sorted(opts.Roots, &s.roots)
|
||||
head := s.Sort
|
||||
t.Outline.List.Sort, t.Outline.List.Columns = &s.Sort, s.Layout
|
||||
restore := DataSelectTheme(c, dataRowUnit(c))
|
||||
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).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
})
|
||||
restore()
|
||||
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.changed = e.Changed() && s.Sort == head
|
||||
v.submitted = e.Submitted()
|
||||
v.retry = dataFooter(c, opts.Status, opts.Error)
|
||||
})
|
||||
v.sorted = s.Sort != before
|
||||
if len(opts.Roots) > 0 {
|
||||
t.end(c, v.changed)
|
||||
}
|
||||
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,265 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
h.c = c
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
v := TreeTable(c, &h.st, h.opts)
|
||||
v.Element.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) {
|
||||
core.Use(c, core.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)}}}).Element.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{}
|
||||
h.st.Tree.Sel, h.st.Tree.Has = "Vestry", true
|
||||
return h
|
||||
})
|
||||
(*h).c = c
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
TreeTable(c, &(*h).st, (*h).opts).Element.Size(388, 208)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTreeTableSelectionFollowsLargeSort(t *testing.T) {
|
||||
kids := make([]int, 3000)
|
||||
for i := range kids {
|
||||
kids[i] = i + 1
|
||||
}
|
||||
var st TreeTableState[int]
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
TreeTable(c, &st, TreeTableOptions[int]{Roots: []int{0}, Label: strconv.Itoa,
|
||||
Children: func(n int) []int {
|
||||
if n == 0 {
|
||||
return kids
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Columns: []TreeColumn[int]{{ID: "n", Title: "N", Cell: func(n int) { ui.Text(c, "#"+strconv.Itoa(n)) }, Compare: cmp.Compare[int]}},
|
||||
}).Element.Size(320, 360)
|
||||
}
|
||||
st.SetOpen(0, true)
|
||||
tt := ui.NewTester(view, 400, 400)
|
||||
if err := tt.Click("#3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Sort = ui.SortOrder{Column: "n", Descending: true}
|
||||
tt.Frame()
|
||||
if k, ok := st.Selected(); !ok || k != 3 || st.Tree.Outline.Item(st.Tree.cursor) != 3 {
|
||||
t.Fatalf("after sort: selected %d %v at row %d", k, ok, st.Tree.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeTableColumnMenuKeyboard(t *testing.T) {
|
||||
h := newTTHarness()
|
||||
tt := ui.NewTester(h.view, 420, 480)
|
||||
tabTo(t, tt, "Columns")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Sort Size ascending")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Sort != (ui.SortOrder{Column: "size"}) || h.sorts != 1 {
|
||||
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
|
||||
}
|
||||
tabTo(t, tt, "Move Name right")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Make Name narrower")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if fmt.Sprint(h.st.Layout.Order) != "[size name]" || h.st.Layout.Widths["name"] != 104 {
|
||||
t.Fatalf("layout %+v", h.st.Layout)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if got := h.order(tt); got != "[Belfry Organ Vestry]" {
|
||||
t.Fatalf("rows not sorted by size: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeTableEmptyClearsSelection(t *testing.T) {
|
||||
h := newTTHarness()
|
||||
h.opts.Multiple = true
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
if err := tt.Click("Organ"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, ok := h.st.Selected(); !ok || k != "Organ" || h.st.Selection.Len() != 1 {
|
||||
t.Fatalf("selected %q %v", k, ok)
|
||||
}
|
||||
h.opts.Roots = nil
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); ok || h.st.Selection.Len() != 0 {
|
||||
t.Fatalf("empty table still selects %q (%d chosen)", k, h.st.Selection.Len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.Settings{Mode: core.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.Element.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
built = 0
|
||||
Tree(c, &st, TreeOptions[int]{Roots: roots, Children: kids, Label: func(n int) string {
|
||||
built++
|
||||
return fmt.Sprintf("Node %d", n)
|
||||
}}).Element.Size(320, 360)
|
||||
}
|
||||
tt := ui.NewTester(view, 400, 400)
|
||||
// Two Label calls per row; a jump builds 2×(10 rows + 2 overscan) + 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{}
|
||||
h.st.Tree.Sel, h.st.Tree.Has = "Pulpit", true
|
||||
return h
|
||||
})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Tree(c, &(*h).st, (*h).opts).Element.Size(360, 228)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTreeSelectionFollowsLargeReorder(t *testing.T) {
|
||||
roots := make([]int, 3000)
|
||||
for i := range roots {
|
||||
roots[i] = i
|
||||
}
|
||||
var st TreeState[int]
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
Tree(c, &st, TreeOptions[int]{Roots: roots, Children: func(int) []int { return nil },
|
||||
Label: func(n int) string { return fmt.Sprintf("Node %d", n) }}).Element.Size(320, 360)
|
||||
}
|
||||
tt := ui.NewTester(view, 400, 400)
|
||||
if err := tt.Click("Node 2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Node 2")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
roots = slices.Clone(roots)
|
||||
slices.Reverse(roots)
|
||||
tt.Frame()
|
||||
if k, ok := st.Selected(); !ok || k != 2 || st.Tree.cursor != 2997 {
|
||||
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, st.Tree.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeEmptyClearsSelection(t *testing.T) {
|
||||
h := newTreeHarness()
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Sacristy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.opts.Roots = nil
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); ok {
|
||||
t.Fatalf("empty tree still selects %q", k)
|
||||
}
|
||||
h.opts.Roots = []string{"Nave", "Crypt", "Sacristy"}
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); ok {
|
||||
t.Fatalf("selection came back as %q", k)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user