diff options
author | Robin Jarry <robin@jarry.cc> | 2022-12-21 11:50:53 +0100 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2023-01-04 22:57:31 +0100 |
commit | 012be0192c88f4fcfd5ed559edff4ca7366eb351 (patch) | |
tree | 558509153975b5d423bf2bd2995483278accd473 | |
parent | 1eb4c2c1e79ea8d66d53caaca24dd1b8ba32b02f (diff) | |
download | aerc-012be0192c88f4fcfd5ed559edff4ca7366eb351.tar.gz |
ui: add reusable table widget
This will be used by the message list index and by the status line.
A table is constructed from rows/width dimensions, a list of column
definitions and a column separator.
Provide functions to parse column definitions from ini config files.
This will be used in the next commit.
Signed-off-by: Robin Jarry <robin@jarry.cc>
Acked-by: Tim Culverhouse <tim@timculverhouse.com>
-rw-r--r-- | config/columns.go | 120 | ||||
-rw-r--r-- | lib/ui/table.go | 210 |
2 files changed, 330 insertions, 0 deletions
diff --git a/config/columns.go b/config/columns.go new file mode 100644 index 00000000..659be2c3 --- /dev/null +++ b/config/columns.go @@ -0,0 +1,120 @@ +package config + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + "strings" + "text/template" + + "git.sr.ht/~rjarry/aerc/lib/templates" + "github.com/go-ini/ini" +) + +type ColumnFlags uint32 + +func (f ColumnFlags) Has(o ColumnFlags) bool { return f&o == o } + +const ( + ALIGN_LEFT ColumnFlags = 1 << iota + ALIGN_CENTER + ALIGN_RIGHT + WIDTH_AUTO // whatever is left + WIDTH_FRACTION // ratio of total width + WIDTH_EXACT // exact number of characters + WIDTH_FIT // fit to column content width +) + +type ColumnDef struct { + Name string + Flags ColumnFlags + Width float64 + Template *template.Template +} + +var columnRe = regexp.MustCompile(`^([\w-]+)(?:([<:>])(=|\*|\d+%?)?)?$`) + +func parseColumnDef(col string, section *ini.Section) (*ColumnDef, error) { + col = strings.TrimSpace(col) + match := columnRe.FindStringSubmatch(col) + if match == nil { + return nil, fmt.Errorf("invalid column def: %v", col) + } + name := match[1] + keyName := fmt.Sprintf("column-%s", name) + + var flags ColumnFlags + switch match[2] { + case "<", "": + flags |= ALIGN_LEFT + case ":": + flags |= ALIGN_CENTER + case ">": + flags |= ALIGN_RIGHT + } + + var width float64 = 0 + switch match[3] { + case "=": + flags |= WIDTH_FIT + case "*", "": + flags |= WIDTH_AUTO + default: + s := match[3] + var divider float64 = 1 + if strings.HasSuffix(s, "%") { + divider = 100 + s = strings.TrimSuffix(s, "%") + flags |= WIDTH_FRACTION + } else { + flags |= WIDTH_EXACT + } + w, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil, fmt.Errorf("%s: %w", keyName, err) + } + if divider == 100 && w > 100 { + return nil, fmt.Errorf("%s: invalid width %.0f%%", keyName, w) + } + width = w / divider + } + key, err := section.GetKey(keyName) + if err != nil { + return nil, err + } + + t, err := templates.ParseTemplate(keyName, key.String()) + if err != nil { + return nil, err + } + + data := templates.DummyData() + var buf bytes.Buffer + err = t.Execute(&buf, data) + if err != nil { + return nil, err + } + + return &ColumnDef{ + Name: name, + Flags: flags, + Width: width, + Template: t, + }, nil +} + +func ParseColumnDefs(key *ini.Key, section *ini.Section) ([]*ColumnDef, error) { + var columns []*ColumnDef + for _, col := range key.Strings(",") { + c, err := parseColumnDef(col, section) + if err != nil { + return nil, err + } + columns = append(columns, c) + } + if len(columns) == 0 { + return nil, fmt.Errorf("%s cannot be empty", key.Name()) + } + return columns, nil +} diff --git a/lib/ui/table.go b/lib/ui/table.go new file mode 100644 index 00000000..13bec12d --- /dev/null +++ b/lib/ui/table.go @@ -0,0 +1,210 @@ +package ui + +import ( + "math" + "regexp" + "strings" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/format" + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" +) + +type Table struct { + Columns []Column + Rows []Row + Width int + Height int + // Optional callback that allows customizing the default drawing routine + // of table rows. If true is returned, the default routine is skipped. + CustomDraw func(t *Table, row int, c *Context) bool + // Optional callback that allows returning a custom style for the row. + GetRowStyle func(t *Table, row int) tcell.Style + + // true if at least one column has WIDTH_FIT + autoFitWidths bool + // if false, widths need to be computed before drawing + widthsComputed bool +} + +type Column struct { + Offset int + Width int + Def *config.ColumnDef + Separator string +} + +type Row struct { + Cells []string + Priv interface{} +} + +func NewTable( + width, height int, + columnDefs []*config.ColumnDef, separator string, + customDraw func(*Table, int, *Context) bool, + getRowStyle func(*Table, int) tcell.Style, +) Table { + if customDraw == nil { + customDraw = func(*Table, int, *Context) bool { return false } + } + if getRowStyle == nil { + getRowStyle = func(*Table, int) tcell.Style { + return tcell.StyleDefault + } + } + columns := make([]Column, len(columnDefs)) + autoFitWidths := false + for c, col := range columnDefs { + if col.Flags.Has(config.WIDTH_FIT) { + autoFitWidths = true + } + columns[c] = Column{Def: col} + if c != len(columns)-1 { + // set separator for all columns except the last one + columns[c].Separator = separator + } + } + return Table{ + Columns: columns, + Width: width, + Height: height, + CustomDraw: customDraw, + GetRowStyle: getRowStyle, + autoFitWidths: autoFitWidths, + } +} + +// add a row to the table, returns true when the table is full +func (t *Table) AddRow(cells []string, priv interface{}) bool { + if len(cells) != len(t.Columns) { + panic("invalid number of cells") + } + if len(t.Rows) >= t.Height { + return true + } + t.Rows = append(t.Rows, Row{Cells: cells, Priv: priv}) + if t.autoFitWidths { + t.widthsComputed = false + } + return len(t.Rows) >= t.Height +} + +func (t *Table) computeWidths() { + contentMaxWidths := make([]int, len(t.Columns)) + if t.autoFitWidths { + for _, row := range t.Rows { + for c := range t.Columns { + w := runewidth.StringWidth(row.Cells[c]) + if w > contentMaxWidths[c] { + contentMaxWidths[c] = w + } + } + } + } + + nonFixed := t.Width + autoWidthCount := 0 + for c := range t.Columns { + col := &t.Columns[c] + switch { + case col.Def.Flags.Has(config.WIDTH_FIT): + col.Width = contentMaxWidths[c] + // compensate for exact width columns + col.Width += runewidth.StringWidth(col.Separator) + case col.Def.Flags.Has(config.WIDTH_EXACT): + col.Width = int(math.Round(col.Def.Width)) + // compensate for exact width columns + col.Width += runewidth.StringWidth(col.Separator) + case col.Def.Flags.Has(config.WIDTH_AUTO): + col.Width = 0 + autoWidthCount += 1 + case col.Def.Flags.Has(config.WIDTH_FRACTION): + col.Width = int(math.Round(float64(t.Width) * col.Def.Width)) + } + nonFixed -= col.Width + } + + autoWidth := 0 + if autoWidthCount > 0 && nonFixed > 0 { + autoWidth = nonFixed / autoWidthCount + if autoWidth == 0 { + autoWidth = 1 + } + } + + offset := 0 + remain := t.Width + for c := range t.Columns { + col := &t.Columns[c] + if col.Width == 0 && autoWidth > 0 { + col.Width = autoWidth + if nonFixed >= 2*autoWidth { + nonFixed -= autoWidth + } + } + // limit width to avoid overflow + if col.Width > remain { + col.Width = remain + } + remain -= col.Width + col.Offset = offset + offset += col.Width + // reserve room for separator + col.Width -= runewidth.StringWidth(col.Separator) + } +} + +var metaCharsRegexp = regexp.MustCompile(`[\t\r\f\n\v]`) + +func (col *Column) alignCell(cell string) string { + cell = metaCharsRegexp.ReplaceAllString(cell, " ") + width := runewidth.StringWidth(cell) + + switch { + case col.Def.Flags.Has(config.ALIGN_LEFT): + if width < col.Width { + cell += strings.Repeat(" ", col.Width-width) + } else if width > col.Width { + cell = runewidth.Truncate(cell, col.Width, "…") + } + case col.Def.Flags.Has(config.ALIGN_CENTER): + if width < col.Width { + padding := strings.Repeat(" ", col.Width-width) + l := len(padding) / 2 + cell = padding[:l] + cell + padding[l:] + } else if width > col.Width { + cell = runewidth.Truncate(cell, col.Width, "…") + } + case col.Def.Flags.Has(config.ALIGN_RIGHT): + if width < col.Width { + cell = strings.Repeat(" ", col.Width-width) + cell + } else if width > col.Width { + cell = format.TruncateHead(cell, col.Width, "…") + } + } + + return cell +} + +func (t *Table) Draw(ctx *Context) { + if !t.widthsComputed { + t.computeWidths() + t.widthsComputed = true + } + for r, row := range t.Rows { + if t.CustomDraw(t, r, ctx) { + continue + } + for c, col := range t.Columns { + if col.Width == 0 { + // column overflows screen width + continue + } + cell := col.alignCell(row.Cells[c]) + style := t.GetRowStyle(t, r) + ctx.Printf(col.Offset, r, style, "%s%s", cell, col.Separator) + } + } +} |