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