aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--config/columns.go120
-rw-r--r--lib/ui/table.go210
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)
+ }
+ }
+}