From f4b3ff36ae4e8b4e5a9a56cb2b12408f63f7a9e0 Mon Sep 17 00:00:00 2001 From: Tordarus Date: Fri, 6 Jun 2025 16:31:11 +0200 Subject: [PATCH] initial commit --- .gitignore | 1 + col_spec.go | 7 ++ go.mod | 17 +++++ go.sum | 19 ++++++ options.go | 6 ++ table.go | 168 ++++++++++++++++++++++++++++++++++++++++++++++++ table_header.go | 44 +++++++++++++ utils.go | 72 +++++++++++++++++++++ 8 files changed, 334 insertions(+) create mode 100644 .gitignore create mode 100644 col_spec.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 options.go create mode 100644 table.go create mode 100644 table_header.go create mode 100644 utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8599d40 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +table_header_test.go diff --git a/col_spec.go b/col_spec.go new file mode 100644 index 0000000..33a5ce9 --- /dev/null +++ b/col_spec.go @@ -0,0 +1,7 @@ +package tprint + +type ColSpec struct { + Name string + MaxWidth int + MaxHeight int +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95d7a4e --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.tordarus.net/tordarus/tprint + +go 1.19 + +require ( + git.tordarus.net/tordarus/gmath v0.0.7 + git.tordarus.net/tordarus/slices v0.0.14 + github.com/fatih/color v1.18.0 + github.com/mattn/go-runewidth v0.0.16 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a39660a --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +git.tordarus.net/tordarus/gmath v0.0.7 h1:tR48idt9AUL0r556ww3ZxByTKJEr6NWCTlhl2ihzYxQ= +git.tordarus.net/tordarus/gmath v0.0.7/go.mod h1:mO7aPlvNrGVE9UFXEuuACjZgMDsM63l3OcQy6xSQnoE= +git.tordarus.net/tordarus/slices v0.0.14 h1:Jy1VRMs777WewJ7mxTgjyQIMm/Zr+co18/XoQ01YZ3A= +git.tordarus.net/tordarus/slices v0.0.14/go.mod h1:RgE7A1aSAezIvPUgcbUuMHu0q4xGKoRevT+DC0eJmwI= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/options.go b/options.go new file mode 100644 index 0000000..3c80ebf --- /dev/null +++ b/options.go @@ -0,0 +1,6 @@ +package tprint + +type TableOptions struct { + Header bool + Border bool +} diff --git a/table.go b/table.go new file mode 100644 index 0000000..9e78a07 --- /dev/null +++ b/table.go @@ -0,0 +1,168 @@ +package tprint + +import ( + "fmt" + "strings" + + "git.tordarus.net/tordarus/gmath" + "git.tordarus.net/tordarus/slices" +) + +type Table struct { + head [][]string + + rowSep bool + headheight int + maxcols int + + colwidth []int + rowheight []int + + data [][][]string +} + +func NewTable(head ...string) *Table { + thead := slices.Map(head, func(s string) []string { return strings.Split(s, "\n") }) + return &Table{ + head: thead, + colwidth: slices.Map(head, func(s string) int { return strLen(s) }), + headheight: len(Search(thead, maxLengthSlice[string])), + rowheight: []int{}, + maxcols: len(thead), + } +} + +func (t *Table) AddRow(row ...interface{}) { + irow := slices.Map(row, func(v interface{}) []string { return strings.Split(fmt.Sprint(v), "\n") }) + t.data = append(t.data, irow) + + rowheight := len(Search(irow, maxLengthSlice[string])) + t.rowheight = append(t.rowheight, rowheight) + if rowheight > 1 { + t.rowSep = true + } + + t.maxcols = gmath.Max(t.maxcols, len(irow)) + + for i, cell := range irow { + maxLineLength := strLen(Search(cell, maxLengthStr)) + if i < len(t.colwidth) { + t.colwidth[i] = gmath.Max(t.colwidth[i], maxLineLength) + } else { + t.colwidth = append(t.colwidth, maxLineLength) + } + + if i >= len(t.head) { + headname := "unknown column" + t.head = append(t.head, []string{headname}) + t.colwidth[i] = gmath.Max(t.colwidth[i], strLen(headname)) + } + } +} + +func (t *Table) StringNoHead() string { + b := new(strings.Builder) + + b.WriteRune('┏') + for i, colwidth := range t.colwidth { + b.WriteString(strings.Repeat("━", colwidth)) + if i < len(t.colwidth)-1 { + b.WriteRune('┯') + } + } + b.WriteRune('┓') + b.WriteRune('\n') + + for i, row := range t.data { + t.printRow(b, row, t.rowheight[i]) + if t.rowSep && i < len(t.data)-1 { + t.addNextCellLine(b) + } + } + + t.addLastCellLine(b) + + return b.String() +} + +func (t *Table) String() string { + b := new(strings.Builder) + + b.WriteRune('┏') + for i, colwidth := range t.colwidth { + b.WriteString(strings.Repeat("━", colwidth)) + if i < len(t.colwidth)-1 { + b.WriteRune('┯') + } + } + b.WriteRune('┓') + b.WriteRune('\n') + + t.printRow(b, t.head, t.headheight) + + b.WriteRune('┣') + for i, colwidth := range t.colwidth { + b.WriteString(strings.Repeat("━", colwidth)) + if i < len(t.colwidth)-1 { + b.WriteRune('┿') + } + } + b.WriteRune('┫') + b.WriteRune('\n') + + for i, row := range t.data { + t.printRow(b, row, t.rowheight[i]) + if t.rowSep && i < len(t.data)-1 { + t.addNextCellLine(b) + } + } + + t.addLastCellLine(b) + + return b.String() +} + +func (t *Table) printRow(b *strings.Builder, rowData [][]string, rowHeight int) { + for lineIndex := 0; lineIndex < rowHeight; lineIndex++ { + b.WriteRune('┃') + for colIndex := 0; colIndex < t.maxcols; colIndex++ { + line := "" + + if colIndex < len(rowData) { + cell := rowData[colIndex] + if lineIndex < len(cell) { + line = cell[lineIndex] + } + } + + b.WriteString(fmt.Sprint(padStringRight(line, ' ', t.colwidth[colIndex]))) + if colIndex < t.maxcols-1 { + b.WriteRune('│') + } + } + b.WriteString("┃\n") + } +} + +func (t *Table) addNextCellLine(b *strings.Builder) { + b.WriteRune('┠') + for i, colwidth := range t.colwidth { + b.WriteString(strings.Repeat("─", colwidth)) + if i < len(t.colwidth)-1 { + b.WriteRune('┼') + } + } + b.WriteRune('┨') + b.WriteRune('\n') +} + +func (t *Table) addLastCellLine(b *strings.Builder) { + b.WriteRune('┗') + for i, colwidth := range t.colwidth { + b.WriteString(strings.Repeat("━", colwidth)) + if i < len(t.colwidth)-1 { + b.WriteRune('┷') + } + } + b.WriteString("┛\n") +} diff --git a/table_header.go b/table_header.go new file mode 100644 index 0000000..1098346 --- /dev/null +++ b/table_header.go @@ -0,0 +1,44 @@ +package tprint + +import ( + "strings" + + "git.tordarus.net/tordarus/slices" +) + +func FormatHeaderTable(header string, table *Table) string { + return formatHeaderTableStr(header, table.String()) +} + +func FormatHeaderTableNoHead(header string, table *Table) string { + return formatHeaderTableStr(header, table.StringNoHead()) +} + +// TODO formatHeaderTableStr is a quick hack +// a better solution would be to support nested tables. +// in that case, a header table is just a table with a singe col, single header row and single data row + +func formatHeaderTableStr(header string, tab string) string { + b := new(strings.Builder) + + splits := strings.Split(tab, "\n") + tabwidth := strLen(splits[0]) + + b.WriteRune('┏') + b.WriteString(strings.Repeat("━", tabwidth-2)) + b.WriteString("┓\n┃") + b.WriteString(padStringCenter(header, ' ', tabwidth-2)) + b.WriteString("┃\n") + + b.WriteRune('┣') + b.WriteString(splits[0][3 : len(splits[0])-3]) + b.WriteString("┫\n") + + slices.Each(splits[1:], func(s string) { + b.WriteString(s) + b.WriteRune('\n') + }) + + ret := b.String() + return ret[:len(ret)-1] +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..689de00 --- /dev/null +++ b/utils.go @@ -0,0 +1,72 @@ +package tprint + +import ( + "strings" + "unicode" + + "github.com/mattn/go-runewidth" +) + +func Search[T any](slice []T, cmp func(a, b T) T) T { + best := *new(T) + for _, v := range slice { + best = cmp(v, best) + } + return best +} + +func maxLengthStr(a, b string) string { + if strLen(a) > strLen(b) { + return a + } + return b +} + +func maxLengthSlice[T any](a, b []T) []T { + if len(a) > len(b) { + return a + } + return b +} + +func strLen(str string) int { + l := 0 + + runes := []rune(str) + + for i := 0; i < len(runes); i++ { + rn := runes[i] + + // skip control sequences + if unicode.IsControl(rn) { + for j := i; j < len(runes)-1 && runes[j] != 'm'; j++ { + i = j + } + i++ + continue + } + + if rn <= 0xFF || rn >= '─' && rn <= '╿' { + l++ + } else { + l += runewidth.RuneWidth(rn) + } + } + + return l +} + +func padStringRight(str string, pad rune, length int) string { + padding := length - strLen(str) + return str + strings.Repeat(string(pad), padding) +} + +func padStringLeft(str string, pad rune, length int) string { + padding := length - strLen(str) + return strings.Repeat(string(pad), padding) + str +} + +func padStringCenter(str string, pad rune, length int) string { + l := strLen(str) + return padStringLeft(padStringRight(str, pad, (length-l)/2+l), pad, length) +}