Files
MujicaUI/pagination.go
T

253 lines
7.7 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// PaginationOptions configure a Pagination.
type PaginationOptions struct {
// Total is the number of records; it must not be negative.
Total int
// PageSizes lists the page sizes offered; nil hides the choice.
PageSizes []int
// Jump shows a field going to a typed page.
Jump bool
// Siblings is how many pages show each side of the current; zero means 1.
Siblings int
Disabled bool
}
// PaginationResult is the built Pagination.
type PaginationResult struct {
*ui.Element
changed bool
}
// Changed reports a new page or page size in this frame, from the user or a correction.
func (r PaginationResult) Changed() bool { return r.changed }
// Pagination moves *page (from 1) through Total records, *pageSize per page.
// A page past the end after Total shrinks is corrected to the last page.
func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult {
if opts.Total < 0 {
panic("mujica: Pagination Total is negative")
}
if *pageSize <= 0 {
panic("mujica: Pagination pageSize must be positive")
}
if opts.PageSizes != nil && !slices.Contains(opts.PageSizes, *pageSize) {
panic("mujica: Pagination pageSize is not one of PageSizes")
}
sib := opts.Siblings
if sib == 0 {
sib = 1
}
if sib < 0 {
panic("mujica: Pagination Siblings is negative")
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Wrap().Role(ui.RoleGroup).Label(msg(c, "pagination.label")).Disabled(opts.Disabled)
res := PaginationResult{Element: row}
pages := paginationPages(opts.Total, *pageSize)
if p := max(1, min(*page, pages)); p != *page {
slog.Debug("mujica: pagination correct", "from", *page, "to", p, "pages", pages)
*page, res.changed = p, true
}
set := func(p int) {
if p != *page {
slog.Debug("mujica: pagination page", "from", *page, "to", p)
*page, res.changed = p, true
}
}
row.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.total"), opts.Total)).TextColor(k.TextMuted).SingleLine()
ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Children(func() {
if paginationButton(c, "", icons.Must("chevron-left"), msg(c, "pagination.prev"), false, *page <= 1).Clicked() {
set(*page - 1)
}
for _, p := range paginationItems(*page, pages, sib) {
if p == 0 {
ui.Text(c, "…").TextColor(k.TextMuted).Padding(0, u).Role(ui.RoleNone)
continue
}
n := strconv.Itoa(p)
name := fmt.Sprintf(msg(c, "pagination.page"), p)
if p == *page {
name += ", " + msg(c, "pagination.current")
}
if paginationButton(c, n, nil, name, p == *page, false).Clicked() {
set(p)
}
}
if paginationButton(c, "", icons.Must("chevron-right"), msg(c, "pagination.next"), false, *page >= pages).Clicked() {
set(*page + 1)
}
})
if opts.PageSizes != nil {
paginationSizes(c, page, pageSize, opts.PageSizes, &res)
}
if opts.Jump {
paginationJump(c, pages, set)
}
})
return res
}
func paginationPages(total, size int) int { return max(1, (total+size-1)/size) }
// paginationItems lists the page numbers to show, 0 standing for an ellipsis.
func paginationItems(cur, pages, sib int) []int {
var out []int
if pages <= 2*sib+5 {
for p := 1; p <= pages; p++ {
out = append(out, p)
}
return out
}
start := max(min(cur-sib, pages-2*sib-2), 3)
end := min(max(cur+sib, 2*sib+3), pages-2)
out = append(out, 1)
if start > 3 {
out = append(out, 0)
} else {
out = append(out, 2)
}
for p := start; p <= end; p++ {
out = append(out, p)
}
if end < pages-2 {
out = append(out, 0)
} else {
out = append(out, pages-1)
}
return append(out, pages)
}
func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on, disabled bool) *ui.Element {
k := Tokens(c)
h := Density(c).ControlHeight()
b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, Density(c).Unit()*2).Radius(theme.ControlRadius).
Label(name).Disabled(disabled)
internal.Ring(b, k.Focus, theme.ControlRadius)
fg := k.Text
switch {
case on:
b.Background(k.Accent).Border(theme.BorderWidth, k.Accent).FontWeight(600)
fg = k.OnAccent
case b.Pressed():
b.Background(k.Selection).Border(theme.BorderWidth, k.ControlBorder)
case b.Hovered():
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
default:
b.Border(theme.BorderWidth, k.Border)
}
internal.Ease(b, Motion(c, theme.StateDuration), fg)
if icon != nil {
b.Tooltip(name)
}
b.Children(func() {
if icon != nil {
ui.Icon(c, icon).Size(16, 16)
} else {
ui.Text(c, text).SingleLine().FontFeatures("tnum")
}
})
return b
}
func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *PaginationResult) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
old := *pageSize
sel := ui.SelectBase(c, pageSize)
sel.Trigger.MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u*2).Radius(theme.ControlRadius).
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "pagination.size"))
internal.Ring(sel.Trigger, k.Focus, theme.ControlRadius)
if sel.Trigger.Hovered() {
sel.Trigger.Background(k.SurfaceHover)
}
internal.Ease(sel.Trigger, Motion(c, theme.StateDuration), k.Text)
sel.Trigger.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), *pageSize)).SingleLine()
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
})
sel.Popup(func(panel *ui.Element) {
panel.Margin(u, 0, 0, 0).Padding(u).Radius(theme.PopupRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16))
for _, s := range sizes {
it := sel.Item(s).Padding(u*1.5, u*3).Radius(theme.ControlRadius).Gap(u * 2)
if it.Highlighted() {
it.Background(k.Selection)
}
internal.Ease(it, Motion(c, theme.StateDuration), k.Text)
it.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), s)).SingleLine().Grow(1)
if s == *pageSize {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
})
}
})
if *pageSize != old {
// Keep the first record of the old page in view.
first := (*page-1)*old + 1
*page = (first-1) / *pageSize + 1
res.changed = true
slog.Debug("mujica: pagination size", "from", old, "to", *pageSize, "page", *page)
}
}
func paginationJump(c *ui.Context, pages int, set func(int)) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
ui.Text(c, msg(c, "pagination.jump")).TextColor(k.TextMuted).SingleLine()
box := ui.Row(c).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Width(64).Padding(0, u*2).
Radius(theme.ControlRadius).Background(k.Surface)
text := ui.Local(box, "text", func() string { return "" })
bad := ui.Local(box, "bad", func() bool { return false })
var in *ui.Element
box.Children(func() {
in = ui.TextInputBase(c, text).FillWidth().Label(msg(c, "pagination.field"))
})
if in.Changed() {
*bad = false
}
if in.Submitted() {
p, err := strconv.Atoi(strings.TrimSpace(*text))
if err != nil || p < 1 || p > pages {
*bad = true
slog.Debug("mujica: pagination jump rejected", "text", *text, "pages", pages)
} else {
*text, *bad = "", false
set(p)
}
}
border := k.ControlBorder
if *bad {
border = k.Danger
in.Error(fmt.Sprintf(msg(c, "pagination.range"), pages))
} else if in.Focused() {
border = k.Focus
}
internal.Ease(box.Border(theme.BorderWidth, border), Motion(c, theme.StateDuration), k.Text)
if *bad {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine()
}
})
}