From 12246969db0d7f60778c2d0dede869c479a35237 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 19 Aug 2018 15:29:05 +0200 Subject: [PATCH] ui/table: Add small package for writing tables --- internal/ui/table/table.go | 206 ++++++++++++++++++++++++++++++++ internal/ui/table/table_test.go | 162 +++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 internal/ui/table/table.go create mode 100644 internal/ui/table/table_test.go diff --git a/internal/ui/table/table.go b/internal/ui/table/table.go new file mode 100644 index 000000000..8939f2ac9 --- /dev/null +++ b/internal/ui/table/table.go @@ -0,0 +1,206 @@ +package table + +import ( + "bytes" + "io" + "strings" + + "text/template" +) + +// Table contains data for a table to be printed. +type Table struct { + columns []string + templates []*template.Template + data []interface{} + footer []string + + CellSeparator string + PrintHeader func(io.Writer, string) error + PrintSeparator func(io.Writer, string) error + PrintData func(io.Writer, int, string) error + PrintFooter func(io.Writer, string) error +} + +var funcmap = template.FuncMap{ + "join": strings.Join, +} + +// New initializes a new Table +func New() *Table { + p := func(w io.Writer, s string) error { + _, err := w.Write(append([]byte(s), '\n')) + return err + } + return &Table{ + CellSeparator: " ", + PrintHeader: p, + PrintSeparator: p, + PrintData: func(w io.Writer, _ int, s string) error { + return p(w, s) + }, + PrintFooter: p, + } +} + +// AddColumn adds a new header field with the header and format, which is +// expected to be template string compatible with text/template. When compiling +// the format fails, AddColumn panics. +func (t *Table) AddColumn(header, format string) { + t.columns = append(t.columns, header) + tmpl, err := template.New("template for " + header).Funcs(funcmap).Parse(format) + if err != nil { + panic(err) + } + + t.templates = append(t.templates, tmpl) +} + +// AddRow adds a new row to the table, which is filled with data. +func (t *Table) AddRow(data interface{}) { + t.data = append(t.data, data) +} + +// AddFooter prints line after the table +func (t *Table) AddFooter(line string) { + t.footer = append(t.footer, line) +} + +func printLine(w io.Writer, print func(io.Writer, string) error, sep string, data []string, widths []int) error { + var fields [][]string + + maxLines := 1 + for _, d := range data { + lines := strings.Split(d, "\n") + if len(lines) > maxLines { + maxLines = len(lines) + } + fields = append(fields, lines) + } + + for i := 0; i < maxLines; i++ { + var s string + + for fieldNum, lines := range fields { + var v string + + if i < len(lines) { + v += lines[i] + } + + // apply padding + pad := widths[fieldNum] - len(v) + if pad > 0 { + v += strings.Repeat(" ", pad) + } + + if fieldNum > 0 { + v = sep + v + } + + s += v + } + + err := print(w, strings.TrimRight(s, " ")) + if err != nil { + return err + } + } + + return nil +} + +// Write prints the table to w. +func (t *Table) Write(w io.Writer) error { + columns := len(t.templates) + if columns == 0 { + return nil + } + + // collect all data fields from all columns + lines := make([][]string, 0, len(t.data)) + buf := bytes.NewBuffer(nil) + + for _, data := range t.data { + row := make([]string, 0, len(t.templates)) + for _, tmpl := range t.templates { + err := tmpl.Execute(buf, data) + if err != nil { + return err + } + + row = append(row, string(buf.Bytes())) + buf.Reset() + } + lines = append(lines, row) + } + + // find max width for each cell + columnWidths := make([]int, columns) + for i, desc := range t.columns { + for _, line := range strings.Split(desc, "\n") { + if columnWidths[i] < len(line) { + columnWidths[i] = len(desc) + } + } + } + for _, line := range lines { + for i, content := range line { + for _, l := range strings.Split(content, "\n") { + if columnWidths[i] < len(l) { + columnWidths[i] = len(l) + } + } + } + } + + // calculate the total width of the table + totalWidth := 0 + for _, width := range columnWidths { + totalWidth += width + } + totalWidth += (columns - 1) * len(t.CellSeparator) + + // write header + if len(t.columns) > 0 { + err := printLine(w, t.PrintHeader, t.CellSeparator, t.columns, columnWidths) + if err != nil { + return err + } + + // draw separation line + err = t.PrintSeparator(w, strings.Repeat("-", totalWidth)) + if err != nil { + return err + } + } + + // write all the lines + for i, line := range lines { + print := func(w io.Writer, s string) error { + return t.PrintData(w, i, s) + } + err := printLine(w, print, t.CellSeparator, line, columnWidths) + if err != nil { + return err + } + } + + // draw separation line + err := t.PrintSeparator(w, strings.Repeat("-", totalWidth)) + if err != nil { + return err + } + + if len(t.footer) > 0 { + // write the footer + for _, line := range t.footer { + err := t.PrintFooter(w, line) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/ui/table/table_test.go b/internal/ui/table/table_test.go new file mode 100644 index 000000000..47b180a91 --- /dev/null +++ b/internal/ui/table/table_test.go @@ -0,0 +1,162 @@ +package table + +import ( + "bytes" + "strings" + "testing" +) + +func TestTable(t *testing.T) { + var tests = []struct { + create func(t testing.TB) *Table + output string + }{ + { + func(t testing.TB) *Table { + return New() + }, + "", + }, + { + func(t testing.TB) *Table { + table := New() + table.AddColumn("first column", "data: {{.First}}") + table.AddRow(struct{ First string }{"first data field"}) + return table + }, + ` +first column +---------------------- +data: first data field +---------------------- +`, + }, + { + func(t testing.TB) *Table { + table := New() + table.AddColumn(" first column ", "data: {{.First}}") + table.AddRow(struct{ First string }{"d"}) + return table + }, + ` + first column +---------------- +data: d +---------------- +`, + }, + { + func(t testing.TB) *Table { + table := New() + table.AddColumn("first column", "data: {{.First}}") + table.AddRow(struct{ First string }{"first data field"}) + table.AddRow(struct{ First string }{"second data field"}) + table.AddFooter("footer1") + table.AddFooter("footer2") + return table + }, + ` +first column +----------------------- +data: first data field +data: second data field +----------------------- +footer1 +footer2 +`, + }, + { + func(t testing.TB) *Table { + table := New() + table.AddColumn(" first name", `{{printf "%12s" .FirstName}}`) + table.AddColumn("last name", "{{.LastName}}") + table.AddRow(struct{ FirstName, LastName string }{"firstname", "lastname"}) + table.AddRow(struct{ FirstName, LastName string }{"John", "Doe"}) + table.AddRow(struct{ FirstName, LastName string }{"Johann", "van den Berjen"}) + return table + }, + ` + first name last name +---------------------------- + firstname lastname + John Doe + Johann van den Berjen +---------------------------- +`, + }, + { + func(t testing.TB) *Table { + table := New() + table.AddColumn("host name", `{{.Host}}`) + table.AddColumn("time", `{{.Time}}`) + table.AddColumn("zz", "xxx") + table.AddColumn("tags", `{{join .Tags ","}}`) + table.AddColumn("dirs", `{{join .Dirs ","}}`) + + type data struct { + Host string + Time string + Tags, Dirs []string + } + table.AddRow(data{"foo", "2018-08-19 22:22:22", []string{"work"}, []string{"/home/user/work"}}) + table.AddRow(data{"foo", "2018-08-19 22:22:22", []string{"other"}, []string{"/home/user/other"}}) + table.AddRow(data{"foo", "2018-08-19 22:22:22", []string{"other"}, []string{"/home/user/other"}}) + return table + }, + ` +host name time zz tags dirs +------------------------------------------------------------ +foo 2018-08-19 22:22:22 xxx work /home/user/work +foo 2018-08-19 22:22:22 xxx other /home/user/other +foo 2018-08-19 22:22:22 xxx other /home/user/other +------------------------------------------------------------ +`, + }, + { + func(t testing.TB) *Table { + table := New() + table.AddColumn("host name", `{{.Host}}`) + table.AddColumn("time", `{{.Time}}`) + table.AddColumn("zz", "xxx") + table.AddColumn("tags", `{{join .Tags "\n"}}`) + table.AddColumn("dirs", `{{join .Dirs "\n"}}`) + + type data struct { + Host string + Time string + Tags, Dirs []string + } + table.AddRow(data{"foo", "2018-08-19 22:22:22", []string{"work", "go"}, []string{"/home/user/work", "/home/user/go"}}) + table.AddRow(data{"foo", "2018-08-19 22:22:22", []string{"other"}, []string{"/home/user/other"}}) + table.AddRow(data{"foo", "2018-08-19 22:22:22", []string{"other", "bar"}, []string{"/home/user/other"}}) + return table + }, + ` +host name time zz tags dirs +------------------------------------------------------------ +foo 2018-08-19 22:22:22 xxx work /home/user/work + go /home/user/go +foo 2018-08-19 22:22:22 xxx other /home/user/other +foo 2018-08-19 22:22:22 xxx other /home/user/other + bar +------------------------------------------------------------ +`, + }, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + table := test.create(t) + buf := bytes.NewBuffer(nil) + err := table.Write(buf) + if err != nil { + t.Fatal(err) + } + + want := strings.TrimLeft(test.output, "\n") + if string(buf.Bytes()) != want { + t.Errorf("wrong output\n---- want ---\n%s\n---- got ---\n%s\n-------\n", want, buf.Bytes()) + } + }) + } +}