diff --git a/helper/table/config.go b/helper/table/config.go new file mode 100644 index 0000000..f89f306 --- /dev/null +++ b/helper/table/config.go @@ -0,0 +1,101 @@ +package table + +import "git.hexq.cn/tiglog/golib/helper/text" + +// ColumnConfig contains configurations that determine and modify the way the +// contents of the column get rendered. +type ColumnConfig struct { + // Name is the name of the Column as it appears in the first Header row. + // If a Header is not provided, or the name is not found in the header, this + // will not work. + Name string + // Number is the Column # from left. When specified, it overrides the Name + // property. If you know the exact Column number, use this instead of Name. + Number int + + // Align defines the horizontal alignment + Align text.Align + // AlignFooter defines the horizontal alignment of Footer rows + AlignFooter text.Align + // AlignHeader defines the horizontal alignment of Header rows + AlignHeader text.Align + + // AutoMerge merges cells with similar values and prevents separators from + // being drawn. Caveats: + // * VAlign is applied on the individual cell and not on the merged cell + // * Does not work in CSV/HTML/Markdown render modes + // * Does not work well with horizontal auto-merge (RowConfig.AutoMerge) + // + // Works best when: + // * Style().Options.SeparateRows == true + // * Style().Color.Row == Style().Color.RowAlternate (or not set) + AutoMerge bool + + // Colors defines the colors to be used on the column + Colors text.Colors + // ColorsFooter defines the colors to be used on the column in Footer rows + ColorsFooter text.Colors + // ColorsHeader defines the colors to be used on the column in Header rows + ColorsHeader text.Colors + + // Hidden when set to true will prevent the column from being rendered. + // This is useful in cases like needing a column for sorting, but not for + // display. + Hidden bool + + // Transformer is a custom-function that changes the way the value gets + // rendered to the console. Refer to text/transformer.go for ready-to-use + // Transformer functions. + Transformer text.Transformer + // TransformerFooter is like Transformer but for Footer rows + TransformerFooter text.Transformer + // TransformerHeader is like Transformer but for Header rows + TransformerHeader text.Transformer + + // VAlign defines the vertical alignment + VAlign text.VAlign + // VAlignFooter defines the vertical alignment in Footer rows + VAlignFooter text.VAlign + // VAlignHeader defines the vertical alignment in Header rows + VAlignHeader text.VAlign + + // WidthMax defines the maximum character length of the column + WidthMax int + // WidthEnforcer enforces the WidthMax value on the column contents; + // default: text.WrapText + WidthMaxEnforcer WidthEnforcer + // WidthMin defines the minimum character length of the column + WidthMin int +} + +func (c ColumnConfig) getWidthMaxEnforcer() WidthEnforcer { + if c.WidthMax == 0 { + return widthEnforcerNone + } + if c.WidthMaxEnforcer != nil { + return c.WidthMaxEnforcer + } + return text.WrapText +} + +// RowConfig contains configurations that determine and modify the way the +// contents of a row get rendered. +type RowConfig struct { + // AutoMerge merges cells with similar values and prevents separators from + // being drawn. Caveats: + // * Align is overridden to text.AlignCenter on the merged cell (unless set + // by AutoMergeAlign value below) + // * Does not work in CSV/HTML/Markdown render modes + // * Does not work well with vertical auto-merge (ColumnConfig.AutoMerge) + AutoMerge bool + + // Alignment to use on a merge (defaults to text.AlignCenter) + AutoMergeAlign text.Align +} + +func (rc RowConfig) getAutoMergeAlign() text.Align { + if rc.AutoMergeAlign == text.AlignDefault { + return text.AlignCenter + } + return rc.AutoMergeAlign +} diff --git a/helper/table/config_test.go b/helper/table/config_test.go new file mode 100644 index 0000000..26491b9 --- /dev/null +++ b/helper/table/config_test.go @@ -0,0 +1,75 @@ +// +// config_test.go +// Copyright (C) 2023 tiglog +// +// Distributed under terms of the MIT license. +// + +package table + +import ( + "testing" + + "git.hexq.cn/tiglog/golib/helper/text" + "github.com/stretchr/testify/assert" +) + +func TestColumnConfig_getWidthMaxEnforcer(t *testing.T) { + t.Run("no width enforcer", func(t *testing.T) { + cc := ColumnConfig{} + + widthEnforcer := cc.getWidthMaxEnforcer() + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 0)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 1)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 5)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 10)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 100)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 1000)) + }) + + t.Run("default width enforcer", func(t *testing.T) { + cc := ColumnConfig{ + WidthMax: 10, + } + + widthEnforcer := cc.getWidthMaxEnforcer() + assert.Equal(t, "", widthEnforcer("1234567890", 0)) + assert.Equal(t, "1\n2\n3\n4\n5\n6\n7\n8\n9\n0", widthEnforcer("1234567890", 1)) + assert.Equal(t, "12345\n67890", widthEnforcer("1234567890", 5)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 10)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 100)) + assert.Equal(t, "1234567890", widthEnforcer("1234567890", 1000)) + }) + + t.Run("custom width enforcer (1)", func(t *testing.T) { + cc := ColumnConfig{ + WidthMax: 10, + WidthMaxEnforcer: text.Trim, + } + + widthEnforcer := cc.getWidthMaxEnforcer() + assert.Equal(t, text.Trim("1234567890", 0), widthEnforcer("1234567890", 0)) + assert.Equal(t, text.Trim("1234567890", 1), widthEnforcer("1234567890", 1)) + assert.Equal(t, text.Trim("1234567890", 5), widthEnforcer("1234567890", 5)) + assert.Equal(t, text.Trim("1234567890", 10), widthEnforcer("1234567890", 10)) + assert.Equal(t, text.Trim("1234567890", 100), widthEnforcer("1234567890", 100)) + assert.Equal(t, text.Trim("1234567890", 1000), widthEnforcer("1234567890", 1000)) + }) + + t.Run("custom width enforcer (2)", func(t *testing.T) { + cc := ColumnConfig{ + WidthMax: 10, + WidthMaxEnforcer: func(col string, maxLen int) string { + return "foo" + }, + } + + widthEnforcer := cc.getWidthMaxEnforcer() + assert.Equal(t, "foo", widthEnforcer("1234567890", 0)) + assert.Equal(t, "foo", widthEnforcer("1234567890", 1)) + assert.Equal(t, "foo", widthEnforcer("1234567890", 5)) + assert.Equal(t, "foo", widthEnforcer("1234567890", 10)) + assert.Equal(t, "foo", widthEnforcer("1234567890", 100)) + assert.Equal(t, "foo", widthEnforcer("1234567890", 1000)) + }) +} diff --git a/helper/table/render.go b/helper/table/render.go new file mode 100644 index 0000000..6e27d15 --- /dev/null +++ b/helper/table/render.go @@ -0,0 +1,408 @@ +package table + +import ( + "fmt" + "strings" + "unicode/utf8" + + "git.hexq.cn/tiglog/golib/helper/text" +) + +// Render renders the Table in a human-readable "pretty" format. Example: +// +// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +// │ 1 │ Arya │ Stark │ 3000 │ │ +// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +// │ 300 │ Tyrion │ Lannister │ 5000 │ │ +// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +// │ │ │ TOTAL │ 10000 │ │ +// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ +func (t *Table) Render() string { + t.initForRender() + + var out strings.Builder + if t.numColumns > 0 { + t.renderTitle(&out) + + // top-most border + t.renderRowsBorderTop(&out) + + // header rows + t.renderRowsHeader(&out) + + // (data) rows + t.renderRows(&out, t.rows, renderHint{}) + + // footer rows + t.renderRowsFooter(&out) + + // bottom-most border + t.renderRowsBorderBottom(&out) + + // caption + if t.caption != "" { + out.WriteRune('\n') + out.WriteString(t.caption) + } + } + return t.render(&out) +} + +func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxColumnLength int, hint renderHint) int { + numColumnsRendered := 1 + + // when working on the first column, and autoIndex is true, insert a new + // column with the row number on it. + if colIdx == 0 && t.autoIndex { + hintAutoIndex := hint + hintAutoIndex.isAutoIndexColumn = true + t.renderColumnAutoIndex(out, hintAutoIndex) + } + + // when working on column number 2 or more, render the column separator + if colIdx > 0 { + t.renderColumnSeparator(out, row, colIdx, hint) + } + + // extract the text, convert-case if not-empty and align horizontally + mergeVertically := t.shouldMergeCellsVertically(colIdx, hint) + var colStr string + if mergeVertically { + // leave colStr empty; align will expand the column as necessary + } else if colIdx < len(row) { + colStr = t.getFormat(hint).Apply(row[colIdx]) + } + align := t.getAlign(colIdx, hint) + + // if horizontal cell merges are enabled, look ahead and see how many cells + // have the same content and merge them all until a cell with a different + // content is found; override alignment to Center in this case + rowConfig := t.getRowConfig(hint) + if rowConfig.AutoMerge && !hint.isSeparatorRow { + // get the real row to consider all lines in each column instead of just + // looking at the current "line" + rowUnwrapped := t.getRow(hint.rowNumber-1, hint) + for idx := colIdx + 1; idx < len(rowUnwrapped); idx++ { + if rowUnwrapped[colIdx] != rowUnwrapped[idx] { + break + } + align = rowConfig.getAutoMergeAlign() + maxColumnLength += t.getMaxColumnLengthForMerging(idx) + numColumnsRendered++ + } + } + colStr = align.Apply(colStr, maxColumnLength) + + // pad both sides of the column + if !hint.isSeparatorRow || (hint.isSeparatorRow && mergeVertically) { + colStr = t.style.Box.PaddingLeft + colStr + t.style.Box.PaddingRight + } + + t.renderColumnColorized(out, colIdx, colStr, hint) + + return colIdx + numColumnsRendered +} + +func (t *Table) renderColumnAutoIndex(out *strings.Builder, hint renderHint) { + var outAutoIndex strings.Builder + outAutoIndex.Grow(t.maxColumnLengths[0]) + + if hint.isSeparatorRow { + numChars := t.autoIndexVIndexMaxLength + utf8.RuneCountInString(t.style.Box.PaddingLeft) + + utf8.RuneCountInString(t.style.Box.PaddingRight) + chars := t.style.Box.MiddleHorizontal + if hint.isAutoIndexColumn && hint.isHeaderOrFooterSeparator() { + chars = text.RepeatAndTrim(" ", len(t.style.Box.MiddleHorizontal)) + } + outAutoIndex.WriteString(text.RepeatAndTrim(chars, numChars)) + } else { + outAutoIndex.WriteString(t.style.Box.PaddingLeft) + rowNumStr := fmt.Sprint(hint.rowNumber) + if hint.isHeaderRow || hint.isFooterRow || hint.rowLineNumber > 1 { + rowNumStr = strings.Repeat(" ", t.autoIndexVIndexMaxLength) + } + outAutoIndex.WriteString(text.AlignRight.Apply(rowNumStr, t.autoIndexVIndexMaxLength)) + outAutoIndex.WriteString(t.style.Box.PaddingRight) + } + + if t.style.Color.IndexColumn != nil { + colors := t.style.Color.IndexColumn + if hint.isFooterRow { + colors = t.style.Color.Footer + } + out.WriteString(colors.Sprint(outAutoIndex.String())) + } else { + out.WriteString(outAutoIndex.String()) + } + hint.isAutoIndexColumn = true + t.renderColumnSeparator(out, rowStr{}, 0, hint) +} + +func (t *Table) renderColumnColorized(out *strings.Builder, colIdx int, colStr string, hint renderHint) { + colors := t.getColumnColors(colIdx, hint) + if colors != nil { + out.WriteString(colors.Sprint(colStr)) + } else if hint.isHeaderRow && t.style.Color.Header != nil { + out.WriteString(t.style.Color.Header.Sprint(colStr)) + } else if hint.isFooterRow && t.style.Color.Footer != nil { + out.WriteString(t.style.Color.Footer.Sprint(colStr)) + } else if hint.isRegularRow() { + if colIdx == t.indexColumn-1 && t.style.Color.IndexColumn != nil { + out.WriteString(t.style.Color.IndexColumn.Sprint(colStr)) + } else if hint.rowNumber%2 == 0 && t.style.Color.RowAlternate != nil { + out.WriteString(t.style.Color.RowAlternate.Sprint(colStr)) + } else if t.style.Color.Row != nil { + out.WriteString(t.style.Color.Row.Sprint(colStr)) + } else { + out.WriteString(colStr) + } + } else { + out.WriteString(colStr) + } +} + +func (t *Table) renderColumnSeparator(out *strings.Builder, row rowStr, colIdx int, hint renderHint) { + if t.style.Options.SeparateColumns { + separator := t.getColumnSeparator(row, colIdx, hint) + + colors := t.getSeparatorColors(hint) + if colors.EscapeSeq() != "" { + out.WriteString(colors.Sprint(separator)) + } else { + out.WriteString(separator) + } + } +} + +func (t *Table) renderLine(out *strings.Builder, row rowStr, hint renderHint) { + // if the output has content, it means that this call is working on line + // number 2 or more; separate them with a newline + if out.Len() > 0 { + out.WriteRune('\n') + } + + // use a brand-new strings.Builder if a row length limit has been set + var outLine *strings.Builder + if t.allowedRowLength > 0 { + outLine = &strings.Builder{} + } else { + outLine = out + } + // grow the strings.Builder to the maximum possible row length + outLine.Grow(t.maxRowLength) + + nextColIdx := 0 + t.renderMarginLeft(outLine, hint) + for colIdx, maxColumnLength := range t.maxColumnLengths { + if colIdx != nextColIdx { + continue + } + nextColIdx = t.renderColumn(outLine, row, colIdx, maxColumnLength, hint) + } + t.renderMarginRight(outLine, hint) + + // merge the strings.Builder objects if a new one was created earlier + if outLine != out { + t.renderLineMergeOutputs(out, outLine) + } + + // if a page size has been set, and said number of lines has already + // been rendered, and the header is not being rendered right now, render + // the header all over again with a spacing line + if hint.isRegularRow() { + t.numLinesRendered++ + if t.pageSize > 0 && t.numLinesRendered%t.pageSize == 0 && !hint.isLastLineOfLastRow() { + t.renderRowsFooter(out) + t.renderRowsBorderBottom(out) + out.WriteString(t.style.Box.PageSeparator) + t.renderRowsBorderTop(out) + t.renderRowsHeader(out) + } + } +} + +func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Builder) { + outLineStr := outLine.String() + if text.RuneWidthWithoutEscSequences(outLineStr) > t.allowedRowLength { + trimLength := t.allowedRowLength - utf8.RuneCountInString(t.style.Box.UnfinishedRow) + if trimLength > 0 { + out.WriteString(text.Trim(outLineStr, trimLength)) + out.WriteString(t.style.Box.UnfinishedRow) + } + } else { + out.WriteString(outLineStr) + } +} + +func (t *Table) renderMarginLeft(out *strings.Builder, hint renderHint) { + out.WriteString(t.style.Format.Direction.Modifier()) + if t.style.Options.DrawBorder { + border := t.getBorderLeft(hint) + colors := t.getBorderColors(hint) + if colors.EscapeSeq() != "" { + out.WriteString(colors.Sprint(border)) + } else { + out.WriteString(border) + } + } +} + +func (t *Table) renderMarginRight(out *strings.Builder, hint renderHint) { + if t.style.Options.DrawBorder { + border := t.getBorderRight(hint) + colors := t.getBorderColors(hint) + if colors.EscapeSeq() != "" { + out.WriteString(colors.Sprint(border)) + } else { + out.WriteString(border) + } + } +} + +func (t *Table) renderRow(out *strings.Builder, row rowStr, hint renderHint) { + if len(row) > 0 { + // fit every column into the allowedColumnLength/maxColumnLength limit + // and in the process find the max. number of lines in any column in + // this row + colMaxLines, rowWrapped := t.wrapRow(row) + + // if there is just 1 line in all columns, add the row as such; else + // split each column into individual lines and render them one-by-one + if colMaxLines == 1 { + hint.isLastLineOfRow = true + t.renderLine(out, rowWrapped, hint) + } else { + // convert one row into N # of rows based on colMaxLines + rowLines := make([]rowStr, len(row)) + for colIdx, colStr := range rowWrapped { + rowLines[colIdx] = t.getVAlign(colIdx, hint).ApplyStr(colStr, colMaxLines) + } + for colLineIdx := 0; colLineIdx < colMaxLines; colLineIdx++ { + rowLine := make(rowStr, len(rowLines)) + for colIdx, colLines := range rowLines { + rowLine[colIdx] = colLines[colLineIdx] + } + hint.isLastLineOfRow = colLineIdx == colMaxLines-1 + hint.rowLineNumber = colLineIdx + 1 + t.renderLine(out, rowLine, hint) + } + } + } +} + +func (t *Table) renderRowSeparator(out *strings.Builder, hint renderHint) { + if hint.isBorderTop || hint.isBorderBottom { + if !t.style.Options.DrawBorder { + return + } + } else if hint.isHeaderRow && !t.style.Options.SeparateHeader { + return + } else if hint.isFooterRow && !t.style.Options.SeparateFooter { + return + } + hint.isSeparatorRow = true + t.renderLine(out, t.rowSeparator, hint) +} + +func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint) { + for rowIdx, row := range rows { + hint.isFirstRow = rowIdx == 0 + hint.isLastRow = rowIdx == len(rows)-1 + hint.rowNumber = rowIdx + 1 + t.renderRow(out, row, hint) + + if (t.style.Options.SeparateRows && rowIdx < len(rows)-1) || // last row before footer + (t.separators[rowIdx] && rowIdx != len(rows)-1) { // manually added separator not after last row + hint.isFirstRow = false + t.renderRowSeparator(out, hint) + } + } +} + +func (t *Table) renderRowsBorderBottom(out *strings.Builder) { + if len(t.rowsFooter) > 0 { + t.renderRowSeparator(out, renderHint{isBorderBottom: true, isFooterRow: true, rowNumber: len(t.rowsFooter)}) + } else { + t.renderRowSeparator(out, renderHint{isBorderBottom: true, isFooterRow: false, rowNumber: len(t.rows)}) + } +} + +func (t *Table) renderRowsBorderTop(out *strings.Builder) { + if len(t.rowsHeader) > 0 || t.autoIndex { + t.renderRowSeparator(out, renderHint{isBorderTop: true, isHeaderRow: true, rowNumber: 0}) + } else { + t.renderRowSeparator(out, renderHint{isBorderTop: true, isHeaderRow: false, rowNumber: 0}) + } +} + +func (t *Table) renderRowsFooter(out *strings.Builder) { + if len(t.rowsFooter) > 0 { + t.renderRowSeparator(out, renderHint{ + isFooterRow: true, + isFirstRow: true, + isSeparatorRow: true, + }) + t.renderRows(out, t.rowsFooter, renderHint{isFooterRow: true}) + } +} + +func (t *Table) renderRowsHeader(out *strings.Builder) { + if len(t.rowsHeader) > 0 || t.autoIndex { + hintSeparator := renderHint{isHeaderRow: true, isLastRow: true, isSeparatorRow: true} + + if len(t.rowsHeader) > 0 { + t.renderRows(out, t.rowsHeader, renderHint{isHeaderRow: true}) + hintSeparator.rowNumber = len(t.rowsHeader) + } else if t.autoIndex { + t.renderRow(out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true}) + hintSeparator.rowNumber = 1 + } + t.renderRowSeparator(out, hintSeparator) + } +} + +func (t *Table) renderTitle(out *strings.Builder) { + if t.title != "" { + colors := t.style.Title.Colors + colorsBorder := t.getBorderColors(renderHint{isTitleRow: true}) + rowLength := t.maxRowLength + if t.allowedRowLength != 0 && t.allowedRowLength < rowLength { + rowLength = t.allowedRowLength + } + if t.style.Options.DrawBorder { + lenBorder := rowLength - text.RuneWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight) + out.WriteString(colorsBorder.Sprint(t.style.Box.TopLeft)) + out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(t.style.Box.MiddleHorizontal, lenBorder))) + out.WriteString(colorsBorder.Sprint(t.style.Box.TopRight)) + } + + lenText := rowLength - text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft+t.style.Box.PaddingRight) + if t.style.Options.DrawBorder { + lenText -= text.RuneWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right) + } + titleText := text.WrapText(t.title, lenText) + for _, titleLine := range strings.Split(titleText, "\n") { + t.renderTitleLine(out, lenText, titleLine, colors, colorsBorder) + } + } +} + +func (t *Table) renderTitleLine(out *strings.Builder, lenText int, titleLine string, colors text.Colors, colorsBorder text.Colors) { + titleLine = strings.TrimSpace(titleLine) + titleLine = t.style.Title.Format.Apply(titleLine) + titleLine = t.style.Title.Align.Apply(titleLine, lenText) + titleLine = t.style.Box.PaddingLeft + titleLine + t.style.Box.PaddingRight + + if out.Len() > 0 { + out.WriteRune('\n') + } + if t.style.Options.DrawBorder { + out.WriteString(colorsBorder.Sprint(t.style.Box.Left)) + } + out.WriteString(colors.Sprint(titleLine)) + if t.style.Options.DrawBorder { + out.WriteString(colorsBorder.Sprint(t.style.Box.Right)) + } +} diff --git a/helper/table/render_bidi_test.go b/helper/table/render_bidi_test.go new file mode 100644 index 0000000..9b0fd66 --- /dev/null +++ b/helper/table/render_bidi_test.go @@ -0,0 +1,53 @@ +package table + +import ( + "testing" + + "github.com/jedib0t/go-pretty/v6/text" +) + +func TestTable_Render_BiDiText(t *testing.T) { + table := Table{} + table.AppendHeader(Row{"תאריך", "סכום", "מחלקה", "תגים"}) + table.AppendRow(Row{"2020-01-01", 5.0, "מחלקה1", []string{"תג1", "תג2"}}) + table.AppendRow(Row{"2021-02-01", 5.0, "מחלקה1", []string{"תג1"}}) + table.AppendRow(Row{"2022-03-01", 5.0, "מחלקה2", []string{"תג1"}}) + table.AppendFooter(Row{"סהכ", 30}) + table.SetAutoIndex(true) + + //table.Style().Format.Direction = text.Default + compareOutput(t, table.Render(), ` ++---+------------+------+--------+-----------+ +| | תאריך | סכום | מחלקה | תגים | ++---+------------+------+--------+-----------+ +| 1 | 2020-01-01 | 5 | מחלקה1 | [תג1 תג2] | +| 2 | 2021-02-01 | 5 | מחלקה1 | [תג1] | +| 3 | 2022-03-01 | 5 | מחלקה2 | [תג1] | ++---+------------+------+--------+-----------+ +| | סהכ | 30 | | | ++---+------------+------+--------+-----------+`) + + table.Style().Format.Direction = text.LeftToRight + compareOutput(t, table.Render(), ` +‪+---+------------+------+--------+-----------+ +‪| | ‪תאריך | ‪סכום | ‪מחלקה | ‪תגים | +‪+---+------------+------+--------+-----------+ +‪| 1 | ‪2020-01-01 | ‪5 | ‪מחלקה1 | ‪[תג1 תג2] | +‪| 2 | ‪2021-02-01 | ‪5 | ‪מחלקה1 | ‪[תג1] | +‪| 3 | ‪2022-03-01 | ‪5 | ‪מחלקה2 | ‪[תג1] | +‪+---+------------+------+--------+-----------+ +‪| | ‪סהכ | ‪30 | | | +‪+---+------------+------+--------+-----------+`) + + table.Style().Format.Direction = text.RightToLeft + compareOutput(t, table.Render(), ` +‫+---+------------+------+--------+-----------+ +‫| | ‫תאריך | ‫סכום | ‫מחלקה | ‫תגים | +‫+---+------------+------+--------+-----------+ +‫| 1 | ‫2020-01-01 | ‫5 | ‫מחלקה1 | ‫[תג1 תג2] | +‫| 2 | ‫2021-02-01 | ‫5 | ‫מחלקה1 | ‫[תג1] | +‫| 3 | ‫2022-03-01 | ‫5 | ‫מחלקה2 | ‫[תג1] | +‫+---+------------+------+--------+-----------+ +‫| | ‫סהכ | ‫30 | | | +‫+---+------------+------+--------+-----------+`) +} diff --git a/helper/table/render_csv.go b/helper/table/render_csv.go new file mode 100644 index 0000000..07da673 --- /dev/null +++ b/helper/table/render_csv.go @@ -0,0 +1,81 @@ +package table + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// RenderCSV renders the Table in CSV format. Example: +// #,First Name,Last Name,Salary, +// 1,Arya,Stark,3000, +// 20,Jon,Snow,2000,"You know nothing\, Jon Snow!" +// 300,Tyrion,Lannister,5000, +// ,,Total,10000, +func (t *Table) RenderCSV() string { + t.initForRender() + + var out strings.Builder + if t.numColumns > 0 { + if t.title != "" { + out.WriteString(t.title) + } + if t.autoIndex && len(t.rowsHeader) == 0 { + t.csvRenderRow(&out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true}) + } + t.csvRenderRows(&out, t.rowsHeader, renderHint{isHeaderRow: true}) + t.csvRenderRows(&out, t.rows, renderHint{}) + t.csvRenderRows(&out, t.rowsFooter, renderHint{isFooterRow: true}) + if t.caption != "" { + out.WriteRune('\n') + out.WriteString(t.caption) + } + } + return t.render(&out) +} + +func (t *Table) csvFixCommas(str string) string { + return strings.Replace(str, ",", "\\,", -1) +} + +func (t *Table) csvFixDoubleQuotes(str string) string { + return strings.Replace(str, "\"", "\\\"", -1) +} + +func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint) { + // when working on line number 2 or more, insert a newline first + if out.Len() > 0 { + out.WriteRune('\n') + } + + // generate the columns to render in CSV format and append to "out" + for colIdx, colStr := range row { + // auto-index column + if colIdx == 0 && t.autoIndex { + if hint.isRegularRow() { + out.WriteString(fmt.Sprint(hint.rowNumber)) + } + out.WriteRune(',') + } + if colIdx > 0 { + out.WriteRune(',') + } + if strings.ContainsAny(colStr, "\",\n") { + out.WriteRune('"') + out.WriteString(t.csvFixCommas(t.csvFixDoubleQuotes(colStr))) + out.WriteRune('"') + } else if utf8.RuneCountInString(colStr) > 0 { + out.WriteString(colStr) + } + } + for colIdx := len(row); colIdx < t.numColumns; colIdx++ { + out.WriteRune(',') + } +} + +func (t *Table) csvRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) { + for rowIdx, row := range rows { + hint.rowNumber = rowIdx + 1 + t.csvRenderRow(out, row, hint) + } +} diff --git a/helper/table/render_csv_test.go b/helper/table/render_csv_test.go new file mode 100644 index 0000000..fc74c42 --- /dev/null +++ b/helper/table/render_csv_test.go @@ -0,0 +1,139 @@ +package table + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTable_RenderCSV(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendRow(testRowTabs) + tw.AppendFooter(testFooter) + tw.SetCaption(testCaption) + tw.SetTitle(testTitle1) + + compareOutput(t, tw.RenderCSV(), ` +Game of Thrones +#,First Name,Last Name,Salary, +1,Arya,Stark,3000, +20,Jon,Snow,2000,"You know nothing\, Jon Snow!" +300,Tyrion,Lannister,5000, +0,Winter,Is,0,"Coming. +The North Remembers! +This is known." +0,Valar,Morghulis,0,Faceless Men +,,Total,10000, +A Song of Ice and Fire`) +} + +func TestTable_RenderCSV_AutoIndex(t *testing.T) { + tw := NewWriter() + for rowIdx := 0; rowIdx < 10; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1) + } + tw.AppendRow(row) + } + for rowIdx := 0; rowIdx < 1; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = AutoIndexColumnID(colIdx) + "F" + } + tw.AppendFooter(row) + } + tw.SetAutoIndex(true) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.RenderCSV(), ` +,A,B,C,D,E,F,G,H,I,J +1,A1,B1,C1,D1,E1,F1,G1,H1,I1,J1 +2,A2,B2,C2,D2,E2,F2,G2,H2,I2,J2 +3,A3,B3,C3,D3,E3,F3,G3,H3,I3,J3 +4,A4,B4,C4,D4,E4,F4,G4,H4,I4,J4 +5,A5,B5,C5,D5,E5,F5,G5,H5,I5,J5 +6,A6,B6,C6,D6,E6,F6,G6,H6,I6,J6 +7,A7,B7,C7,D7,E7,F7,G7,H7,I7,J7 +8,A8,B8,C8,D8,E8,F8,G8,H8,I8,J8 +9,A9,B9,C9,D9,E9,F9,G9,H9,I9,J9 +10,A10,B10,C10,D10,E10,F10,G10,H10,I10,J10 +,AF,BF,CF,DF,EF,FF,GF,HF,IF,JF`) +} + +func TestTable_RenderCSV_Empty(t *testing.T) { + tw := NewWriter() + assert.Empty(t, tw.RenderCSV()) +} + +func TestTable_RenderCSV_HiddenColumns(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + + // ensure sorting is done before hiding the columns + tw.SortBy([]SortBy{ + {Name: "Salary", Mode: DscNumeric}, + }) + + t.Run("every column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4})) + + compareOutput(t, tw.RenderCSV(), "") + }) + + t.Run("first column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0})) + + compareOutput(t, tw.RenderCSV(), ` +First Name,Last Name,Salary, +>>Tyrion,Lannister<<,5013, +>>Arya,Stark<<,3013, +>>Jon,Snow<<,2013,"~You know nothing\, Jon Snow!~" +,Total,10000,`) + }) + + t.Run("column hidden in the middle", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1})) + + compareOutput(t, tw.RenderCSV(), ` +#,Last Name,Salary, +307,Lannister<<,5013, +8,Stark<<,3013, +27,Snow<<,2013,"~You know nothing\, Jon Snow!~" +,Total,10000,`) + }) + + t.Run("last column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4})) + + compareOutput(t, tw.RenderCSV(), ` +#,First Name,Last Name,Salary +307,>>Tyrion,Lannister<<,5013 +8,>>Arya,Stark<<,3013 +27,>>Jon,Snow<<,2013 +,,Total,10000`) + }) +} + +func TestTable_RenderCSV_Sorted(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(Row{11, "Sansa", "Stark", 6000}) + tw.AppendFooter(testFooter) + tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}}) + + compareOutput(t, tw.RenderCSV(), ` +#,First Name,Last Name,Salary, +300,Tyrion,Lannister,5000, +20,Jon,Snow,2000,"You know nothing\, Jon Snow!" +1,Arya,Stark,3000, +11,Sansa,Stark,6000, +,,Total,10000,`) +} diff --git a/helper/table/render_hint.go b/helper/table/render_hint.go new file mode 100644 index 0000000..e46cdc8 --- /dev/null +++ b/helper/table/render_hint.go @@ -0,0 +1,39 @@ +package table + +// renderHint has hints for the Render*() logic +type renderHint struct { + isAutoIndexColumn bool // auto-index column? + isAutoIndexRow bool // auto-index row? + isBorderBottom bool // bottom-border? + isBorderTop bool // top-border? + isFirstRow bool // first-row of header/footer/regular-rows? + isFooterRow bool // footer row? + isHeaderRow bool // header row? + isLastLineOfRow bool // last-line of the current row? + isLastRow bool // last-row of header/footer/regular-rows? + isSeparatorRow bool // separator row? + isTitleRow bool // title row? + rowLineNumber int // the line number for a multi-line row + rowNumber int // the row number/index +} + +func (h *renderHint) isBorderOrSeparator() bool { + return h.isBorderTop || h.isSeparatorRow || h.isBorderBottom +} + +func (h *renderHint) isRegularRow() bool { + return !h.isHeaderRow && !h.isFooterRow +} + +func (h *renderHint) isRegularNonSeparatorRow() bool { + return !h.isHeaderRow && !h.isFooterRow && !h.isSeparatorRow +} + +func (h *renderHint) isHeaderOrFooterSeparator() bool { + return h.isSeparatorRow && !h.isBorderBottom && !h.isBorderTop && + ((h.isHeaderRow && !h.isLastRow) || (h.isFooterRow && (!h.isFirstRow || h.rowNumber > 0))) +} + +func (h *renderHint) isLastLineOfLastRow() bool { + return h.isLastLineOfRow && h.isLastRow +} diff --git a/helper/table/render_html.go b/helper/table/render_html.go new file mode 100644 index 0000000..10a7325 --- /dev/null +++ b/helper/table/render_html.go @@ -0,0 +1,244 @@ +package table + +import ( + "fmt" + "html" + "strings" +) + +const ( + // DefaultHTMLCSSClass stores the css-class to use when none-provided via + // SetHTMLCSSClass(cssClass string). + DefaultHTMLCSSClass = "go-pretty-table" +) + +// RenderHTML renders the Table in HTML format. Example: +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +//
#First NameLast NameSalary 
1AryaStark3000 
20JonSnow2000You know nothing, Jon Snow!
300TyrionLannister5000 
  Total10000 
+func (t *Table) RenderHTML() string { + t.initForRender() + + var out strings.Builder + if t.numColumns > 0 { + out.WriteString("\n") + t.htmlRenderTitle(&out) + t.htmlRenderRowsHeader(&out) + t.htmlRenderRows(&out, t.rows, renderHint{}) + t.htmlRenderRowsFooter(&out) + t.htmlRenderCaption(&out) + out.WriteString("
") + } + return t.render(&out) +} + +func (t *Table) htmlGetColStrAndTag(row rowStr, colIdx int, hint renderHint) (string, string) { + // get the column contents + var colStr string + if colIdx < len(row) { + colStr = row[colIdx] + } + + // header uses "th" instead of "td" + colTagName := "td" + if hint.isHeaderRow { + colTagName = "th" + } + + return colStr, colTagName +} + +func (t *Table) htmlRenderCaption(out *strings.Builder) { + if t.caption != "" { + out.WriteString(" ") + out.WriteString(t.caption) + out.WriteString("\n") + } +} + +func (t *Table) htmlRenderColumn(out *strings.Builder, colStr string) { + if t.style.HTML.EscapeText { + colStr = html.EscapeString(colStr) + } + if t.style.HTML.Newline != "\n" { + colStr = strings.Replace(colStr, "\n", t.style.HTML.Newline, -1) + } + out.WriteString(colStr) +} + +func (t *Table) htmlRenderColumnAttributes(out *strings.Builder, row rowStr, colIdx int, hint renderHint) { + // determine the HTML "align"/"valign" property values + align := t.getAlign(colIdx, hint).HTMLProperty() + vAlign := t.getVAlign(colIdx, hint).HTMLProperty() + // determine the HTML "class" property values for the colors + class := t.getColumnColors(colIdx, hint).HTMLProperty() + + if align != "" { + out.WriteRune(' ') + out.WriteString(align) + } + if class != "" { + out.WriteRune(' ') + out.WriteString(class) + } + if vAlign != "" { + out.WriteRune(' ') + out.WriteString(vAlign) + } +} + +func (t *Table) htmlRenderColumnAutoIndex(out *strings.Builder, hint renderHint) { + if hint.isHeaderRow { + out.WriteString(" ") + out.WriteString(t.style.HTML.EmptyColumn) + out.WriteString("\n") + } else if hint.isFooterRow { + out.WriteString(" ") + out.WriteString(t.style.HTML.EmptyColumn) + out.WriteString("\n") + } else { + out.WriteString(" ") + out.WriteString(fmt.Sprint(hint.rowNumber)) + out.WriteString("\n") + } +} + +func (t *Table) htmlRenderRow(out *strings.Builder, row rowStr, hint renderHint) { + out.WriteString(" \n") + for colIdx := 0; colIdx < t.numColumns; colIdx++ { + // auto-index column + if colIdx == 0 && t.autoIndex { + t.htmlRenderColumnAutoIndex(out, hint) + } + + colStr, colTagName := t.htmlGetColStrAndTag(row, colIdx, hint) + // write the row + out.WriteString(" <") + out.WriteString(colTagName) + t.htmlRenderColumnAttributes(out, row, colIdx, hint) + out.WriteString(">") + if len(colStr) == 0 { + out.WriteString(t.style.HTML.EmptyColumn) + } else { + t.htmlRenderColumn(out, colStr) + } + out.WriteString("\n") + } + out.WriteString(" \n") +} + +func (t *Table) htmlRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) { + if len(rows) > 0 { + // determine that tag to use based on the type of the row + rowsTag := "tbody" + if hint.isHeaderRow { + rowsTag = "thead" + } else if hint.isFooterRow { + rowsTag = "tfoot" + } + + var renderedTagOpen, shouldRenderTagClose bool + for idx, row := range rows { + hint.rowNumber = idx + 1 + if len(row) > 0 { + if !renderedTagOpen { + out.WriteString(" <") + out.WriteString(rowsTag) + out.WriteString(">\n") + renderedTagOpen = true + } + t.htmlRenderRow(out, row, hint) + shouldRenderTagClose = true + } + } + if shouldRenderTagClose { + out.WriteString(" \n") + } + } +} + +func (t *Table) htmlRenderRowsFooter(out *strings.Builder) { + if len(t.rowsFooter) > 0 { + t.htmlRenderRows(out, t.rowsFooter, renderHint{isFooterRow: true}) + } +} + +func (t *Table) htmlRenderRowsHeader(out *strings.Builder) { + if len(t.rowsHeader) > 0 { + t.htmlRenderRows(out, t.rowsHeader, renderHint{isHeaderRow: true}) + } else if t.autoIndex { + hint := renderHint{isAutoIndexRow: true, isHeaderRow: true} + t.htmlRenderRows(out, []rowStr{t.getAutoIndexColumnIDs()}, hint) + } +} + +func (t *Table) htmlRenderTitle(out *strings.Builder) { + if t.title != "" { + align := t.style.Title.Align.HTMLProperty() + colors := t.style.Title.Colors.HTMLProperty() + title := t.style.Title.Format.Apply(t.title) + + out.WriteString(" ') + out.WriteString(title) + out.WriteString("\n") + } +} diff --git a/helper/table/render_html_test.go b/helper/table/render_html_test.go new file mode 100644 index 0000000..ec507ee --- /dev/null +++ b/helper/table/render_html_test.go @@ -0,0 +1,519 @@ +package table + +import ( + "fmt" + "testing" + + "github.com/jedib0t/go-pretty/v6/text" + "github.com/stretchr/testify/assert" +) + +func TestTable_RenderHTML(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetColumnConfigs([]ColumnConfig{ + {Name: "Salary", VAlign: text.VAlignBottom}, + {Number: 5, VAlign: text.VAlignBottom}, + }) + tw.SetTitle(testTitle1) + tw.SetCaption(testCaption) + tw.Style().Title = TitleOptions{ + Align: text.AlignLeft, + Colors: text.Colors{text.BgBlack, text.Bold, text.FgHiBlue}, + Format: text.FormatTitle, + } + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Game Of Thrones
#First NameLast NameSalary 
1AryaStark3000 
20JonSnow2000You know nothing, Jon Snow!
300TyrionLannister5000 
0WinterIs0Coming.
The North Remembers!
This is known.
  Total10000 
A Song of Ice and Fire
`) +} + +func TestTable_RenderHTML_AutoIndex(t *testing.T) { + tw := NewWriter() + for rowIdx := 0; rowIdx < 3; rowIdx++ { + row := make(Row, 3) + for colIdx := 0; colIdx < 3; colIdx++ { + row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1) + } + tw.AppendRow(row) + } + for rowIdx := 0; rowIdx < 1; rowIdx++ { + row := make(Row, 3) + for colIdx := 0; colIdx < 3; colIdx++ { + row[colIdx] = AutoIndexColumnID(colIdx) + "F" + } + tw.AppendFooter(row) + } + tw.SetOutputMirror(nil) + tw.SetAutoIndex(true) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 ABC
1A1B1C1
2A2B2C2
3A3B3C3
 AFBFCF
`) +} + +func TestTable_RenderHTML_Colored(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetCaption(testCaption) + tw.SetTitle(testTitle1) + tw.Style().HTML.CSSClass = "go-pretty-table-colored" + colorsBlackOnWhite := text.Colors{text.BgWhite, text.FgBlack} + tw.SetColumnConfigs([]ColumnConfig{ + { + Name: "#", + Colors: text.Colors{text.Bold}, + ColorsHeader: colorsBlackOnWhite, + }, { + Name: "First Name", + Colors: text.Colors{text.FgCyan}, + ColorsHeader: colorsBlackOnWhite, + }, { + Name: "Last Name", + Colors: text.Colors{text.FgMagenta}, + ColorsHeader: colorsBlackOnWhite, + ColorsFooter: colorsBlackOnWhite, + }, { + Name: "Salary", + Colors: text.Colors{text.FgYellow}, + ColorsHeader: colorsBlackOnWhite, + ColorsFooter: colorsBlackOnWhite, + VAlign: text.VAlignBottom, + }, { + Number: 5, + Colors: text.Colors{text.FgBlack}, + ColorsHeader: colorsBlackOnWhite, + VAlign: text.VAlignBottom, + }, + }) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Game of Thrones
#First NameLast NameSalary 
1AryaStark3000 
20JonSnow2000You know nothing, Jon Snow!
300TyrionLannister5000 
0WinterIs0Coming.
The North Remembers!
This is known.
  Total10000 
A Song of Ice and Fire
`) +} + +func TestTable_RenderHTML_CustomStyle(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRow(Row{1, "Arya", "Stark", 3000, "Not today."}) + tw.AppendRow(Row{1, "Jon", "Snow", 2000, "You know\nnothing,\nJon Snow!"}) + tw.AppendRow(Row{300, "Tyrion", "Lannister", 5000}) + tw.AppendFooter(testFooter) + tw.SetAutoIndex(true) + tw.Style().HTML = HTMLOptions{ + CSSClass: "game-of-thrones", + EmptyColumn: " ", + EscapeText: false, + Newline: "", + } + tw.SetOutputMirror(nil) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 #First NameLast NameSalary 
11AryaStark3000Not today.
21JonSnow2000You knownothing,Jon Snow!
3300TyrionLannister5000 
   Total10000 
`) +} + +func TestTable_RenderHTML_Empty(t *testing.T) { + tw := NewWriter() + assert.Empty(t, tw.RenderHTML()) +} + +func TestTable_RenderHTML_HiddenColumns(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + + // ensure sorting is done before hiding the columns + tw.SortBy([]SortBy{ + {Name: "Salary", Mode: DscNumeric}, + }) + + t.Run("every column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4})) + + compareOutput(t, tw.RenderHTML(), "") + }) + + t.Run("first column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0})) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
First NameLast NameSalary 
>>TyrionLannister<<5013 
>>AryaStark<<3013 
>>JonSnow<<2013~You know nothing, Jon Snow!~
 Total10000 
`) + }) + + t.Run("column hidden in the middle", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1})) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Last NameSalary 
307Lannister<<5013 
8Stark<<3013 
27Snow<<2013~You know nothing, Jon Snow!~
 Total10000 
`) + }) + + t.Run("last column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4})) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#First NameLast NameSalary
307>>TyrionLannister<<5013
8>>AryaStark<<3013
27>>JonSnow<<2013
  Total10000
`) + }) +} + +func TestTable_RenderHTML_Sorted(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(Row{11, "Sansa", "Stark", 6000}) + tw.AppendFooter(testFooter) + tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}}) + + compareOutput(t, tw.RenderHTML(), ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#First NameLast NameSalary 
300TyrionLannister5000 
20JonSnow2000You know nothing, Jon Snow!
1AryaStark3000 
11SansaStark6000 
  Total10000 
`) +} diff --git a/helper/table/render_init.go b/helper/table/render_init.go new file mode 100644 index 0000000..ebe4f5a --- /dev/null +++ b/helper/table/render_init.go @@ -0,0 +1,294 @@ +package table + +import ( + "fmt" + "strings" + + "git.hexq.cn/tiglog/golib/helper/text" +) + +func (t *Table) analyzeAndStringify(row Row, hint renderHint) rowStr { + // update t.numColumns if this row is the longest seen till now + if len(row) > t.numColumns { + // init the slice for the first time; and pad it the rest of the time + if t.numColumns == 0 { + t.columnIsNonNumeric = make([]bool, len(row)) + } else { + t.columnIsNonNumeric = append(t.columnIsNonNumeric, make([]bool, len(row)-t.numColumns)...) + } + // update t.numColumns + t.numColumns = len(row) + } + + // convert each column to string and figure out if it has non-numeric data + rowOut := make(rowStr, len(row)) + for colIdx, col := range row { + // if the column is not a number, keep track of it + if !hint.isHeaderRow && !hint.isFooterRow && !t.columnIsNonNumeric[colIdx] && !isNumber(col) { + t.columnIsNonNumeric[colIdx] = true + } + + rowOut[colIdx] = t.analyzeAndStringifyColumn(colIdx, col, hint) + } + return rowOut +} + +func (t *Table) analyzeAndStringifyColumn(colIdx int, col interface{}, hint renderHint) string { + // convert to a string and store it in the row + var colStr string + if transformer := t.getColumnTransformer(colIdx, hint); transformer != nil { + colStr = transformer(col) + } else if colStrVal, ok := col.(string); ok { + colStr = colStrVal + } else { + colStr = fmt.Sprint(col) + } + if strings.Contains(colStr, "\t") { + colStr = strings.Replace(colStr, "\t", " ", -1) + } + if strings.Contains(colStr, "\r") { + colStr = strings.Replace(colStr, "\r", "", -1) + } + return fmt.Sprintf("%s%s", t.style.Format.Direction.Modifier(), colStr) +} + +func (t *Table) extractMaxColumnLengths(rows []rowStr, hint renderHint) { + for rowIdx, row := range rows { + hint.rowNumber = rowIdx + 1 + t.extractMaxColumnLengthsFromRow(row, t.getMergedColumnIndices(row, hint)) + } +} + +func (t *Table) extractMaxColumnLengthsFromRow(row rowStr, mci mergedColumnIndices) { + for colIdx, colStr := range row { + longestLineLen := text.LongestLineLen(colStr) + maxColWidth := t.getColumnWidthMax(colIdx) + if maxColWidth > 0 && maxColWidth < longestLineLen { + longestLineLen = maxColWidth + } + mergedColumnsLength := mci.mergedLength(colIdx, t.maxColumnLengths) + if longestLineLen > mergedColumnsLength { + if mergedColumnsLength > 0 { + t.extractMaxColumnLengthsFromRowForMergedColumns(colIdx, longestLineLen, mci) + } else { + t.maxColumnLengths[colIdx] = longestLineLen + } + } else if maxColWidth == 0 && longestLineLen > t.maxColumnLengths[colIdx] { + t.maxColumnLengths[colIdx] = longestLineLen + } + } +} + +func (t *Table) extractMaxColumnLengthsFromRowForMergedColumns(colIdx int, mergedColumnLength int, mci mergedColumnIndices) { + numMergedColumns := mci.len(colIdx) + mergedColumnLength -= (numMergedColumns - 1) * text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) + maxLengthSplitAcrossColumns := mergedColumnLength / numMergedColumns + if maxLengthSplitAcrossColumns > t.maxColumnLengths[colIdx] { + t.maxColumnLengths[colIdx] = maxLengthSplitAcrossColumns + } + for otherColIdx := range mci[colIdx] { + if maxLengthSplitAcrossColumns > t.maxColumnLengths[otherColIdx] { + t.maxColumnLengths[otherColIdx] = maxLengthSplitAcrossColumns + } + } +} + +func (t *Table) initForRender() { + // pick a default style if none was set until now + t.Style() + + // reset rendering state + t.reset() + + // initialize the column configs and normalize them + t.initForRenderColumnConfigs() + + // initialize and stringify all the raw rows + t.initForRenderRows() + + // find the longest continuous line in each column + t.initForRenderColumnLengths() + + // generate a separator row and calculate maximum row length + t.initForRenderRowSeparator() + + // reset the counter for the number of lines rendered + t.numLinesRendered = 0 +} + +func (t *Table) initForRenderColumnConfigs() { + t.columnConfigMap = map[int]ColumnConfig{} + for _, colCfg := range t.columnConfigs { + // find the column number if none provided; this logic can work only if + // a header row is present and has a column with the given name + if colCfg.Number == 0 { + for _, row := range t.rowsHeaderRaw { + colCfg.Number = row.findColumnNumber(colCfg.Name) + if colCfg.Number > 0 { + break + } + } + } + if colCfg.Number > 0 { + t.columnConfigMap[colCfg.Number-1] = colCfg + } + } +} + +func (t *Table) initForRenderColumnLengths() { + t.maxColumnLengths = make([]int, t.numColumns) + t.extractMaxColumnLengths(t.rowsHeader, renderHint{isHeaderRow: true}) + t.extractMaxColumnLengths(t.rows, renderHint{}) + t.extractMaxColumnLengths(t.rowsFooter, renderHint{isFooterRow: true}) + + // increase the column lengths if any are under the limits + for colIdx := range t.maxColumnLengths { + minWidth := t.getColumnWidthMin(colIdx) + if minWidth > 0 && t.maxColumnLengths[colIdx] < minWidth { + t.maxColumnLengths[colIdx] = minWidth + } + } +} + +func (t *Table) initForRenderHideColumns() { + if !t.hasHiddenColumns() { + return + } + colIdxMap := t.hideColumns() + + // re-create columnIsNonNumeric with new column indices + columnIsNonNumeric := make([]bool, t.numColumns) + for oldColIdx, nonNumeric := range t.columnIsNonNumeric { + if newColIdx, ok := colIdxMap[oldColIdx]; ok { + columnIsNonNumeric[newColIdx] = nonNumeric + } + } + t.columnIsNonNumeric = columnIsNonNumeric + + // re-create columnConfigMap with new column indices + columnConfigMap := make(map[int]ColumnConfig) + for oldColIdx, cc := range t.columnConfigMap { + if newColIdx, ok := colIdxMap[oldColIdx]; ok { + columnConfigMap[newColIdx] = cc + } + } + t.columnConfigMap = columnConfigMap +} + +func (t *Table) initForRenderRows() { + // auto-index: calc the index column's max length + t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRaw))) + + // stringify all the rows to make it easy to render + if t.rowPainter != nil { + t.rowsColors = make([]text.Colors, len(t.rowsRaw)) + } + t.rows = t.initForRenderRowsStringify(t.rowsRaw, renderHint{}) + t.rowsFooter = t.initForRenderRowsStringify(t.rowsFooterRaw, renderHint{isFooterRow: true}) + t.rowsHeader = t.initForRenderRowsStringify(t.rowsHeaderRaw, renderHint{isHeaderRow: true}) + + // sort the rows as requested + t.initForRenderSortRows() + + // suppress columns without any content + t.initForRenderSuppressColumns() + + // strip out hidden columns + t.initForRenderHideColumns() +} + +func (t *Table) initForRenderRowsStringify(rows []Row, hint renderHint) []rowStr { + rowsStr := make([]rowStr, len(rows)) + for idx, row := range rows { + if t.rowPainter != nil && hint.isRegularRow() { + t.rowsColors[idx] = t.rowPainter(row) + } + rowsStr[idx] = t.analyzeAndStringify(row, hint) + } + return rowsStr +} + +func (t *Table) initForRenderRowSeparator() { + t.maxRowLength = 0 + if t.autoIndex { + t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft) + t.maxRowLength += len(fmt.Sprint(len(t.rows))) + t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingRight) + if t.style.Options.SeparateColumns { + t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) + } + } + if t.style.Options.SeparateColumns { + t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) * (t.numColumns - 1) + } + t.rowSeparator = make(rowStr, t.numColumns) + for colIdx, maxColumnLength := range t.maxColumnLengths { + maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight) + t.maxRowLength += maxColumnLength + t.rowSeparator[colIdx] = text.RepeatAndTrim(t.style.Box.MiddleHorizontal, maxColumnLength) + } + if t.style.Options.DrawBorder { + t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right) + } +} + +func (t *Table) initForRenderSortRows() { + if len(t.sortBy) == 0 { + return + } + + // sort the rows + sortedRowIndices := t.getSortedRowIndices() + sortedRows := make([]rowStr, len(t.rows)) + for idx := range t.rows { + sortedRows[idx] = t.rows[sortedRowIndices[idx]] + } + t.rows = sortedRows + + // sort the rowsColors + if len(t.rowsColors) > 0 { + sortedRowsColors := make([]text.Colors, len(t.rows)) + for idx := range t.rows { + sortedRowsColors[idx] = t.rowsColors[sortedRowIndices[idx]] + } + t.rowsColors = sortedRowsColors + } +} + +func (t *Table) initForRenderSuppressColumns() { + shouldSuppressColumn := func(colIdx int) bool { + for _, row := range t.rows { + if colIdx < len(row) && row[colIdx] != "" { + return false + } + } + return true + } + + if t.suppressEmptyColumns { + for colIdx := 0; colIdx < t.numColumns; colIdx++ { + if shouldSuppressColumn(colIdx) { + cc := t.columnConfigMap[colIdx] + cc.Hidden = true + t.columnConfigMap[colIdx] = cc + } + } + } +} + +// reset initializes all the variables used to maintain rendering information +// that are written to in this file +func (t *Table) reset() { + t.autoIndexVIndexMaxLength = 0 + t.columnConfigMap = nil + t.columnIsNonNumeric = nil + t.maxColumnLengths = nil + t.maxRowLength = 0 + t.numColumns = 0 + t.numLinesRendered = 0 + t.rowSeparator = nil + t.rows = nil + t.rowsColors = nil + t.rowsFooter = nil + t.rowsHeader = nil +} diff --git a/helper/table/render_markdown.go b/helper/table/render_markdown.go new file mode 100644 index 0000000..127adcd --- /dev/null +++ b/helper/table/render_markdown.go @@ -0,0 +1,113 @@ +package table + +import ( + "fmt" + "strings" +) + +// RenderMarkdown renders the Table in Markdown format. Example: +// | # | First Name | Last Name | Salary | | +// | ---:| --- | --- | ---:| --- | +// | 1 | Arya | Stark | 3000 | | +// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | +// | 300 | Tyrion | Lannister | 5000 | | +// | | | Total | 10000 | | +func (t *Table) RenderMarkdown() string { + t.initForRender() + + var out strings.Builder + if t.numColumns > 0 { + t.markdownRenderTitle(&out) + t.markdownRenderRowsHeader(&out) + t.markdownRenderRows(&out, t.rows, renderHint{}) + t.markdownRenderRowsFooter(&out) + t.markdownRenderCaption(&out) + } + return t.render(&out) +} + +func (t *Table) markdownRenderCaption(out *strings.Builder) { + if t.caption != "" { + out.WriteRune('\n') + out.WriteRune('_') + out.WriteString(t.caption) + out.WriteRune('_') + } +} + +func (t *Table) markdownRenderRow(out *strings.Builder, row rowStr, hint renderHint) { + // when working on line number 2 or more, insert a newline first + if out.Len() > 0 { + out.WriteRune('\n') + } + + // render each column up to the max. columns seen in all the rows + out.WriteRune('|') + for colIdx := 0; colIdx < t.numColumns; colIdx++ { + t.markdownRenderRowAutoIndex(out, colIdx, hint) + + if hint.isSeparatorRow { + out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty()) + } else { + var colStr string + if colIdx < len(row) { + colStr = row[colIdx] + } + out.WriteRune(' ') + if strings.Contains(colStr, "|") { + colStr = strings.Replace(colStr, "|", "\\|", -1) + } + if strings.Contains(colStr, "\n") { + colStr = strings.Replace(colStr, "\n", "
", -1) + } + out.WriteString(colStr) + out.WriteRune(' ') + } + out.WriteRune('|') + } +} + +func (t *Table) markdownRenderRowAutoIndex(out *strings.Builder, colIdx int, hint renderHint) { + if colIdx == 0 && t.autoIndex { + out.WriteRune(' ') + if hint.isSeparatorRow { + out.WriteString("---:") + } else if hint.isRegularRow() { + out.WriteString(fmt.Sprintf("%d ", hint.rowNumber)) + } + out.WriteRune('|') + } +} + +func (t *Table) markdownRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) { + if len(rows) > 0 { + for idx, row := range rows { + hint.rowNumber = idx + 1 + t.markdownRenderRow(out, row, hint) + + if idx == len(rows)-1 && hint.isHeaderRow { + t.markdownRenderRow(out, t.rowSeparator, renderHint{isSeparatorRow: true}) + } + } + } +} + +func (t *Table) markdownRenderRowsFooter(out *strings.Builder) { + t.markdownRenderRows(out, t.rowsFooter, renderHint{isFooterRow: true}) + +} + +func (t *Table) markdownRenderRowsHeader(out *strings.Builder) { + if len(t.rowsHeader) > 0 { + t.markdownRenderRows(out, t.rowsHeader, renderHint{isHeaderRow: true}) + } else if t.autoIndex { + t.markdownRenderRows(out, []rowStr{t.getAutoIndexColumnIDs()}, renderHint{isAutoIndexRow: true, isHeaderRow: true}) + } +} + +func (t *Table) markdownRenderTitle(out *strings.Builder) { + if t.title != "" { + out.WriteString("# ") + out.WriteString(t.title) + } +} diff --git a/helper/table/render_markdown_test.go b/helper/table/render_markdown_test.go new file mode 100644 index 0000000..cb8e8de --- /dev/null +++ b/helper/table/render_markdown_test.go @@ -0,0 +1,143 @@ +package table + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTable_RenderMarkdown(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowNewLines) + tw.AppendRow(testRowPipes) + tw.AppendFooter(testFooter) + tw.SetCaption(testCaption) + tw.SetTitle(testTitle1) + + compareOutput(t, tw.RenderMarkdown(), ` +# Game of Thrones +| # | First Name | Last Name | Salary | | +| ---:| --- | --- | ---:| --- | +| 1 | Arya | Stark | 3000 | | +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | +| 300 | Tyrion | Lannister | 5000 | | +| 0 | Valar | Morghulis | 0 | Faceless
Men | +| 0 | Valar | Morghulis | 0 | Faceless\|Men | +| | | Total | 10000 | | +_A Song of Ice and Fire_`) +} + +func TestTable_RenderMarkdown_AutoIndex(t *testing.T) { + tw := NewWriter() + for rowIdx := 0; rowIdx < 10; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1) + } + tw.AppendRow(row) + } + for rowIdx := 0; rowIdx < 1; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = AutoIndexColumnID(colIdx) + "F" + } + tw.AppendFooter(row) + } + tw.SetAutoIndex(true) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.RenderMarkdown(), ` +| | A | B | C | D | E | F | G | H | I | J | +| ---:| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | A1 | B1 | C1 | D1 | E1 | F1 | G1 | H1 | I1 | J1 | +| 2 | A2 | B2 | C2 | D2 | E2 | F2 | G2 | H2 | I2 | J2 | +| 3 | A3 | B3 | C3 | D3 | E3 | F3 | G3 | H3 | I3 | J3 | +| 4 | A4 | B4 | C4 | D4 | E4 | F4 | G4 | H4 | I4 | J4 | +| 5 | A5 | B5 | C5 | D5 | E5 | F5 | G5 | H5 | I5 | J5 | +| 6 | A6 | B6 | C6 | D6 | E6 | F6 | G6 | H6 | I6 | J6 | +| 7 | A7 | B7 | C7 | D7 | E7 | F7 | G7 | H7 | I7 | J7 | +| 8 | A8 | B8 | C8 | D8 | E8 | F8 | G8 | H8 | I8 | J8 | +| 9 | A9 | B9 | C9 | D9 | E9 | F9 | G9 | H9 | I9 | J9 | +| 10 | A10 | B10 | C10 | D10 | E10 | F10 | G10 | H10 | I10 | J10 | +| | AF | BF | CF | DF | EF | FF | GF | HF | IF | JF |`) +} + +func TestTable_RenderMarkdown_Empty(t *testing.T) { + tw := NewWriter() + assert.Empty(t, tw.RenderMarkdown()) +} + +func TestTable_RenderMarkdown_HiddenColumns(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + + // ensure sorting is done before hiding the columns + tw.SortBy([]SortBy{ + {Name: "Salary", Mode: DscNumeric}, + }) + + t.Run("every column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4})) + + compareOutput(t, tw.RenderMarkdown(), "") + }) + + t.Run("first column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0})) + + compareOutput(t, tw.RenderMarkdown(), ` +| First Name | Last Name | Salary | | +| --- | --- | ---:| --- | +| >>Tyrion | Lannister<< | 5013 | | +| >>Arya | Stark<< | 3013 | | +| >>Jon | Snow<< | 2013 | ~You know nothing, Jon Snow!~ | +| | Total | 10000 | |`) + }) + + t.Run("column hidden in the middle", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1})) + + compareOutput(t, tw.RenderMarkdown(), ` +| # | Last Name | Salary | | +| ---:| --- | ---:| --- | +| 307 | Lannister<< | 5013 | | +| 8 | Stark<< | 3013 | | +| 27 | Snow<< | 2013 | ~You know nothing, Jon Snow!~ | +| | Total | 10000 | |`) + }) + + t.Run("last column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4})) + + compareOutput(t, tw.RenderMarkdown(), ` +| # | First Name | Last Name | Salary | +| ---:| --- | --- | ---:| +| 307 | >>Tyrion | Lannister<< | 5013 | +| 8 | >>Arya | Stark<< | 3013 | +| 27 | >>Jon | Snow<< | 2013 | +| | | Total | 10000 |`) + }) +} + +func TestTable_RendeMarkdown_Sorted(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(Row{11, "Sansa", "Stark", 6000}) + tw.AppendFooter(testFooter) + tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}}) + + compareOutput(t, tw.RenderMarkdown(), ` +| # | First Name | Last Name | Salary | | +| ---:| --- | --- | ---:| --- | +| 300 | Tyrion | Lannister | 5000 | | +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | +| 1 | Arya | Stark | 3000 | | +| 11 | Sansa | Stark | 6000 | | +| | | Total | 10000 | |`) +} diff --git a/helper/table/render_test.go b/helper/table/render_test.go new file mode 100644 index 0000000..a899e57 --- /dev/null +++ b/helper/table/render_test.go @@ -0,0 +1,1863 @@ +package table + +import ( + "fmt" + "sort" + "strings" + "testing" + + "git.hexq.cn/tiglog/golib/helper/text" + "github.com/stretchr/testify/assert" +) + +func compareOutput(t *testing.T, out string, expectedOut string) { + if strings.HasPrefix(expectedOut, "\n") { + expectedOut = strings.Replace(expectedOut, "\n", "", 1) + } + assert.Equal(t, expectedOut, out) + if out != expectedOut { + fmt.Printf("Expected:\n%s\nActual:\n%s\n", expectedOut, out) + } else { + fmt.Println(out) + } +} + +func compareOutputColored(t *testing.T, out string, expectedOut string) { + if strings.HasPrefix(expectedOut, "\n") { + expectedOut = strings.Replace(expectedOut, "\n", "", 1) + } + assert.Equal(t, expectedOut, out) + if out != expectedOut { + fmt.Printf("Expected:\n%s\nActual:\n%s\n", expectedOut, out) + + // dump formatted output that can be "pasted" into the expectation in + // the test in case of valid changed behavior + outLines := strings.Split(out, "\n") + fmt.Printf("\"\" +\n") + for idx, line := range outLines { + if idx < len(outLines)-1 { + fmt.Printf("%#v +", line+"\n") + } else { + fmt.Printf("%#v,", line) + } + fmt.Printf("\n") + } + } else { + fmt.Println(out) + } +} + +func generateColumnConfigsWithHiddenColumns(colsToHide []int) []ColumnConfig { + cc := []ColumnConfig{ + { + Name: "#", + Transformer: func(val interface{}) string { + return fmt.Sprint(val.(int) + 7) + }, + }, { + Name: "First Name", + Transformer: func(val interface{}) string { + return fmt.Sprintf(">>%s", val) + }, + }, { + Name: "Last Name", + Transformer: func(val interface{}) string { + return fmt.Sprintf("%s<<", val) + }, + }, { + Name: "Salary", + Transformer: func(val interface{}) string { + return fmt.Sprint(val.(int) + 13) + }, + }, { + Number: 5, + Transformer: func(val interface{}) string { + return fmt.Sprintf("~%s~", val) + }, + }, + } + for _, colToHide := range colsToHide { + cc[colToHide].Hidden = true + } + return cc +} + +func TestTable_Render(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetCaption(testCaption) + tw.SetStyle(styleTest) + tw.SetTitle(testTitle2) + + compareOutput(t, tw.Render(), ` +(---------------------------------------------------------------------) +[] +[] +{-----^------------^-----------^--------^-----------------------------} +[< #>||||< >] +{-----+------------+-----------+--------+-----------------------------} +[< 1>|||< 3000>|< >] +[< 20>|||< 2000>|] +[<300>|||< 5000>|< >] +[< 0>|||< 0>|] +[< >|< >|< >|< >|] +[< >|< >|< >|< >|] +{-----+------------+-----------+--------+-----------------------------} +[< >|< >||< 10000>|< >] +\-----v------------v-----------v--------v-----------------------------/ +A Song of Ice and Fire`) +} + +func TestTable_Render_AutoIndex(t *testing.T) { + tw := NewWriter() + for rowIdx := 0; rowIdx < 10; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1) + } + tw.AppendRow(row) + } + tw.SetAutoIndex(true) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.Render(), ` +┌────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ +│ │ A │ B │ C │ D │ E │ F │ G │ H │ I │ J │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ 1 │ A1 │ B1 │ C1 │ D1 │ E1 │ F1 │ G1 │ H1 │ I1 │ J1 │ +│ 2 │ A2 │ B2 │ C2 │ D2 │ E2 │ F2 │ G2 │ H2 │ I2 │ J2 │ +│ 3 │ A3 │ B3 │ C3 │ D3 │ E3 │ F3 │ G3 │ H3 │ I3 │ J3 │ +│ 4 │ A4 │ B4 │ C4 │ D4 │ E4 │ F4 │ G4 │ H4 │ I4 │ J4 │ +│ 5 │ A5 │ B5 │ C5 │ D5 │ E5 │ F5 │ G5 │ H5 │ I5 │ J5 │ +│ 6 │ A6 │ B6 │ C6 │ D6 │ E6 │ F6 │ G6 │ H6 │ I6 │ J6 │ +│ 7 │ A7 │ B7 │ C7 │ D7 │ E7 │ F7 │ G7 │ H7 │ I7 │ J7 │ +│ 8 │ A8 │ B8 │ C8 │ D8 │ E8 │ F8 │ G8 │ H8 │ I8 │ J8 │ +│ 9 │ A9 │ B9 │ C9 │ D9 │ E9 │ F9 │ G9 │ H9 │ I9 │ J9 │ +│ 10 │ A10 │ B10 │ C10 │ D10 │ E10 │ F10 │ G10 │ H10 │ I10 │ J10 │ +└────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘`) +} + +func TestTable_Render_AutoMerge(t *testing.T) { + rcAutoMerge := RowConfig{AutoMerge: true} + + t.Run("columns only", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE\nEXE", "RCE\nRUN"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true}, + {Number: 3, AutoMerge: true}, + {Number: 4, AutoMerge: true}, + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬─────┬─────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │ RCE │ +│ │ │ │ │ │ EXE │ RUN │ +├───┼─────────┼────────┼───────────┼───────────┼─────┼─────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ Y │ +├───┤ │ │ ├───────────┼─────┼─────┤ +│ 2 │ │ │ │ C 2 │ Y │ N │ +├───┤ │ ├───────────┼───────────┼─────┼─────┤ +│ 3 │ │ │ NS 1B │ C 3 │ N │ N │ +├───┤ ├────────┼───────────┼───────────┼─────┼─────┤ +│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │ N │ +├───┤ │ │ ├───────────┼─────┼─────┤ +│ 5 │ │ │ │ C 5 │ Y │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┼─────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ Y │ +├───┤ │ │ ├───────────┼─────┼─────┤ +│ 7 │ │ │ │ C 7 │ Y │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────┼─────┤ +│ │ │ │ │ 7 │ 5 │ 3 │ +└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘`) + }) + + t.Run("columns only with hidden columns", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE\nEXE", "RCE\nRUN"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "Y", "Y"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "N"}) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true}, + {Number: 3, AutoMerge: true}, + {Number: 4, Hidden: true}, + {Number: 5, Hidden: true, Align: text.AlignCenter}, + {Number: 6, Hidden: true, Align: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌─────────┬────────┬───────────┐ +│ NODE IP │ PODS │ NAMESPACE │ +├─────────┼────────┼───────────┤ +│ 1.1.1.1 │ Pod 1A │ NS 1A │ +│ │ │ │ +│ │ │ │ +│ │ ├───────────┤ +│ │ │ NS 1B │ +│ ├────────┼───────────┤ +│ │ Pod 1B │ NS 2 │ +│ │ │ │ +│ │ │ │ +├─────────┼────────┼───────────┤ +│ 2.2.2.2 │ Pod 2 │ NS 3 │ +│ │ │ │ +│ │ │ │ +├─────────┼────────┼───────────┤ +│ │ │ │ +└─────────┴────────┴───────────┘`) + }) + + t.Run("rows only", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rcAutoMerge) + tw.AppendHeader(Row{"", "", "", "", "EXE", "RUN"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬───────────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │ +│ ├─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ │ │ │ │ │ EXE │ RUN │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┼─────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1B │ C 3 │ N │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 4 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 4 │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ 5 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 5 │ Y │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼───────────┤ +│ 7 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 7 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ │ │ │ │ 7 │ 5 │ 3 │ +└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘`) + }) + + t.Run("rows and columns", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rcAutoMerge) + tw.AppendHeader(Row{"", "", "", "", "EXE", "RUN"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true}, + {Number: 3, AutoMerge: true}, + {Number: 4, AutoMerge: true}, + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬───────────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │ +│ │ │ │ │ ├─────┬─────┤ +│ │ │ │ │ │ EXE │ RUN │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┤ │ │ ├───────────┼─────┬─────┤ +│ 2 │ │ │ │ C 2 │ Y │ N │ +├───┤ │ ├───────────┼───────────┼─────┴─────┤ +│ 3 │ │ │ NS 1B │ C 3 │ N │ +├───┤ ├────────┼───────────┼───────────┼───────────┤ +│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │ +├───┤ │ │ ├───────────┼─────┬─────┤ +│ 5 │ │ │ │ C 5 │ Y │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├───┤ │ │ ├───────────┼───────────┤ +│ 7 │ │ │ │ C 7 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ │ │ │ │ 7 │ 5 │ 3 │ +└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘`) + }) + + t.Run("rows and columns no headers or footers", func(t *testing.T) { + tw := NewWriter() + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 6, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌─────────┬────────┬───────┬─────┬───────┐ +│ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├─────────┼────────┼───────┼─────┼───┬───┤ +│ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ N │ +├─────────┼────────┼───────┼─────┼───┼───┤ +│ 1.1.1.1 │ Pod 1A │ NS 1B │ C 3 │ N │ N │ +├─────────┼────────┼───────┼─────┼───┴───┤ +│ 1.1.1.1 │ Pod 1B │ NS 2 │ C 4 │ N │ +├─────────┼────────┼───────┼─────┼───┬───┤ +│ 1.1.1.1 │ Pod 1B │ NS 2 │ C 5 │ Y │ N │ +├─────────┼────────┼───────┼─────┼───┴───┤ +│ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├─────────┼────────┼───────┼─────┼───────┤ +│ 2.2.2.2 │ Pod 2 │ NS 3 │ C 7 │ Y │ +└─────────┴────────┴───────┴─────┴───────┘`) + }) + + t.Run("rows and columns no headers or footers with auto-index", func(t *testing.T) { + tw := NewWriter() + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 6, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────┬─────┬───┬───┐ +│ │ A │ B │ C │ D │ E │ F │ +├───┼─────────┼────────┼───────┼─────┼───┴───┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┼─────────┼────────┼───────┼─────┼───┬───┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ N │ +├───┼─────────┼────────┼───────┼─────┼───┼───┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1B │ C 3 │ N │ N │ +├───┼─────────┼────────┼───────┼─────┼───┴───┤ +│ 4 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 4 │ N │ +├───┼─────────┼────────┼───────┼─────┼───┬───┤ +│ 5 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 5 │ Y │ N │ +├───┼─────────┼────────┼───────┼─────┼───┴───┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├───┼─────────┼────────┼───────┼─────┼───────┤ +│ 7 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 7 │ Y │ +└───┴─────────┴────────┴───────┴─────┴───────┘`) + }) + + t.Run("rows and columns and footers", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE", "ID"}, rcAutoMerge) + tw.AppendHeader(Row{"", "", "", "", "EXE", "RUN", ""}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y", 123}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N", 234}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N", 345}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N", 456}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N", 567}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y", 678}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y", 789}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 5}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 5}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 5}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true}, + {Number: 3, AutoMerge: true}, + {Number: 4, AutoMerge: true}, + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬───────────┬─────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │ ID │ +│ │ │ │ │ ├─────┬─────┼─────┤ +│ │ │ │ │ │ EXE │ RUN │ │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┼─────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ 123 │ +├───┤ │ │ ├───────────┼─────┬─────┼─────┤ +│ 2 │ │ │ │ C 2 │ Y │ N │ 234 │ +├───┤ │ ├───────────┼───────────┼─────┼─────┼─────┤ +│ 3 │ │ │ NS 1B │ C 3 │ N │ N │ 345 │ +├───┤ ├────────┼───────────┼───────────┼─────┴─────┼─────┤ +│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │ 456 │ +├───┤ │ │ ├───────────┼─────┬─────┼─────┤ +│ 5 │ │ │ │ C 5 │ Y │ N │ 567 │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┼─────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ 678 │ +├───┤ │ │ ├───────────┼───────────┼─────┤ +│ 7 │ │ │ │ C 7 │ Y │ 789 │ +├───┼─────────┴────────┴───────────┼───────────┼───────────┼─────┤ +│ │ │ 7 │ 5 │ │ +│ │ │ ├─────┬─────┼─────┤ +│ │ │ │ 5 │ 3 │ │ +│ │ │ ├─────┴─────┼─────┤ +│ │ │ │ 5 │ │ +│ │ │ ├─────┬─────┼─────┤ +│ │ │ │ 5 │ 3 │ │ +│ │ │ ├─────┴─────┼─────┤ +│ │ │ │ 5 │ │ +└───┴──────────────────────────────┴───────────┴───────────┴─────┘`) + }) + + t.Run("samurai sudoku", func(t *testing.T) { + tw := NewWriter() + tw.AppendRow(Row{"1.1\n1.1", "1.2\n1.2", "1.3\n1.3", " ", "2.1\n2.1", "2.2\n2.2", "2.3\n2.3"}) + tw.AppendRow(Row{"1.4\n1.4", "1.5\n1.5", "1.6\n1.6", " ", "2.4\n2.4", "2.5\n2.5", "2.6\n2.6"}) + tw.AppendRow(Row{"1.7\n1.7", "1.8\n1.8", "1.9\n0.1", "0.2\n0.2", "2.7\n0.3", "2.8\n2.8", "2.9\n2.9"}) + tw.AppendRow(Row{" ", " ", "0.4\n0.4", "0.5\n0.5", "0.6\n0.6", " ", " "}, rcAutoMerge) + tw.AppendRow(Row{"3.1\n3.1", "3.2\n3.2", "3.3\n0.7", "0.8\n0.8", "4.1\n0.9", "4.2\n4.2", "4.3\n4.3"}) + tw.AppendRow(Row{"3.4\n3.4", "3.5\n3.5", "3.6\n3.6", " ", "4.4\n4.4", "4.5\n4.5", "4.6\n4.6"}) + tw.AppendRow(Row{"3.7\n3.7", "3.8\n3.8", "3.9\n3.9", " ", "4.7\n4.7", "4.8\n4.8", "4.9\n4.9"}) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 4, AutoMerge: true}, + }) + tw.SetStyle(StyleLight) + tw.Style().Box.PaddingLeft = "" + tw.Style().Box.PaddingRight = "" + tw.Style().Options.DrawBorder = true + tw.Style().Options.SeparateRows = true + tw.Style().Options.SeparateColumns = true + + compareOutput(t, tw.Render(), ` +┌───┬───┬───┬───┬───┬───┬───┐ +│1.1│1.2│1.3│ │2.1│2.2│2.3│ +│1.1│1.2│1.3│ │2.1│2.2│2.3│ +├───┼───┼───┤ ├───┼───┼───┤ +│1.4│1.5│1.6│ │2.4│2.5│2.6│ +│1.4│1.5│1.6│ │2.4│2.5│2.6│ +├───┼───┼───┼───┼───┼───┼───┤ +│1.7│1.8│1.9│0.2│2.7│2.8│2.9│ +│1.7│1.8│0.1│0.2│0.3│2.8│2.9│ +├───┴───┼───┼───┼───┼───┴───┤ +│ │0.4│0.5│0.6│ │ +│ │0.4│0.5│0.6│ │ +├───┬───┼───┼───┼───┼───┬───┤ +│3.1│3.2│3.3│0.8│4.1│4.2│4.3│ +│3.1│3.2│0.7│0.8│0.9│4.2│4.3│ +├───┼───┼───┼───┼───┼───┼───┤ +│3.4│3.5│3.6│ │4.4│4.5│4.6│ +│3.4│3.5│3.6│ │4.4│4.5│4.6│ +├───┼───┼───┤ ├───┼───┼───┤ +│3.7│3.8│3.9│ │4.7│4.8│4.9│ +│3.7│3.8│3.9│ │4.7│4.8│4.9│ +└───┴───┴───┴───┴───┴───┴───┘`) + }) + + t.Run("long column no merge", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Column 1", "Column 2", "Column 3", "Column 4", "Column 5", "Column 6", "Column 7", "Column 8"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DRW", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DRH", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DRY"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 7, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 8, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬──────────┬──────────┬──────────┬──────────┬──────────────────────────┬──────────────────────────┬──────────────────────────┬──────────────────────────┐ +│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ +├───┼──────────┼──────────┼──────────┼──────────┼──────────────────────────┼──────────────────────────┼──────────────────────────┼──────────────────────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ +│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ +│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ +│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ +│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ +│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRW │ 0F4EB42DRH │ 0F4EB42DRY │ +├───┼──────────┼──────────┼──────────┼──────────┼──────────────────────────┴──────────────────────────┴──────────────────────────┴──────────────────────────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +└───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────┘`) + }) + + t.Run("long column partially merged #1", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Column 1", "Column 2", "Column 3", "Column 4", "Column 5", "Column 6", "Column 7", "Column 8"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DRR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DRR"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 7, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 8, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬──────────┬──────────┬──────────┬──────────┬─────────────┬─────────────┬─────────────┬─────────────┐ +│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ +├───┼──────────┼──────────┼──────────┼──────────┼─────────────┴─────────────┼─────────────┴─────────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ +│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ +│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ +│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ +│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ +│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRR │ +├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────┴───────────────────────────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────────────────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +└───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────────────────┘`) + }) + + t.Run("long column partially merged #2", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Column 1", "Column 2", "Column 3", "Column 4", "Column 5", "Column 6", "Column 7", "Column 8"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DRE"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 7, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 8, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────────────────────┐ +│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ +├───┼──────────┼──────────┼──────────┼──────────┼──────────┴──────────┴──────────┼──────────────────────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ +│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ +│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ +│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ +│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ +│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRE │ +├───┼──────────┼──────────┼──────────┼──────────┼────────────────────────────────┴──────────────────────────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────────────────────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +└───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────────────────────┘`) + }) + + t.Run("long column fully merged", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Column 1", "Column 2", "Column 3", "Column 4", "Column 5", "Column 6", "Column 7", "Column 8"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR", "4F8F5CB531E3D49A61CF417CD133792CCFA501FD8DA53EE368FED20E5FE0248C3A0B64F98A6533CEE1DA614C3A8DDEC791FF05FEE6D971D57C1348320F4EB42DR"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 7, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + {Number: 8, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 24, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐ +│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ +├───┼──────────┼──────────┼──────────┼──────────┼──────────┴──────────┴──────────┴──────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ +│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ +│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ +│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ +│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ +│ │ │ │ │ │ 0F4EB42DR │ +├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +└───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────┘`) + }) + + t.Run("headers too", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rcAutoMerge) + tw.AppendHeader(Row{"", "", "", "", "EXE EXE EXE", "EXE EXE EXE"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 3}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 6, 4, 4}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬───────────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │ +│ ├─────────┴────────┴───────────┴───────────┼───────────┤ +│ │ │ EXE EXE │ +│ │ │ EXE │ +├───┼─────────┬────────┬───────────┬───────────┼───────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┼─────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1B │ C 3 │ N │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 4 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 4 │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤ +│ 5 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 5 │ Y │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼───────────┤ +│ 7 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 7 │ Y │ +├───┼─────────┴────────┴───────────┼───────────┼─────┬─────┤ +│ │ │ 7 │ 5 │ 3 │ +│ ├──────────────────────────────┼───────────┼─────┴─────┤ +│ │ │ 6 │ 4 │ +└───┴──────────────────────────────┴───────────┴───────────┘`) + }) + + t.Run("headers and footers too", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE1", "RCE2"}, rcAutoMerge) + tw.AppendHeader(Row{"", "", "", "", "EXE EXE EXE", "EXE EXE EXE"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 5}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 6, 4, 4}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬──────┬──────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE1 │ RCE2 │ +│ ├─────────┴────────┴───────────┴───────────┼──────┴──────┤ +│ │ │ EXE EXE │ +│ │ │ EXE │ +├───┼─────────┬────────┬───────────┬───────────┼─────────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────────────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────────────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1B │ C 3 │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────────────┤ +│ 4 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 4 │ N │ +├───┼─────────┼────────┼───────────┼───────────┼─────────────┤ +│ 5 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 5 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────────────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼─────────────┤ +│ 7 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 7 │ Y │ +├───┼─────────┴────────┴───────────┼───────────┼─────────────┤ +│ │ │ 7 │ 5 │ +│ ├──────────────────────────────┼───────────┼─────────────┤ +│ │ │ 6 │ 4 │ +└───┴──────────────────────────────┴───────────┴─────────────┘`) + }) + + t.Run("long header column", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"Node IP", "Pods", "Namespace", "Container", "RCE1", "RCE2", "RCE3"}, rcAutoMerge) + tw.AppendHeader(Row{"", "", "", "", "EXE EXE EXE", "EXE EXE EXE", "EXE EXE EXE"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 7, 5, 5, 5}, rcAutoMerge) + tw.AppendFooter(Row{"", "", "", 6, 4, 4, 3}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + {Number: 7, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬─────────┬────────┬───────────┬───────────┬──────┬──────┬──────┐ +│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE1 │ RCE2 │ RCE3 │ +│ ├─────────┴────────┴───────────┴───────────┼──────┴──────┴──────┤ +│ │ │ EXE EXE │ +│ │ │ EXE │ +├───┼─────────┬────────┬───────────┬───────────┼────────────────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼────────────────────┤ +│ 2 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼────────────────────┤ +│ 3 │ 1.1.1.1 │ Pod 1A │ NS 1B │ C 3 │ N │ +├───┼─────────┼────────┼───────────┼───────────┼────────────────────┤ +│ 4 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 4 │ N │ +├───┼─────────┼────────┼───────────┼───────────┼────────────────────┤ +│ 5 │ 1.1.1.1 │ Pod 1B │ NS 2 │ C 5 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼────────────────────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │ +├───┼─────────┼────────┼───────────┼───────────┼────────────────────┤ +│ 7 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 7 │ Y │ +├───┼─────────┴────────┴───────────┼───────────┼────────────────────┤ +│ │ │ 7 │ 5 │ +│ ├──────────────────────────────┼───────────┼─────────────┬──────┤ +│ │ │ 6 │ 4 │ 3 │ +└───┴──────────────────────────────┴───────────┴─────────────┴──────┘`) + }) + + t.Run("everything", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"COLUMNS", "COLUMNS", "COLUMNS", "COLUMNS", "COLUMNS", "COLUMNS", "COLUMNS"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "Y", "N"}, rcAutoMerge) + tw.AppendRow(Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "N", "Y", "Y"}, rcAutoMerge) + tw.AppendRow(Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y", "Y"}, rcAutoMerge) + tw.AppendFooter(Row{"foo", "foo", "foo", "foo", "bar", "bar", "bar"}, rcAutoMerge) + tw.AppendFooter(Row{7, 7, 7, 7, 7, 7, 7}, rcAutoMerge) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true}, + {Number: 3, AutoMerge: true}, + {Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + {Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + {Number: 7, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter, WidthMax: 7, WidthMaxEnforcer: text.WrapHard}, + }) + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌───┬───────────────────────────────────────────────────┐ +│ │ COLUMNS │ +├───┼─────────┬─────────┬─────────┬─────────┬───────────┤ +│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┤ │ │ ├─────────┼───────┬───┤ +│ 2 │ │ │ │ C 2 │ Y │ N │ +├───┤ │ ├─────────┼─────────┼───────┴───┤ +│ 3 │ │ │ NS 1B │ C 3 │ N │ +├───┤ ├─────────┼─────────┼─────────┼───┬───┬───┤ +│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │ Y │ N │ +├───┤ │ │ ├─────────┼───┴───┴───┤ +│ 5 │ │ │ │ C 5 │ Y │ +├───┼─────────┼─────────┼─────────┼─────────┼───┬───────┤ +│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ N │ Y │ +├───┤ │ │ ├─────────┼───┴───────┤ +│ 7 │ │ │ │ C 7 │ Y │ +├───┼─────────┴─────────┴─────────┴─────────┼───────────┤ +│ │ FOO │ BAR │ +│ ├───────────────────────────────────────┴───────────┤ +│ │ 7 │ +└───┴───────────────────────────────────────────────────┘`) + }) +} + +func TestTable_Render_BorderAndSeparators(t *testing.T) { + table := Table{} + table.AppendHeader(testHeader) + table.AppendRows(testRows) + table.AppendFooter(testFooter) + compareOutput(t, table.Render(), ` ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 1 | Arya | Stark | 3000 | | +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | +| 300 | Tyrion | Lannister | 5000 | | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+`) + + table.Style().Options = OptionsNoBorders + compareOutput(t, table.Render(), ` + # | FIRST NAME | LAST NAME | SALARY | +-----+------------+-----------+--------+----------------------------- + 1 | Arya | Stark | 3000 | + 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! + 300 | Tyrion | Lannister | 5000 | +-----+------------+-----------+--------+----------------------------- + | | TOTAL | 10000 | `) + + table.Style().Options.SeparateColumns = false + compareOutput(t, table.Render(), ` + # FIRST NAME LAST NAME SALARY +----------------------------------------------------------------- + 1 Arya Stark 3000 + 20 Jon Snow 2000 You know nothing, Jon Snow! + 300 Tyrion Lannister 5000 +----------------------------------------------------------------- + TOTAL 10000 `) + + table.Style().Options.SeparateFooter = false + compareOutput(t, table.Render(), ` + # FIRST NAME LAST NAME SALARY +----------------------------------------------------------------- + 1 Arya Stark 3000 + 20 Jon Snow 2000 You know nothing, Jon Snow! + 300 Tyrion Lannister 5000 + TOTAL 10000 `) + + table.Style().Options = OptionsNoBordersAndSeparators + compareOutput(t, table.Render(), ` + # FIRST NAME LAST NAME SALARY + 1 Arya Stark 3000 + 20 Jon Snow 2000 You know nothing, Jon Snow! + 300 Tyrion Lannister 5000 + TOTAL 10000 `) + + table.Style().Options.DrawBorder = true + compareOutput(t, table.Render(), ` ++-----------------------------------------------------------------+ +| # FIRST NAME LAST NAME SALARY | +| 1 Arya Stark 3000 | +| 20 Jon Snow 2000 You know nothing, Jon Snow! | +| 300 Tyrion Lannister 5000 | +| TOTAL 10000 | ++-----------------------------------------------------------------+`) + + table.Style().Options.SeparateFooter = true + compareOutput(t, table.Render(), ` ++-----------------------------------------------------------------+ +| # FIRST NAME LAST NAME SALARY | +| 1 Arya Stark 3000 | +| 20 Jon Snow 2000 You know nothing, Jon Snow! | +| 300 Tyrion Lannister 5000 | ++-----------------------------------------------------------------+ +| TOTAL 10000 | ++-----------------------------------------------------------------+`) + + table.Style().Options.SeparateHeader = true + compareOutput(t, table.Render(), ` ++-----------------------------------------------------------------+ +| # FIRST NAME LAST NAME SALARY | ++-----------------------------------------------------------------+ +| 1 Arya Stark 3000 | +| 20 Jon Snow 2000 You know nothing, Jon Snow! | +| 300 Tyrion Lannister 5000 | ++-----------------------------------------------------------------+ +| TOTAL 10000 | ++-----------------------------------------------------------------+`) + + table.Style().Options.SeparateRows = true + compareOutput(t, table.Render(), ` ++-----------------------------------------------------------------+ +| # FIRST NAME LAST NAME SALARY | ++-----------------------------------------------------------------+ +| 1 Arya Stark 3000 | ++-----------------------------------------------------------------+ +| 20 Jon Snow 2000 You know nothing, Jon Snow! | ++-----------------------------------------------------------------+ +| 300 Tyrion Lannister 5000 | ++-----------------------------------------------------------------+ +| TOTAL 10000 | ++-----------------------------------------------------------------+`) + + table.Style().Options.SeparateColumns = true + compareOutput(t, table.Render(), ` ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 1 | Arya | Stark | 3000 | | ++-----+------------+-----------+--------+-----------------------------+ +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | ++-----+------------+-----------+--------+-----------------------------+ +| 300 | Tyrion | Lannister | 5000 | | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+`) +} + +func TestTable_Render_BorderAndSeparators_Colored(t *testing.T) { + table := Table{} + table.AppendHeader(testHeader) + table.AppendRows(testRows) + table.AppendFooter(testFooter) + + compareOutput(t, table.Render(), ` ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 1 | Arya | Stark | 3000 | | +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | +| 300 | Tyrion | Lannister | 5000 | | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+`) + + table.Style().Color.Border = text.Colors{text.FgRed} + table.Style().Color.Separator = text.Colors{text.FgYellow} + compareOutputColored(t, table.Render(), ""+ + "\x1b[31m+\x1b[0m\x1b[31m-----\x1b[0m\x1b[31m+\x1b[0m\x1b[31m------------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m--------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------------------------\x1b[0m\x1b[31m+\x1b[0m\n"+ + "\x1b[31m|\x1b[0m # \x1b[33m|\x1b[0m FIRST NAME \x1b[33m|\x1b[0m LAST NAME \x1b[33m|\x1b[0m SALARY \x1b[33m|\x1b[0m \x1b[31m|\x1b[0m\n"+ + "\x1b[31m+\x1b[0m\x1b[33m-----\x1b[0m\x1b[33m+\x1b[0m\x1b[33m------------\x1b[0m\x1b[33m+\x1b[0m\x1b[33m-----------\x1b[0m\x1b[33m+\x1b[0m\x1b[33m--------\x1b[0m\x1b[33m+\x1b[0m\x1b[33m-----------------------------\x1b[0m\x1b[31m+\x1b[0m\n"+ + "\x1b[31m|\x1b[0m 1 \x1b[33m|\x1b[0m Arya \x1b[33m|\x1b[0m Stark \x1b[33m|\x1b[0m 3000 \x1b[33m|\x1b[0m \x1b[31m|\x1b[0m\n"+ + "\x1b[31m|\x1b[0m 20 \x1b[33m|\x1b[0m Jon \x1b[33m|\x1b[0m Snow \x1b[33m|\x1b[0m 2000 \x1b[33m|\x1b[0m You know nothing, Jon Snow! \x1b[31m|\x1b[0m\n"+ + "\x1b[31m|\x1b[0m 300 \x1b[33m|\x1b[0m Tyrion \x1b[33m|\x1b[0m Lannister \x1b[33m|\x1b[0m 5000 \x1b[33m|\x1b[0m \x1b[31m|\x1b[0m\n"+ + "\x1b[31m+\x1b[0m\x1b[33m-----\x1b[0m\x1b[33m+\x1b[0m\x1b[33m------------\x1b[0m\x1b[33m+\x1b[0m\x1b[33m-----------\x1b[0m\x1b[33m+\x1b[0m\x1b[33m--------\x1b[0m\x1b[33m+\x1b[0m\x1b[33m-----------------------------\x1b[0m\x1b[31m+\x1b[0m\n"+ + "\x1b[31m|\x1b[0m \x1b[33m|\x1b[0m \x1b[33m|\x1b[0m TOTAL \x1b[33m|\x1b[0m 10000 \x1b[33m|\x1b[0m \x1b[31m|\x1b[0m\n"+ + "\x1b[31m+\x1b[0m\x1b[31m-----\x1b[0m\x1b[31m+\x1b[0m\x1b[31m------------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m--------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------------------------\x1b[0m\x1b[31m+\x1b[0m", + ) + + table.SetStyle(StyleLight) + table.Style().Color.Border = text.Colors{text.FgRed} + table.Style().Color.Separator = text.Colors{text.FgYellow} + compareOutputColored(t, table.Render(), ""+ + "\x1b[31m┌\x1b[0m\x1b[31m─────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m────────────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m───────────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m────────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m─────────────────────────────\x1b[0m\x1b[31m┐\x1b[0m\n"+ + "\x1b[31m│\x1b[0m # \x1b[33m│\x1b[0m FIRST NAME \x1b[33m│\x1b[0m LAST NAME \x1b[33m│\x1b[0m SALARY \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m├\x1b[0m\x1b[33m─────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m───────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m─────────────────────────────\x1b[0m\x1b[31m┤\x1b[0m\n"+ + "\x1b[31m│\x1b[0m 1 \x1b[33m│\x1b[0m Arya \x1b[33m│\x1b[0m Stark \x1b[33m│\x1b[0m 3000 \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m│\x1b[0m 20 \x1b[33m│\x1b[0m Jon \x1b[33m│\x1b[0m Snow \x1b[33m│\x1b[0m 2000 \x1b[33m│\x1b[0m You know nothing, Jon Snow! \x1b[31m│\x1b[0m\n"+ + "\x1b[31m│\x1b[0m 300 \x1b[33m│\x1b[0m Tyrion \x1b[33m│\x1b[0m Lannister \x1b[33m│\x1b[0m 5000 \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m├\x1b[0m\x1b[33m─────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m───────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m─────────────────────────────\x1b[0m\x1b[31m┤\x1b[0m\n"+ + "\x1b[31m│\x1b[0m \x1b[33m│\x1b[0m \x1b[33m│\x1b[0m TOTAL \x1b[33m│\x1b[0m 10000 \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m└\x1b[0m\x1b[31m─────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m────────────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m───────────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m────────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m─────────────────────────────\x1b[0m\x1b[31m┘\x1b[0m", + ) + + table.SetTitle("Game of Thrones") + compareOutputColored(t, table.Render(), ""+ + "\x1b[31m┌\x1b[0m\x1b[31m─────────────────────────────────────────────────────────────────────\x1b[0m\x1b[31m┐\x1b[0m\n"+ + "\x1b[31m│\x1b[0m Game of Thrones \x1b[31m│\x1b[0m\n"+ + "\x1b[31m├\x1b[0m\x1b[31m─────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m────────────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m───────────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m────────\x1b[0m\x1b[31m┬\x1b[0m\x1b[31m─────────────────────────────\x1b[0m\x1b[31m┤\x1b[0m\n"+ + "\x1b[31m│\x1b[0m # \x1b[33m│\x1b[0m FIRST NAME \x1b[33m│\x1b[0m LAST NAME \x1b[33m│\x1b[0m SALARY \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m├\x1b[0m\x1b[33m─────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m───────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m─────────────────────────────\x1b[0m\x1b[31m┤\x1b[0m\n"+ + "\x1b[31m│\x1b[0m 1 \x1b[33m│\x1b[0m Arya \x1b[33m│\x1b[0m Stark \x1b[33m│\x1b[0m 3000 \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m│\x1b[0m 20 \x1b[33m│\x1b[0m Jon \x1b[33m│\x1b[0m Snow \x1b[33m│\x1b[0m 2000 \x1b[33m│\x1b[0m You know nothing, Jon Snow! \x1b[31m│\x1b[0m\n"+ + "\x1b[31m│\x1b[0m 300 \x1b[33m│\x1b[0m Tyrion \x1b[33m│\x1b[0m Lannister \x1b[33m│\x1b[0m 5000 \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m├\x1b[0m\x1b[33m─────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m───────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m────────\x1b[0m\x1b[33m┼\x1b[0m\x1b[33m─────────────────────────────\x1b[0m\x1b[31m┤\x1b[0m\n"+ + "\x1b[31m│\x1b[0m \x1b[33m│\x1b[0m \x1b[33m│\x1b[0m TOTAL \x1b[33m│\x1b[0m 10000 \x1b[33m│\x1b[0m \x1b[31m│\x1b[0m\n"+ + "\x1b[31m└\x1b[0m\x1b[31m─────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m────────────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m───────────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m────────\x1b[0m\x1b[31m┴\x1b[0m\x1b[31m─────────────────────────────\x1b[0m\x1b[31m┘\x1b[0m", + ) +} + +func TestTable_Render_Colored(t *testing.T) { + t.Run("simple", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetAutoIndex(true) + tw.SetStyle(StyleColoredBright) + tw.Style().Options.DrawBorder = true + tw.Style().Options.SeparateColumns = true + tw.Style().Options.SeparateFooter = true + tw.Style().Options.SeparateHeader = true + tw.Style().Options.SeparateRows = true + + compareOutputColored(t, tw.Render(), ""+ + "\x1b[106;30m+\x1b[0m\x1b[106;30m---\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m-----\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m------------\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m-----------\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m--------\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m-----------------------------\x1b[0m\x1b[106;30m+\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m \x1b[0m\x1b[106;30m|\x1b[0m\x1b[106;30m # \x1b[0m\x1b[106;30m|\x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m|\x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m|\x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m|\x1b[0m\x1b[106;30m \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[106;30m+\x1b[0m\x1b[106;30m---\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m-----\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m------------\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m-----------\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m--------\x1b[0m\x1b[106;30m+\x1b[0m\x1b[106;30m-----------------------------\x1b[0m\x1b[106;30m+\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m 1 \x1b[0m\x1b[106;30m|\x1b[0m\x1b[107;30m 1 \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[106;30m+\x1b[0m\x1b[106;30m---\x1b[0m\x1b[106;30m+\x1b[0m\x1b[107;30m-----\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m------------\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m-----------\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m--------\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m-----------------------------\x1b[0m\x1b[106;30m+\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m 2 \x1b[0m\x1b[106;30m|\x1b[0m\x1b[47;30m 20 \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[106;30m+\x1b[0m\x1b[106;30m---\x1b[0m\x1b[106;30m+\x1b[0m\x1b[47;30m-----\x1b[0m\x1b[47;30m+\x1b[0m\x1b[47;30m------------\x1b[0m\x1b[47;30m+\x1b[0m\x1b[47;30m-----------\x1b[0m\x1b[47;30m+\x1b[0m\x1b[47;30m--------\x1b[0m\x1b[47;30m+\x1b[0m\x1b[47;30m-----------------------------\x1b[0m\x1b[106;30m+\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m 3 \x1b[0m\x1b[106;30m|\x1b[0m\x1b[107;30m 300 \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m|\x1b[0m\x1b[107;30m \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[106;30m+\x1b[0m\x1b[106;30m---\x1b[0m\x1b[106;30m+\x1b[0m\x1b[107;30m-----\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m------------\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m-----------\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m--------\x1b[0m\x1b[107;30m+\x1b[0m\x1b[107;30m-----------------------------\x1b[0m\x1b[106;30m+\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m 4 \x1b[0m\x1b[106;30m|\x1b[0m\x1b[47;30m 0 \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m Winter \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m Is \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m 0 \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m Coming. \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m \x1b[0m\x1b[106;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m The North Remembers! \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[106;30m|\x1b[0m\x1b[106;30m \x1b[0m\x1b[106;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m \x1b[0m\x1b[47;30m|\x1b[0m\x1b[47;30m This is known. \x1b[0m\x1b[106;30m|\x1b[0m\n"+ + "\x1b[46;30m+\x1b[0m\x1b[46;30m---\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m-----\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m------------\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m-----------\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m--------\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m-----------------------------\x1b[0m\x1b[46;30m+\x1b[0m\n"+ + "\x1b[46;30m|\x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m|\x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m|\x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m|\x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m|\x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m|\x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m|\x1b[0m\n"+ + "\x1b[46;30m+\x1b[0m\x1b[46;30m---\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m-----\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m------------\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m-----------\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m--------\x1b[0m\x1b[46;30m+\x1b[0m\x1b[46;30m-----------------------------\x1b[0m\x1b[46;30m+\x1b[0m", + ) + }) + + t.Run("with borders", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetTitle(testTitle1) + tw.Style().Title.Colors = text.Colors{text.FgYellow} + tw.Style().Color = ColorOptions{ + Header: text.Colors{text.FgRed}, + Row: text.Colors{text.FgGreen}, + RowAlternate: text.Colors{text.FgHiGreen}, + Footer: text.Colors{text.FgBlue}, + } + + compareOutputColored(t, tw.Render(), ""+ + "\x1b[33m+\x1b[0m\x1b[33m---------------------------------------------------------------------\x1b[0m\x1b[33m+\x1b[0m\n"+ + "\x1b[33m|\x1b[0m\x1b[33m Game of Thrones \x1b[0m\x1b[33m|\x1b[0m\n"+ + "\x1b[31m+\x1b[0m\x1b[31m-----\x1b[0m\x1b[31m+\x1b[0m\x1b[31m------------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m--------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------------------------\x1b[0m\x1b[31m+\x1b[0m\n"+ + "\x1b[31m|\x1b[0m\x1b[31m # \x1b[0m\x1b[31m|\x1b[0m\x1b[31m FIRST NAME \x1b[0m\x1b[31m|\x1b[0m\x1b[31m LAST NAME \x1b[0m\x1b[31m|\x1b[0m\x1b[31m SALARY \x1b[0m\x1b[31m|\x1b[0m\x1b[31m \x1b[0m\x1b[31m|\x1b[0m\n"+ + "\x1b[31m+\x1b[0m\x1b[31m-----\x1b[0m\x1b[31m+\x1b[0m\x1b[31m------------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m--------\x1b[0m\x1b[31m+\x1b[0m\x1b[31m-----------------------------\x1b[0m\x1b[31m+\x1b[0m\n"+ + "\x1b[32m|\x1b[0m\x1b[32m 1 \x1b[0m\x1b[32m|\x1b[0m\x1b[32m Arya \x1b[0m\x1b[32m|\x1b[0m\x1b[32m Stark \x1b[0m\x1b[32m|\x1b[0m\x1b[32m 3000 \x1b[0m\x1b[32m|\x1b[0m\x1b[32m \x1b[0m\x1b[32m|\x1b[0m\n"+ + "\x1b[92m|\x1b[0m\x1b[92m 20 \x1b[0m\x1b[92m|\x1b[0m\x1b[92m Jon \x1b[0m\x1b[92m|\x1b[0m\x1b[92m Snow \x1b[0m\x1b[92m|\x1b[0m\x1b[92m 2000 \x1b[0m\x1b[92m|\x1b[0m\x1b[92m You know nothing, Jon Snow! \x1b[0m\x1b[92m|\x1b[0m\n"+ + "\x1b[32m|\x1b[0m\x1b[32m 300 \x1b[0m\x1b[32m|\x1b[0m\x1b[32m Tyrion \x1b[0m\x1b[32m|\x1b[0m\x1b[32m Lannister \x1b[0m\x1b[32m|\x1b[0m\x1b[32m 5000 \x1b[0m\x1b[32m|\x1b[0m\x1b[32m \x1b[0m\x1b[32m|\x1b[0m\n"+ + "\x1b[92m|\x1b[0m\x1b[92m 0 \x1b[0m\x1b[92m|\x1b[0m\x1b[92m Winter \x1b[0m\x1b[92m|\x1b[0m\x1b[92m Is \x1b[0m\x1b[92m|\x1b[0m\x1b[92m 0 \x1b[0m\x1b[92m|\x1b[0m\x1b[92m Coming. \x1b[0m\x1b[92m|\x1b[0m\n"+ + "\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m The North Remembers! \x1b[0m\x1b[92m|\x1b[0m\n"+ + "\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m \x1b[0m\x1b[92m|\x1b[0m\x1b[92m This is known. \x1b[0m\x1b[92m|\x1b[0m\n"+ + "\x1b[34m+\x1b[0m\x1b[34m-----\x1b[0m\x1b[34m+\x1b[0m\x1b[34m------------\x1b[0m\x1b[34m+\x1b[0m\x1b[34m-----------\x1b[0m\x1b[34m+\x1b[0m\x1b[34m--------\x1b[0m\x1b[34m+\x1b[0m\x1b[34m-----------------------------\x1b[0m\x1b[34m+\x1b[0m\n"+ + "\x1b[34m|\x1b[0m\x1b[34m \x1b[0m\x1b[34m|\x1b[0m\x1b[34m \x1b[0m\x1b[34m|\x1b[0m\x1b[34m TOTAL \x1b[0m\x1b[34m|\x1b[0m\x1b[34m 10000 \x1b[0m\x1b[34m|\x1b[0m\x1b[34m \x1b[0m\x1b[34m|\x1b[0m\n"+ + "\x1b[34m+\x1b[0m\x1b[34m-----\x1b[0m\x1b[34m+\x1b[0m\x1b[34m------------\x1b[0m\x1b[34m+\x1b[0m\x1b[34m-----------\x1b[0m\x1b[34m+\x1b[0m\x1b[34m--------\x1b[0m\x1b[34m+\x1b[0m\x1b[34m-----------------------------\x1b[0m\x1b[34m+\x1b[0m", + ) + }) + + t.Run("with borders and separators not colored", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetTitle(testTitle1) + tw.Style().Title.Colors = text.Colors{text.FgYellow} + tw.Style().Color = ColorOptions{ + Header: text.Colors{text.FgRed}, + Row: text.Colors{text.FgGreen}, + RowAlternate: text.Colors{text.FgHiGreen}, + Footer: text.Colors{text.FgBlue}, + } + tw.Style().Options.DoNotColorBordersAndSeparators = true + + compareOutputColored(t, tw.Render(), ""+ + "+---------------------------------------------------------------------+\n"+ + "|\x1b[33m Game of Thrones \x1b[0m|\n"+ + "+-----+------------+-----------+--------+-----------------------------+\n"+ + "|\x1b[31m # \x1b[0m|\x1b[31m FIRST NAME \x1b[0m|\x1b[31m LAST NAME \x1b[0m|\x1b[31m SALARY \x1b[0m|\x1b[31m \x1b[0m|\n"+ + "+-----+------------+-----------+--------+-----------------------------+\n"+ + "|\x1b[32m 1 \x1b[0m|\x1b[32m Arya \x1b[0m|\x1b[32m Stark \x1b[0m|\x1b[32m 3000 \x1b[0m|\x1b[32m \x1b[0m|\n"+ + "|\x1b[92m 20 \x1b[0m|\x1b[92m Jon \x1b[0m|\x1b[92m Snow \x1b[0m|\x1b[92m 2000 \x1b[0m|\x1b[92m You know nothing, Jon Snow! \x1b[0m|\n"+ + "|\x1b[32m 300 \x1b[0m|\x1b[32m Tyrion \x1b[0m|\x1b[32m Lannister \x1b[0m|\x1b[32m 5000 \x1b[0m|\x1b[32m \x1b[0m|\n"+ + "|\x1b[92m 0 \x1b[0m|\x1b[92m Winter \x1b[0m|\x1b[92m Is \x1b[0m|\x1b[92m 0 \x1b[0m|\x1b[92m Coming. \x1b[0m|\n"+ + "|\x1b[92m \x1b[0m|\x1b[92m \x1b[0m|\x1b[92m \x1b[0m|\x1b[92m \x1b[0m|\x1b[92m The North Remembers! \x1b[0m|\n"+ + "|\x1b[92m \x1b[0m|\x1b[92m \x1b[0m|\x1b[92m \x1b[0m|\x1b[92m \x1b[0m|\x1b[92m This is known. \x1b[0m|\n"+ + "+-----+------------+-----------+--------+-----------------------------+\n"+ + "|\x1b[34m \x1b[0m|\x1b[34m \x1b[0m|\x1b[34m TOTAL \x1b[0m|\x1b[34m 10000 \x1b[0m|\x1b[34m \x1b[0m|\n"+ + "+-----+------------+-----------+--------+-----------------------------+", + ) + }) + + t.Run("column customizations", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetCaption(testCaption) + tw.SetColumnConfigs([]ColumnConfig{ + {Name: "#", Colors: testColor, ColorsHeader: testColorHiRedBold}, + {Name: "First Name", Colors: testColor, ColorsHeader: testColorHiRedBold}, + {Name: "Last Name", Colors: testColor, ColorsHeader: testColorHiRedBold, ColorsFooter: testColorHiBlueBold}, + {Name: "Salary", Colors: testColor, ColorsHeader: testColorHiRedBold, ColorsFooter: testColorHiBlueBold}, + {Number: 5, Colors: text.Colors{text.FgCyan}}, + }) + tw.SetStyle(StyleRounded) + + compareOutputColored(t, tw.Render(), ""+ + "╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮\n"+ + "│\x1b[91;1m # \x1b[0m│\x1b[91;1m FIRST NAME \x1b[0m│\x1b[91;1m LAST NAME \x1b[0m│\x1b[91;1m SALARY \x1b[0m│ │\n"+ + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤\n"+ + "│\x1b[32m 1 \x1b[0m│\x1b[32m Arya \x1b[0m│\x1b[32m Stark \x1b[0m│\x1b[32m 3000 \x1b[0m│\x1b[36m \x1b[0m│\n"+ + "│\x1b[32m 20 \x1b[0m│\x1b[32m Jon \x1b[0m│\x1b[32m Snow \x1b[0m│\x1b[32m 2000 \x1b[0m│\x1b[36m You know nothing, Jon Snow! \x1b[0m│\n"+ + "│\x1b[32m 300 \x1b[0m│\x1b[32m Tyrion \x1b[0m│\x1b[32m Lannister \x1b[0m│\x1b[32m 5000 \x1b[0m│\x1b[36m \x1b[0m│\n"+ + "│\x1b[32m 0 \x1b[0m│\x1b[32m Winter \x1b[0m│\x1b[32m Is \x1b[0m│\x1b[32m 0 \x1b[0m│\x1b[36m Coming. \x1b[0m│\n"+ + "│\x1b[32m \x1b[0m│\x1b[32m \x1b[0m│\x1b[32m \x1b[0m│\x1b[32m \x1b[0m│\x1b[36m The North Remembers! \x1b[0m│\n"+ + "│\x1b[32m \x1b[0m│\x1b[32m \x1b[0m│\x1b[32m \x1b[0m│\x1b[32m \x1b[0m│\x1b[36m This is known. \x1b[0m│\n"+ + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤\n"+ + "│ │ │\x1b[94;1m TOTAL \x1b[0m│\x1b[94;1m 10000 \x1b[0m│ │\n"+ + "╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯\n"+ + "A Song of Ice and Fire", + ) + }) + + t.Run("colored table within table", func(t *testing.T) { + table := Table{} + table.AppendHeader(testHeader) + table.AppendRows(testRows) + table.AppendFooter(testFooter) + table.SetStyle(StyleColoredBright) + table.SetIndexColumn(1) + + // colored is simple; render the colored table into another table + tableOuter := Table{} + tableOuter.AppendRow(Row{table.Render()}) + tableOuter.SetStyle(StyleRounded) + + compareOutputColored(t, tableOuter.Render(), ""+ + "╭───────────────────────────────────────────────────────────────────╮\n"+ + "│ \x1b[106;30m # \x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m \x1b[0m │\n"+ + "│ \x1b[106;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m │\n"+ + "│ \x1b[106;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m │\n"+ + "│ \x1b[106;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m │\n"+ + "│ \x1b[46;30m \x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m \x1b[0m │\n"+ + "╰───────────────────────────────────────────────────────────────────╯", + ) + }) + + t.Run("colored table within colored table", func(t *testing.T) { + table := Table{} + table.AppendHeader(testHeader) + table.AppendRows(testRows) + table.AppendFooter(testFooter) + table.SetStyle(StyleColoredBright) + table.SetIndexColumn(1) + + // colored is simple; render the colored table into another colored table + tableOuter := Table{} + tableOuter.AppendHeader(Row{"Colored Table within a Colored Table"}) + tableOuter.AppendRow(Row{"\n" + table.Render() + "\n"}) + tableOuter.SetColumnConfigs([]ColumnConfig{{Number: 1, AlignHeader: text.AlignCenter}}) + tableOuter.SetStyle(StyleColoredBright) + + compareOutputColored(t, tableOuter.Render(), ""+ + "\x1b[106;30m COLORED TABLE WITHIN A COLORED TABLE \x1b[0m\n"+ + "\x1b[107;30m \x1b[0m\n"+ + "\x1b[107;30m \x1b[106;30m # \x1b[0m\x1b[107;30m\x1b[106;30m FIRST NAME \x1b[0m\x1b[107;30m\x1b[106;30m LAST NAME \x1b[0m\x1b[107;30m\x1b[106;30m SALARY \x1b[0m\x1b[107;30m\x1b[106;30m \x1b[0m\x1b[107;30m \x1b[0m\n"+ + "\x1b[107;30m \x1b[106;30m 1 \x1b[0m\x1b[107;30m\x1b[107;30m Arya \x1b[0m\x1b[107;30m\x1b[107;30m Stark \x1b[0m\x1b[107;30m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m\x1b[107;30m \x1b[0m\x1b[107;30m \x1b[0m\n"+ + "\x1b[107;30m \x1b[106;30m 20 \x1b[0m\x1b[107;30m\x1b[47;30m Jon \x1b[0m\x1b[107;30m\x1b[47;30m Snow \x1b[0m\x1b[107;30m\x1b[47;30m 2000 \x1b[0m\x1b[107;30m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\x1b[107;30m \x1b[0m\n"+ + "\x1b[107;30m \x1b[106;30m 300 \x1b[0m\x1b[107;30m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m\x1b[107;30m \x1b[0m\x1b[107;30m \x1b[0m\n"+ + "\x1b[107;30m \x1b[46;30m \x1b[0m\x1b[107;30m\x1b[46;30m \x1b[0m\x1b[107;30m\x1b[46;30m TOTAL \x1b[0m\x1b[107;30m\x1b[46;30m 10000 \x1b[0m\x1b[107;30m\x1b[46;30m \x1b[0m\x1b[107;30m \x1b[0m\n"+ + "\x1b[107;30m \x1b[0m", + ) + }) + + t.Run("colored table with auto-index", func(t *testing.T) { + table := Table{} + table.AppendHeader(testHeader) + table.AppendRows(testRows) + table.AppendFooter(testFooter) + table.SetAutoIndex(true) + table.SetStyle(StyleColoredDark) + table.SetTitle(testTitle2) + + compareOutputColored(t, table.Render(), ""+ + "\x1b[106;30;1m When you play the Game of Thrones, you win or you die. There is no \x1b[0m\n"+ + "\x1b[106;30;1m middle ground. \x1b[0m\n"+ + "\x1b[96;100m \x1b[0m\x1b[96;100m # \x1b[0m\x1b[96;100m FIRST NAME \x1b[0m\x1b[96;100m LAST NAME \x1b[0m\x1b[96;100m SALARY \x1b[0m\x1b[96;100m \x1b[0m\n"+ + "\x1b[96;100m 1 \x1b[0m\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n"+ + "\x1b[96;100m 2 \x1b[0m\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n"+ + "\x1b[96;100m 3 \x1b[0m\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n"+ + "\x1b[36;100m \x1b[0m\x1b[36;100m \x1b[0m\x1b[36;100m \x1b[0m\x1b[36;100m TOTAL \x1b[0m\x1b[36;100m 10000 \x1b[0m\x1b[36;100m \x1b[0m", + ) + }) +} + +func TestTable_Render_ColumnConfigs(t *testing.T) { + generatePrefixTransformer := func(prefix string) text.Transformer { + return func(val interface{}) string { + return fmt.Sprintf("%s%v", prefix, val) + } + } + generateSuffixTransformer := func(suffix string) text.Transformer { + return func(val interface{}) string { + return fmt.Sprintf("%v%s", val, suffix) + } + } + salaryTransformer := text.Transformer(func(val interface{}) string { + if valInt, ok := val.(int); ok { + return fmt.Sprintf("$ %.2f", float64(valInt)+0.03) + } + return strings.Replace(fmt.Sprint(val), "ry", "riii", -1) + }) + + tw := NewWriter() + tw.AppendHeader(testHeaderMultiLine) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooterMultiLine) + tw.SetAutoIndex(true) + tw.SetColumnConfigs([]ColumnConfig{ + { + Name: fmt.Sprint(testHeaderMultiLine[1]), // First Name + Align: text.AlignRight, + AlignFooter: text.AlignRight, + AlignHeader: text.AlignRight, + Colors: text.Colors{text.BgBlack, text.FgRed}, + ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold}, + ColorsFooter: text.Colors{text.BgRed, text.FgBlack}, + Transformer: generatePrefixTransformer("(r_"), + TransformerFooter: generatePrefixTransformer("(f_"), + TransformerHeader: generatePrefixTransformer("(h_"), + VAlign: text.VAlignTop, + VAlignFooter: text.VAlignTop, + VAlignHeader: text.VAlignTop, + WidthMax: 10, + }, { + Name: fmt.Sprint(testHeaderMultiLine[2]), // Last Name + Align: text.AlignLeft, + AlignFooter: text.AlignLeft, + AlignHeader: text.AlignLeft, + Colors: text.Colors{text.BgBlack, text.FgGreen}, + ColorsHeader: text.Colors{text.BgGreen, text.FgBlack, text.Bold}, + ColorsFooter: text.Colors{text.BgGreen, text.FgBlack}, + Transformer: generateSuffixTransformer("_r)"), + TransformerFooter: generateSuffixTransformer("_f)"), + TransformerHeader: generateSuffixTransformer("_h)"), + VAlign: text.VAlignMiddle, + VAlignFooter: text.VAlignMiddle, + VAlignHeader: text.VAlignMiddle, + WidthMax: 10, + }, { + Number: 4, // Salary + Colors: text.Colors{text.BgBlack, text.FgBlue}, + ColorsHeader: text.Colors{text.BgBlue, text.FgBlack, text.Bold}, + ColorsFooter: text.Colors{text.BgBlue, text.FgBlack}, + Transformer: salaryTransformer, + TransformerFooter: salaryTransformer, + TransformerHeader: salaryTransformer, + VAlign: text.VAlignBottom, + VAlignFooter: text.VAlignBottom, + VAlignHeader: text.VAlignBottom, + WidthMin: 16, + }, { + Name: "Non-existent Column", + Colors: text.Colors{text.BgYellow, text.FgHiRed}, + }, + }) + tw.SetStyle(styleTest) + + compareOutputColored(t, tw.Render(), ""+ + "(---^-----^-----------^------------^------------------^-----------------------------)\n"+ + "[< >|< #>|\x1b[41;30;1m< (H_FIRST>\x1b[0m|\x1b[42;30;1m\x1b[0m|\x1b[44;30;1m< >\x1b[0m|< >]\n"+ + "[< >|< >|\x1b[41;30;1m< NAME>\x1b[0m|\x1b[42;30;1m\x1b[0m|\x1b[44;30;1m< SALARIII>\x1b[0m|< >]\n"+ + "{---+-----+-----------+------------+------------------+-----------------------------}\n"+ + "[<1>|< 1>|\x1b[40;31m< (r_Arya>\x1b[0m|\x1b[40;32m\x1b[0m|\x1b[40;34m< $ 3000.03>\x1b[0m|< >]\n"+ + "[<2>|< 20>|\x1b[40;31m< (r_Jon>\x1b[0m|\x1b[40;32m\x1b[0m|\x1b[40;34m< $ 2000.03>\x1b[0m|]\n"+ + "[<3>|<300>|\x1b[40;31m<(r_Tyrion>\x1b[0m|\x1b[40;32m\x1b[0m|\x1b[40;34m< >\x1b[0m|< >]\n"+ + "[< >|< >|\x1b[40;31m< >\x1b[0m|\x1b[40;32m\x1b[0m|\x1b[40;34m< $ 5000.03>\x1b[0m|< >]\n"+ + "[<4>|< 0>|\x1b[40;31m<(r_Winter>\x1b[0m|\x1b[40;32m< >\x1b[0m|\x1b[40;34m< >\x1b[0m|]\n"+ + "[< >|< >|\x1b[40;31m< >\x1b[0m|\x1b[40;32m\x1b[0m|\x1b[40;34m< >\x1b[0m|]\n"+ + "[< >|< >|\x1b[40;31m< >\x1b[0m|\x1b[40;32m< >\x1b[0m|\x1b[40;34m< $ 0.03>\x1b[0m|]\n"+ + "{---+-----+-----------+------------+------------------+-----------------------------}\n"+ + "[< >|< >|\x1b[41;30m< (F_>\x1b[0m|\x1b[42;30m\x1b[0m|\x1b[44;30m< >\x1b[0m|< >]\n"+ + "[< >|< >|\x1b[41;30m< >\x1b[0m|\x1b[42;30m\x1b[0m|\x1b[44;30m< $ 10000.03>\x1b[0m|< >]\n"+ + "\\---v-----v-----------v------------v------------------v-----------------------------/", + ) +} + +func TestTable_Render_Empty(t *testing.T) { + tw := NewWriter() + assert.Empty(t, tw.Render()) +} + +func TestTable_Render_HiddenColumns(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + + // ensure sorting is done before hiding the columns + tw.SortBy([]SortBy{ + {Name: "Salary", Mode: DscNumeric}, + }) + + t.Run("no columns hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns(nil)) + + compareOutput(t, tw.Render(), ` ++-----+------------+-------------+--------+-------------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-------------+--------+-------------------------------+ +| 307 | >>Tyrion | Lannister<< | 5013 | | +| 8 | >>Arya | Stark<< | 3013 | | +| 27 | >>Jon | Snow<< | 2013 | ~You know nothing, Jon Snow!~ | ++-----+------------+-------------+--------+-------------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-------------+--------+-------------------------------+`) + }) + + t.Run("every column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4})) + + compareOutput(t, tw.Render(), "") + }) + + t.Run("some columns hidden (1)", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1, 2, 3, 4})) + + compareOutput(t, tw.Render(), ` ++-----+ +| # | ++-----+ +| 307 | +| 8 | +| 27 | ++-----+ +| | ++-----+`) + }) + + t.Run("some columns hidden (2)", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1, 2, 3})) + + compareOutput(t, tw.Render(), ` ++-----+-------------------------------+ +| # | | ++-----+-------------------------------+ +| 307 | | +| 8 | | +| 27 | ~You know nothing, Jon Snow!~ | ++-----+-------------------------------+ +| | | ++-----+-------------------------------+`) + }) + + t.Run("some columns hidden (3)", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 4})) + + compareOutput(t, tw.Render(), ` ++------------+-------------+--------+ +| FIRST NAME | LAST NAME | SALARY | ++------------+-------------+--------+ +| >>Tyrion | Lannister<< | 5013 | +| >>Arya | Stark<< | 3013 | +| >>Jon | Snow<< | 2013 | ++------------+-------------+--------+ +| | TOTAL | 10000 | ++------------+-------------+--------+`) + }) + + t.Run("first column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0})) + + compareOutput(t, tw.Render(), ` ++------------+-------------+--------+-------------------------------+ +| FIRST NAME | LAST NAME | SALARY | | ++------------+-------------+--------+-------------------------------+ +| >>Tyrion | Lannister<< | 5013 | | +| >>Arya | Stark<< | 3013 | | +| >>Jon | Snow<< | 2013 | ~You know nothing, Jon Snow!~ | ++------------+-------------+--------+-------------------------------+ +| | TOTAL | 10000 | | ++------------+-------------+--------+-------------------------------+`) + }) + + t.Run("column hidden in the middle", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1})) + + compareOutput(t, tw.Render(), ` ++-----+-------------+--------+-------------------------------+ +| # | LAST NAME | SALARY | | ++-----+-------------+--------+-------------------------------+ +| 307 | Lannister<< | 5013 | | +| 8 | Stark<< | 3013 | | +| 27 | Snow<< | 2013 | ~You know nothing, Jon Snow!~ | ++-----+-------------+--------+-------------------------------+ +| | TOTAL | 10000 | | ++-----+-------------+--------+-------------------------------+`) + }) + + t.Run("last column hidden", func(t *testing.T) { + tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4})) + + compareOutput(t, tw.Render(), ` ++-----+------------+-------------+--------+ +| # | FIRST NAME | LAST NAME | SALARY | ++-----+------------+-------------+--------+ +| 307 | >>Tyrion | Lannister<< | 5013 | +| 8 | >>Arya | Stark<< | 3013 | +| 27 | >>Jon | Snow<< | 2013 | ++-----+------------+-------------+--------+ +| | | TOTAL | 10000 | ++-----+------------+-------------+--------+`) + }) +} + +func TestTable_Render_Paged(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(Row{"", "", "Total", 10000}) + tw.SetPageSize(1) + + compareOutput(t, tw.Render(), ` ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 1 | Arya | Stark | 3000 | | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+ + ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+ + ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 300 | Tyrion | Lannister | 5000 | | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+ + ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| 0 | Winter | Is | 0 | Coming. | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+ + ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| | | | | The North Remembers! | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+ + ++-----+------------+-----------+--------+-----------------------------+ +| # | FIRST NAME | LAST NAME | SALARY | | ++-----+------------+-----------+--------+-----------------------------+ +| | | | | This is known. | ++-----+------------+-----------+--------+-----------------------------+ +| | | TOTAL | 10000 | | ++-----+------------+-----------+--------+-----------------------------+`) +} + +func TestTable_Render_Reset(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) + + tw.ResetFooters() + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) + + tw.ResetHeaders() + compareOutput(t, tw.Render(), ` +┌─────┬────────┬───────────┬──────┬─────────────────────────────┐ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +└─────┴────────┴───────────┴──────┴─────────────────────────────┘`) + + tw.ResetRows() + assert.Empty(t, tw.Render()) +} + +func TestTable_Render_RowPainter(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetIndexColumn(1) + tw.SetRowPainter(func(row Row) text.Colors { + if salary, ok := row[3].(int); ok { + if salary > 3000 { + return text.Colors{text.BgYellow, text.FgBlack} + } else if salary < 2000 { + return text.Colors{text.BgRed, text.FgBlack} + } + } + return nil + }) + tw.SetStyle(StyleLight) + tw.SortBy([]SortBy{{Name: "Salary", Mode: AscNumeric}}) + + expectedOutLines := []string{ + "┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐", + "│ # │ FIRST NAME │ LAST NAME │ SALARY │ │", + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", + "│ 0 │\x1b[41;30m Winter \x1b[0m│\x1b[41;30m Is \x1b[0m│\x1b[41;30m 0 \x1b[0m│\x1b[41;30m Coming. \x1b[0m│", + "│ │\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m The North Remembers! \x1b[0m│", + "│ │\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m This is known. \x1b[0m│", + "│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │", + "│ 1 │ Arya │ Stark │ 3000 │ │", + "│ 300 │\x1b[43;30m Tyrion \x1b[0m│\x1b[43;30m Lannister \x1b[0m│\x1b[43;30m 5000 \x1b[0m│\x1b[43;30m \x1b[0m│", + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", + "│ │ │ TOTAL │ 10000 │ │", + "└─────┴────────────┴───────────┴────────┴─────────────────────────────┘", + } + expectedOut := strings.Join(expectedOutLines, "\n") + assert.Equal(t, expectedOut, tw.Render()) + + tw.SetStyle(StyleColoredBright) + tw.Style().Color.RowAlternate = tw.Style().Color.Row + expectedOutLines = []string{ + "\x1b[106;30m # \x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m \x1b[0m", + "\x1b[106;30m 0 \x1b[0m\x1b[41;30m Winter \x1b[0m\x1b[41;30m Is \x1b[0m\x1b[41;30m 0 \x1b[0m\x1b[41;30m Coming. \x1b[0m", + "\x1b[106;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m The North Remembers! \x1b[0m", + "\x1b[106;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m This is known. \x1b[0m", + "\x1b[106;30m 20 \x1b[0m\x1b[107;30m Jon \x1b[0m\x1b[107;30m Snow \x1b[0m\x1b[107;30m 2000 \x1b[0m\x1b[107;30m You know nothing, Jon Snow! \x1b[0m", + "\x1b[106;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m", + "\x1b[106;30m 300 \x1b[0m\x1b[43;30m Tyrion \x1b[0m\x1b[43;30m Lannister \x1b[0m\x1b[43;30m 5000 \x1b[0m\x1b[43;30m \x1b[0m", + "\x1b[46;30m \x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m \x1b[0m", + } + expectedOut = strings.Join(expectedOutLines, "\n") + assert.Equal(t, expectedOut, tw.Render()) +} + +func TestTable_Render_Sorted(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(Row{11, "Sansa", "Stark", 6000}) + tw.AppendFooter(testFooter) + tw.SetStyle(StyleLight) + tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}}) + + compareOutput(t, tw.Render(), `┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 11 │ Sansa │ Stark │ 6000 │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) +} + +func TestTable_Render_Separator(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendSeparator() // doesn't make any difference + tw.AppendRows(testRows) + tw.AppendSeparator() + tw.AppendSeparator() // doesn't make any difference + tw.AppendRow(testRowMultiLine) + tw.AppendSeparator() + tw.AppendSeparator() // doesn't make any difference + tw.AppendSeparator() // doesn't make any difference + tw.AppendRow(Row{11, "Sansa", "Stark", 6000}) + tw.AppendSeparator() // doesn't make any difference + tw.AppendSeparator() // doesn't make any difference + tw.AppendSeparator() // doesn't make any difference + tw.AppendSeparator() // doesn't make any difference + tw.AppendFooter(testFooter) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 0 │ Winter │ Is │ 0 │ Coming. │ +│ │ │ │ │ The North Remembers! │ +│ │ │ │ │ This is known. │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 11 │ Sansa │ Stark │ 6000 │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) +} + +func TestTable_Render_Styles(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + tw.SetStyle(StyleLight) + + styles := map[*Style]string{ + &StyleDefault: "+-----+------------+-----------+--------+-----------------------------+\n| # | FIRST NAME | LAST NAME | SALARY | |\n+-----+------------+-----------+--------+-----------------------------+\n| 1 | Arya | Stark | 3000 | |\n| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |\n| 300 | Tyrion | Lannister | 5000 | |\n+-----+------------+-----------+--------+-----------------------------+\n| | | TOTAL | 10000 | |\n+-----+------------+-----------+--------+-----------------------------+", + &StyleBold: "┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃\n┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃\n┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃\n┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃\n┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃\n┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛", + &StyleColoredBlackOnBlueWhite: "\x1b[104;30m # \x1b[0m\x1b[104;30m FIRST NAME \x1b[0m\x1b[104;30m LAST NAME \x1b[0m\x1b[104;30m SALARY \x1b[0m\x1b[104;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[44;30m \x1b[0m\x1b[44;30m \x1b[0m\x1b[44;30m TOTAL \x1b[0m\x1b[44;30m 10000 \x1b[0m\x1b[44;30m \x1b[0m", + &StyleColoredBlackOnCyanWhite: "\x1b[106;30m # \x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[46;30m \x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m \x1b[0m", + &StyleColoredBlackOnGreenWhite: "\x1b[102;30m # \x1b[0m\x1b[102;30m FIRST NAME \x1b[0m\x1b[102;30m LAST NAME \x1b[0m\x1b[102;30m SALARY \x1b[0m\x1b[102;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[42;30m \x1b[0m\x1b[42;30m \x1b[0m\x1b[42;30m TOTAL \x1b[0m\x1b[42;30m 10000 \x1b[0m\x1b[42;30m \x1b[0m", + &StyleColoredBlackOnMagentaWhite: "\x1b[105;30m # \x1b[0m\x1b[105;30m FIRST NAME \x1b[0m\x1b[105;30m LAST NAME \x1b[0m\x1b[105;30m SALARY \x1b[0m\x1b[105;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[45;30m \x1b[0m\x1b[45;30m \x1b[0m\x1b[45;30m TOTAL \x1b[0m\x1b[45;30m 10000 \x1b[0m\x1b[45;30m \x1b[0m", + &StyleColoredBlackOnRedWhite: "\x1b[101;30m # \x1b[0m\x1b[101;30m FIRST NAME \x1b[0m\x1b[101;30m LAST NAME \x1b[0m\x1b[101;30m SALARY \x1b[0m\x1b[101;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m TOTAL \x1b[0m\x1b[41;30m 10000 \x1b[0m\x1b[41;30m \x1b[0m", + &StyleColoredBlackOnYellowWhite: "\x1b[103;30m # \x1b[0m\x1b[103;30m FIRST NAME \x1b[0m\x1b[103;30m LAST NAME \x1b[0m\x1b[103;30m SALARY \x1b[0m\x1b[103;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[43;30m \x1b[0m\x1b[43;30m \x1b[0m\x1b[43;30m TOTAL \x1b[0m\x1b[43;30m 10000 \x1b[0m\x1b[43;30m \x1b[0m", + &StyleColoredBlueWhiteOnBlack: "\x1b[94;100m # \x1b[0m\x1b[94;100m FIRST NAME \x1b[0m\x1b[94;100m LAST NAME \x1b[0m\x1b[94;100m SALARY \x1b[0m\x1b[94;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[34;100m \x1b[0m\x1b[34;100m \x1b[0m\x1b[34;100m TOTAL \x1b[0m\x1b[34;100m 10000 \x1b[0m\x1b[34;100m \x1b[0m", + &StyleColoredBright: "\x1b[106;30m # \x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m \x1b[0m\n\x1b[107;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[47;30m 20 \x1b[0m\x1b[47;30m Jon \x1b[0m\x1b[47;30m Snow \x1b[0m\x1b[47;30m 2000 \x1b[0m\x1b[47;30m You know nothing, Jon Snow! \x1b[0m\n\x1b[107;30m 300 \x1b[0m\x1b[107;30m Tyrion \x1b[0m\x1b[107;30m Lannister \x1b[0m\x1b[107;30m 5000 \x1b[0m\x1b[107;30m \x1b[0m\n\x1b[46;30m \x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m \x1b[0m", + &StyleColoredCyanWhiteOnBlack: "\x1b[96;100m # \x1b[0m\x1b[96;100m FIRST NAME \x1b[0m\x1b[96;100m LAST NAME \x1b[0m\x1b[96;100m SALARY \x1b[0m\x1b[96;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[36;100m \x1b[0m\x1b[36;100m \x1b[0m\x1b[36;100m TOTAL \x1b[0m\x1b[36;100m 10000 \x1b[0m\x1b[36;100m \x1b[0m", + &StyleColoredDark: "\x1b[96;100m # \x1b[0m\x1b[96;100m FIRST NAME \x1b[0m\x1b[96;100m LAST NAME \x1b[0m\x1b[96;100m SALARY \x1b[0m\x1b[96;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[36;100m \x1b[0m\x1b[36;100m \x1b[0m\x1b[36;100m TOTAL \x1b[0m\x1b[36;100m 10000 \x1b[0m\x1b[36;100m \x1b[0m", + &StyleColoredGreenWhiteOnBlack: "\x1b[92;100m # \x1b[0m\x1b[92;100m FIRST NAME \x1b[0m\x1b[92;100m LAST NAME \x1b[0m\x1b[92;100m SALARY \x1b[0m\x1b[92;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[32;100m \x1b[0m\x1b[32;100m \x1b[0m\x1b[32;100m TOTAL \x1b[0m\x1b[32;100m 10000 \x1b[0m\x1b[32;100m \x1b[0m", + &StyleColoredMagentaWhiteOnBlack: "\x1b[95;100m # \x1b[0m\x1b[95;100m FIRST NAME \x1b[0m\x1b[95;100m LAST NAME \x1b[0m\x1b[95;100m SALARY \x1b[0m\x1b[95;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[35;100m \x1b[0m\x1b[35;100m \x1b[0m\x1b[35;100m TOTAL \x1b[0m\x1b[35;100m 10000 \x1b[0m\x1b[35;100m \x1b[0m", + &StyleColoredRedWhiteOnBlack: "\x1b[91;100m # \x1b[0m\x1b[91;100m FIRST NAME \x1b[0m\x1b[91;100m LAST NAME \x1b[0m\x1b[91;100m SALARY \x1b[0m\x1b[91;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[31;100m \x1b[0m\x1b[31;100m \x1b[0m\x1b[31;100m TOTAL \x1b[0m\x1b[31;100m 10000 \x1b[0m\x1b[31;100m \x1b[0m", + &StyleColoredYellowWhiteOnBlack: "\x1b[93;100m # \x1b[0m\x1b[93;100m FIRST NAME \x1b[0m\x1b[93;100m LAST NAME \x1b[0m\x1b[93;100m SALARY \x1b[0m\x1b[93;100m \x1b[0m\n\x1b[97;40m 1 \x1b[0m\x1b[97;40m Arya \x1b[0m\x1b[97;40m Stark \x1b[0m\x1b[97;40m 3000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[37;40m 20 \x1b[0m\x1b[37;40m Jon \x1b[0m\x1b[37;40m Snow \x1b[0m\x1b[37;40m 2000 \x1b[0m\x1b[37;40m You know nothing, Jon Snow! \x1b[0m\n\x1b[97;40m 300 \x1b[0m\x1b[97;40m Tyrion \x1b[0m\x1b[97;40m Lannister \x1b[0m\x1b[97;40m 5000 \x1b[0m\x1b[97;40m \x1b[0m\n\x1b[33;100m \x1b[0m\x1b[33;100m \x1b[0m\x1b[33;100m TOTAL \x1b[0m\x1b[33;100m 10000 \x1b[0m\x1b[33;100m \x1b[0m", + &StyleDouble: "╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗\n║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║\n╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣\n║ 1 ║ Arya ║ Stark ║ 3000 ║ ║\n║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║\n║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║\n╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣\n║ ║ ║ TOTAL ║ 10000 ║ ║\n╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝", + &StyleLight: "┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐\n│ # │ FIRST NAME │ LAST NAME │ SALARY │ │\n├─────┼────────────┼───────────┼────────┼─────────────────────────────┤\n│ 1 │ Arya │ Stark │ 3000 │ │\n│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │\n│ 300 │ Tyrion │ Lannister │ 5000 │ │\n├─────┼────────────┼───────────┼────────┼─────────────────────────────┤\n│ │ │ TOTAL │ 10000 │ │\n└─────┴────────────┴───────────┴────────┴─────────────────────────────┘", + &StyleRounded: "╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮\n│ # │ FIRST NAME │ LAST NAME │ SALARY │ │\n├─────┼────────────┼───────────┼────────┼─────────────────────────────┤\n│ 1 │ Arya │ Stark │ 3000 │ │\n│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │\n│ 300 │ Tyrion │ Lannister │ 5000 │ │\n├─────┼────────────┼───────────┼────────┼─────────────────────────────┤\n│ │ │ TOTAL │ 10000 │ │\n╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯", + &styleTest: "(-----^------------^-----------^--------^-----------------------------)\n[< #>||||< >]\n{-----+------------+-----------+--------+-----------------------------}\n[< 1>|||< 3000>|< >]\n[< 20>|||< 2000>|]\n[<300>|||< 5000>|< >]\n{-----+------------+-----------+--------+-----------------------------}\n[< >|< >||< 10000>|< >]\n\\-----v------------v-----------v--------v-----------------------------/", + } + var mismatches []string + for style, expectedOut := range styles { + tw.SetStyle(*style) + out := tw.Render() + assert.Equal(t, expectedOut, out) + if expectedOut != out { + mismatches = append(mismatches, fmt.Sprintf("&%s: %#v,", style.Name, out)) + fmt.Printf("// %s renders a Table like below:\n", style.Name) + for _, line := range strings.Split(out, "\n") { + fmt.Printf("// %s\n", line) + } + fmt.Println() + } + } + sort.Strings(mismatches) + for _, mismatch := range mismatches { + fmt.Println(mismatch) + } +} + +func TestTable_Render_SuppressEmptyColumns(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows([]Row{ + {1, "Arya", "", 3000}, + {20, "Jon", "", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "", 5000}, + }) + tw.AppendRow(Row{11, "Sansa", "", 6000}) + tw.AppendFooter(Row{"", "", "TOTAL", 10000}) + tw.SetStyle(StyleLight) + + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ │ 3000 │ │ +│ 20 │ Jon │ │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ │ 5000 │ │ +│ 11 │ Sansa │ │ 6000 │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) + + tw.SuppressEmptyColumns() + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ SALARY │ │ +├─────┼────────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ 3000 │ │ +│ 20 │ Jon │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ 5000 │ │ +│ 11 │ Sansa │ 6000 │ │ +├─────┼────────────┼────────┼─────────────────────────────┤ +│ │ │ 10000 │ │ +└─────┴────────────┴────────┴─────────────────────────────┘`) +} + +func TestTable_Render_TableWithinTable(t *testing.T) { + twInner := NewWriter() + twInner.AppendHeader(testHeader) + twInner.AppendRows(testRows) + twInner.AppendFooter(testFooter) + twInner.SetStyle(StyleLight) + + twOuter := NewWriter() + twOuter.AppendHeader(Row{"Table within a Table"}) + twOuter.AppendRow(Row{twInner.Render()}) + twOuter.SetColumnConfigs([]ColumnConfig{{Number: 1, AlignHeader: text.AlignCenter}}) + twOuter.SetStyle(StyleDouble) + + compareOutput(t, twOuter.Render(), ` +╔═════════════════════════════════════════════════════════════════════════╗ +║ TABLE WITHIN A TABLE ║ +╠═════════════════════════════════════════════════════════════════════════╣ +║ ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ ║ +║ │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ ║ +║ ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ ║ +║ │ 1 │ Arya │ Stark │ 3000 │ │ ║ +║ │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ ║ +║ │ 300 │ Tyrion │ Lannister │ 5000 │ │ ║ +║ ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ ║ +║ │ │ │ TOTAL │ 10000 │ │ ║ +║ └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ ║ +╚═════════════════════════════════════════════════════════════════════════╝`) +} + +func TestTable_Render_TableWithTransformers(t *testing.T) { + bolden := func(val interface{}) string { + return text.Bold.Sprint(val) + } + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + tw.SetColumnConfigs([]ColumnConfig{{ + Name: "Salary", + Transformer: bolden, + TransformerFooter: bolden, + TransformerHeader: bolden, + }}) + tw.SetStyle(StyleLight) + + expectedOut := []string{ + "┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐", + "│ # │ FIRST NAME │ LAST NAME │ \x1b[1mSALARY\x1b[0m │ │", + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", + "│ 1 │ Arya │ Stark │ \x1b[1m3000\x1b[0m │ │", + "│ 20 │ Jon │ Snow │ \x1b[1m2000\x1b[0m │ You know nothing, Jon Snow! │", + "│ 300 │ Tyrion │ Lannister │ \x1b[1m5000\x1b[0m │ │", + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", + "│ │ │ TOTAL │ \x1b[1m10000\x1b[0m │ │", + "└─────┴────────────┴───────────┴────────┴─────────────────────────────┘", + } + out := tw.Render() + assert.Equal(t, strings.Join(expectedOut, "\n"), out) + if strings.Join(expectedOut, "\n") != out { + for _, line := range strings.Split(out, "\n") { + fmt.Printf("%#v,\n", line) + } + } +} + +func TestTable_Render_SetWidth_Title(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + tw.SetTitle("Game Of Thrones") + + t.Run("length 20", func(t *testing.T) { + tw.SetAllowedRowLength(20) + + expectedOut := []string{ + "+------------------+", + "| Game Of Thrones |", + "+-----+----------- ~", + "| # | FIRST NAME ~", + "+-----+----------- ~", + "| 1 | Arya ~", + "| 20 | Jon ~", + "| 300 | Tyrion ~", + "+-----+----------- ~", + "| | ~", + "+-----+----------- ~", + } + + assert.Equal(t, strings.Join(expectedOut, "\n"), tw.Render()) + }) + + t.Run("length 30", func(t *testing.T) { + tw.SetAllowedRowLength(30) + + expectedOut := []string{ + "+----------------------------+", + "| Game Of Thrones |", + "+-----+------------+-------- ~", + "| # | FIRST NAME | LAST NA ~", + "+-----+------------+-------- ~", + "| 1 | Arya | Stark ~", + "| 20 | Jon | Snow ~", + "| 300 | Tyrion | Lannist ~", + "+-----+------------+-------- ~", + "| | | TOTAL ~", + "+-----+------------+-------- ~", + } + + assert.Equal(t, strings.Join(expectedOut, "\n"), tw.Render()) + }) +} + +func TestTable_Render_WidthEnforcer(t *testing.T) { + tw := NewWriter() + tw.AppendRows([]Row{ + {"U2", "Hey", "2021-04-19 13:37", "Yuh yuh yuh"}, + {"S12", "Uhhhh", "2021-04-19 13:37", "Some dummy data here"}, + {"R123", "Lobsters", "2021-04-19 13:37", "I like lobsters"}, + {"R123", "Some big name here and it's pretty big", "2021-04-19 13:37", "Abcdefghijklmnopqrstuvwxyz"}, + {"R123", "Small name", "2021-04-19 13:37", "Abcdefghijklmnopqrstuvwxyz"}, + }) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 2, WidthMax: 20, WidthMaxEnforcer: text.Trim}, + }) + + compareOutput(t, tw.Render(), ` ++------+----------------------+------------------+----------------------------+ +| U2 | Hey | 2021-04-19 13:37 | Yuh yuh yuh | +| S12 | Uhhhh | 2021-04-19 13:37 | Some dummy data here | +| R123 | Lobsters | 2021-04-19 13:37 | I like lobsters | +| R123 | Some big name here a | 2021-04-19 13:37 | Abcdefghijklmnopqrstuvwxyz | +| R123 | Small name | 2021-04-19 13:37 | Abcdefghijklmnopqrstuvwxyz | ++------+----------------------+------------------+----------------------------+`) +} diff --git a/helper/table/sort.go b/helper/table/sort.go new file mode 100644 index 0000000..ae55f30 --- /dev/null +++ b/helper/table/sort.go @@ -0,0 +1,127 @@ +package table + +import ( + "sort" + "strconv" +) + +// SortBy defines What to sort (Column Name or Number), and How to sort (Mode). +type SortBy struct { + // Name is the name of the Column as it appears in the first Header row. + // If a Header is not provided, or the name is not found in the header, this + // will not work. + Name string + // Number is the Column # from left. When specified, it overrides the Name + // property. If you know the exact Column number, use this instead of Name. + Number int + + // Mode tells the Writer how to Sort. Asc/Dsc/etc. + Mode SortMode +} + +// SortMode defines How to sort. +type SortMode int + +const ( + // Asc sorts the column in Ascending order alphabetically. + Asc SortMode = iota + // AscNumeric sorts the column in Ascending order numerically. + AscNumeric + // Dsc sorts the column in Descending order alphabetically. + Dsc + // DscNumeric sorts the column in Descending order numerically. + DscNumeric +) + +type rowsSorter struct { + rows []rowStr + sortBy []SortBy + sortedIndices []int +} + +// getSortedRowIndices sorts and returns the row indices in Sorted order as +// directed by Table.sortBy which can be set using Table.SortBy(...) +func (t *Table) getSortedRowIndices() []int { + sortedIndices := make([]int, len(t.rows)) + for idx := range t.rows { + sortedIndices[idx] = idx + } + + if t.sortBy != nil && len(t.sortBy) > 0 { + sort.Sort(rowsSorter{ + rows: t.rows, + sortBy: t.parseSortBy(t.sortBy), + sortedIndices: sortedIndices, + }) + } + + return sortedIndices +} + +func (t *Table) parseSortBy(sortBy []SortBy) []SortBy { + var resSortBy []SortBy + for _, col := range sortBy { + colNum := 0 + if col.Number > 0 && col.Number <= t.numColumns { + colNum = col.Number + } else if col.Name != "" && len(t.rowsHeader) > 0 { + for idx, colName := range t.rowsHeader[0] { + if col.Name == colName { + colNum = idx + 1 + break + } + } + } + if colNum > 0 { + resSortBy = append(resSortBy, SortBy{ + Name: col.Name, + Number: colNum, + Mode: col.Mode, + }) + } + } + return resSortBy +} + +func (rs rowsSorter) Len() int { + return len(rs.rows) +} + +func (rs rowsSorter) Swap(i, j int) { + rs.sortedIndices[i], rs.sortedIndices[j] = rs.sortedIndices[j], rs.sortedIndices[i] +} + +func (rs rowsSorter) Less(i, j int) bool { + realI, realJ := rs.sortedIndices[i], rs.sortedIndices[j] + for _, col := range rs.sortBy { + rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], col.Number-1 + if colIdx < len(rowI) && colIdx < len(rowJ) { + shouldContinue, returnValue := rs.lessColumns(rowI, rowJ, colIdx, col) + if !shouldContinue { + return returnValue + } + } + } + return false +} + +func (rs rowsSorter) lessColumns(rowI rowStr, rowJ rowStr, colIdx int, col SortBy) (bool, bool) { + if rowI[colIdx] == rowJ[colIdx] { + return true, false + } else if col.Mode == Asc { + return false, rowI[colIdx] < rowJ[colIdx] + } else if col.Mode == Dsc { + return false, rowI[colIdx] > rowJ[colIdx] + } + + iVal, iErr := strconv.ParseFloat(rowI[colIdx], 64) + jVal, jErr := strconv.ParseFloat(rowJ[colIdx], 64) + if iErr == nil && jErr == nil { + if col.Mode == AscNumeric { + return false, iVal < jVal + } else if col.Mode == DscNumeric { + return false, jVal < iVal + } + } + return true, false +} diff --git a/helper/table/sort_test.go b/helper/table/sort_test.go new file mode 100644 index 0000000..d63164d --- /dev/null +++ b/helper/table/sort_test.go @@ -0,0 +1,148 @@ +package table + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTable_sortRows_WithName(t *testing.T) { + table := Table{} + table.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"}) + table.AppendRows([]Row{ + {1, "Arya", "Stark", 3000}, + {11, "Sansa", "Stark", 3000}, + {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "Lannister", 5000}, + }) + table.SetStyle(StyleDefault) + table.initForRenderRows() + + // sort by nothing + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + // sort by "#" + table.SortBy([]SortBy{{Name: "#", Mode: AscNumeric}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "#", Mode: DscNumeric}}) + assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices()) + + // sort by First Name, Last Name + table.SortBy([]SortBy{{Name: "First Name", Mode: Asc}, {Name: "Last Name", Mode: Asc}}) + assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "First Name", Mode: Asc}, {Name: "Last Name", Mode: Dsc}}) + assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "First Name", Mode: Dsc}, {Name: "Last Name", Mode: Asc}}) + assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "First Name", Mode: Dsc}, {Name: "Last Name", Mode: Dsc}}) + assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices()) + + // sort by Last Name, First Name + table.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}}) + assert.Equal(t, []int{3, 2, 0, 1}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Dsc}}) + assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "First Name", Mode: Asc}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "First Name", Mode: Dsc}}) + assert.Equal(t, []int{1, 0, 2, 3}, table.getSortedRowIndices()) + + // sort by Unknown Column + table.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "Foo Bar", Mode: Dsc}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + // sort by Salary + table.SortBy([]SortBy{{Name: "Salary", Mode: AscNumeric}}) + assert.Equal(t, []int{2, 0, 1, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Name: "Salary", Mode: DscNumeric}}) + assert.Equal(t, []int{3, 0, 1, 2}, table.getSortedRowIndices()) + + table.SortBy(nil) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) +} + +func TestTable_sortRows_WithoutName(t *testing.T) { + table := Table{} + table.AppendRows([]Row{ + {1, "Arya", "Stark", 3000}, + {11, "Sansa", "Stark", 3000}, + {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "Lannister", 5000}, + }) + table.SetStyle(StyleDefault) + table.initForRenderRows() + + // sort by nothing + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + // sort by "#" + table.SortBy([]SortBy{{Number: 1, Mode: AscNumeric}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 1, Mode: DscNumeric}}) + assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices()) + + // sort by First Name, Last Name + table.SortBy([]SortBy{{Number: 2, Mode: Asc}, {Number: 3, Mode: Asc}}) + assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 2, Mode: Asc}, {Number: 3, Mode: Dsc}}) + assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 2, Mode: Dsc}, {Number: 3, Mode: Asc}}) + assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 2, Mode: Dsc}, {Number: 3, Mode: Dsc}}) + assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices()) + + // sort by Last Name, First Name + table.SortBy([]SortBy{{Number: 3, Mode: Asc}, {Number: 2, Mode: Asc}}) + assert.Equal(t, []int{3, 2, 0, 1}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 3, Mode: Asc}, {Number: 2, Mode: Dsc}}) + assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 3, Mode: Dsc}, {Number: 2, Mode: Asc}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 3, Mode: Dsc}, {Number: 2, Mode: Dsc}}) + assert.Equal(t, []int{1, 0, 2, 3}, table.getSortedRowIndices()) + + // sort by Unknown Column + table.SortBy([]SortBy{{Number: 3, Mode: Dsc}, {Number: 99, Mode: Dsc}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) + + // sort by Salary + table.SortBy([]SortBy{{Number: 4, Mode: AscNumeric}}) + assert.Equal(t, []int{2, 0, 1, 3}, table.getSortedRowIndices()) + + table.SortBy([]SortBy{{Number: 4, Mode: DscNumeric}}) + assert.Equal(t, []int{3, 0, 1, 2}, table.getSortedRowIndices()) + + table.SortBy(nil) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) +} + +func TestTable_sortRows_InvalidMode(t *testing.T) { + table := Table{} + table.AppendRows([]Row{ + {1, "Arya", "Stark", 3000}, + {11, "Sansa", "Stark", 3000}, + {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "Lannister", 5000}, + }) + table.SetStyle(StyleDefault) + table.initForRenderRows() + + // sort by "First Name" + table.SortBy([]SortBy{{Number: 2, Mode: AscNumeric}}) + assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices()) +} diff --git a/helper/table/style.go b/helper/table/style.go new file mode 100644 index 0000000..bba1cde --- /dev/null +++ b/helper/table/style.go @@ -0,0 +1,879 @@ +package table + +import "git.hexq.cn/tiglog/golib/helper/text" + +// Style declares how to render the Table and provides very fine-grained control +// on how the Table gets rendered on the Console. +type Style struct { + Name string // name of the Style + Box BoxStyle // characters to use for the boxes + Color ColorOptions // colors to use for the rows and columns + Format FormatOptions // formatting options for the rows and columns + HTML HTMLOptions // rendering options for HTML mode + Options Options // misc. options for the table + Title TitleOptions // formation options for the title text +} + +var ( + // StyleDefault renders a Table like below: + // +-----+------------+-----------+--------+-----------------------------+ + // | # | FIRST NAME | LAST NAME | SALARY | | + // +-----+------------+-----------+--------+-----------------------------+ + // | 1 | Arya | Stark | 3000 | | + // | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | + // | 300 | Tyrion | Lannister | 5000 | | + // +-----+------------+-----------+--------+-----------------------------+ + // | | | TOTAL | 10000 | | + // +-----+------------+-----------+--------+-----------------------------+ + StyleDefault = Style{ + Name: "StyleDefault", + Box: StyleBoxDefault, + Color: ColorOptionsDefault, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsDefault, + Title: TitleOptionsDefault, + } + + // StyleBold renders a Table like below: + // ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + // ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃ + // ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + // ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃ + // ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃ + // ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃ + // ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + // ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃ + // ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + StyleBold = Style{ + Name: "StyleBold", + Box: StyleBoxBold, + Color: ColorOptionsDefault, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsDefault, + Title: TitleOptionsDefault, + } + + // StyleColoredBright renders a Table without any borders or separators, + // and with Black text on Cyan background for Header/Footer and + // White background for other rows. + StyleColoredBright = Style{ + Name: "StyleColoredBright", + Box: StyleBoxDefault, + Color: ColorOptionsBright, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsDark, + } + + // StyleColoredDark renders a Table without any borders or separators, and + // with Header/Footer in Cyan text and other rows with White text, all on + // Black background. + StyleColoredDark = Style{ + Name: "StyleColoredDark", + Box: StyleBoxDefault, + Color: ColorOptionsDark, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBright, + } + + // StyleColoredBlackOnBlueWhite renders a Table without any borders or + // separators, and with Black text on Blue background for Header/Footer and + // White background for other rows. + StyleColoredBlackOnBlueWhite = Style{ + Name: "StyleColoredBlackOnBlueWhite", + Box: StyleBoxDefault, + Color: ColorOptionsBlackOnBlueWhite, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlueOnBlack, + } + + // StyleColoredBlackOnCyanWhite renders a Table without any borders or + // separators, and with Black text on Cyan background for Header/Footer and + // White background for other rows. + StyleColoredBlackOnCyanWhite = Style{ + Name: "StyleColoredBlackOnCyanWhite", + Box: StyleBoxDefault, + Color: ColorOptionsBlackOnCyanWhite, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsCyanOnBlack, + } + + // StyleColoredBlackOnGreenWhite renders a Table without any borders or + // separators, and with Black text on Green background for Header/Footer and + // White background for other rows. + StyleColoredBlackOnGreenWhite = Style{ + Name: "StyleColoredBlackOnGreenWhite", + Box: StyleBoxDefault, + Color: ColorOptionsBlackOnGreenWhite, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsGreenOnBlack, + } + + // StyleColoredBlackOnMagentaWhite renders a Table without any borders or + // separators, and with Black text on Magenta background for Header/Footer and + // White background for other rows. + StyleColoredBlackOnMagentaWhite = Style{ + Name: "StyleColoredBlackOnMagentaWhite", + Box: StyleBoxDefault, + Color: ColorOptionsBlackOnMagentaWhite, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsMagentaOnBlack, + } + + // StyleColoredBlackOnYellowWhite renders a Table without any borders or + // separators, and with Black text on Yellow background for Header/Footer and + // White background for other rows. + StyleColoredBlackOnYellowWhite = Style{ + Name: "StyleColoredBlackOnYellowWhite", + Box: StyleBoxDefault, + Color: ColorOptionsBlackOnYellowWhite, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsYellowOnBlack, + } + + // StyleColoredBlackOnRedWhite renders a Table without any borders or + // separators, and with Black text on Red background for Header/Footer and + // White background for other rows. + StyleColoredBlackOnRedWhite = Style{ + Name: "StyleColoredBlackOnRedWhite", + Box: StyleBoxDefault, + Color: ColorOptionsBlackOnRedWhite, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsRedOnBlack, + } + + // StyleColoredBlueWhiteOnBlack renders a Table without any borders or + // separators, and with Header/Footer in Blue text and other rows with + // White text, all on Black background. + StyleColoredBlueWhiteOnBlack = Style{ + Name: "StyleColoredBlueWhiteOnBlack", + Box: StyleBoxDefault, + Color: ColorOptionsBlueWhiteOnBlack, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlackOnBlue, + } + + // StyleColoredCyanWhiteOnBlack renders a Table without any borders or + // separators, and with Header/Footer in Cyan text and other rows with + // White text, all on Black background. + StyleColoredCyanWhiteOnBlack = Style{ + Name: "StyleColoredCyanWhiteOnBlack", + Box: StyleBoxDefault, + Color: ColorOptionsCyanWhiteOnBlack, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlackOnCyan, + } + + // StyleColoredGreenWhiteOnBlack renders a Table without any borders or + // separators, and with Header/Footer in Green text and other rows with + // White text, all on Black background. + StyleColoredGreenWhiteOnBlack = Style{ + Name: "StyleColoredGreenWhiteOnBlack", + Box: StyleBoxDefault, + Color: ColorOptionsGreenWhiteOnBlack, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlackOnGreen, + } + + // StyleColoredMagentaWhiteOnBlack renders a Table without any borders or + // separators, and with Header/Footer in Magenta text and other rows with + // White text, all on Black background. + StyleColoredMagentaWhiteOnBlack = Style{ + Name: "StyleColoredMagentaWhiteOnBlack", + Box: StyleBoxDefault, + Color: ColorOptionsMagentaWhiteOnBlack, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlackOnMagenta, + } + + // StyleColoredRedWhiteOnBlack renders a Table without any borders or + // separators, and with Header/Footer in Red text and other rows with + // White text, all on Black background. + StyleColoredRedWhiteOnBlack = Style{ + Name: "StyleColoredRedWhiteOnBlack", + Box: StyleBoxDefault, + Color: ColorOptionsRedWhiteOnBlack, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlackOnRed, + } + + // StyleColoredYellowWhiteOnBlack renders a Table without any borders or + // separators, and with Header/Footer in Yellow text and other rows with + // White text, all on Black background. + StyleColoredYellowWhiteOnBlack = Style{ + Name: "StyleColoredYellowWhiteOnBlack", + Box: StyleBoxDefault, + Color: ColorOptionsYellowWhiteOnBlack, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsNoBordersAndSeparators, + Title: TitleOptionsBlackOnYellow, + } + + // StyleDouble renders a Table like below: + // ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗ + // ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║ + // ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣ + // ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║ + // ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║ + // ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║ + // ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣ + // ║ ║ ║ TOTAL ║ 10000 ║ ║ + // ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝ + StyleDouble = Style{ + Name: "StyleDouble", + Box: StyleBoxDouble, + Color: ColorOptionsDefault, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsDefault, + Title: TitleOptionsDefault, + } + + // StyleLight renders a Table like below: + // ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ │ │ TOTAL │ 10000 │ │ + // └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ + StyleLight = Style{ + Name: "StyleLight", + Box: StyleBoxLight, + Color: ColorOptionsDefault, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsDefault, + Title: TitleOptionsDefault, + } + + // StyleRounded renders a Table like below: + // ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ │ │ TOTAL │ 10000 │ │ + // ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯ + StyleRounded = Style{ + Name: "StyleRounded", + Box: StyleBoxRounded, + Color: ColorOptionsDefault, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsDefault, + Title: TitleOptionsDefault, + } + + // styleTest renders a Table like below: + // (-----^------------^-----------^--------^-----------------------------) + // [< #>||||< >] + // {-----+------------+-----------+--------+-----------------------------} + // [< 1>|||< 3000>|< >] + // [< 20>|||< 2000>|] + // [<300>|||< 5000>|< >] + // {-----+------------+-----------+--------+-----------------------------} + // [< >|< >||< 10000>|< >] + // \-----v------------v-----------v--------v-----------------------------/ + styleTest = Style{ + Name: "styleTest", + Box: styleBoxTest, + Color: ColorOptionsDefault, + Format: FormatOptionsDefault, + HTML: DefaultHTMLOptions, + Options: OptionsDefault, + Title: TitleOptionsDefault, + } +) + +// BoxStyle defines the characters/strings to use to render the borders and +// separators for the Table. +type BoxStyle struct { + BottomLeft string + BottomRight string + BottomSeparator string + EmptySeparator string + Left string + LeftSeparator string + MiddleHorizontal string + MiddleSeparator string + MiddleVertical string + PaddingLeft string + PaddingRight string + PageSeparator string + Right string + RightSeparator string + TopLeft string + TopRight string + TopSeparator string + UnfinishedRow string +} + +var ( + // StyleBoxDefault defines a Boxed-Table like below: + // +-----+------------+-----------+--------+-----------------------------+ + // | # | FIRST NAME | LAST NAME | SALARY | | + // +-----+------------+-----------+--------+-----------------------------+ + // | 1 | Arya | Stark | 3000 | | + // | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | + // | 300 | Tyrion | Lannister | 5000 | | + // +-----+------------+-----------+--------+-----------------------------+ + // | | | TOTAL | 10000 | | + // +-----+------------+-----------+--------+-----------------------------+ + StyleBoxDefault = BoxStyle{ + BottomLeft: "+", + BottomRight: "+", + BottomSeparator: "+", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")), + Left: "|", + LeftSeparator: "+", + MiddleHorizontal: "-", + MiddleSeparator: "+", + MiddleVertical: "|", + PaddingLeft: " ", + PaddingRight: " ", + PageSeparator: "\n", + Right: "|", + RightSeparator: "+", + TopLeft: "+", + TopRight: "+", + TopSeparator: "+", + UnfinishedRow: " ~", + } + + // StyleBoxBold defines a Boxed-Table like below: + // ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + // ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃ + // ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + // ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃ + // ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃ + // ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃ + // ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + // ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃ + // ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + StyleBoxBold = BoxStyle{ + BottomLeft: "┗", + BottomRight: "┛", + BottomSeparator: "┻", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("╋")), + Left: "┃", + LeftSeparator: "┣", + MiddleHorizontal: "━", + MiddleSeparator: "╋", + MiddleVertical: "┃", + PaddingLeft: " ", + PaddingRight: " ", + PageSeparator: "\n", + Right: "┃", + RightSeparator: "┫", + TopLeft: "┏", + TopRight: "┓", + TopSeparator: "┳", + UnfinishedRow: " ≈", + } + + // StyleBoxDouble defines a Boxed-Table like below: + // ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗ + // ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║ + // ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣ + // ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║ + // ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║ + // ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║ + // ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣ + // ║ ║ ║ TOTAL ║ 10000 ║ ║ + // ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝ + StyleBoxDouble = BoxStyle{ + BottomLeft: "╚", + BottomRight: "╝", + BottomSeparator: "╩", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("╬")), + Left: "║", + LeftSeparator: "╠", + MiddleHorizontal: "═", + MiddleSeparator: "╬", + MiddleVertical: "║", + PaddingLeft: " ", + PaddingRight: " ", + PageSeparator: "\n", + Right: "║", + RightSeparator: "╣", + TopLeft: "╔", + TopRight: "╗", + TopSeparator: "╦", + UnfinishedRow: " ≈", + } + + // StyleBoxLight defines a Boxed-Table like below: + // ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ │ │ TOTAL │ 10000 │ │ + // └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ + StyleBoxLight = BoxStyle{ + BottomLeft: "└", + BottomRight: "┘", + BottomSeparator: "┴", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("┼")), + Left: "│", + LeftSeparator: "├", + MiddleHorizontal: "─", + MiddleSeparator: "┼", + MiddleVertical: "│", + PaddingLeft: " ", + PaddingRight: " ", + PageSeparator: "\n", + Right: "│", + RightSeparator: "┤", + TopLeft: "┌", + TopRight: "┐", + TopSeparator: "┬", + UnfinishedRow: " ≈", + } + + // StyleBoxRounded defines a Boxed-Table like below: + // ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ │ │ TOTAL │ 10000 │ │ + // ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯ + StyleBoxRounded = BoxStyle{ + BottomLeft: "╰", + BottomRight: "╯", + BottomSeparator: "┴", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("┼")), + Left: "│", + LeftSeparator: "├", + MiddleHorizontal: "─", + MiddleSeparator: "┼", + MiddleVertical: "│", + PaddingLeft: " ", + PaddingRight: " ", + PageSeparator: "\n", + Right: "│", + RightSeparator: "┤", + TopLeft: "╭", + TopRight: "╮", + TopSeparator: "┬", + UnfinishedRow: " ≈", + } + + // styleBoxTest defines a Boxed-Table like below: + // (-----^------------^-----------^--------^-----------------------------) + // [< #>||||< >] + // {-----+------------+-----------+--------+-----------------------------} + // [< 1>|||< 3000>|< >] + // [< 20>|||< 2000>|] + // [<300>|||< 5000>|< >] + // {-----+------------+-----------+--------+-----------------------------} + // [< >|< >||< 10000>|< >] + // \-----v------------v-----------v--------v-----------------------------/ + styleBoxTest = BoxStyle{ + BottomLeft: "\\", + BottomRight: "/", + BottomSeparator: "v", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")), + Left: "[", + LeftSeparator: "{", + MiddleHorizontal: "--", + MiddleSeparator: "+", + MiddleVertical: "|", + PaddingLeft: "<", + PaddingRight: ">", + PageSeparator: "\n", + Right: "]", + RightSeparator: "}", + TopLeft: "(", + TopRight: ")", + TopSeparator: "^", + UnfinishedRow: " ~~~", + } +) + +// ColorOptions defines the ANSI colors to use for parts of the Table. +type ColorOptions struct { + Border text.Colors // borders (if nil, uses one of the below) + Footer text.Colors // footer row(s) colors + Header text.Colors // header row(s) colors + IndexColumn text.Colors // index-column colors (row #, etc.) + Row text.Colors // regular row(s) colors + RowAlternate text.Colors // regular row(s) colors for the even-numbered rows + Separator text.Colors // separators (if nil, uses one of the above) +} + +var ( + // ColorOptionsDefault defines sensible ANSI color options - basically NONE. + ColorOptionsDefault = ColorOptions{} + + // ColorOptionsBright renders dark text on bright background. + ColorOptionsBright = ColorOptionsBlackOnCyanWhite + + // ColorOptionsDark renders bright text on dark background. + ColorOptionsDark = ColorOptionsCyanWhiteOnBlack + + // ColorOptionsBlackOnBlueWhite renders Black text on Blue/White background. + ColorOptionsBlackOnBlueWhite = ColorOptions{ + Footer: text.Colors{text.BgBlue, text.FgBlack}, + Header: text.Colors{text.BgHiBlue, text.FgBlack}, + IndexColumn: text.Colors{text.BgHiBlue, text.FgBlack}, + Row: text.Colors{text.BgHiWhite, text.FgBlack}, + RowAlternate: text.Colors{text.BgWhite, text.FgBlack}, + } + + // ColorOptionsBlackOnCyanWhite renders Black text on Cyan/White background. + ColorOptionsBlackOnCyanWhite = ColorOptions{ + Footer: text.Colors{text.BgCyan, text.FgBlack}, + Header: text.Colors{text.BgHiCyan, text.FgBlack}, + IndexColumn: text.Colors{text.BgHiCyan, text.FgBlack}, + Row: text.Colors{text.BgHiWhite, text.FgBlack}, + RowAlternate: text.Colors{text.BgWhite, text.FgBlack}, + } + + // ColorOptionsBlackOnGreenWhite renders Black text on Green/White + // background. + ColorOptionsBlackOnGreenWhite = ColorOptions{ + Footer: text.Colors{text.BgGreen, text.FgBlack}, + Header: text.Colors{text.BgHiGreen, text.FgBlack}, + IndexColumn: text.Colors{text.BgHiGreen, text.FgBlack}, + Row: text.Colors{text.BgHiWhite, text.FgBlack}, + RowAlternate: text.Colors{text.BgWhite, text.FgBlack}, + } + + // ColorOptionsBlackOnMagentaWhite renders Black text on Magenta/White + // background. + ColorOptionsBlackOnMagentaWhite = ColorOptions{ + Footer: text.Colors{text.BgMagenta, text.FgBlack}, + Header: text.Colors{text.BgHiMagenta, text.FgBlack}, + IndexColumn: text.Colors{text.BgHiMagenta, text.FgBlack}, + Row: text.Colors{text.BgHiWhite, text.FgBlack}, + RowAlternate: text.Colors{text.BgWhite, text.FgBlack}, + } + + // ColorOptionsBlackOnRedWhite renders Black text on Red/White background. + ColorOptionsBlackOnRedWhite = ColorOptions{ + Footer: text.Colors{text.BgRed, text.FgBlack}, + Header: text.Colors{text.BgHiRed, text.FgBlack}, + IndexColumn: text.Colors{text.BgHiRed, text.FgBlack}, + Row: text.Colors{text.BgHiWhite, text.FgBlack}, + RowAlternate: text.Colors{text.BgWhite, text.FgBlack}, + } + + // ColorOptionsBlackOnYellowWhite renders Black text on Yellow/White + // background. + ColorOptionsBlackOnYellowWhite = ColorOptions{ + Footer: text.Colors{text.BgYellow, text.FgBlack}, + Header: text.Colors{text.BgHiYellow, text.FgBlack}, + IndexColumn: text.Colors{text.BgHiYellow, text.FgBlack}, + Row: text.Colors{text.BgHiWhite, text.FgBlack}, + RowAlternate: text.Colors{text.BgWhite, text.FgBlack}, + } + + // ColorOptionsBlueWhiteOnBlack renders Blue/White text on Black background. + ColorOptionsBlueWhiteOnBlack = ColorOptions{ + Footer: text.Colors{text.FgBlue, text.BgHiBlack}, + Header: text.Colors{text.FgHiBlue, text.BgHiBlack}, + IndexColumn: text.Colors{text.FgHiBlue, text.BgHiBlack}, + Row: text.Colors{text.FgHiWhite, text.BgBlack}, + RowAlternate: text.Colors{text.FgWhite, text.BgBlack}, + } + + // ColorOptionsCyanWhiteOnBlack renders Cyan/White text on Black background. + ColorOptionsCyanWhiteOnBlack = ColorOptions{ + Footer: text.Colors{text.FgCyan, text.BgHiBlack}, + Header: text.Colors{text.FgHiCyan, text.BgHiBlack}, + IndexColumn: text.Colors{text.FgHiCyan, text.BgHiBlack}, + Row: text.Colors{text.FgHiWhite, text.BgBlack}, + RowAlternate: text.Colors{text.FgWhite, text.BgBlack}, + } + + // ColorOptionsGreenWhiteOnBlack renders Green/White text on Black + // background. + ColorOptionsGreenWhiteOnBlack = ColorOptions{ + Footer: text.Colors{text.FgGreen, text.BgHiBlack}, + Header: text.Colors{text.FgHiGreen, text.BgHiBlack}, + IndexColumn: text.Colors{text.FgHiGreen, text.BgHiBlack}, + Row: text.Colors{text.FgHiWhite, text.BgBlack}, + RowAlternate: text.Colors{text.FgWhite, text.BgBlack}, + } + + // ColorOptionsMagentaWhiteOnBlack renders Magenta/White text on Black + // background. + ColorOptionsMagentaWhiteOnBlack = ColorOptions{ + Footer: text.Colors{text.FgMagenta, text.BgHiBlack}, + Header: text.Colors{text.FgHiMagenta, text.BgHiBlack}, + IndexColumn: text.Colors{text.FgHiMagenta, text.BgHiBlack}, + Row: text.Colors{text.FgHiWhite, text.BgBlack}, + RowAlternate: text.Colors{text.FgWhite, text.BgBlack}, + } + + // ColorOptionsRedWhiteOnBlack renders Red/White text on Black background. + ColorOptionsRedWhiteOnBlack = ColorOptions{ + Footer: text.Colors{text.FgRed, text.BgHiBlack}, + Header: text.Colors{text.FgHiRed, text.BgHiBlack}, + IndexColumn: text.Colors{text.FgHiRed, text.BgHiBlack}, + Row: text.Colors{text.FgHiWhite, text.BgBlack}, + RowAlternate: text.Colors{text.FgWhite, text.BgBlack}, + } + + // ColorOptionsYellowWhiteOnBlack renders Yellow/White text on Black + // background. + ColorOptionsYellowWhiteOnBlack = ColorOptions{ + Footer: text.Colors{text.FgYellow, text.BgHiBlack}, + Header: text.Colors{text.FgHiYellow, text.BgHiBlack}, + IndexColumn: text.Colors{text.FgHiYellow, text.BgHiBlack}, + Row: text.Colors{text.FgHiWhite, text.BgBlack}, + RowAlternate: text.Colors{text.FgWhite, text.BgBlack}, + } +) + +// FormatOptions defines the text-formatting to perform on parts of the Table. +type FormatOptions struct { + Direction text.Direction // (forced) BiDi direction for each Column + Footer text.Format // footer row(s) text format + Header text.Format // header row(s) text format + Row text.Format // (data) row(s) text format +} + +var ( + // FormatOptionsDefault defines sensible formatting options. + FormatOptionsDefault = FormatOptions{ + Footer: text.FormatUpper, + Header: text.FormatUpper, + Row: text.FormatDefault, + } +) + +// HTMLOptions defines the global options to control HTML rendering. +type HTMLOptions struct { + CSSClass string // CSS class to set on the overall tag + EmptyColumn string // string to replace "" columns with (entire content being "") + EscapeText bool // escape text into HTML-safe content? + Newline string // string to replace "\n" characters with +} + +var ( + // DefaultHTMLOptions defines sensible HTML rendering defaults. + DefaultHTMLOptions = HTMLOptions{ + CSSClass: DefaultHTMLCSSClass, + EmptyColumn: " ", + EscapeText: true, + Newline: "
", + } +) + +// Options defines the global options that determine how the Table is +// rendered. +type Options struct { + // DoNotColorBordersAndSeparators disables coloring all the borders and row + // or column separators. + DoNotColorBordersAndSeparators bool + + // DrawBorder enables or disables drawing the border around the Table. + // Example of a table where it is disabled: + // # │ FIRST NAME │ LAST NAME │ SALARY │ + // ─────┼────────────┼───────────┼────────┼───────────────────────────── + // 1 │ Arya │ Stark │ 3000 │ + // 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! + // 300 │ Tyrion │ Lannister │ 5000 │ + // ─────┼────────────┼───────────┼────────┼───────────────────────────── + // │ │ TOTAL │ 10000 │ + DrawBorder bool + + // SeparateColumns enables or disable drawing border between columns. + // Example of a table where it is disabled: + // ┌─────────────────────────────────────────────────────────────────┐ + // │ # FIRST NAME LAST NAME SALARY │ + // ├─────────────────────────────────────────────────────────────────┤ + // │ 1 Arya Stark 3000 │ + // │ 20 Jon Snow 2000 You know nothing, Jon Snow! │ + // │ 300 Tyrion Lannister 5000 │ + // │ TOTAL 10000 │ + // └─────────────────────────────────────────────────────────────────┘ + SeparateColumns bool + + // SeparateFooter enables or disable drawing border between the footer and + // the rows. Example of a table where it is disabled: + // ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // │ │ │ TOTAL │ 10000 │ │ + // └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ + SeparateFooter bool + + // SeparateHeader enables or disable drawing border between the header and + // the rows. Example of a table where it is disabled: + // ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ │ │ TOTAL │ 10000 │ │ + // └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ + SeparateHeader bool + + // SeparateRows enables or disables drawing separators between each row. + // Example of a table where it is enabled: + // ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ + // │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 1 │ Arya │ Stark │ 3000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ 300 │ Tyrion │ Lannister │ 5000 │ │ + // ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ + // │ │ │ TOTAL │ 10000 │ │ + // └─────┴────────────┴───────────┴────────┴─────────────────────────────┘ + SeparateRows bool +} + +var ( + // OptionsDefault defines sensible global options. + OptionsDefault = Options{ + DrawBorder: true, + SeparateColumns: true, + SeparateFooter: true, + SeparateHeader: true, + SeparateRows: false, + } + + // OptionsNoBorders sets up a table without any borders. + OptionsNoBorders = Options{ + DrawBorder: false, + SeparateColumns: true, + SeparateFooter: true, + SeparateHeader: true, + SeparateRows: false, + } + + // OptionsNoBordersAndSeparators sets up a table without any borders or + // separators. + OptionsNoBordersAndSeparators = Options{ + DrawBorder: false, + SeparateColumns: false, + SeparateFooter: false, + SeparateHeader: false, + SeparateRows: false, + } +) + +// TitleOptions defines the way the title text is to be rendered. +type TitleOptions struct { + Align text.Align + Colors text.Colors + Format text.Format +} + +var ( + // TitleOptionsDefault defines sensible title options - basically NONE. + TitleOptionsDefault = TitleOptions{} + + // TitleOptionsBright renders Bright Bold text on Dark background. + TitleOptionsBright = TitleOptionsBlackOnCyan + + // TitleOptionsDark renders Dark Bold text on Bright background. + TitleOptionsDark = TitleOptionsCyanOnBlack + + // TitleOptionsBlackOnBlue renders Black text on Blue background. + TitleOptionsBlackOnBlue = TitleOptions{ + Colors: append(ColorOptionsBlackOnBlueWhite.Header, text.Bold), + } + + // TitleOptionsBlackOnCyan renders Black Bold text on Cyan background. + TitleOptionsBlackOnCyan = TitleOptions{ + Colors: append(ColorOptionsBlackOnCyanWhite.Header, text.Bold), + } + + // TitleOptionsBlackOnGreen renders Black Bold text onGreen background. + TitleOptionsBlackOnGreen = TitleOptions{ + Colors: append(ColorOptionsBlackOnGreenWhite.Header, text.Bold), + } + + // TitleOptionsBlackOnMagenta renders Black Bold text on Magenta background. + TitleOptionsBlackOnMagenta = TitleOptions{ + Colors: append(ColorOptionsBlackOnMagentaWhite.Header, text.Bold), + } + + // TitleOptionsBlackOnRed renders Black Bold text on Red background. + TitleOptionsBlackOnRed = TitleOptions{ + Colors: append(ColorOptionsBlackOnRedWhite.Header, text.Bold), + } + + // TitleOptionsBlackOnYellow renders Black Bold text on Yellow background. + TitleOptionsBlackOnYellow = TitleOptions{ + Colors: append(ColorOptionsBlackOnYellowWhite.Header, text.Bold), + } + + // TitleOptionsBlueOnBlack renders Blue Bold text on Black background. + TitleOptionsBlueOnBlack = TitleOptions{ + Colors: append(ColorOptionsBlueWhiteOnBlack.Header, text.Bold), + } + + // TitleOptionsCyanOnBlack renders Cyan Bold text on Black background. + TitleOptionsCyanOnBlack = TitleOptions{ + Colors: append(ColorOptionsCyanWhiteOnBlack.Header, text.Bold), + } + + // TitleOptionsGreenOnBlack renders Green Bold text on Black background. + TitleOptionsGreenOnBlack = TitleOptions{ + Colors: append(ColorOptionsGreenWhiteOnBlack.Header, text.Bold), + } + + // TitleOptionsMagentaOnBlack renders Magenta Bold text on Black background. + TitleOptionsMagentaOnBlack = TitleOptions{ + Colors: append(ColorOptionsMagentaWhiteOnBlack.Header, text.Bold), + } + + // TitleOptionsRedOnBlack renders Red Bold text on Black background. + TitleOptionsRedOnBlack = TitleOptions{ + Colors: append(ColorOptionsRedWhiteOnBlack.Header, text.Bold), + } + + // TitleOptionsYellowOnBlack renders Yellow Bold text on Black background. + TitleOptionsYellowOnBlack = TitleOptions{ + Colors: append(ColorOptionsYellowWhiteOnBlack.Header, text.Bold), + } +) diff --git a/helper/table/table.go b/helper/table/table.go new file mode 100644 index 0000000..e71ef9e --- /dev/null +++ b/helper/table/table.go @@ -0,0 +1,784 @@ +package table + +import ( + "fmt" + "io" + "strings" + + "git.hexq.cn/tiglog/golib/helper/text" +) + +// Row defines a single row in the Table. +type Row []interface{} + +func (r Row) findColumnNumber(colName string) int { + for colIdx, col := range r { + if fmt.Sprint(col) == colName { + return colIdx + 1 + } + } + return 0 +} + +// RowPainter is a custom function that takes a Row as input and returns the +// text.Colors{} to use on the entire row +type RowPainter func(row Row) text.Colors + +// rowStr defines a single row in the Table comprised of just string objects. +type rowStr []string + +// areEqual returns true if the contents of the 2 given columns are the same +func (row rowStr) areEqual(colIdx1 int, colIdx2 int) bool { + return colIdx1 >= 0 && colIdx2 < len(row) && row[colIdx1] == row[colIdx2] +} + +// Table helps print a 2-dimensional array in a human readable pretty-table. +type Table struct { + // allowedRowLength is the max allowed length for a row (or line of output) + allowedRowLength int + // enable automatic indexing of the rows and columns like a spreadsheet? + autoIndex bool + // autoIndexVIndexMaxLength denotes the length in chars for the last rownum + autoIndexVIndexMaxLength int + // caption stores the text to be rendered just below the table; and doesn't + // get used when rendered as a CSV + caption string + // columnIsNonNumeric stores if a column contains non-numbers in all rows + columnIsNonNumeric []bool + // columnConfigs stores the custom-configuration for 1 or more columns + columnConfigs []ColumnConfig + // columnConfigMap stores the custom-configuration by column + // number and is generated before rendering + columnConfigMap map[int]ColumnConfig + // htmlCSSClass stores the HTML CSS Class to use on the
node + htmlCSSClass string + // indexColumn stores the number of the column considered as the "index" + indexColumn int + // maxColumnLengths stores the length of the longest line in each column + maxColumnLengths []int + // maxRowLength stores the length of the longest row + maxRowLength int + // numColumns stores the (max.) number of columns seen + numColumns int + // numLinesRendered keeps track of the number of lines rendered and helps in + // paginating long tables + numLinesRendered int + // outputMirror stores an io.Writer where the "Render" functions would write + outputMirror io.Writer + // pageSize stores the maximum lines to render before rendering the header + // again (to denote a page break) - useful when you are dealing with really + // long tables + pageSize int + // rows stores the rows that make up the body (in string form) + rows []rowStr + // rowsColors stores the text.Colors over-rides for each row as defined by + // rowPainter + rowsColors []text.Colors + // rowsConfigs stores RowConfig for each row + rowsConfigMap map[int]RowConfig + // rowsRaw stores the rows that make up the body + rowsRaw []Row + // rowsFooter stores the rows that make up the footer (in string form) + rowsFooter []rowStr + // rowsFooterConfigs stores RowConfig for each footer row + rowsFooterConfigMap map[int]RowConfig + // rowsFooterRaw stores the rows that make up the footer + rowsFooterRaw []Row + // rowsHeader stores the rows that make up the header (in string form) + rowsHeader []rowStr + // rowsHeaderConfigs stores RowConfig for each header row + rowsHeaderConfigMap map[int]RowConfig + // rowsHeaderRaw stores the rows that make up the header + rowsHeaderRaw []Row + // rowPainter is a custom function that given a Row, returns the colors to + // use on the entire row + rowPainter RowPainter + // rowSeparator is a dummy row that contains the separator columns (dashes + // that make up the separator between header/body/footer + rowSeparator rowStr + // separators is used to keep track of all rowIndices after which a + // separator has to be rendered + separators map[int]bool + // sortBy stores a map of Column + sortBy []SortBy + // style contains all the strings used to draw the table, and more + style *Style + // suppressEmptyColumns hides columns which have no content on all regular + // rows + suppressEmptyColumns bool + // title contains the text to appear above the table + title string +} + +// AppendFooter appends the row to the List of footers to render. +// +// Only the first item in the "config" will be tagged against this row. +func (t *Table) AppendFooter(row Row, config ...RowConfig) { + t.rowsFooterRaw = append(t.rowsFooterRaw, row) + if len(config) > 0 { + if t.rowsFooterConfigMap == nil { + t.rowsFooterConfigMap = make(map[int]RowConfig) + } + t.rowsFooterConfigMap[len(t.rowsFooterRaw)-1] = config[0] + } +} + +// AppendHeader appends the row to the List of headers to render. +// +// Only the first item in the "config" will be tagged against this row. +func (t *Table) AppendHeader(row Row, config ...RowConfig) { + t.rowsHeaderRaw = append(t.rowsHeaderRaw, row) + if len(config) > 0 { + if t.rowsHeaderConfigMap == nil { + t.rowsHeaderConfigMap = make(map[int]RowConfig) + } + t.rowsHeaderConfigMap[len(t.rowsHeaderRaw)-1] = config[0] + } +} + +// AppendRow appends the row to the List of rows to render. +// +// Only the first item in the "config" will be tagged against this row. +func (t *Table) AppendRow(row Row, config ...RowConfig) { + t.rowsRaw = append(t.rowsRaw, row) + if len(config) > 0 { + if t.rowsConfigMap == nil { + t.rowsConfigMap = make(map[int]RowConfig) + } + t.rowsConfigMap[len(t.rowsRaw)-1] = config[0] + } +} + +// AppendRows appends the rows to the List of rows to render. +// +// Only the first item in the "config" will be tagged against all the rows. +func (t *Table) AppendRows(rows []Row, config ...RowConfig) { + for _, row := range rows { + t.AppendRow(row, config...) + } +} + +// AppendSeparator helps render a separator row after the current last row. You +// could call this function over and over, but it will be a no-op unless you +// call AppendRow or AppendRows in between. Likewise, if the last thing you +// append is a separator, it will not be rendered in addition to the usual table +// separator. +// +// ****************************************************************************** +// Please note the following caveats: +// 1. SetPageSize(): this may end up creating consecutive separator rows near +// the end of a page or at the beginning of a page +// 2. SortBy(): since SortBy could inherently alter the ordering of rows, the +// separators may not appear after the row it was originally intended to +// follow +// +// ****************************************************************************** +func (t *Table) AppendSeparator() { + if t.separators == nil { + t.separators = make(map[int]bool) + } + if len(t.rowsRaw) > 0 { + t.separators[len(t.rowsRaw)-1] = true + } +} + +// Length returns the number of rows to be rendered. +func (t *Table) Length() int { + return len(t.rowsRaw) +} + +// ResetFooters resets and clears all the Footer rows appended earlier. +func (t *Table) ResetFooters() { + t.rowsFooterRaw = nil +} + +// ResetHeaders resets and clears all the Header rows appended earlier. +func (t *Table) ResetHeaders() { + t.rowsHeaderRaw = nil +} + +// ResetRows resets and clears all the rows appended earlier. +func (t *Table) ResetRows() { + t.rowsRaw = nil + t.separators = nil +} + +// SetAllowedRowLength sets the maximum allowed length or a row (or line of +// output) when rendered as a table. Rows that are longer than this limit will +// be "snipped" to the length. Length has to be a positive value to take effect. +func (t *Table) SetAllowedRowLength(length int) { + t.allowedRowLength = length +} + +// SetAutoIndex adds a generated header with columns such as "A", "B", "C", etc. +// and a leading column with the row number similar to what you'd see on any +// spreadsheet application. NOTE: Appending a Header will void this +// functionality. +func (t *Table) SetAutoIndex(autoIndex bool) { + t.autoIndex = autoIndex +} + +// SetCaption sets the text to be rendered just below the table. This will not +// show up when the Table is rendered as a CSV. +func (t *Table) SetCaption(format string, a ...interface{}) { + t.caption = fmt.Sprintf(format, a...) +} + +// SetColumnConfigs sets the configs for each Column. +func (t *Table) SetColumnConfigs(configs []ColumnConfig) { + t.columnConfigs = configs +} + +// SetHTMLCSSClass sets the the HTML CSS Class to use on the
node +// when rendering the Table in HTML format. +// +// Deprecated: in favor of Style().HTML.CSSClass +func (t *Table) SetHTMLCSSClass(cssClass string) { + t.htmlCSSClass = cssClass +} + +// SetIndexColumn sets the given Column # as the column that has the row +// "Number". Valid values range from 1 to N. Note that this is not 0-indexed. +func (t *Table) SetIndexColumn(colNum int) { + t.indexColumn = colNum +} + +// SetOutputMirror sets an io.Writer for all the Render functions to "Write" to +// in addition to returning a string. +func (t *Table) SetOutputMirror(mirror io.Writer) { + t.outputMirror = mirror +} + +// SetPageSize sets the maximum number of lines to render before rendering the +// header rows again. This can be useful when dealing with tables containing a +// long list of rows that can span pages. Please note that the pagination logic +// will not consider Header/Footer lines for paging. +func (t *Table) SetPageSize(numLines int) { + t.pageSize = numLines +} + +// SetRowPainter sets the RowPainter function which determines the colors to use +// on a row. Before rendering, this function is invoked on all rows and the +// color of each row is determined. This color takes precedence over other ways +// to set color (ColumnConfig.Color*, SetColor*()). +func (t *Table) SetRowPainter(painter RowPainter) { + t.rowPainter = painter +} + +// SetStyle overrides the DefaultStyle with the provided one. +func (t *Table) SetStyle(style Style) { + t.style = &style +} + +// SetTitle sets the title text to be rendered above the table. +func (t *Table) SetTitle(format string, a ...interface{}) { + t.title = fmt.Sprintf(format, a...) +} + +// SortBy sets the rules for sorting the Rows in the order specified. i.e., the +// first SortBy instruction takes precedence over the second and so on. Any +// duplicate instructions on the same column will be discarded while sorting. +func (t *Table) SortBy(sortBy []SortBy) { + t.sortBy = sortBy +} + +// Style returns the current style. +func (t *Table) Style() *Style { + if t.style == nil { + tempStyle := StyleDefault + t.style = &tempStyle + } + return t.style +} + +// SuppressEmptyColumns hides columns when the column is empty in ALL the +// regular rows. +func (t *Table) SuppressEmptyColumns() { + t.suppressEmptyColumns = true +} + +func (t *Table) getAlign(colIdx int, hint renderHint) text.Align { + align := text.AlignDefault + if cfg, ok := t.columnConfigMap[colIdx]; ok { + if hint.isHeaderRow { + align = cfg.AlignHeader + } else if hint.isFooterRow { + align = cfg.AlignFooter + } else { + align = cfg.Align + } + } + if align == text.AlignDefault { + if !t.columnIsNonNumeric[colIdx] { + align = text.AlignRight + } else if hint.isAutoIndexRow { + align = text.AlignCenter + } + } + return align +} + +func (t *Table) getAutoIndexColumnIDs() rowStr { + row := make(rowStr, t.numColumns) + for colIdx := range row { + row[colIdx] = AutoIndexColumnID(colIdx) + } + return row +} + +func (t *Table) getBorderColors(hint renderHint) text.Colors { + if t.style.Options.DoNotColorBordersAndSeparators { + return nil + } else if t.style.Color.Border != nil { + return t.style.Color.Border + } else if hint.isTitleRow { + return t.style.Title.Colors + } else if hint.isHeaderRow { + return t.style.Color.Header + } else if hint.isFooterRow { + return t.style.Color.Footer + } else if t.autoIndex { + return t.style.Color.IndexColumn + } else if hint.rowNumber%2 == 0 && t.style.Color.RowAlternate != nil { + return t.style.Color.RowAlternate + } + return t.style.Color.Row +} + +func (t *Table) getBorderLeft(hint renderHint) string { + border := t.style.Box.Left + if hint.isBorderTop { + if t.title != "" { + border = t.style.Box.LeftSeparator + } else { + border = t.style.Box.TopLeft + } + } else if hint.isBorderBottom { + border = t.style.Box.BottomLeft + } else if hint.isSeparatorRow { + if t.autoIndex && hint.isHeaderOrFooterSeparator() { + border = t.style.Box.Left + } else if !t.autoIndex && t.shouldMergeCellsVertically(0, hint) { + border = t.style.Box.Left + } else { + border = t.style.Box.LeftSeparator + } + } + return border +} + +func (t *Table) getBorderRight(hint renderHint) string { + border := t.style.Box.Right + if hint.isBorderTop { + if t.title != "" { + border = t.style.Box.RightSeparator + } else { + border = t.style.Box.TopRight + } + } else if hint.isBorderBottom { + border = t.style.Box.BottomRight + } else if hint.isSeparatorRow { + if t.shouldMergeCellsVertically(t.numColumns-1, hint) { + border = t.style.Box.Right + } else { + border = t.style.Box.RightSeparator + } + } + return border +} + +func (t *Table) getColumnColors(colIdx int, hint renderHint) text.Colors { + if hint.isBorderOrSeparator() { + if colors := t.getColumnColorsForBorderOrSeparator(colIdx, hint); colors != nil { + return colors + } + } + if t.rowPainter != nil && hint.isRegularNonSeparatorRow() && !t.isIndexColumn(colIdx, hint) { + if colors := t.rowsColors[hint.rowNumber-1]; colors != nil { + return colors + } + } + if cfg, ok := t.columnConfigMap[colIdx]; ok { + if hint.isSeparatorRow { + return nil + } else if hint.isHeaderRow { + return cfg.ColorsHeader + } else if hint.isFooterRow { + return cfg.ColorsFooter + } + return cfg.Colors + } + return nil +} + +func (t *Table) getColumnColorsForBorderOrSeparator(colIdx int, hint renderHint) text.Colors { + if t.style.Options.DoNotColorBordersAndSeparators { + return text.Colors{} // not nil to force caller to paint with no colors + } + if (hint.isBorderBottom || hint.isBorderTop) && t.style.Color.Border != nil { + return t.style.Color.Border + } + if hint.isSeparatorRow && t.style.Color.Separator != nil { + return t.style.Color.Separator + } + return nil +} + +func (t *Table) getColumnSeparator(row rowStr, colIdx int, hint renderHint) string { + separator := t.style.Box.MiddleVertical + if hint.isSeparatorRow { + if hint.isBorderTop { + if t.shouldMergeCellsHorizontallyBelow(row, colIdx, hint) { + separator = t.style.Box.MiddleHorizontal + } else { + separator = t.style.Box.TopSeparator + } + } else if hint.isBorderBottom { + if t.shouldMergeCellsHorizontallyAbove(row, colIdx, hint) { + separator = t.style.Box.MiddleHorizontal + } else { + separator = t.style.Box.BottomSeparator + } + } else { + separator = t.getColumnSeparatorNonBorder( + t.shouldMergeCellsHorizontallyAbove(row, colIdx, hint), + t.shouldMergeCellsHorizontallyBelow(row, colIdx, hint), + colIdx, + hint, + ) + } + } + return separator +} + +func (t *Table) getColumnSeparatorNonBorder(mergeCellsAbove bool, mergeCellsBelow bool, colIdx int, hint renderHint) string { + mergeNextCol := t.shouldMergeCellsVertically(colIdx, hint) + if hint.isAutoIndexColumn { + return t.getColumnSeparatorNonBorderAutoIndex(mergeNextCol, hint) + } + + mergeCurrCol := t.shouldMergeCellsVertically(colIdx-1, hint) + return t.getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove, mergeCellsBelow, mergeCurrCol, mergeNextCol) +} + +func (t *Table) getColumnSeparatorNonBorderAutoIndex(mergeNextCol bool, hint renderHint) string { + if hint.isHeaderOrFooterSeparator() { + if mergeNextCol { + return t.style.Box.MiddleVertical + } + return t.style.Box.LeftSeparator + } else if mergeNextCol { + return t.style.Box.RightSeparator + } + return t.style.Box.MiddleSeparator +} + +func (t *Table) getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove bool, mergeCellsBelow bool, mergeCurrCol bool, mergeNextCol bool) string { + if mergeCellsAbove && mergeCellsBelow && mergeCurrCol && mergeNextCol { + return t.style.Box.EmptySeparator + } else if mergeCellsAbove && mergeCellsBelow { + return t.style.Box.MiddleHorizontal + } else if mergeCellsAbove { + return t.style.Box.TopSeparator + } else if mergeCellsBelow { + return t.style.Box.BottomSeparator + } else if mergeCurrCol && mergeNextCol { + return t.style.Box.MiddleVertical + } else if mergeCurrCol { + return t.style.Box.LeftSeparator + } else if mergeNextCol { + return t.style.Box.RightSeparator + } + return t.style.Box.MiddleSeparator +} + +func (t *Table) getColumnTransformer(colIdx int, hint renderHint) text.Transformer { + var transformer text.Transformer + if cfg, ok := t.columnConfigMap[colIdx]; ok { + if hint.isHeaderRow { + transformer = cfg.TransformerHeader + } else if hint.isFooterRow { + transformer = cfg.TransformerFooter + } else { + transformer = cfg.Transformer + } + } + return transformer +} + +func (t *Table) getColumnWidthMax(colIdx int) int { + if cfg, ok := t.columnConfigMap[colIdx]; ok { + return cfg.WidthMax + } + return 0 +} + +func (t *Table) getColumnWidthMin(colIdx int) int { + if cfg, ok := t.columnConfigMap[colIdx]; ok { + return cfg.WidthMin + } + return 0 +} + +func (t *Table) getFormat(hint renderHint) text.Format { + if hint.isSeparatorRow { + return text.FormatDefault + } else if hint.isHeaderRow { + return t.style.Format.Header + } else if hint.isFooterRow { + return t.style.Format.Footer + } + return t.style.Format.Row +} + +func (t *Table) getMaxColumnLengthForMerging(colIdx int) int { + maxColumnLength := t.maxColumnLengths[colIdx] + maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingRight + t.style.Box.PaddingLeft) + if t.style.Options.SeparateColumns { + maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.EmptySeparator) + } + return maxColumnLength +} + +// getMergedColumnIndices returns a map of colIdx values to all the other colIdx +// values (that are being merged) and their lengths. +func (t *Table) getMergedColumnIndices(row rowStr, hint renderHint) mergedColumnIndices { + if !t.getRowConfig(hint).AutoMerge { + return nil + } + + mci := make(mergedColumnIndices) + for colIdx := 0; colIdx < t.numColumns-1; colIdx++ { + // look backward + for otherColIdx := colIdx - 1; colIdx >= 0 && otherColIdx >= 0; otherColIdx-- { + if row[colIdx] != row[otherColIdx] { + break + } + mci.safeAppend(colIdx, otherColIdx) + } + // look forward + for otherColIdx := colIdx + 1; colIdx < len(row) && otherColIdx < len(row); otherColIdx++ { + if row[colIdx] != row[otherColIdx] { + break + } + mci.safeAppend(colIdx, otherColIdx) + } + } + return mci +} + +func (t *Table) getRow(rowIdx int, hint renderHint) rowStr { + switch { + case hint.isHeaderRow: + if rowIdx >= 0 && rowIdx < len(t.rowsHeader) { + return t.rowsHeader[rowIdx] + } + case hint.isFooterRow: + if rowIdx >= 0 && rowIdx < len(t.rowsFooter) { + return t.rowsFooter[rowIdx] + } + default: + if rowIdx >= 0 && rowIdx < len(t.rows) { + return t.rows[rowIdx] + } + } + return rowStr{} +} + +func (t *Table) getRowConfig(hint renderHint) RowConfig { + rowIdx := hint.rowNumber - 1 + if rowIdx < 0 { + rowIdx = 0 + } + + switch { + case hint.isHeaderRow: + return t.rowsHeaderConfigMap[rowIdx] + case hint.isFooterRow: + return t.rowsFooterConfigMap[rowIdx] + default: + return t.rowsConfigMap[rowIdx] + } +} + +func (t *Table) getSeparatorColors(hint renderHint) text.Colors { + if t.style.Options.DoNotColorBordersAndSeparators { + return nil + } else if (hint.isBorderBottom || hint.isBorderTop) && t.style.Color.Border != nil { + return t.style.Color.Border + } else if t.style.Color.Separator != nil { + return t.style.Color.Separator + } else if hint.isHeaderRow { + return t.style.Color.Header + } else if hint.isFooterRow { + return t.style.Color.Footer + } else if hint.isAutoIndexColumn { + return t.style.Color.IndexColumn + } else if hint.rowNumber > 0 && hint.rowNumber%2 == 0 { + return t.style.Color.RowAlternate + } + return t.style.Color.Row +} + +func (t *Table) getVAlign(colIdx int, hint renderHint) text.VAlign { + vAlign := text.VAlignDefault + if cfg, ok := t.columnConfigMap[colIdx]; ok { + if hint.isHeaderRow { + vAlign = cfg.VAlignHeader + } else if hint.isFooterRow { + vAlign = cfg.VAlignFooter + } else { + vAlign = cfg.VAlign + } + } + return vAlign +} + +func (t *Table) hasHiddenColumns() bool { + for _, cc := range t.columnConfigMap { + if cc.Hidden { + return true + } + } + return false +} + +func (t *Table) hideColumns() map[int]int { + colIdxMap := make(map[int]int) + numColumns := 0 + hideColumnsInRows := func(rows []rowStr) []rowStr { + var rsp []rowStr + for _, row := range rows { + var rowNew rowStr + for colIdx, col := range row { + cc := t.columnConfigMap[colIdx] + if !cc.Hidden { + rowNew = append(rowNew, col) + colIdxMap[colIdx] = len(rowNew) - 1 + } + } + if len(rowNew) > numColumns { + numColumns = len(rowNew) + } + rsp = append(rsp, rowNew) + } + return rsp + } + + // hide columns as directed + t.rows = hideColumnsInRows(t.rows) + t.rowsFooter = hideColumnsInRows(t.rowsFooter) + t.rowsHeader = hideColumnsInRows(t.rowsHeader) + + // reset numColumns to the new number of columns + t.numColumns = numColumns + + return colIdxMap +} + +func (t *Table) isIndexColumn(colIdx int, hint renderHint) bool { + return t.indexColumn == colIdx+1 || hint.isAutoIndexColumn +} + +func (t *Table) render(out *strings.Builder) string { + outStr := out.String() + if t.outputMirror != nil && len(outStr) > 0 { + _, _ = t.outputMirror.Write([]byte(outStr)) + _, _ = t.outputMirror.Write([]byte("\n")) + } + return outStr +} + +func (t *Table) shouldMergeCellsHorizontallyAbove(row rowStr, colIdx int, hint renderHint) bool { + if hint.isAutoIndexColumn || hint.isAutoIndexRow { + return false + } + + rowConfig := t.getRowConfig(hint) + if hint.isSeparatorRow { + if hint.isHeaderRow && hint.rowNumber == 1 { + rowConfig = t.getRowConfig(hint) + row = t.getRow(hint.rowNumber-1, hint) + } else if hint.isFooterRow && hint.isFirstRow { + rowConfig = t.getRowConfig(renderHint{isLastRow: true, rowNumber: len(t.rows)}) + row = t.getRow(len(t.rows)-1, renderHint{}) + } else if hint.isFooterRow && hint.isBorderBottom { + row = t.getRow(len(t.rowsFooter)-1, renderHint{isFooterRow: true}) + } else { + row = t.getRow(hint.rowNumber-1, hint) + } + } + + if rowConfig.AutoMerge { + return row.areEqual(colIdx-1, colIdx) + } + return false +} + +func (t *Table) shouldMergeCellsHorizontallyBelow(row rowStr, colIdx int, hint renderHint) bool { + if hint.isAutoIndexColumn || hint.isAutoIndexRow { + return false + } + + var rowConfig RowConfig + if hint.isSeparatorRow { + if hint.isHeaderRow && hint.rowNumber == 0 { + rowConfig = t.getRowConfig(renderHint{isHeaderRow: true, rowNumber: 1}) + row = t.getRow(0, hint) + } else if hint.isHeaderRow && hint.isLastRow { + rowConfig = t.getRowConfig(renderHint{rowNumber: 1}) + row = t.getRow(0, renderHint{}) + } else if hint.isHeaderRow { + rowConfig = t.getRowConfig(renderHint{isHeaderRow: true, rowNumber: hint.rowNumber + 1}) + row = t.getRow(hint.rowNumber, hint) + } else if hint.isFooterRow && hint.rowNumber >= 0 { + rowConfig = t.getRowConfig(renderHint{isFooterRow: true, rowNumber: 1}) + row = t.getRow(hint.rowNumber, renderHint{isFooterRow: true}) + } else if hint.isRegularRow() { + rowConfig = t.getRowConfig(renderHint{rowNumber: hint.rowNumber + 1}) + row = t.getRow(hint.rowNumber, renderHint{}) + } + } + + if rowConfig.AutoMerge { + return row.areEqual(colIdx-1, colIdx) + } + return false +} + +func (t *Table) shouldMergeCellsVertically(colIdx int, hint renderHint) bool { + if t.columnConfigMap[colIdx].AutoMerge && colIdx < t.numColumns { + if hint.isSeparatorRow { + rowPrev := t.getRow(hint.rowNumber-1, hint) + rowNext := t.getRow(hint.rowNumber, hint) + if colIdx < len(rowPrev) && colIdx < len(rowNext) { + return rowPrev[colIdx] == rowNext[colIdx] || "" == rowNext[colIdx] + } + } else { + rowPrev := t.getRow(hint.rowNumber-2, hint) + rowCurr := t.getRow(hint.rowNumber-1, hint) + if colIdx < len(rowPrev) && colIdx < len(rowCurr) { + return rowPrev[colIdx] == rowCurr[colIdx] || "" == rowCurr[colIdx] + } + } + } + return false +} + +func (t *Table) wrapRow(row rowStr) (int, rowStr) { + colMaxLines := 0 + rowWrapped := make(rowStr, len(row)) + for colIdx, colStr := range row { + widthEnforcer := t.columnConfigMap[colIdx].getWidthMaxEnforcer() + maxWidth := t.getColumnWidthMax(colIdx) + if maxWidth == 0 { + maxWidth = t.maxColumnLengths[colIdx] + } + rowWrapped[colIdx] = widthEnforcer(colStr, maxWidth) + colNumLines := strings.Count(rowWrapped[colIdx], "\n") + 1 + if colNumLines > colMaxLines { + colMaxLines = colNumLines + } + } + return colMaxLines, rowWrapped +} diff --git a/helper/table/table_test.go b/helper/table/table_test.go new file mode 100644 index 0000000..853b3bd --- /dev/null +++ b/helper/table/table_test.go @@ -0,0 +1,375 @@ +package table + +import ( + "strings" + "testing" + "unicode/utf8" + + "git.hexq.cn/tiglog/golib/helper/text" + "github.com/stretchr/testify/assert" +) + +var ( + testCaption = "A Song of Ice and Fire" + testColor = text.Colors{text.FgGreen} + testColorHiRedBold = text.Colors{text.FgHiRed, text.Bold} + testColorHiBlueBold = text.Colors{text.FgHiBlue, text.Bold} + testCSSClass = "test-css-class" + testFooter = Row{"", "", "Total", 10000} + testFooterMultiLine = Row{"", "", "Total\nSalary", 10000} + testHeader = Row{"#", "First Name", "Last Name", "Salary"} + testHeaderMultiLine = Row{"#", "First\nName", "Last\nName", "Salary"} + testRows = []Row{ + {1, "Arya", "Stark", 3000}, + {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "Lannister", 5000}, + } + testRowMultiLine = Row{0, "Winter", "Is", 0, "Coming.\r\nThe North Remembers!\nThis is known."} + testRowNewLines = Row{0, "Valar", "Morghulis", 0, "Faceless\nMen"} + testRowPipes = Row{0, "Valar", "Morghulis", 0, "Faceless|Men"} + testRowTabs = Row{0, "Valar", "Morghulis", 0, "Faceless\tMen"} + testTitle1 = "Game of Thrones" + testTitle2 = "When you play the Game of Thrones, you win or you die. There is no middle ground." +) + +func init() { + text.EnableColors() +} + +type myMockOutputMirror struct { + mirroredOutput string +} + +func (t *myMockOutputMirror) Write(p []byte) (n int, err error) { + t.mirroredOutput += string(p) + return len(p), nil +} + +func TestNewWriter(t *testing.T) { + tw := NewWriter() + assert.NotNil(t, tw.Style()) + assert.Equal(t, StyleDefault, *tw.Style()) + + tw.SetStyle(StyleBold) + assert.NotNil(t, tw.Style()) + assert.Equal(t, StyleBold, *tw.Style()) +} + +func TestTable_AppendFooter(t *testing.T) { + table := Table{} + assert.Equal(t, 0, len(table.rowsFooterRaw)) + + table.AppendFooter([]interface{}{}) + assert.Equal(t, 0, table.Length()) + assert.Equal(t, 1, len(table.rowsFooterRaw)) + assert.Equal(t, 0, len(table.rowsHeaderRaw)) + + table.AppendFooter([]interface{}{}) + assert.Equal(t, 0, table.Length()) + assert.Equal(t, 2, len(table.rowsFooterRaw)) + assert.Equal(t, 0, len(table.rowsHeaderRaw)) + + table.AppendFooter([]interface{}{}, RowConfig{AutoMerge: true}) + assert.Equal(t, 0, table.Length()) + assert.Equal(t, 3, len(table.rowsFooterRaw)) + assert.Equal(t, 0, len(table.rowsHeaderRaw)) + assert.False(t, table.rowsFooterConfigMap[0].AutoMerge) + assert.False(t, table.rowsFooterConfigMap[1].AutoMerge) + assert.True(t, table.rowsFooterConfigMap[2].AutoMerge) +} + +func TestTable_AppendHeader(t *testing.T) { + table := Table{} + assert.Equal(t, 0, len(table.rowsHeaderRaw)) + + table.AppendHeader([]interface{}{}) + assert.Equal(t, 0, table.Length()) + assert.Equal(t, 0, len(table.rowsFooterRaw)) + assert.Equal(t, 1, len(table.rowsHeaderRaw)) + + table.AppendHeader([]interface{}{}) + assert.Equal(t, 0, table.Length()) + assert.Equal(t, 0, len(table.rowsFooterRaw)) + assert.Equal(t, 2, len(table.rowsHeaderRaw)) + + table.AppendHeader([]interface{}{}, RowConfig{AutoMerge: true}) + assert.Equal(t, 0, table.Length()) + assert.Equal(t, 0, len(table.rowsFooterRaw)) + assert.Equal(t, 3, len(table.rowsHeaderRaw)) + assert.False(t, table.rowsHeaderConfigMap[0].AutoMerge) + assert.False(t, table.rowsHeaderConfigMap[1].AutoMerge) + assert.True(t, table.rowsHeaderConfigMap[2].AutoMerge) +} + +func TestTable_AppendRow(t *testing.T) { + table := Table{} + assert.Equal(t, 0, table.Length()) + + table.AppendRow([]interface{}{}) + assert.Equal(t, 1, table.Length()) + assert.Equal(t, 0, len(table.rowsFooter)) + assert.Equal(t, 0, len(table.rowsHeader)) + + table.AppendRow([]interface{}{}) + assert.Equal(t, 2, table.Length()) + assert.Equal(t, 0, len(table.rowsFooter)) + assert.Equal(t, 0, len(table.rowsHeader)) + + table.AppendRow([]interface{}{}, RowConfig{AutoMerge: true}) + assert.Equal(t, 3, table.Length()) + assert.Equal(t, 0, len(table.rowsFooterRaw)) + assert.Equal(t, 0, len(table.rowsHeaderRaw)) + assert.False(t, table.rowsConfigMap[0].AutoMerge) + assert.False(t, table.rowsConfigMap[1].AutoMerge) + assert.True(t, table.rowsConfigMap[2].AutoMerge) +} + +func TestTable_AppendRows(t *testing.T) { + table := Table{} + assert.Equal(t, 0, table.Length()) + + table.AppendRows([]Row{{}}) + assert.Equal(t, 1, table.Length()) + assert.Equal(t, 0, len(table.rowsFooter)) + assert.Equal(t, 0, len(table.rowsHeader)) + + table.AppendRows([]Row{{}}) + assert.Equal(t, 2, table.Length()) + assert.Equal(t, 0, len(table.rowsFooter)) + assert.Equal(t, 0, len(table.rowsHeader)) + + table.AppendRows([]Row{{}, {}}, RowConfig{AutoMerge: true}) + assert.Equal(t, 4, table.Length()) + assert.Equal(t, 0, len(table.rowsFooterRaw)) + assert.Equal(t, 0, len(table.rowsHeaderRaw)) + assert.False(t, table.rowsConfigMap[0].AutoMerge) + assert.False(t, table.rowsConfigMap[1].AutoMerge) + assert.True(t, table.rowsConfigMap[2].AutoMerge) + assert.True(t, table.rowsConfigMap[3].AutoMerge) +} + +func TestTable_Length(t *testing.T) { + table := Table{} + assert.Zero(t, table.Length()) + + table.AppendRow(testRows[0]) + assert.Equal(t, 1, table.Length()) + table.AppendRow(testRows[1]) + assert.Equal(t, 2, table.Length()) + + table.AppendHeader(testHeader) + assert.Equal(t, 2, table.Length()) +} + +func TestTable_ResetFooters(t *testing.T) { + table := Table{} + table.AppendFooter(testFooter) + assert.NotEmpty(t, table.rowsFooterRaw) + + table.ResetFooters() + assert.Empty(t, table.rowsFooterRaw) +} + +func TestTable_ResetHeaders(t *testing.T) { + table := Table{} + table.AppendHeader(testHeader) + assert.NotEmpty(t, table.rowsHeaderRaw) + + table.ResetHeaders() + assert.Empty(t, table.rowsHeaderRaw) +} + +func TestTable_ResetRows(t *testing.T) { + table := Table{} + table.AppendRows(testRows) + assert.NotEmpty(t, table.rowsRaw) + + table.ResetRows() + assert.Empty(t, table.rowsRaw) +} + +func TestTable_SetAllowedRowLength(t *testing.T) { + table := Table{} + table.AppendRows(testRows) + table.SetStyle(styleTest) + + expectedOutWithNoRowLimit := `(-----^--------^-----------^------^-----------------------------) +[< 1>|||<3000>|< >] +[< 20>|||<2000>|] +[<300>|||<5000>|< >] +\-----v--------v-----------v------v-----------------------------/` + assert.Zero(t, table.allowedRowLength) + assert.Equal(t, expectedOutWithNoRowLimit, table.Render()) + + table.SetAllowedRowLength(utf8.RuneCountInString(table.style.Box.UnfinishedRow)) + assert.Equal(t, utf8.RuneCountInString(table.style.Box.UnfinishedRow), table.allowedRowLength) + assert.Equal(t, "", table.Render()) + + table.SetAllowedRowLength(5) + expectedOutWithRowLimit := `( ~~~ +[ ~~~ +[ ~~~ +[ ~~~ +\ ~~~` + assert.Equal(t, 5, table.allowedRowLength) + assert.Equal(t, expectedOutWithRowLimit, table.Render()) + + table.SetAllowedRowLength(30) + expectedOutWithRowLimit = `(-----^--------^---------- ~~~ +[< 1>|||||||||<3000>|< >] +[< 20>|||<2000>|] +[<300>|||<5000>|< >] +\-----v--------v-----------v------v-----------------------------/` + assert.False(t, table.autoIndex) + assert.Equal(t, expectedOut, table.Render()) + + table.SetAutoIndex(true) + expectedOut = `(---^-----^--------^-----------^------^-----------------------------) +[< >|< A>|< B >|< C >|< D>|< E >] +{---+-----+--------+-----------+------+-----------------------------} +[<1>|< 1>|||<3000>|< >] +[<2>|< 20>|||<2000>|] +[<3>|<300>|||<5000>|< >] +\---v-----v--------v-----------v------v-----------------------------/` + assert.True(t, table.autoIndex) + assert.Equal(t, expectedOut, table.Render()) + + table.AppendHeader(testHeader) + expectedOut = `(---^-----^------------^-----------^--------^-----------------------------) +[< >|< #>||||< >] +{---+-----+------------+-----------+--------+-----------------------------} +[<1>|< 1>|||< 3000>|< >] +[<2>|< 20>|||< 2000>|] +[<3>|<300>|||< 5000>|< >] +\---v-----v------------v-----------v--------v-----------------------------/` + assert.True(t, table.autoIndex) + assert.Equal(t, expectedOut, table.Render()) + + table.AppendRow(testRowMultiLine) + expectedOut = `(---^-----^------------^-----------^--------^-----------------------------) +[< >|< #>||||< >] +{---+-----+------------+-----------+--------+-----------------------------} +[<1>|< 1>|||< 3000>|< >] +[<2>|< 20>|||< 2000>|] +[<3>|<300>|||< 5000>|< >] +[<4>|< 0>|||< 0>|] +[< >|< >|< >|< >|< >|] +[< >|< >|< >|< >|< >|] +\---v-----v------------v-----------v--------v-----------------------------/` + assert.Equal(t, expectedOut, table.Render()) + + table.SetStyle(StyleLight) + expectedOut = `┌───┬─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ │ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├───┼─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 1 │ 1 │ Arya │ Stark │ 3000 │ │ +│ 2 │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 3 │ 300 │ Tyrion │ Lannister │ 5000 │ │ +│ 4 │ 0 │ Winter │ Is │ 0 │ Coming. │ +│ │ │ │ │ │ The North Remembers! │ +│ │ │ │ │ │ This is known. │ +└───┴─────┴────────────┴───────────┴────────┴─────────────────────────────┘` + assert.Equal(t, expectedOut, table.Render()) +} + +func TestTable_SetCaption(t *testing.T) { + table := Table{} + assert.Empty(t, table.caption) + + table.SetCaption(testCaption) + assert.NotEmpty(t, table.caption) + assert.Equal(t, testCaption, table.caption) +} + +func TestTable_SetColumnConfigs(t *testing.T) { + table := Table{} + assert.Empty(t, table.columnConfigs) + + table.SetColumnConfigs([]ColumnConfig{{}, {}, {}}) + assert.NotEmpty(t, table.columnConfigs) + assert.Equal(t, 3, len(table.columnConfigs)) +} + +func TestTable_SetHTMLCSSClass(t *testing.T) { + table := Table{} + table.AppendRow(testRows[0]) + expectedHTML := `
+ + + + + + + + +
1AryaStark3000
` + assert.Equal(t, "", table.htmlCSSClass) + assert.Equal(t, expectedHTML, table.RenderHTML()) + + table.SetHTMLCSSClass(testCSSClass) + assert.Equal(t, testCSSClass, table.htmlCSSClass) + assert.Equal(t, strings.Replace(expectedHTML, DefaultHTMLCSSClass, testCSSClass, -1), table.RenderHTML()) +} + +func TestTable_SetOutputMirror(t *testing.T) { + table := Table{} + table.AppendRow(testRows[0]) + expectedOut := `+---+------+-------+------+ +| 1 | Arya | Stark | 3000 | ++---+------+-------+------+` + assert.Equal(t, nil, table.outputMirror) + assert.Equal(t, expectedOut, table.Render()) + + mockOutputMirror := &myMockOutputMirror{} + table.SetOutputMirror(mockOutputMirror) + assert.Equal(t, mockOutputMirror, table.outputMirror) + assert.Equal(t, expectedOut, table.Render()) + assert.Equal(t, expectedOut+"\n", mockOutputMirror.mirroredOutput) +} + +func TestTable_SePageSize(t *testing.T) { + table := Table{} + assert.Equal(t, 0, table.pageSize) + + table.SetPageSize(13) + assert.Equal(t, 13, table.pageSize) +} + +func TestTable_SortByColumn(t *testing.T) { + table := Table{} + assert.Empty(t, table.sortBy) + + table.SortBy([]SortBy{{Name: "#", Mode: Asc}}) + assert.Equal(t, 1, len(table.sortBy)) + + table.SortBy([]SortBy{{Name: "First Name", Mode: Dsc}, {Name: "Last Name", Mode: Asc}}) + assert.Equal(t, 2, len(table.sortBy)) +} + +func TestTable_SetStyle(t *testing.T) { + table := Table{} + assert.NotNil(t, table.Style()) + assert.Equal(t, StyleDefault, *table.Style()) + + table.SetStyle(StyleDefault) + assert.NotNil(t, table.Style()) + assert.Equal(t, StyleDefault, *table.Style()) +} diff --git a/helper/table/util.go b/helper/table/util.go new file mode 100644 index 0000000..e6d2ed7 --- /dev/null +++ b/helper/table/util.go @@ -0,0 +1,69 @@ +package table + +import ( + "reflect" +) + +// AutoIndexColumnID returns a unique Column ID/Name for the given Column Number. +// The functionality is similar to what you get in an Excel spreadsheet w.r.t. +// the Column ID/Name. +func AutoIndexColumnID(colIdx int) string { + charIdx := colIdx % 26 + out := string(rune(65 + charIdx)) + colIdx = colIdx / 26 + if colIdx > 0 { + return AutoIndexColumnID(colIdx-1) + out + } + return out +} + +// WidthEnforcer is a function that helps enforce a width condition on a string. +type WidthEnforcer func(col string, maxLen int) string + +// widthEnforcerNone returns the input string as is without any modifications. +func widthEnforcerNone(col string, maxLen int) string { + return col +} + +// isNumber returns true if the argument is a numeric type; false otherwise. +func isNumber(x interface{}) bool { + if x == nil { + return false + } + + switch reflect.TypeOf(x).Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + } + return false +} + +type mergedColumnIndices map[int]map[int]bool + +func (m mergedColumnIndices) mergedLength(colIdx int, maxColumnLengths []int) int { + mergedLength := maxColumnLengths[colIdx] + for otherColIdx := range m[colIdx] { + mergedLength += maxColumnLengths[otherColIdx] + } + return mergedLength +} + +func (m mergedColumnIndices) len(colIdx int) int { + return len(m[colIdx]) + 1 +} + +func (m mergedColumnIndices) safeAppend(colIdx, otherColIdx int) { + // map + if m[colIdx] == nil { + m[colIdx] = make(map[int]bool) + } + m[colIdx][otherColIdx] = true + + // reverse map + if m[otherColIdx] == nil { + m[otherColIdx] = make(map[int]bool) + } + m[otherColIdx][colIdx] = true +} diff --git a/helper/table/util_test.go b/helper/table/util_test.go new file mode 100644 index 0000000..b28ca7c --- /dev/null +++ b/helper/table/util_test.go @@ -0,0 +1,53 @@ +package table + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleAutoIndexColumnID() { + fmt.Printf("AutoIndexColumnID( 0): \"%s\"\n", AutoIndexColumnID(0)) + fmt.Printf("AutoIndexColumnID( 1): \"%s\"\n", AutoIndexColumnID(1)) + fmt.Printf("AutoIndexColumnID( 2): \"%s\"\n", AutoIndexColumnID(2)) + fmt.Printf("AutoIndexColumnID( 25): \"%s\"\n", AutoIndexColumnID(25)) + fmt.Printf("AutoIndexColumnID( 26): \"%s\"\n", AutoIndexColumnID(26)) + fmt.Printf("AutoIndexColumnID( 702): \"%s\"\n", AutoIndexColumnID(702)) + fmt.Printf("AutoIndexColumnID(18278): \"%s\"\n", AutoIndexColumnID(18278)) + + // Output: AutoIndexColumnID( 0): "A" + // AutoIndexColumnID( 1): "B" + // AutoIndexColumnID( 2): "C" + // AutoIndexColumnID( 25): "Z" + // AutoIndexColumnID( 26): "AA" + // AutoIndexColumnID( 702): "AAA" + // AutoIndexColumnID(18278): "AAAA" +} + +func TestAutoIndexColumnID(t *testing.T) { + assert.Equal(t, "A", AutoIndexColumnID(0)) + assert.Equal(t, "Z", AutoIndexColumnID(25)) + assert.Equal(t, "AA", AutoIndexColumnID(26)) + assert.Equal(t, "ZZ", AutoIndexColumnID(701)) + assert.Equal(t, "AAA", AutoIndexColumnID(702)) + assert.Equal(t, "ZZZ", AutoIndexColumnID(18277)) + assert.Equal(t, "AAAA", AutoIndexColumnID(18278)) +} + +func TestIsNumber(t *testing.T) { + assert.True(t, isNumber(int(1))) + assert.True(t, isNumber(int8(1))) + assert.True(t, isNumber(int16(1))) + assert.True(t, isNumber(int32(1))) + assert.True(t, isNumber(int64(1))) + assert.True(t, isNumber(uint(1))) + assert.True(t, isNumber(uint8(1))) + assert.True(t, isNumber(uint16(1))) + assert.True(t, isNumber(uint32(1))) + assert.True(t, isNumber(uint64(1))) + assert.True(t, isNumber(float32(1))) + assert.True(t, isNumber(float64(1))) + assert.False(t, isNumber("1")) + assert.False(t, isNumber(nil)) +} diff --git a/helper/table/writer.go b/helper/table/writer.go new file mode 100644 index 0000000..c70ba33 --- /dev/null +++ b/helper/table/writer.go @@ -0,0 +1,43 @@ +package table + +import ( + "io" +) + +// Writer declares the interfaces that can be used to setup and render a table. +type Writer interface { + AppendFooter(row Row, configs ...RowConfig) + AppendHeader(row Row, configs ...RowConfig) + AppendRow(row Row, configs ...RowConfig) + AppendRows(rows []Row, configs ...RowConfig) + AppendSeparator() + Length() int + Render() string + RenderCSV() string + RenderHTML() string + RenderMarkdown() string + ResetFooters() + ResetHeaders() + ResetRows() + SetAllowedRowLength(length int) + SetAutoIndex(autoIndex bool) + SetCaption(format string, a ...interface{}) + SetColumnConfigs(configs []ColumnConfig) + SetIndexColumn(colNum int) + SetOutputMirror(mirror io.Writer) + SetPageSize(numLines int) + SetRowPainter(painter RowPainter) + SetStyle(style Style) + SetTitle(format string, a ...interface{}) + SortBy(sortBy []SortBy) + Style() *Style + SuppressEmptyColumns() + + // deprecated; in favor of Style().HTML.CSSClass + SetHTMLCSSClass(cssClass string) +} + +// NewWriter initializes and returns a Writer. +func NewWriter() Writer { + return &Table{} +} diff --git a/helper/table/writer_test.go b/helper/table/writer_test.go new file mode 100644 index 0000000..98539ab --- /dev/null +++ b/helper/table/writer_test.go @@ -0,0 +1,78 @@ +package table + +import ( + "fmt" + + "git.hexq.cn/tiglog/golib/helper/text" +) + +func Example_simple() { + // simple table with zero customizations + tw := NewWriter() + // append a header row + tw.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"}) + // append some data rows + tw.AppendRows([]Row{ + {1, "Arya", "Stark", 3000}, + {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "Lannister", 5000}, + }) + // append a footer row + tw.AppendFooter(Row{"", "", "Total", 10000}) + // render it + fmt.Printf("Table without any customizations:\n%s", tw.Render()) + + // Output: Table without any customizations: + // +-----+------------+-----------+--------+-----------------------------+ + // | # | FIRST NAME | LAST NAME | SALARY | | + // +-----+------------+-----------+--------+-----------------------------+ + // | 1 | Arya | Stark | 3000 | | + // | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | + // | 300 | Tyrion | Lannister | 5000 | | + // +-----+------------+-----------+--------+-----------------------------+ + // | | | TOTAL | 10000 | | + // +-----+------------+-----------+--------+-----------------------------+ +} + +func Example_styled() { + // table with some amount of customization + tw := NewWriter() + // append a header row + tw.AppendHeader(Row{"First Name", "Last Name", "Salary"}) + // append some data rows + tw.AppendRows([]Row{ + {"Jaime", "Lannister", 5000}, + {"Arya", "Stark", 3000, "A girl has no name."}, + {"Sansa", "Stark", 4000}, + {"Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {"Tyrion", "Lannister", 5000, "A Lannister always pays his debts."}, + }) + // append a footer row + tw.AppendFooter(Row{"", "Total", 10000}) + // auto-index rows + tw.SetAutoIndex(true) + // sort by last name and then by salary + tw.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "Salary", Mode: AscNumeric}}) + // use a ready-to-use style + tw.SetStyle(StyleLight) + // customize the style and change some stuff + tw.Style().Format.Header = text.FormatLower + tw.Style().Format.Row = text.FormatLower + tw.Style().Format.Footer = text.FormatLower + tw.Style().Options.SeparateColumns = false + // render it + fmt.Printf("Table with customizations:\n%s", tw.Render()) + + // Output: Table with customizations: + // ┌──────────────────────────────────────────────────────────────────────┐ + // │ first name last name salary │ + // ├──────────────────────────────────────────────────────────────────────┤ + // │ 1 arya stark 3000 a girl has no name. │ + // │ 2 sansa stark 4000 │ + // │ 3 jon snow 2000 you know nothing, jon snow! │ + // │ 4 jaime lannister 5000 │ + // │ 5 tyrion lannister 5000 a lannister always pays his debts. │ + // ├──────────────────────────────────────────────────────────────────────┤ + // │ total 10000 │ + // └──────────────────────────────────────────────────────────────────────┘ +} diff --git a/helper/text/align.go b/helper/text/align.go new file mode 100644 index 0000000..ade9ded --- /dev/null +++ b/helper/text/align.go @@ -0,0 +1,137 @@ +package text + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +// Align denotes how text is to be aligned horizontally. +type Align int + +// Align enumerations +const ( + AlignDefault Align = iota // same as AlignLeft + AlignLeft // "left " + AlignCenter // " center " + AlignJustify // "justify it" + AlignRight // " right" +) + +// Apply aligns the text as directed. For ex.: +// * AlignDefault.Apply("Jon Snow", 12) returns "Jon Snow " +// * AlignLeft.Apply("Jon Snow", 12) returns "Jon Snow " +// * AlignCenter.Apply("Jon Snow", 12) returns " Jon Snow " +// * AlignJustify.Apply("Jon Snow", 12) returns "Jon Snow" +// * AlignRight.Apply("Jon Snow", 12) returns " Jon Snow" +func (a Align) Apply(text string, maxLength int) string { + text = a.trimString(text) + sLen := utf8.RuneCountInString(text) + sLenWoE := RuneWidthWithoutEscSequences(text) + numEscChars := sLen - sLenWoE + + // now, align the text + switch a { + case AlignDefault, AlignLeft: + return fmt.Sprintf("%-"+strconv.Itoa(maxLength+numEscChars)+"s", text) + case AlignCenter: + if sLenWoE < maxLength { + // left pad with half the number of spaces needed before using %text + return fmt.Sprintf("%"+strconv.Itoa(maxLength+numEscChars)+"s", + text+strings.Repeat(" ", int((maxLength-sLenWoE)/2))) + } + case AlignJustify: + return a.justifyText(text, sLenWoE, maxLength) + } + return fmt.Sprintf("%"+strconv.Itoa(maxLength+numEscChars)+"s", text) +} + +// HTMLProperty returns the equivalent HTML horizontal-align tag property. +func (a Align) HTMLProperty() string { + switch a { + case AlignLeft: + return "align=\"left\"" + case AlignCenter: + return "align=\"center\"" + case AlignJustify: + return "align=\"justify\"" + case AlignRight: + return "align=\"right\"" + default: + return "" + } +} + +// MarkdownProperty returns the equivalent Markdown horizontal-align separator. +func (a Align) MarkdownProperty() string { + switch a { + case AlignLeft: + return ":--- " + case AlignCenter: + return ":---:" + case AlignRight: + return " ---:" + default: + return " --- " + } +} + +func (a Align) justifyText(text string, textLength int, maxLength int) string { + // split the text into individual words + wordsUnfiltered := strings.Split(text, " ") + words := Filter(wordsUnfiltered, func(item string) bool { + return item != "" + }) + // empty string implies spaces for maxLength + if len(words) == 0 { + return strings.Repeat(" ", maxLength) + } + // get the number of spaces to insert into the text + numSpacesNeeded := maxLength - textLength + strings.Count(text, " ") + numSpacesNeededBetweenWords := 0 + if len(words) > 1 { + numSpacesNeededBetweenWords = numSpacesNeeded / (len(words) - 1) + } + // create the output string word by word with spaces in between + var outText strings.Builder + outText.Grow(maxLength) + for idx, word := range words { + if idx > 0 { + // insert spaces only after the first word + if idx == len(words)-1 { + // insert all the remaining space before the last word + outText.WriteString(strings.Repeat(" ", numSpacesNeeded)) + numSpacesNeeded = 0 + } else { + // insert the determined number of spaces between each word + outText.WriteString(strings.Repeat(" ", numSpacesNeededBetweenWords)) + // and reduce the number of spaces needed after this + numSpacesNeeded -= numSpacesNeededBetweenWords + } + } + outText.WriteString(word) + if idx == len(words)-1 && numSpacesNeeded > 0 { + outText.WriteString(strings.Repeat(" ", numSpacesNeeded)) + } + } + return outText.String() +} + +func (a Align) trimString(text string) string { + switch a { + case AlignDefault, AlignLeft: + if strings.HasSuffix(text, " ") { + return strings.TrimRight(text, " ") + } + case AlignRight: + if strings.HasPrefix(text, " ") { + return strings.TrimLeft(text, " ") + } + default: + if strings.HasPrefix(text, " ") || strings.HasSuffix(text, " ") { + return strings.Trim(text, " ") + } + } + return text +} diff --git a/helper/text/align_test.go b/helper/text/align_test.go new file mode 100644 index 0000000..b3250d1 --- /dev/null +++ b/helper/text/align_test.go @@ -0,0 +1,138 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleAlign_Apply() { + fmt.Printf("AlignDefault: '%s'\n", AlignDefault.Apply("Jon Snow", 12)) + fmt.Printf("AlignLeft : '%s'\n", AlignLeft.Apply("Jon Snow", 12)) + fmt.Printf("AlignCenter : '%s'\n", AlignCenter.Apply("Jon Snow", 12)) + fmt.Printf("AlignJustify: '%s'\n", AlignJustify.Apply("Jon Snow", 12)) + fmt.Printf("AlignRight : '%s'\n", AlignRight.Apply("Jon Snow", 12)) + + // Output: AlignDefault: 'Jon Snow ' + // AlignLeft : 'Jon Snow ' + // AlignCenter : ' Jon Snow ' + // AlignJustify: 'Jon Snow' + // AlignRight : ' Jon Snow' +} + +func TestAlign_Apply(t *testing.T) { + // AlignDefault & AlignLeft are the same + assert.Equal(t, "Jon Snow ", AlignDefault.Apply("Jon Snow", 12)) + assert.Equal(t, " Jon Snow ", AlignDefault.Apply(" Jon Snow", 12)) + assert.Equal(t, " ", AlignDefault.Apply("", 12)) + assert.Equal(t, "Jon Snow ", AlignLeft.Apply("Jon Snow ", 12)) + assert.Equal(t, " Jon Snow ", AlignLeft.Apply(" Jon Snow ", 12)) + assert.Equal(t, " ", AlignLeft.Apply("", 12)) + + // AlignCenter + assert.Equal(t, " Jon Snow ", AlignCenter.Apply("Jon Snow ", 12)) + assert.Equal(t, " Jon Snow ", AlignCenter.Apply(" Jon Snow", 12)) + assert.Equal(t, " Jon Snow ", AlignCenter.Apply(" Jon Snow ", 12)) + assert.Equal(t, " ", AlignCenter.Apply("", 12)) + + // AlignJustify + assert.Equal(t, "Jon Snow", AlignJustify.Apply("Jon Snow", 12)) + assert.Equal(t, "JS vs. DT", AlignJustify.Apply("JS vs. DT", 12)) + assert.Equal(t, "JS is AT", AlignJustify.Apply("JS is AT", 12)) + assert.Equal(t, "JS is AT", AlignJustify.Apply("JS is AT", 12)) + assert.Equal(t, "JonSnow ", AlignJustify.Apply("JonSnow", 12)) + assert.Equal(t, "JonSnow ", AlignJustify.Apply(" JonSnow", 12)) + assert.Equal(t, " ", AlignJustify.Apply("", 12)) + + // Align Right + assert.Equal(t, " Jon Snow", AlignRight.Apply("Jon Snow", 12)) + assert.Equal(t, " Jon Snow ", AlignRight.Apply("Jon Snow ", 12)) + assert.Equal(t, " Jon Snow ", AlignRight.Apply(" Jon Snow ", 12)) + assert.Equal(t, " ", AlignRight.Apply("", 12)) +} + +func TestAlign_Apply_ColoredText(t *testing.T) { + // AlignDefault & AlignLeft are the same + assert.Equal(t, "\x1b[33mJon Snow\x1b[0m ", AlignDefault.Apply("\x1b[33mJon Snow\x1b[0m", 12)) + assert.Equal(t, "\x1b[33m Jon Snow\x1b[0m ", AlignDefault.Apply("\x1b[33m Jon Snow\x1b[0m", 12)) + assert.Equal(t, "\x1b[33m\x1b[0m ", AlignDefault.Apply("\x1b[33m\x1b[0m", 12)) + assert.Equal(t, "\x1b[33mJon Snow \x1b[0m ", AlignLeft.Apply("\x1b[33mJon Snow \x1b[0m", 12)) + assert.Equal(t, "\x1b[33m Jon Snow \x1b[0m ", AlignLeft.Apply("\x1b[33m Jon Snow \x1b[0m", 12)) + assert.Equal(t, "\x1b[33m\x1b[0m ", AlignLeft.Apply("\x1b[33m\x1b[0m", 12)) + + // AlignCenter + assert.Equal(t, " \x1b[33mJon Snow \x1b[0m ", AlignCenter.Apply("\x1b[33mJon Snow \x1b[0m", 12)) + assert.Equal(t, " \x1b[33m Jon Snow\x1b[0m ", AlignCenter.Apply("\x1b[33m Jon Snow\x1b[0m", 12)) + assert.Equal(t, " \x1b[33m Jon Snow \x1b[0m", AlignCenter.Apply("\x1b[33m Jon Snow \x1b[0m", 12)) + assert.Equal(t, " \x1b[33m\x1b[0m ", AlignCenter.Apply("\x1b[33m\x1b[0m", 12)) + + // AlignJustify + assert.Equal(t, "\x1b[33mJon Snow\x1b[0m", AlignJustify.Apply("\x1b[33mJon Snow\x1b[0m", 12)) + assert.Equal(t, "\x1b[33mJS vs. DT\x1b[0m", AlignJustify.Apply("\x1b[33mJS vs. DT\x1b[0m", 12)) + assert.Equal(t, "\x1b[33mJS is AT\x1b[0m", AlignJustify.Apply("\x1b[33mJS is AT\x1b[0m", 12)) + assert.Equal(t, "\x1b[33mJS is AT\x1b[0m", AlignJustify.Apply("\x1b[33mJS is AT\x1b[0m", 12)) + assert.Equal(t, "\x1b[33mJonSnow\x1b[0m ", AlignJustify.Apply("\x1b[33mJonSnow\x1b[0m", 12)) + assert.Equal(t, "\x1b[33m JonSnow\x1b[0m", AlignJustify.Apply("\x1b[33m JonSnow\x1b[0m", 12)) + assert.Equal(t, "\x1b[33m\x1b[0m ", AlignJustify.Apply("\x1b[33m\x1b[0m", 12)) + + // Align Right + assert.Equal(t, " \x1b[33mJon Snow\x1b[0m", AlignRight.Apply("\x1b[33mJon Snow\x1b[0m", 12)) + assert.Equal(t, " \x1b[33mJon Snow \x1b[0m", AlignRight.Apply("\x1b[33mJon Snow \x1b[0m", 12)) + assert.Equal(t, " \x1b[33m Jon Snow \x1b[0m", AlignRight.Apply("\x1b[33m Jon Snow \x1b[0m", 12)) + assert.Equal(t, " \x1b[33m\x1b[0m", AlignRight.Apply("\x1b[33m\x1b[0m", 12)) +} + +func ExampleAlign_HTMLProperty() { + fmt.Printf("AlignDefault: '%s'\n", AlignDefault.HTMLProperty()) + fmt.Printf("AlignLeft : '%s'\n", AlignLeft.HTMLProperty()) + fmt.Printf("AlignCenter : '%s'\n", AlignCenter.HTMLProperty()) + fmt.Printf("AlignJustify: '%s'\n", AlignJustify.HTMLProperty()) + fmt.Printf("AlignRight : '%s'\n", AlignRight.HTMLProperty()) + + // Output: AlignDefault: '' + // AlignLeft : 'align="left"' + // AlignCenter : 'align="center"' + // AlignJustify: 'align="justify"' + // AlignRight : 'align="right"' +} + +func TestAlign_HTMLProperty(t *testing.T) { + aligns := map[Align]string{ + AlignDefault: "", + AlignLeft: "left", + AlignCenter: "center", + AlignJustify: "justify", + AlignRight: "right", + } + for align, htmlStyle := range aligns { + assert.Contains(t, align.HTMLProperty(), htmlStyle) + } +} + +func ExampleAlign_MarkdownProperty() { + fmt.Printf("AlignDefault: '%s'\n", AlignDefault.MarkdownProperty()) + fmt.Printf("AlignLeft : '%s'\n", AlignLeft.MarkdownProperty()) + fmt.Printf("AlignCenter : '%s'\n", AlignCenter.MarkdownProperty()) + fmt.Printf("AlignJustify: '%s'\n", AlignJustify.MarkdownProperty()) + fmt.Printf("AlignRight : '%s'\n", AlignRight.MarkdownProperty()) + + // Output: AlignDefault: ' --- ' + // AlignLeft : ':--- ' + // AlignCenter : ':---:' + // AlignJustify: ' --- ' + // AlignRight : ' ---:' +} + +func TestAlign_MarkdownProperty(t *testing.T) { + aligns := map[Align]string{ + AlignDefault: " --- ", + AlignLeft: ":--- ", + AlignCenter: ":---:", + AlignJustify: " --- ", + AlignRight: " ---:", + } + for align, markdownSeparator := range aligns { + assert.Contains(t, align.MarkdownProperty(), markdownSeparator) + } +} diff --git a/helper/text/ansi.go b/helper/text/ansi.go new file mode 100644 index 0000000..aaf5b30 --- /dev/null +++ b/helper/text/ansi.go @@ -0,0 +1,55 @@ +package text + +import "strings" + +// ANSICodesSupported will be true on consoles where ANSI Escape Codes/Sequences +// are supported. +var ANSICodesSupported = areANSICodesSupported() + +// Escape encodes the string with the ANSI Escape Sequence. +// For ex.: +// Escape("Ghost", "") == "Ghost" +// Escape("Ghost", "\x1b[91m") == "\x1b[91mGhost\x1b[0m" +// Escape("\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m" +// Escape("Nymeria\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m" +// Escape("Nymeria \x1b[94mGhost\x1b[0m Lady", "\x1b[91m") == "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m" +func Escape(str string, escapeSeq string) string { + out := "" + if !strings.HasPrefix(str, EscapeStart) { + out += escapeSeq + } + out += strings.Replace(str, EscapeReset, EscapeReset+escapeSeq, -1) + if !strings.HasSuffix(out, EscapeReset) { + out += EscapeReset + } + if strings.Contains(out, escapeSeq+EscapeReset) { + out = strings.Replace(out, escapeSeq+EscapeReset, "", -1) + } + return out +} + +// StripEscape strips all ANSI Escape Sequence from the string. +// For ex.: +// StripEscape("Ghost") == "Ghost" +// StripEscape("\x1b[91mGhost\x1b[0m") == "Ghost" +// StripEscape("\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "GhostLady" +// StripEscape("\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "NymeriaGhostLady" +// StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m") == "Nymeria Ghost Lady" +func StripEscape(str string) string { + var out strings.Builder + out.Grow(RuneWidthWithoutEscSequences(str)) + + isEscSeq := false + for _, sChr := range str { + if sChr == EscapeStartRune { + isEscSeq = true + } + if !isEscSeq { + out.WriteRune(sChr) + } + if isEscSeq && sChr == EscapeStopRune { + isEscSeq = false + } + } + return out.String() +} diff --git a/helper/text/ansi_test.go b/helper/text/ansi_test.go new file mode 100644 index 0000000..e885103 --- /dev/null +++ b/helper/text/ansi_test.go @@ -0,0 +1,50 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEscape(t *testing.T) { + assert.Equal(t, "\x1b[91mGhost\x1b[0m", Escape("Ghost", FgHiRed.EscapeSeq())) + assert.Equal(t, "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", Escape(FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq())) + assert.Equal(t, "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", Escape("Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq())) + assert.Equal(t, "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m", Escape("Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady", FgHiRed.EscapeSeq())) +} + +func ExampleEscape() { + fmt.Printf("Escape(%#v, %#v) == %#v\n", "Ghost", "", Escape("Ghost", "")) + fmt.Printf("Escape(%#v, %#v) == %#v\n", "Ghost", FgHiRed.EscapeSeq(), Escape("Ghost", FgHiRed.EscapeSeq())) + fmt.Printf("Escape(%#v, %#v) == %#v\n", FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq(), Escape(FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq())) + fmt.Printf("Escape(%#v, %#v) == %#v\n", "Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq(), Escape("Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq())) + fmt.Printf("Escape(%#v, %#v) == %#v\n", "Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady", FgHiRed.EscapeSeq(), Escape("Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady", FgHiRed.EscapeSeq())) + + // Output: Escape("Ghost", "") == "Ghost" + // Escape("Ghost", "\x1b[91m") == "\x1b[91mGhost\x1b[0m" + // Escape("\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m" + // Escape("Nymeria\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m" + // Escape("Nymeria \x1b[94mGhost\x1b[0m Lady", "\x1b[91m") == "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m" +} + +func TestStripEscape(t *testing.T) { + assert.Equal(t, "Ghost", StripEscape(FgHiRed.Sprint("Ghost"))) + assert.Equal(t, "GhostLady", StripEscape(FgHiBlue.Sprint("Ghost")+"Lady")) + assert.Equal(t, "NymeriaGhostLady", StripEscape("Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady")) + assert.Equal(t, "Nymeria Ghost Lady", StripEscape("Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady")) +} + +func ExampleStripEscape() { + fmt.Printf("StripEscape(%#v) == %#v\n", "Ghost", StripEscape("Ghost")) + fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[91mGhost\x1b[0m", StripEscape("\x1b[91mGhost\x1b[0m")) + fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", StripEscape("\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m")) + fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", StripEscape("\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m")) + fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m", StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m")) + + // Output: StripEscape("Ghost") == "Ghost" + // StripEscape("\x1b[91mGhost\x1b[0m") == "Ghost" + // StripEscape("\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "GhostLady" + // StripEscape("\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "NymeriaGhostLady" + // StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m") == "Nymeria Ghost Lady" +} diff --git a/helper/text/ansi_unix.go b/helper/text/ansi_unix.go new file mode 100644 index 0000000..635be79 --- /dev/null +++ b/helper/text/ansi_unix.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package text + +func areANSICodesSupported() bool { + return true +} diff --git a/helper/text/ansi_windows.go b/helper/text/ansi_windows.go new file mode 100644 index 0000000..d2a3288 --- /dev/null +++ b/helper/text/ansi_windows.go @@ -0,0 +1,32 @@ +//go:build windows +// +build windows + +package text + +import ( + "os" + "sync" + + "golang.org/x/sys/windows" +) + +var ( + enableVTPMutex = sync.Mutex{} +) + +func areANSICodesSupported() bool { + enableVTPMutex.Lock() + defer enableVTPMutex.Unlock() + + outHandle := windows.Handle(os.Stdout.Fd()) + var outMode uint32 + if err := windows.GetConsoleMode(outHandle, &outMode); err == nil { + if outMode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + return true + } + if err := windows.SetConsoleMode(outHandle, outMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err == nil { + return true + } + } + return false +} diff --git a/helper/text/color.go b/helper/text/color.go new file mode 100644 index 0000000..f5b7bdd --- /dev/null +++ b/helper/text/color.go @@ -0,0 +1,183 @@ +package text + +import ( + "fmt" + "sort" + "strconv" + "strings" + "sync" +) + +var ( + colorsEnabled = areANSICodesSupported() +) + +// DisableColors (forcefully) disables color coding globally. +func DisableColors() { + colorsEnabled = false +} + +// EnableColors (forcefully) enables color coding globally. +func EnableColors() { + colorsEnabled = true +} + +// The logic here is inspired from github.com/fatih/color; the following is +// the the bare minimum logic required to print Colored to the console. +// The differences: +// * This one caches the escape sequences for cases with multiple colors +// * This one handles cases where the incoming already has colors in the +// form of escape sequences; in which case, text that does not have any +// escape sequences are colored/escaped + +// Color represents a single color to render with. +type Color int + +// Base colors -- attributes in reality +const ( + Reset Color = iota + Bold + Faint + Italic + Underline + BlinkSlow + BlinkRapid + ReverseVideo + Concealed + CrossedOut +) + +// Foreground colors +const ( + FgBlack Color = iota + 30 + FgRed + FgGreen + FgYellow + FgBlue + FgMagenta + FgCyan + FgWhite +) + +// Foreground Hi-Intensity colors +const ( + FgHiBlack Color = iota + 90 + FgHiRed + FgHiGreen + FgHiYellow + FgHiBlue + FgHiMagenta + FgHiCyan + FgHiWhite +) + +// Background colors +const ( + BgBlack Color = iota + 40 + BgRed + BgGreen + BgYellow + BgBlue + BgMagenta + BgCyan + BgWhite +) + +// Background Hi-Intensity colors +const ( + BgHiBlack Color = iota + 100 + BgHiRed + BgHiGreen + BgHiYellow + BgHiBlue + BgHiMagenta + BgHiCyan + BgHiWhite +) + +// EscapeSeq returns the ANSI escape sequence for the color. +func (c Color) EscapeSeq() string { + return EscapeStart + strconv.Itoa(int(c)) + EscapeStop +} + +// HTMLProperty returns the "class" attribute for the color. +func (c Color) HTMLProperty() string { + out := "" + if class, ok := colorCSSClassMap[c]; ok { + out = fmt.Sprintf("class=\"%s\"", class) + } + return out +} + +// Sprint colorizes and prints the given string(s). +func (c Color) Sprint(a ...interface{}) string { + return colorize(fmt.Sprint(a...), c.EscapeSeq()) +} + +// Sprintf formats and colorizes and prints the given string(s). +func (c Color) Sprintf(format string, a ...interface{}) string { + return colorize(fmt.Sprintf(format, a...), c.EscapeSeq()) +} + +// Colors represents an array of Color objects to render with. +// Example: Colors{FgCyan, BgBlack} +type Colors []Color + +var ( + // colorsSeqMap caches the escape sequence for a set of colors + colorsSeqMap = sync.Map{} +) + +// EscapeSeq returns the ANSI escape sequence for the colors set. +func (c Colors) EscapeSeq() string { + if len(c) == 0 { + return "" + } + + colorsKey := fmt.Sprintf("%#v", c) + escapeSeq, ok := colorsSeqMap.Load(colorsKey) + if !ok || escapeSeq == "" { + colorNums := make([]string, len(c)) + for idx, color := range c { + colorNums[idx] = strconv.Itoa(int(color)) + } + escapeSeq = EscapeStart + strings.Join(colorNums, ";") + EscapeStop + colorsSeqMap.Store(colorsKey, escapeSeq) + } + return escapeSeq.(string) +} + +// HTMLProperty returns the "class" attribute for the colors. +func (c Colors) HTMLProperty() string { + if len(c) == 0 { + return "" + } + + var classes []string + for _, color := range c { + if class, ok := colorCSSClassMap[color]; ok { + classes = append(classes, class) + } + } + if len(classes) > 1 { + sort.Strings(classes) + } + return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " ")) +} + +// Sprint colorizes and prints the given string(s). +func (c Colors) Sprint(a ...interface{}) string { + return colorize(fmt.Sprint(a...), c.EscapeSeq()) +} + +// Sprintf formats and colorizes and prints the given string(s). +func (c Colors) Sprintf(format string, a ...interface{}) string { + return colorize(fmt.Sprintf(format, a...), c.EscapeSeq()) +} + +func colorize(s string, escapeSeq string) string { + if !colorsEnabled || escapeSeq == "" { + return s + } + return Escape(s, escapeSeq) +} diff --git a/helper/text/color_html.go b/helper/text/color_html.go new file mode 100644 index 0000000..3e5c55b --- /dev/null +++ b/helper/text/color_html.go @@ -0,0 +1,48 @@ +package text + +var ( + // colorCSSClassMap contains the equivalent CSS-class for all colors + colorCSSClassMap = map[Color]string{ + Bold: "bold", + Faint: "faint", + Italic: "italic", + Underline: "underline", + BlinkSlow: "blink-slow", + BlinkRapid: "blink-rapid", + ReverseVideo: "reverse-video", + Concealed: "concealed", + CrossedOut: "crossed-out", + FgBlack: "fg-black", + FgRed: "fg-red", + FgGreen: "fg-green", + FgYellow: "fg-yellow", + FgBlue: "fg-blue", + FgMagenta: "fg-magenta", + FgCyan: "fg-cyan", + FgWhite: "fg-white", + FgHiBlack: "fg-hi-black", + FgHiRed: "fg-hi-red", + FgHiGreen: "fg-hi-green", + FgHiYellow: "fg-hi-yellow", + FgHiBlue: "fg-hi-blue", + FgHiMagenta: "fg-hi-magenta", + FgHiCyan: "fg-hi-cyan", + FgHiWhite: "fg-hi-white", + BgBlack: "bg-black", + BgRed: "bg-red", + BgGreen: "bg-green", + BgYellow: "bg-yellow", + BgBlue: "bg-blue", + BgMagenta: "bg-magenta", + BgCyan: "bg-cyan", + BgWhite: "bg-white", + BgHiBlack: "bg-hi-black", + BgHiRed: "bg-hi-red", + BgHiGreen: "bg-hi-green", + BgHiYellow: "bg-hi-yellow", + BgHiBlue: "bg-hi-blue", + BgHiMagenta: "bg-hi-magenta", + BgHiCyan: "bg-hi-cyan", + BgHiWhite: "bg-hi-white", + } +) diff --git a/helper/text/color_test.go b/helper/text/color_test.go new file mode 100644 index 0000000..d31cac0 --- /dev/null +++ b/helper/text/color_test.go @@ -0,0 +1,158 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func init() { + EnableColors() +} + +func TestColor_EnableAndDisable(t *testing.T) { + defer EnableColors() + + EnableColors() + assert.Equal(t, "\x1b[31mtest\x1b[0m", FgRed.Sprint("test")) + + DisableColors() + assert.Equal(t, "test", FgRed.Sprint("test")) + + EnableColors() + assert.Equal(t, "\x1b[31mtest\x1b[0m", FgRed.Sprint("test")) +} + +func ExampleColor_EscapeSeq() { + fmt.Printf("Black Background: %#v\n", BgBlack.EscapeSeq()) + fmt.Printf("Black Foreground: %#v\n", FgBlack.EscapeSeq()) + + // Output: Black Background: "\x1b[40m" + // Black Foreground: "\x1b[30m" +} + +func TestColor_EscapeSeq(t *testing.T) { + assert.Equal(t, "\x1b[40m", BgBlack.EscapeSeq()) +} + +func ExampleColor_HTMLProperty() { + fmt.Printf("Bold: %#v\n", Bold.HTMLProperty()) + fmt.Printf("Black Background: %#v\n", BgBlack.HTMLProperty()) + fmt.Printf("Black Foreground: %#v\n", FgBlack.HTMLProperty()) + + // Output: Bold: "class=\"bold\"" + // Black Background: "class=\"bg-black\"" + // Black Foreground: "class=\"fg-black\"" +} + +func TestColor_HTMLProperty(t *testing.T) { + assert.Equal(t, "class=\"bold\"", Bold.HTMLProperty()) + assert.Equal(t, "class=\"bg-black\"", BgBlack.HTMLProperty()) + assert.Equal(t, "class=\"fg-black\"", FgBlack.HTMLProperty()) +} + +func ExampleColor_Sprint() { + fmt.Printf("%#v\n", BgBlack.Sprint("Black Background")) + fmt.Printf("%#v\n", FgBlack.Sprint("Black Foreground")) + + // Output: "\x1b[40mBlack Background\x1b[0m" + // "\x1b[30mBlack Foreground\x1b[0m" +} + +func TestColor_Sprint(t *testing.T) { + assert.Equal(t, "\x1b[31mtest true\x1b[0m", FgRed.Sprint("test ", true)) + + assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31mtrue\x1b[0m", FgRed.Sprint("\x1b[32mtest\x1b[0m", true)) + assert.Equal(t, "\x1b[32mtest true\x1b[0m", FgRed.Sprint("\x1b[32mtest ", true)) + assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31m \x1b[0m", FgRed.Sprint("\x1b[32mtest\x1b[0m ")) + assert.Equal(t, "\x1b[32mtest\x1b[0m", FgRed.Sprint("\x1b[32mtest\x1b[0m")) +} + +func ExampleColor_Sprintf() { + fmt.Printf("%#v\n", BgBlack.Sprintf("%s %s", "Black", "Background")) + fmt.Printf("%#v\n", FgBlack.Sprintf("%s %s", "Black", "Foreground")) + + // Output: "\x1b[40mBlack Background\x1b[0m" + // "\x1b[30mBlack Foreground\x1b[0m" +} + +func TestColor_Sprintf(t *testing.T) { + assert.Equal(t, "\x1b[31mtest true\x1b[0m", FgRed.Sprintf("test %s", "true")) +} + +func ExampleColors_EscapeSeq() { + fmt.Printf("Black Background: %#v\n", Colors{BgBlack}.EscapeSeq()) + fmt.Printf("Black Foreground: %#v\n", Colors{FgBlack}.EscapeSeq()) + fmt.Printf("Black Background, White Foreground: %#v\n", Colors{BgBlack, FgWhite}.EscapeSeq()) + fmt.Printf("Black Foreground, White Background: %#v\n", Colors{FgBlack, BgWhite}.EscapeSeq()) + + // Output: Black Background: "\x1b[40m" + // Black Foreground: "\x1b[30m" + // Black Background, White Foreground: "\x1b[40;37m" + // Black Foreground, White Background: "\x1b[30;47m" +} + +func TestColors_EscapeSeq(t *testing.T) { + assert.Equal(t, "", Colors{}.EscapeSeq()) + assert.Equal(t, "\x1b[40;37m", Colors{BgBlack, FgWhite}.EscapeSeq()) +} + +func ExampleColors_HTMLProperty() { + fmt.Printf("Black Background: %#v\n", Colors{BgBlack}.HTMLProperty()) + fmt.Printf("Black Foreground: %#v\n", Colors{FgBlack}.HTMLProperty()) + fmt.Printf("Black Background, White Foreground: %#v\n", Colors{BgBlack, FgWhite}.HTMLProperty()) + fmt.Printf("Black Foreground, White Background: %#v\n", Colors{FgBlack, BgWhite}.HTMLProperty()) + fmt.Printf("Bold Italic Underline Red Text: %#v\n", Colors{Bold, Italic, Underline, FgRed}.HTMLProperty()) + + // Output: Black Background: "class=\"bg-black\"" + // Black Foreground: "class=\"fg-black\"" + // Black Background, White Foreground: "class=\"bg-black fg-white\"" + // Black Foreground, White Background: "class=\"bg-white fg-black\"" + // Bold Italic Underline Red Text: "class=\"bold fg-red italic underline\"" +} + +func TestColors_HTMLProperty(t *testing.T) { + assert.Equal(t, "", Colors{}.HTMLProperty()) + assert.Equal(t, "class=\"bg-black fg-white\"", Colors{BgBlack, FgWhite}.HTMLProperty()) + assert.Equal(t, "class=\"bold fg-red\"", Colors{Bold, FgRed}.HTMLProperty()) +} + +func ExampleColors_Sprint() { + fmt.Printf("%#v\n", Colors{BgBlack}.Sprint("Black Background")) + fmt.Printf("%#v\n", Colors{BgBlack, FgWhite}.Sprint("Black Background, White Foreground")) + fmt.Printf("%#v\n", Colors{FgBlack}.Sprint("Black Foreground")) + fmt.Printf("%#v\n", Colors{FgBlack, BgWhite}.Sprint("Black Foreground, White Background")) + + // Output: "\x1b[40mBlack Background\x1b[0m" + // "\x1b[40;37mBlack Background, White Foreground\x1b[0m" + // "\x1b[30mBlack Foreground\x1b[0m" + // "\x1b[30;47mBlack Foreground, White Background\x1b[0m" +} + +func TestColors_Sprint(t *testing.T) { + assert.Equal(t, "test true", Colors{}.Sprint("test ", true)) + assert.Equal(t, "\x1b[31mtest true\x1b[0m", Colors{FgRed}.Sprint("test ", true)) + + assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31mtrue\x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest\x1b[0m", true)) + assert.Equal(t, "\x1b[32mtest true\x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest ", true)) + assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31m \x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest\x1b[0m ")) + assert.Equal(t, "\x1b[32mtest\x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest\x1b[0m")) +} + +func ExampleColors_Sprintf() { + fmt.Printf("%#v\n", Colors{BgBlack}.Sprintf("%s %s", "Black", "Background")) + fmt.Printf("%#v\n", Colors{BgBlack, FgWhite}.Sprintf("%s, %s", "Black Background", "White Foreground")) + fmt.Printf("%#v\n", Colors{FgBlack}.Sprintf("%s %s", "Black", "Foreground")) + fmt.Printf("%#v\n", Colors{FgBlack, BgWhite}.Sprintf("%s, %s", "Black Foreground", "White Background")) + + // Output: "\x1b[40mBlack Background\x1b[0m" + // "\x1b[40;37mBlack Background, White Foreground\x1b[0m" + // "\x1b[30mBlack Foreground\x1b[0m" + // "\x1b[30;47mBlack Foreground, White Background\x1b[0m" +} + +func TestColors_Sprintf(t *testing.T) { + assert.Equal(t, "test true", Colors{}.Sprintf("test %s", "true")) + assert.Equal(t, "\x1b[31mtest true\x1b[0m", Colors{FgRed}.Sprintf("test %s", "true")) +} diff --git a/helper/text/cursor.go b/helper/text/cursor.go new file mode 100644 index 0000000..6d54281 --- /dev/null +++ b/helper/text/cursor.go @@ -0,0 +1,39 @@ +package text + +import ( + "fmt" +) + +// Cursor helps move the cursor on the console in multiple directions. +type Cursor rune + +const ( + // CursorDown helps move the Cursor Down X lines + CursorDown Cursor = 'B' + + // CursorLeft helps move the Cursor Left X characters + CursorLeft Cursor = 'D' + + // CursorRight helps move the Cursor Right X characters + CursorRight Cursor = 'C' + + // CursorUp helps move the Cursor Up X lines + CursorUp Cursor = 'A' + + // EraseLine helps erase all characters to the Right of the Cursor in the + // current line + EraseLine Cursor = 'K' +) + +// Sprint prints the Escape Sequence to move the Cursor once. +func (c Cursor) Sprint() string { + return fmt.Sprintf("%s%c", EscapeStart, c) +} + +// Sprintn prints the Escape Sequence to move the Cursor "n" times. +func (c Cursor) Sprintn(n int) string { + if c == EraseLine { + return c.Sprint() + } + return fmt.Sprintf("%s%d%c", EscapeStart, n, c) +} diff --git a/helper/text/cursor_test.go b/helper/text/cursor_test.go new file mode 100644 index 0000000..b47710a --- /dev/null +++ b/helper/text/cursor_test.go @@ -0,0 +1,52 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleCursor_Sprint() { + fmt.Printf("CursorDown : %#v\n", CursorDown.Sprint()) + fmt.Printf("CursorLeft : %#v\n", CursorLeft.Sprint()) + fmt.Printf("CursorRight: %#v\n", CursorRight.Sprint()) + fmt.Printf("CursorUp : %#v\n", CursorUp.Sprint()) + fmt.Printf("EraseLine : %#v\n", EraseLine.Sprint()) + + // Output: CursorDown : "\x1b[B" + // CursorLeft : "\x1b[D" + // CursorRight: "\x1b[C" + // CursorUp : "\x1b[A" + // EraseLine : "\x1b[K" +} + +func TestCursor_Sprint(t *testing.T) { + assert.Equal(t, "\x1b[B", CursorDown.Sprint()) + assert.Equal(t, "\x1b[D", CursorLeft.Sprint()) + assert.Equal(t, "\x1b[C", CursorRight.Sprint()) + assert.Equal(t, "\x1b[A", CursorUp.Sprint()) + assert.Equal(t, "\x1b[K", EraseLine.Sprint()) +} + +func ExampleCursor_Sprintn() { + fmt.Printf("CursorDown : %#v\n", CursorDown.Sprintn(5)) + fmt.Printf("CursorLeft : %#v\n", CursorLeft.Sprintn(5)) + fmt.Printf("CursorRight: %#v\n", CursorRight.Sprintn(5)) + fmt.Printf("CursorUp : %#v\n", CursorUp.Sprintn(5)) + fmt.Printf("EraseLine : %#v\n", EraseLine.Sprintn(5)) + + // Output: CursorDown : "\x1b[5B" + // CursorLeft : "\x1b[5D" + // CursorRight: "\x1b[5C" + // CursorUp : "\x1b[5A" + // EraseLine : "\x1b[K" +} + +func TestCursor_Sprintn(t *testing.T) { + assert.Equal(t, "\x1b[5B", CursorDown.Sprintn(5)) + assert.Equal(t, "\x1b[5D", CursorLeft.Sprintn(5)) + assert.Equal(t, "\x1b[5C", CursorRight.Sprintn(5)) + assert.Equal(t, "\x1b[5A", CursorUp.Sprintn(5)) + assert.Equal(t, "\x1b[K", EraseLine.Sprintn(5)) +} diff --git a/helper/text/direction.go b/helper/text/direction.go new file mode 100644 index 0000000..25eccc2 --- /dev/null +++ b/helper/text/direction.go @@ -0,0 +1,24 @@ +package text + +// Direction defines the overall flow of text. Similar to bidi.Direction, but +// simplified and specific to this package. +type Direction int + +// Available Directions. +const ( + Default Direction = iota + LeftToRight + RightToLeft +) + +// Modifier returns a character to force the given direction for the text that +// follows the modifier. +func (d Direction) Modifier() string { + switch d { + case LeftToRight: + return "\u202a" + case RightToLeft: + return "\u202b" + } + return "" +} diff --git a/helper/text/direction_test.go b/helper/text/direction_test.go new file mode 100644 index 0000000..f6d308c --- /dev/null +++ b/helper/text/direction_test.go @@ -0,0 +1,13 @@ +package text + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDirection_Modifier(t *testing.T) { + assert.Equal(t, "", Default.Modifier()) + assert.Equal(t, "\u202a", LeftToRight.Modifier()) + assert.Equal(t, "\u202b", RightToLeft.Modifier()) +} diff --git a/helper/text/escape.go b/helper/text/escape.go new file mode 100644 index 0000000..d54e66e --- /dev/null +++ b/helper/text/escape.go @@ -0,0 +1,51 @@ +package text + +import "strings" + +// Constants +const ( + CSIStartRune = rune(91) // [ + CSIStopRune = 'm' + EscapeReset = EscapeStart + "0" + EscapeStop + EscapeStart = "\x1b[" + EscapeStartRune = rune(27) // \x1b + EscapeStop = "m" + EscapeStopRune = 'm' + OSIStartRune = rune(93) // ] + OSIStopRune = '\\' +) + +type escKind int + +const ( + escKindUnknown escKind = iota + escKindCSI + escKindOSI +) + +type escSeq struct { + isIn bool + content strings.Builder + kind escKind +} + +func (e *escSeq) InspectRune(r rune) { + if !e.isIn && r == EscapeStartRune { + e.isIn = true + e.kind = escKindUnknown + e.content.Reset() + e.content.WriteRune(r) + } else if e.isIn { + switch { + case e.kind == escKindUnknown && r == CSIStartRune: + e.kind = escKindCSI + case e.kind == escKindUnknown && r == OSIStartRune: + e.kind = escKindOSI + case e.kind == escKindCSI && r == CSIStopRune || e.kind == escKindOSI && r == OSIStopRune: + e.isIn = false + e.kind = escKindUnknown + } + e.content.WriteRune(r) + } + return +} diff --git a/helper/text/filter.go b/helper/text/filter.go new file mode 100644 index 0000000..765c29d --- /dev/null +++ b/helper/text/filter.go @@ -0,0 +1,12 @@ +package text + +// Filter filters the slice 's' to items which return truth when passed to 'f'. +func Filter(s []string, f func(string) bool) []string { + var out []string + for _, item := range s { + if f(item) { + out = append(out, item) + } + } + return out +} diff --git a/helper/text/filter_test.go b/helper/text/filter_test.go new file mode 100644 index 0000000..f8b0969 --- /dev/null +++ b/helper/text/filter_test.go @@ -0,0 +1,30 @@ +package text + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleFilter() { + slice := []string{"Arya Stark", "Bran Stark", "Jon Snow", "Sansa Stark"} + filter := func(item string) bool { + return strings.HasSuffix(item, "Stark") + } + fmt.Printf("%#v\n", Filter(slice, filter)) + + // Output: []string{"Arya Stark", "Bran Stark", "Sansa Stark"} +} + +func TestFilter(t *testing.T) { + slice := []string{"Arya Stark", "Bran Stark", "Jon Snow", "Sansa Stark"} + filter := func(item string) bool { + return strings.HasSuffix(item, "Stark") + } + + filteredSlice := Filter(slice, filter) + assert.Equal(t, 3, len(filteredSlice)) + assert.NotContains(t, filteredSlice, "Jon Snow") +} diff --git a/helper/text/format.go b/helper/text/format.go new file mode 100644 index 0000000..458620d --- /dev/null +++ b/helper/text/format.go @@ -0,0 +1,100 @@ +package text + +import ( + "strings" + "unicode" +) + +// Format lets you transform the text in supported methods while keeping escape +// sequences in the string intact and untouched. +type Format int + +// Format enumerations +const ( + FormatDefault Format = iota // default_Case + FormatLower // lower + FormatTitle // Title + FormatUpper // UPPER +) + +// Apply converts the text as directed. +func (tc Format) Apply(text string) string { + switch tc { + case FormatLower: + return strings.ToLower(text) + case FormatTitle: + return toTitle(text) + case FormatUpper: + return toUpper(text) + default: + return text + } +} + +func toTitle(text string) string { + prev, inEscSeq := ' ', false + return strings.Map( + func(r rune) rune { + if r == EscapeStartRune { + inEscSeq = true + } + if !inEscSeq { + if isSeparator(prev) { + prev = r + r = unicode.ToUpper(r) + } else { + prev = r + } + } + if inEscSeq && r == EscapeStopRune { + inEscSeq = false + } + return r + }, + text, + ) +} + +func toUpper(text string) string { + inEscSeq := false + return strings.Map( + func(r rune) rune { + if r == EscapeStartRune { + inEscSeq = true + } + if !inEscSeq { + r = unicode.ToUpper(r) + } + if inEscSeq && r == EscapeStopRune { + inEscSeq = false + } + return r + }, + text, + ) +} + +// isSeparator returns true if the given rune is a separator. This function is +// lifted straight out of the standard library @ strings/strings.go. +func isSeparator(r rune) bool { + // ASCII alphanumerics and underscore are not separators + if r <= 0x7F { + switch { + case '0' <= r && r <= '9': + return false + case 'a' <= r && r <= 'z': + return false + case 'A' <= r && r <= 'Z': + return false + case r == '_': + return false + } + return true + } + // Letters and digits are not separators + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return false + } + // Otherwise, all we can do for now is treat spaces as separators. + return unicode.IsSpace(r) +} diff --git a/helper/text/format_test.go b/helper/text/format_test.go new file mode 100644 index 0000000..4657107 --- /dev/null +++ b/helper/text/format_test.go @@ -0,0 +1,45 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleFormat_Apply() { + fmt.Printf("FormatDefault: %#v\n", FormatDefault.Apply("jon Snow")) + fmt.Printf("FormatLower : %#v\n", FormatLower.Apply("jon Snow")) + fmt.Printf("FormatTitle : %#v\n", FormatTitle.Apply("jon Snow")) + fmt.Printf("FormatUpper : %#v\n", FormatUpper.Apply("jon Snow")) + fmt.Println() + fmt.Printf("FormatDefault (w/EscSeq): %#v\n", FormatDefault.Apply(Bold.Sprint("jon Snow"))) + fmt.Printf("FormatLower (w/EscSeq): %#v\n", FormatLower.Apply(Bold.Sprint("jon Snow"))) + fmt.Printf("FormatTitle (w/EscSeq): %#v\n", FormatTitle.Apply(Bold.Sprint("jon Snow"))) + fmt.Printf("FormatUpper (w/EscSeq): %#v\n", FormatUpper.Apply(Bold.Sprint("jon Snow"))) + + // Output: FormatDefault: "jon Snow" + // FormatLower : "jon snow" + // FormatTitle : "Jon Snow" + // FormatUpper : "JON SNOW" + // + // FormatDefault (w/EscSeq): "\x1b[1mjon Snow\x1b[0m" + // FormatLower (w/EscSeq): "\x1b[1mjon snow\x1b[0m" + // FormatTitle (w/EscSeq): "\x1b[1mJon Snow\x1b[0m" + // FormatUpper (w/EscSeq): "\x1b[1mJON SNOW\x1b[0m" +} + +func TestFormat_Apply(t *testing.T) { + text := "A big croc0dile; Died - Empty_fanged ツ \u2008." + assert.Equal(t, text, FormatDefault.Apply(text)) + assert.Equal(t, "a big croc0dile; died - empty_fanged ツ \u2008.", FormatLower.Apply(text)) + assert.Equal(t, "A Big Croc0dile; Died - Empty_fanged ツ \u2008.", FormatTitle.Apply(text)) + assert.Equal(t, "A BIG CROC0DILE; DIED - EMPTY_FANGED ツ \u2008.", FormatUpper.Apply(text)) + + // test with escape sequences + text = Colors{Bold}.Sprint(text) + assert.Equal(t, "\x1b[1mA big croc0dile; Died - Empty_fanged ツ \u2008.\x1b[0m", FormatDefault.Apply(text)) + assert.Equal(t, "\x1b[1ma big croc0dile; died - empty_fanged ツ \u2008.\x1b[0m", FormatLower.Apply(text)) + assert.Equal(t, "\x1b[1mA Big Croc0dile; Died - Empty_fanged ツ \u2008.\x1b[0m", FormatTitle.Apply(text)) + assert.Equal(t, "\x1b[1mA BIG CROC0DILE; DIED - EMPTY_FANGED ツ \u2008.\x1b[0m", FormatUpper.Apply(text)) +} diff --git a/helper/text/hyperlink.go b/helper/text/hyperlink.go new file mode 100644 index 0000000..00a551a --- /dev/null +++ b/helper/text/hyperlink.go @@ -0,0 +1,14 @@ +package text + +import "fmt" + +func Hyperlink(url, text string) string { + if url == "" { + return text + } + if text == "" { + return url + } + // source https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text) +} diff --git a/helper/text/hyperlink_test.go b/helper/text/hyperlink_test.go new file mode 100644 index 0000000..7d7692c --- /dev/null +++ b/helper/text/hyperlink_test.go @@ -0,0 +1,13 @@ +package text + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHyperlink(t *testing.T) { + assert.Equal(t, "Ghost", Hyperlink("", "Ghost")) + assert.Equal(t, "https://example.com", Hyperlink("https://example.com", "")) + assert.Equal(t, "\x1b]8;;https://example.com\x1b\\Ghost\x1b]8;;\x1b\\", Hyperlink("https://example.com", "Ghost")) +} diff --git a/helper/text/string.go b/helper/text/string.go new file mode 100644 index 0000000..6a21dda --- /dev/null +++ b/helper/text/string.go @@ -0,0 +1,219 @@ +package text + +import ( + "strings" + "unicode/utf8" + + "github.com/mattn/go-runewidth" +) + +// RuneWidth stuff +var ( + rwCondition = runewidth.NewCondition() +) + +// InsertEveryN inserts the rune every N characters in the string. For ex.: +// InsertEveryN("Ghost", '-', 1) == "G-h-o-s-t" +// InsertEveryN("Ghost", '-', 2) == "Gh-os-t" +// InsertEveryN("Ghost", '-', 3) == "Gho-st" +// InsertEveryN("Ghost", '-', 4) == "Ghos-t" +// InsertEveryN("Ghost", '-', 5) == "Ghost" +func InsertEveryN(str string, runeToInsert rune, n int) string { + if n <= 0 { + return str + } + + sLen := RuneWidthWithoutEscSequences(str) + var out strings.Builder + out.Grow(sLen + (sLen / n)) + outLen, eSeq := 0, escSeq{} + for idx, c := range str { + if eSeq.isIn { + eSeq.InspectRune(c) + out.WriteRune(c) + continue + } + eSeq.InspectRune(c) + if !eSeq.isIn && outLen > 0 && (outLen%n) == 0 && idx != sLen { + out.WriteRune(runeToInsert) + } + out.WriteRune(c) + if !eSeq.isIn { + outLen += RuneWidth(c) + } + } + return out.String() +} + +// LongestLineLen returns the length of the longest "line" within the +// argument string. For ex.: +// LongestLineLen("Ghost!\nCome back here!\nRight now!") == 15 +func LongestLineLen(str string) int { + maxLength, currLength, eSeq := 0, 0, escSeq{} + for _, c := range str { + if eSeq.isIn { + eSeq.InspectRune(c) + continue + } + eSeq.InspectRune(c) + if c == '\n' { + if currLength > maxLength { + maxLength = currLength + } + currLength = 0 + } else if !eSeq.isIn { + currLength += RuneWidth(c) + } + } + if currLength > maxLength { + maxLength = currLength + } + return maxLength +} + +// OverrideRuneWidthEastAsianWidth can *probably* help with alignment, and +// length calculation issues when dealing with Unicode character-set and a +// non-English language set in the LANG variable. +// +// Set this to 'false' to force the "runewidth" library to pretend to deal with +// English character-set. Be warned that if the text/content you are dealing +// with contains East Asian character-set, this may result in unexpected +// behavior. +// +// References: +// * https://github.com/mattn/go-runewidth/issues/64#issuecomment-1221642154 +// * https://github.com/jedib0t/go-pretty/issues/220 +// * https://github.com/jedib0t/go-pretty/issues/204 +func OverrideRuneWidthEastAsianWidth(val bool) { + rwCondition.EastAsianWidth = val +} + +// Pad pads the given string with as many characters as needed to make it as +// long as specified (maxLen). This function does not count escape sequences +// while calculating length of the string. Ex.: +// Pad("Ghost", 0, ' ') == "Ghost" +// Pad("Ghost", 3, ' ') == "Ghost" +// Pad("Ghost", 5, ' ') == "Ghost" +// Pad("Ghost", 7, ' ') == "Ghost " +// Pad("Ghost", 10, '.') == "Ghost....." +func Pad(str string, maxLen int, paddingChar rune) string { + strLen := RuneWidthWithoutEscSequences(str) + if strLen < maxLen { + str += strings.Repeat(string(paddingChar), maxLen-strLen) + } + return str +} + +// RepeatAndTrim repeats the given string until it is as long as maxRunes. +// For ex.: +// RepeatAndTrim("", 5) == "" +// RepeatAndTrim("Ghost", 0) == "" +// RepeatAndTrim("Ghost", 5) == "Ghost" +// RepeatAndTrim("Ghost", 7) == "GhostGh" +// RepeatAndTrim("Ghost", 10) == "GhostGhost" +func RepeatAndTrim(str string, maxRunes int) string { + if str == "" || maxRunes == 0 { + return "" + } else if maxRunes == utf8.RuneCountInString(str) { + return str + } + repeatedS := strings.Repeat(str, int(maxRunes/utf8.RuneCountInString(str))+1) + return Trim(repeatedS, maxRunes) +} + +// RuneCount is similar to utf8.RuneCountInString, except for the fact that it +// ignores escape sequences while counting. For ex.: +// RuneCount("") == 0 +// RuneCount("Ghost") == 5 +// RuneCount("\x1b[33mGhost\x1b[0m") == 5 +// RuneCount("\x1b[33mGhost\x1b[0") == 5 +// Deprecated: in favor of RuneWidthWithoutEscSequences +func RuneCount(str string) int { + return RuneWidthWithoutEscSequences(str) +} + +// RuneWidth returns the mostly accurate character-width of the rune. This is +// not 100% accurate as the character width is usually dependent on the +// typeface (font) used in the console/terminal. For ex.: +// RuneWidth('A') == 1 +// RuneWidth('ツ') == 2 +// RuneWidth('⊙') == 1 +// RuneWidth('︿') == 2 +// RuneWidth(0x27) == 0 +func RuneWidth(r rune) int { + return rwCondition.RuneWidth(r) +} + +// RuneWidthWithoutEscSequences is similar to RuneWidth, except for the fact +// that it ignores escape sequences while counting. For ex.: +// RuneWidthWithoutEscSequences("") == 0 +// RuneWidthWithoutEscSequences("Ghost") == 5 +// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m") == 5 +// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0") == 5 +func RuneWidthWithoutEscSequences(str string) int { + count, eSeq := 0, escSeq{} + for _, c := range str { + if eSeq.isIn { + eSeq.InspectRune(c) + continue + } + eSeq.InspectRune(c) + if !eSeq.isIn { + count += RuneWidth(c) + } + } + return count +} + +// Snip returns the given string with a fixed length. For ex.: +// Snip("Ghost", 0, "~") == "Ghost" +// Snip("Ghost", 1, "~") == "~" +// Snip("Ghost", 3, "~") == "Gh~" +// Snip("Ghost", 5, "~") == "Ghost" +// Snip("Ghost", 7, "~") == "Ghost " +// Snip("\x1b[33mGhost\x1b[0m", 7, "~") == "\x1b[33mGhost\x1b[0m " +func Snip(str string, length int, snipIndicator string) string { + if length > 0 { + lenStr := RuneWidthWithoutEscSequences(str) + if lenStr > length { + lenStrFinal := length - RuneWidthWithoutEscSequences(snipIndicator) + return Trim(str, lenStrFinal) + snipIndicator + } + } + return str +} + +// Trim trims a string to the given length while ignoring escape sequences. For +// ex.: +// Trim("Ghost", 3) == "Gho" +// Trim("Ghost", 6) == "Ghost" +// Trim("\x1b[33mGhost\x1b[0m", 3) == "\x1b[33mGho\x1b[0m" +// Trim("\x1b[33mGhost\x1b[0m", 6) == "\x1b[33mGhost\x1b[0m" +func Trim(str string, maxLen int) string { + if maxLen <= 0 { + return "" + } + + var out strings.Builder + out.Grow(maxLen) + + outLen, eSeq := 0, escSeq{} + for _, sChr := range str { + if eSeq.isIn { + eSeq.InspectRune(sChr) + out.WriteRune(sChr) + continue + } + eSeq.InspectRune(sChr) + if eSeq.isIn { + out.WriteRune(sChr) + continue + } + if outLen < maxLen { + outLen++ + out.WriteRune(sChr) + continue + } + } + return out.String() +} diff --git a/helper/text/string_test.go b/helper/text/string_test.go new file mode 100644 index 0000000..96b1f17 --- /dev/null +++ b/helper/text/string_test.go @@ -0,0 +1,282 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleInsertEveryN() { + fmt.Printf("InsertEveryN(\"Ghost\", '-', 0): %#v\n", InsertEveryN("Ghost", '-', 0)) + fmt.Printf("InsertEveryN(\"Ghost\", '-', 1): %#v\n", InsertEveryN("Ghost", '-', 1)) + fmt.Printf("InsertEveryN(\"Ghost\", '-', 2): %#v\n", InsertEveryN("Ghost", '-', 2)) + fmt.Printf("InsertEveryN(\"Ghost\", '-', 3): %#v\n", InsertEveryN("Ghost", '-', 3)) + fmt.Printf("InsertEveryN(\"Ghost\", '-', 4): %#v\n", InsertEveryN("Ghost", '-', 4)) + fmt.Printf("InsertEveryN(\"Ghost\", '-', 5): %#v\n", InsertEveryN("Ghost", '-', 5)) + fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 0): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 0)) + fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 1): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 1)) + fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 2): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 2)) + fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 3): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3)) + fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 4): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4)) + fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 5): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5)) + + // Output: InsertEveryN("Ghost", '-', 0): "Ghost" + // InsertEveryN("Ghost", '-', 1): "G-h-o-s-t" + // InsertEveryN("Ghost", '-', 2): "Gh-os-t" + // InsertEveryN("Ghost", '-', 3): "Gho-st" + // InsertEveryN("Ghost", '-', 4): "Ghos-t" + // InsertEveryN("Ghost", '-', 5): "Ghost" + // InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 0): "\x1b[33mGhost\x1b[0m" + // InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 1): "\x1b[33mG-h-o-s-t\x1b[0m" + // InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 2): "\x1b[33mGh-os-t\x1b[0m" + // InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3): "\x1b[33mGho-st\x1b[0m" + // InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4): "\x1b[33mGhos-t\x1b[0m" + // InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5): "\x1b[33mGhost\x1b[0m" +} + +func TestInsertEveryN(t *testing.T) { + assert.Equal(t, "Ghost", InsertEveryN("Ghost", '-', 0)) + assert.Equal(t, "Gツhツoツsツt", InsertEveryN("Ghost", 'ツ', 1)) + assert.Equal(t, "G-h-o-s-t", InsertEveryN("Ghost", '-', 1)) + assert.Equal(t, "Gh-os-t", InsertEveryN("Ghost", '-', 2)) + assert.Equal(t, "Gho-st", InsertEveryN("Ghost", '-', 3)) + assert.Equal(t, "Ghos-t", InsertEveryN("Ghost", '-', 4)) + assert.Equal(t, "Ghost", InsertEveryN("Ghost", '-', 5)) + assert.Equal(t, "\x1b[33mGhost\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 0)) + assert.Equal(t, "\x1b[33mGツhツoツsツt\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", 'ツ', 1)) + assert.Equal(t, "\x1b[33mG-h-o-s-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 1)) + assert.Equal(t, "\x1b[33mGh-os-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 2)) + assert.Equal(t, "\x1b[33mGho-st\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3)) + assert.Equal(t, "\x1b[33mGhos-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4)) + assert.Equal(t, "\x1b[33mGhost\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5)) + assert.Equal(t, "G\x1b]8;;http://example.com\x1b\\-h-o-s-t\x1b]8;;\x1b\\", InsertEveryN("G\x1b]8;;http://example.com\x1b\\host\x1b]8;;\x1b\\", '-', 1)) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\G-h-o-s-t\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", '-', 1)) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\G-h-o-s\x1b]8;;\x1b\\-t", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghos\x1b]8;;\x1b\\t", '-', 1)) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gツhツoツsツt\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 'ツ', 1)) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghツosツt\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 'ツ', 2)) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", '-', 5)) +} + +func ExampleLongestLineLen() { + fmt.Printf("LongestLineLen(\"\"): %d\n", LongestLineLen("")) + fmt.Printf("LongestLineLen(\"\\n\\n\"): %d\n", LongestLineLen("\n\n")) + fmt.Printf("LongestLineLen(\"Ghost\"): %d\n", LongestLineLen("Ghost")) + fmt.Printf("LongestLineLen(\"Ghostツ\"): %d\n", LongestLineLen("Ghostツ")) + fmt.Printf("LongestLineLen(\"Winter\\nIs\\nComing\"): %d\n", LongestLineLen("Winter\nIs\nComing")) + fmt.Printf("LongestLineLen(\"Mother\\nOf\\nDragons\"): %d\n", LongestLineLen("Mother\nOf\nDragons")) + fmt.Printf("LongestLineLen(\"\\x1b[33mMother\\x1b[0m\\nOf\\nDragons\"): %d\n", LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons")) + + // Output: LongestLineLen(""): 0 + // LongestLineLen("\n\n"): 0 + // LongestLineLen("Ghost"): 5 + // LongestLineLen("Ghostツ"): 7 + // LongestLineLen("Winter\nIs\nComing"): 6 + // LongestLineLen("Mother\nOf\nDragons"): 7 + // LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons"): 7 +} + +func TestLongestLineLen(t *testing.T) { + assert.Equal(t, 0, LongestLineLen("")) + assert.Equal(t, 0, LongestLineLen("\n\n")) + assert.Equal(t, 5, LongestLineLen("Ghost")) + assert.Equal(t, 7, LongestLineLen("Ghostツ")) + assert.Equal(t, 6, LongestLineLen("Winter\nIs\nComing")) + assert.Equal(t, 7, LongestLineLen("Mother\nOf\nDragons")) + assert.Equal(t, 7, LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons")) + assert.Equal(t, 7, LongestLineLen("Mother\nOf\n\x1b]8;;http://example.com\x1b\\Dragons\x1b]8;;\x1b\\")) +} + +func TestOverrideRuneWidthEastAsianWidth(t *testing.T) { + originalValue := rwCondition.EastAsianWidth + defer func() { + rwCondition.EastAsianWidth = originalValue + }() + + OverrideRuneWidthEastAsianWidth(true) + assert.Equal(t, 2, RuneWidthWithoutEscSequences("╋")) + OverrideRuneWidthEastAsianWidth(false) + assert.Equal(t, 1, RuneWidthWithoutEscSequences("╋")) + + // Note for posterity. We want the length of the box drawing character to + // be reported as 1. However, with an environment where LANG is set to + // something like 'zh_CN.UTF-8', the value being returned is 2, which breaks + // text alignment/padding logic in this library. + // + // If a future version of runewidth is able to address this internally and + // return 1 for the above, the function being tested can be marked for + // deprecation. +} + +func ExamplePad() { + fmt.Printf("%#v\n", Pad("Ghost", 0, ' ')) + fmt.Printf("%#v\n", Pad("Ghost", 3, ' ')) + fmt.Printf("%#v\n", Pad("Ghost", 5, ' ')) + fmt.Printf("%#v\n", Pad("\x1b[33mGhost\x1b[0m", 7, '-')) + fmt.Printf("%#v\n", Pad("\x1b[33mGhost\x1b[0m", 10, '.')) + + // Output: "Ghost" + // "Ghost" + // "Ghost" + // "\x1b[33mGhost\x1b[0m--" + // "\x1b[33mGhost\x1b[0m....." +} + +func TestPad(t *testing.T) { + assert.Equal(t, "Ghost", Pad("Ghost", 0, ' ')) + assert.Equal(t, "Ghost", Pad("Ghost", 3, ' ')) + assert.Equal(t, "Ghost", Pad("Ghost", 5, ' ')) + assert.Equal(t, "Ghost ", Pad("Ghost", 7, ' ')) + assert.Equal(t, "Ghost.....", Pad("Ghost", 10, '.')) + assert.Equal(t, "\x1b[33mGhost\x1b[0 ", Pad("\x1b[33mGhost\x1b[0", 7, ' ')) + assert.Equal(t, "\x1b[33mGhost\x1b[0.....", Pad("\x1b[33mGhost\x1b[0", 10, '.')) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\ ", Pad("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 7, ' ')) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\.....", Pad("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 10, '.')) +} + +func ExampleRepeatAndTrim() { + fmt.Printf("RepeatAndTrim(\"\", 5): %#v\n", RepeatAndTrim("", 5)) + fmt.Printf("RepeatAndTrim(\"Ghost\", 0): %#v\n", RepeatAndTrim("Ghost", 0)) + fmt.Printf("RepeatAndTrim(\"Ghost\", 3): %#v\n", RepeatAndTrim("Ghost", 3)) + fmt.Printf("RepeatAndTrim(\"Ghost\", 5): %#v\n", RepeatAndTrim("Ghost", 5)) + fmt.Printf("RepeatAndTrim(\"Ghost\", 7): %#v\n", RepeatAndTrim("Ghost", 7)) + fmt.Printf("RepeatAndTrim(\"Ghost\", 10): %#v\n", RepeatAndTrim("Ghost", 10)) + + // Output: RepeatAndTrim("", 5): "" + // RepeatAndTrim("Ghost", 0): "" + // RepeatAndTrim("Ghost", 3): "Gho" + // RepeatAndTrim("Ghost", 5): "Ghost" + // RepeatAndTrim("Ghost", 7): "GhostGh" + // RepeatAndTrim("Ghost", 10): "GhostGhost" +} + +func TestRepeatAndTrim(t *testing.T) { + assert.Equal(t, "", RepeatAndTrim("", 5)) + assert.Equal(t, "", RepeatAndTrim("Ghost", 0)) + assert.Equal(t, "Gho", RepeatAndTrim("Ghost", 3)) + assert.Equal(t, "Ghost", RepeatAndTrim("Ghost", 5)) + assert.Equal(t, "GhostGh", RepeatAndTrim("Ghost", 7)) + assert.Equal(t, "GhostGhost", RepeatAndTrim("Ghost", 10)) + assert.Equal(t, "───", RepeatAndTrim("─", 3)) +} + +func ExampleRuneCount() { + fmt.Printf("RuneCount(\"\"): %d\n", RuneCount("")) + fmt.Printf("RuneCount(\"Ghost\"): %d\n", RuneCount("Ghost")) + fmt.Printf("RuneCount(\"Ghostツ\"): %d\n", RuneCount("Ghostツ")) + fmt.Printf("RuneCount(\"\\x1b[33mGhost\\x1b[0m\"): %d\n", RuneCount("\x1b[33mGhost\x1b[0m")) + fmt.Printf("RuneCount(\"\\x1b[33mGhost\\x1b[0\"): %d\n", RuneCount("\x1b[33mGhost\x1b[0")) + + // Output: RuneCount(""): 0 + // RuneCount("Ghost"): 5 + // RuneCount("Ghostツ"): 7 + // RuneCount("\x1b[33mGhost\x1b[0m"): 5 + // RuneCount("\x1b[33mGhost\x1b[0"): 5 +} + +func TestRuneCount(t *testing.T) { + assert.Equal(t, 0, RuneCount("")) + assert.Equal(t, 5, RuneCount("Ghost")) + assert.Equal(t, 7, RuneCount("Ghostツ")) + assert.Equal(t, 5, RuneCount("\x1b[33mGhost\x1b[0m")) + assert.Equal(t, 5, RuneCount("\x1b[33mGhost\x1b[0")) + assert.Equal(t, 5, RuneCount("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\")) +} + +func ExampleRuneWidth() { + fmt.Printf("RuneWidth('A'): %d\n", RuneWidth('A')) + fmt.Printf("RuneWidth('ツ'): %d\n", RuneWidth('ツ')) + fmt.Printf("RuneWidth('⊙'): %d\n", RuneWidth('⊙')) + fmt.Printf("RuneWidth('︿'): %d\n", RuneWidth('︿')) + fmt.Printf("RuneWidth(rune(27)): %d\n", RuneWidth(rune(27))) // ANSI escape sequence + + // Output: RuneWidth('A'): 1 + // RuneWidth('ツ'): 2 + // RuneWidth('⊙'): 1 + // RuneWidth('︿'): 2 + // RuneWidth(rune(27)): 0 +} + +func TestRuneWidth(t *testing.T) { + assert.Equal(t, 1, RuneWidth('A')) + assert.Equal(t, 2, RuneWidth('ツ')) + assert.Equal(t, 1, RuneWidth('⊙')) + assert.Equal(t, 2, RuneWidth('︿')) + assert.Equal(t, 0, RuneWidth(rune(27))) // ANSI escape sequence +} + +func ExampleRuneWidthWithoutEscSequences() { + fmt.Printf("RuneWidthWithoutEscSequences(\"\"): %d\n", RuneWidthWithoutEscSequences("")) + fmt.Printf("RuneWidthWithoutEscSequences(\"Ghost\"): %d\n", RuneWidthWithoutEscSequences("Ghost")) + fmt.Printf("RuneWidthWithoutEscSequences(\"Ghostツ\"): %d\n", RuneWidthWithoutEscSequences("Ghostツ")) + fmt.Printf("RuneWidthWithoutEscSequences(\"\\x1b[33mGhost\\x1b[0m\"): %d\n", RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m")) + fmt.Printf("RuneWidthWithoutEscSequences(\"\\x1b[33mGhost\\x1b[0\"): %d\n", RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0")) + + // Output: RuneWidthWithoutEscSequences(""): 0 + // RuneWidthWithoutEscSequences("Ghost"): 5 + // RuneWidthWithoutEscSequences("Ghostツ"): 7 + // RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m"): 5 + // RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0"): 5 +} + +func TestRuneWidthWithoutEscSequences(t *testing.T) { + assert.Equal(t, 0, RuneWidthWithoutEscSequences("")) + assert.Equal(t, 5, RuneWidthWithoutEscSequences("Ghost")) + assert.Equal(t, 7, RuneWidthWithoutEscSequences("Ghostツ")) + assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m")) + assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0")) + assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\")) +} + +func ExampleSnip() { + fmt.Printf("Snip(\"Ghost\", 0, \"~\"): %#v\n", Snip("Ghost", 0, "~")) + fmt.Printf("Snip(\"Ghost\", 1, \"~\"): %#v\n", Snip("Ghost", 1, "~")) + fmt.Printf("Snip(\"Ghost\", 3, \"~\"): %#v\n", Snip("Ghost", 3, "~")) + fmt.Printf("Snip(\"Ghost\", 5, \"~\"): %#v\n", Snip("Ghost", 5, "~")) + fmt.Printf("Snip(\"Ghost\", 7, \"~\"): %#v\n", Snip("Ghost", 7, "~")) + fmt.Printf("Snip(\"\\x1b[33mGhost\\x1b[0m\", 7, \"~\"): %#v\n", Snip("\x1b[33mGhost\x1b[0m", 7, "~")) + + // Output: Snip("Ghost", 0, "~"): "Ghost" + // Snip("Ghost", 1, "~"): "~" + // Snip("Ghost", 3, "~"): "Gh~" + // Snip("Ghost", 5, "~"): "Ghost" + // Snip("Ghost", 7, "~"): "Ghost" + // Snip("\x1b[33mGhost\x1b[0m", 7, "~"): "\x1b[33mGhost\x1b[0m" +} + +func TestSnip(t *testing.T) { + assert.Equal(t, "Ghost", Snip("Ghost", 0, "~")) + assert.Equal(t, "~", Snip("Ghost", 1, "~")) + assert.Equal(t, "Gh~", Snip("Ghost", 3, "~")) + assert.Equal(t, "Ghost", Snip("Ghost", 5, "~")) + assert.Equal(t, "Ghost", Snip("Ghost", 7, "~")) + assert.Equal(t, "\x1b[33mGhost\x1b[0m", Snip("\x1b[33mGhost\x1b[0m", 7, "~")) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", Snip("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 7, "~")) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\~", Snip("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3, "~")) + assert.Equal(t, "\x1b[33m\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\\x1b[0m~", Snip("\x1b[33m\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\\x1b[0m", 3, "~")) +} + +func ExampleTrim() { + fmt.Printf("Trim(\"Ghost\", 0): %#v\n", Trim("Ghost", 0)) + fmt.Printf("Trim(\"Ghost\", 3): %#v\n", Trim("Ghost", 3)) + fmt.Printf("Trim(\"Ghost\", 6): %#v\n", Trim("Ghost", 6)) + fmt.Printf("Trim(\"\\x1b[33mGhost\\x1b[0m\", 0): %#v\n", Trim("\x1b[33mGhost\x1b[0m", 0)) + fmt.Printf("Trim(\"\\x1b[33mGhost\\x1b[0m\", 3): %#v\n", Trim("\x1b[33mGhost\x1b[0m", 3)) + fmt.Printf("Trim(\"\\x1b[33mGhost\\x1b[0m\", 6): %#v\n", Trim("\x1b[33mGhost\x1b[0m", 6)) + + // Output: Trim("Ghost", 0): "" + // Trim("Ghost", 3): "Gho" + // Trim("Ghost", 6): "Ghost" + // Trim("\x1b[33mGhost\x1b[0m", 0): "" + // Trim("\x1b[33mGhost\x1b[0m", 3): "\x1b[33mGho\x1b[0m" + // Trim("\x1b[33mGhost\x1b[0m", 6): "\x1b[33mGhost\x1b[0m" +} + +func TestTrim(t *testing.T) { + assert.Equal(t, "", Trim("Ghost", 0)) + assert.Equal(t, "Gho", Trim("Ghost", 3)) + assert.Equal(t, "Ghost", Trim("Ghost", 6)) + assert.Equal(t, "\x1b[33mGho\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 3)) + assert.Equal(t, "\x1b[33mGhost\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 6)) + assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gho\x1b]8;;\x1b\\", Trim("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3)) +} diff --git a/helper/text/transformer.go b/helper/text/transformer.go new file mode 100644 index 0000000..872d663 --- /dev/null +++ b/helper/text/transformer.go @@ -0,0 +1,228 @@ +package text + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// Transformer related constants +const ( + unixTimeMinMilliseconds = int64(10000000000) + unixTimeMinMicroseconds = unixTimeMinMilliseconds * 1000 + unixTimeMinNanoSeconds = unixTimeMinMicroseconds * 1000 +) + +// Transformer related variables +var ( + colorsNumberPositive = Colors{FgHiGreen} + colorsNumberNegative = Colors{FgHiRed} + colorsNumberZero = Colors{} + colorsURL = Colors{Underline, FgBlue} + rfc3339Milli = "2006-01-02T15:04:05.000Z07:00" + rfc3339Micro = "2006-01-02T15:04:05.000000Z07:00" + + possibleTimeLayouts = []string{ + time.RFC3339, + rfc3339Milli, // strfmt.DateTime.String()'s default layout + rfc3339Micro, + time.RFC3339Nano, + } +) + +// Transformer helps format the contents of an object to the user's liking. +type Transformer func(val interface{}) string + +// NewNumberTransformer returns a number Transformer that: +// * transforms the number as directed by 'format' (ex.: %.2f) +// * colors negative values Red +// * colors positive values Green +func NewNumberTransformer(format string) Transformer { + return func(val interface{}) string { + if valStr := transformInt(format, val); valStr != "" { + return valStr + } + if valStr := transformUint(format, val); valStr != "" { + return valStr + } + if valStr := transformFloat(format, val); valStr != "" { + return valStr + } + return fmt.Sprint(val) + } +} + +func transformInt(format string, val interface{}) string { + transform := func(val int64) string { + if val < 0 { + return colorsNumberNegative.Sprintf("-"+format, -val) + } + if val > 0 { + return colorsNumberPositive.Sprintf(format, val) + } + return colorsNumberZero.Sprintf(format, val) + } + + if number, ok := val.(int); ok { + return transform(int64(number)) + } + if number, ok := val.(int8); ok { + return transform(int64(number)) + } + if number, ok := val.(int16); ok { + return transform(int64(number)) + } + if number, ok := val.(int32); ok { + return transform(int64(number)) + } + if number, ok := val.(int64); ok { + return transform(int64(number)) + } + return "" +} + +func transformUint(format string, val interface{}) string { + transform := func(val uint64) string { + if val > 0 { + return colorsNumberPositive.Sprintf(format, val) + } + return colorsNumberZero.Sprintf(format, val) + } + + if number, ok := val.(uint); ok { + return transform(uint64(number)) + } + if number, ok := val.(uint8); ok { + return transform(uint64(number)) + } + if number, ok := val.(uint16); ok { + return transform(uint64(number)) + } + if number, ok := val.(uint32); ok { + return transform(uint64(number)) + } + if number, ok := val.(uint64); ok { + return transform(uint64(number)) + } + return "" +} + +func transformFloat(format string, val interface{}) string { + transform := func(val float64) string { + if val < 0 { + return colorsNumberNegative.Sprintf("-"+format, -val) + } + if val > 0 { + return colorsNumberPositive.Sprintf(format, val) + } + return colorsNumberZero.Sprintf(format, val) + } + + if number, ok := val.(float32); ok { + return transform(float64(number)) + } + if number, ok := val.(float64); ok { + return transform(float64(number)) + } + return "" +} + +// NewJSONTransformer returns a Transformer that can format a JSON string or an +// object into pretty-indented JSON-strings. +func NewJSONTransformer(prefix string, indent string) Transformer { + return func(val interface{}) string { + if valStr, ok := val.(string); ok { + var b bytes.Buffer + if err := json.Indent(&b, []byte(strings.TrimSpace(valStr)), prefix, indent); err == nil { + return string(b.Bytes()) + } + } else if b, err := json.MarshalIndent(val, prefix, indent); err == nil { + return string(b) + } + return fmt.Sprintf("%#v", val) + } +} + +// NewTimeTransformer returns a Transformer that can format a timestamp (a +// time.Time) into a well-defined time format defined using the provided layout +// (ex.: time.RFC3339). +// +// If a non-nil location value is provided, the time will be localized to that +// location (use time.Local to get localized timestamps). +func NewTimeTransformer(layout string, location *time.Location) Transformer { + return func(val interface{}) string { + rsp := fmt.Sprint(val) + if valTime, ok := val.(time.Time); ok { + rsp = formatTime(valTime, layout, location) + } else { + // cycle through some supported layouts to see if the string form + // of the object matches any of these layouts + for _, possibleTimeLayout := range possibleTimeLayouts { + if valTime, err := time.Parse(possibleTimeLayout, rsp); err == nil { + rsp = formatTime(valTime, layout, location) + break + } + } + } + return rsp + } +} + +// NewUnixTimeTransformer returns a Transformer that can format a unix-timestamp +// into a well-defined time format as defined by 'layout'. This can handle +// unix-time in Seconds, MilliSeconds, Microseconds and Nanoseconds. +// +// If a non-nil location value is provided, the time will be localized to that +// location (use time.Local to get localized timestamps). +func NewUnixTimeTransformer(layout string, location *time.Location) Transformer { + transformer := NewTimeTransformer(layout, location) + + return func(val interface{}) string { + if unixTime, ok := val.(int64); ok { + return formatTimeUnix(unixTime, transformer) + } else if unixTimeStr, ok := val.(string); ok { + if unixTime, err := strconv.ParseInt(unixTimeStr, 10, 64); err == nil { + return formatTimeUnix(unixTime, transformer) + } + } + return fmt.Sprint(val) + } +} + +// NewURLTransformer returns a Transformer that can format and pretty print a string +// that contains a URL (the text is underlined and colored Blue). +func NewURLTransformer(colors ...Color) Transformer { + colorsToUse := colorsURL + if len(colors) > 0 { + colorsToUse = colors + } + + return func(val interface{}) string { + return colorsToUse.Sprint(val) + } +} + +func formatTime(t time.Time, layout string, location *time.Location) string { + rsp := "" + if t.Unix() > 0 { + if location != nil { + t = t.In(location) + } + rsp = t.Format(layout) + } + return rsp +} + +func formatTimeUnix(unixTime int64, timeTransformer Transformer) string { + if unixTime >= unixTimeMinNanoSeconds { + unixTime = unixTime / time.Second.Nanoseconds() + } else if unixTime >= unixTimeMinMicroseconds { + unixTime = unixTime / (time.Second.Nanoseconds() / 1000) + } else if unixTime >= unixTimeMinMilliseconds { + unixTime = unixTime / (time.Second.Nanoseconds() / 1000000) + } + return timeTransformer(time.Unix(unixTime, 0)) +} diff --git a/helper/text/transformer_test.go b/helper/text/transformer_test.go new file mode 100644 index 0000000..b42aea4 --- /dev/null +++ b/helper/text/transformer_test.go @@ -0,0 +1,233 @@ +package text + +import ( + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewNumberTransformer(t *testing.T) { + signColorsMap := map[string]Colors{ + "negative": colorsNumberNegative, + "positive": colorsNumberPositive, + "zero": nil, + "nil": nil, + } + colorValuesMap := map[string]map[interface{}]string{ + "negative": { + int(-5): "%05d", + int8(-5): "%05d", + int16(-5): "%05d", + int32(-5): "%05d", + int64(-5): "%05d", + float32(-5.55555): "%08.2f", + float64(-5.55555): "%08.2f", + }, + "positive": { + int(5): "%05d", + int8(5): "%05d", + int16(5): "%05d", + int32(5): "%05d", + int64(5): "%05d", + uint(5): "%05d", + uint8(5): "%05d", + uint16(5): "%05d", + uint32(5): "%05d", + uint64(5): "%05d", + float32(5.55555): "%08.2f", + float64(5.55555): "%08.2f", + }, + "zero": { + int(0): "%05d", + int8(0): "%05d", + int16(0): "%05d", + int32(0): "%05d", + int64(0): "%05d", + uint(0): "%05d", + uint8(0): "%05d", + uint16(0): "%05d", + uint32(0): "%05d", + uint64(0): "%05d", + float32(0.00000): "%08.2f", + float64(0.00000): "%08.2f", + }, + "nil": { + nil: "%v", + }, + } + + for sign, valuesFormatMap := range colorValuesMap { + for value, format := range valuesFormatMap { + transformer := NewNumberTransformer(format) + expected := signColorsMap[sign].Sprintf(format, value) + if sign == "negative" { + expected = strings.Replace(expected, "-0", "-00", 1) + } + actual := transformer(value) + var kind reflect.Kind + if value != nil { + kind = reflect.TypeOf(value).Kind() + } + message := fmt.Sprintf("%s.%s: expected=%v, actual=%v; format=%#v", + sign, kind, expected, actual, format) + + assert.Equal(t, expected, actual, message) + } + } + + // invalid input + assert.Equal(t, "foo", NewNumberTransformer("%05d")("foo")) +} + +type jsonTest struct { + Foo string `json:"foo"` + Bar int32 `json:"bar"` + Baz float64 `json:"baz"` + Nan jsonNestTest `json:"nan"` +} + +type jsonNestTest struct { + A string + B int32 + C float64 +} + +func TestNewJSONTransformer(t *testing.T) { + transformer := NewJSONTransformer("", " ") + + // instance of a struct + inputObj := jsonTest{ + Foo: "fooooooo", + Bar: 13, + Baz: 3.14, + Nan: jsonNestTest{ + A: "a", + B: 2, + C: 3.0, + }, + } + expectedOutput := `{ + "foo": "fooooooo", + "bar": 13, + "baz": 3.14, + "nan": { + "A": "a", + "B": 2, + "C": 3 + } +}` + assert.Equal(t, expectedOutput, transformer(inputObj)) + + // numbers + assert.Equal(t, "1", transformer(int(1))) + assert.Equal(t, "1.2345", transformer(float32(1.2345))) + + // slices + assert.Equal(t, "[\n 1,\n 2,\n 3\n]", transformer([]uint{1, 2, 3})) + + // strings + assert.Equal(t, "\"foo\"", transformer("foo")) + assert.Equal(t, "\"{foo...\"", transformer("{foo...")) // malformed JSON + + // strings with valid JSON + input := "{\"foo\":\"bar\",\"baz\":[1,2,3]}" + expectedOutput = `{ + "foo": "bar", + "baz": [ + 1, + 2, + 3 + ] +}` + assert.Equal(t, expectedOutput, transformer(input)) +} + +func TestNewTimeTransformer(t *testing.T) { + inStr := "2010-11-12T13:14:15-07:00" + inTime, err := time.Parse(time.RFC3339, inStr) + assert.Nil(t, err) + + location, err := time.LoadLocation("America/Los_Angeles") + assert.Nil(t, err) + transformer := NewTimeTransformer(time.RFC3339, location) + expected := "2010-11-12T12:14:15-08:00" + assert.Equal(t, expected, transformer(inStr)) + assert.Equal(t, expected, transformer(inTime)) + for _, possibleTimeLayout := range possibleTimeLayouts { + assert.Equal(t, expected, transformer(inTime.Format(possibleTimeLayout)), possibleTimeLayout) + } + + location, err = time.LoadLocation("Asia/Singapore") + assert.Nil(t, err) + transformer = NewTimeTransformer(time.UnixDate, location) + expected = "Sat Nov 13 04:14:15 +08 2010" + assert.Equal(t, expected, transformer(inStr)) + assert.Equal(t, expected, transformer(inTime)) + for _, possibleTimeLayout := range possibleTimeLayouts { + assert.Equal(t, expected, transformer(inTime.Format(possibleTimeLayout)), possibleTimeLayout) + } + + location, err = time.LoadLocation("Europe/London") + assert.Nil(t, err) + transformer = NewTimeTransformer(time.RFC3339, location) + expected = "2010-11-12T20:14:15Z" + assert.Equal(t, expected, transformer(inStr)) + assert.Equal(t, expected, transformer(inTime)) + for _, possibleTimeLayout := range possibleTimeLayouts { + assert.Equal(t, expected, transformer(inTime.Format(possibleTimeLayout)), possibleTimeLayout) + } +} + +func TestNewUnixTimeTransformer(t *testing.T) { + inStr := "2010-11-12T13:14:15-07:00" + inTime, err := time.Parse(time.RFC3339, inStr) + assert.Nil(t, err) + inUnixTime := inTime.Unix() + + location, err := time.LoadLocation("America/Los_Angeles") + assert.Nil(t, err) + transformer := NewUnixTimeTransformer(time.RFC3339, location) + expected := "2010-11-12T12:14:15-08:00" + assert.Equal(t, expected, transformer(fmt.Sprint(inUnixTime)), "seconds in string") + assert.Equal(t, expected, transformer(inUnixTime), "seconds") + assert.Equal(t, expected, transformer(inUnixTime*1000), "milliseconds") + assert.Equal(t, expected, transformer(inUnixTime*1000000), "microseconds") + assert.Equal(t, expected, transformer(inUnixTime*1000000000), "nanoseconds") + + location, err = time.LoadLocation("Asia/Singapore") + assert.Nil(t, err) + transformer = NewUnixTimeTransformer(time.UnixDate, location) + expected = "Sat Nov 13 04:14:15 +08 2010" + assert.Equal(t, expected, transformer(fmt.Sprint(inUnixTime)), "seconds in string") + assert.Equal(t, expected, transformer(inUnixTime), "seconds") + assert.Equal(t, expected, transformer(inUnixTime*1000), "milliseconds") + assert.Equal(t, expected, transformer(inUnixTime*1000000), "microseconds") + assert.Equal(t, expected, transformer(inUnixTime*1000000000), "nanoseconds") + + location, err = time.LoadLocation("Europe/London") + assert.Nil(t, err) + transformer = NewUnixTimeTransformer(time.RFC3339, location) + expected = "2010-11-12T20:14:15Z" + assert.Equal(t, expected, transformer(fmt.Sprint(inUnixTime)), "seconds in string") + assert.Equal(t, expected, transformer(inUnixTime), "seconds") + assert.Equal(t, expected, transformer(inUnixTime*1000), "milliseconds") + assert.Equal(t, expected, transformer(inUnixTime*1000000), "microseconds") + assert.Equal(t, expected, transformer(inUnixTime*1000000000), "nanoseconds") + + assert.Equal(t, "0.123456", transformer(float32(0.123456))) +} + +func TestNewURLTransformer(t *testing.T) { + url := "https://winter.is.coming" + + transformer := NewURLTransformer() + assert.Equal(t, colorsURL.Sprint(url), transformer(url)) + + transformer2 := NewURLTransformer(FgRed, BgWhite, Bold) + assert.Equal(t, Colors{FgRed, BgWhite, Bold}.Sprint(url), transformer2(url)) + assert.Equal(t, colorsURL.Sprint(url), transformer(url)) +} diff --git a/helper/text/valign.go b/helper/text/valign.go new file mode 100644 index 0000000..76b1943 --- /dev/null +++ b/helper/text/valign.go @@ -0,0 +1,67 @@ +package text + +import "strings" + +// VAlign denotes how text is to be aligned vertically. +type VAlign int + +// VAlign enumerations +const ( + VAlignDefault VAlign = iota // same as VAlignTop + VAlignTop // "top\n\n" + VAlignMiddle // "\nmiddle\n" + VAlignBottom // "\n\nbottom" +) + +// Apply aligns the lines vertically. For ex.: +// * VAlignTop.Apply({"Game", "Of", "Thrones"}, 5) +// returns {"Game", "Of", "Thrones", "", ""} +// * VAlignMiddle.Apply({"Game", "Of", "Thrones"}, 5) +// returns {"", "Game", "Of", "Thrones", ""} +// * VAlignBottom.Apply({"Game", "Of", "Thrones"}, 5) +// returns {"", "", "Game", "Of", "Thrones"} +func (va VAlign) Apply(lines []string, maxLines int) []string { + if len(lines) == maxLines { + return lines + } else if len(lines) > maxLines { + maxLines = len(lines) + } + + insertIdx := 0 + if va == VAlignMiddle { + insertIdx = int(maxLines-len(lines)) / 2 + } else if va == VAlignBottom { + insertIdx = maxLines - len(lines) + } + + linesOut := strings.Split(strings.Repeat("\n", maxLines-1), "\n") + for idx, line := range lines { + linesOut[idx+insertIdx] = line + } + return linesOut +} + +// ApplyStr aligns the string (of 1 or more lines) vertically. For ex.: +// * VAlignTop.ApplyStr("Game\nOf\nThrones", 5) +// returns {"Game", "Of", "Thrones", "", ""} +// * VAlignMiddle.ApplyStr("Game\nOf\nThrones", 5) +// returns {"", "Game", "Of", "Thrones", ""} +// * VAlignBottom.ApplyStr("Game\nOf\nThrones", 5) +// returns {"", "", "Game", "Of", "Thrones"} +func (va VAlign) ApplyStr(text string, maxLines int) []string { + return va.Apply(strings.Split(text, "\n"), maxLines) +} + +// HTMLProperty returns the equivalent HTML vertical-align tag property. +func (va VAlign) HTMLProperty() string { + switch va { + case VAlignTop: + return "valign=\"top\"" + case VAlignMiddle: + return "valign=\"middle\"" + case VAlignBottom: + return "valign=\"bottom\"" + default: + return "" + } +} diff --git a/helper/text/valign_test.go b/helper/text/valign_test.go new file mode 100644 index 0000000..d641b37 --- /dev/null +++ b/helper/text/valign_test.go @@ -0,0 +1,101 @@ +package text + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleVAlign_Apply() { + lines := []string{"Game", "Of", "Thrones"} + maxLines := 5 + fmt.Printf("VAlignDefault: %#v\n", VAlignDefault.Apply(lines, maxLines)) + fmt.Printf("VAlignTop : %#v\n", VAlignTop.Apply(lines, maxLines)) + fmt.Printf("VAlignMiddle : %#v\n", VAlignMiddle.Apply(lines, maxLines)) + fmt.Printf("VAlignBottom : %#v\n", VAlignBottom.Apply(lines, maxLines)) + + // Output: VAlignDefault: []string{"Game", "Of", "Thrones", "", ""} + // VAlignTop : []string{"Game", "Of", "Thrones", "", ""} + // VAlignMiddle : []string{"", "Game", "Of", "Thrones", ""} + // VAlignBottom : []string{"", "", "Game", "Of", "Thrones"} +} + +func TestVAlign_Apply(t *testing.T) { + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignDefault.Apply([]string{"Game", "Of", "Thrones"}, 1)) + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignDefault.Apply([]string{"Game", "Of", "Thrones"}, 3)) + assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""}, + VAlignDefault.Apply([]string{"Game", "Of", "Thrones"}, 5)) + + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignTop.Apply([]string{"Game", "Of", "Thrones"}, 1)) + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignTop.Apply([]string{"Game", "Of", "Thrones"}, 3)) + assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""}, + VAlignTop.Apply([]string{"Game", "Of", "Thrones"}, 5)) + + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignMiddle.Apply([]string{"Game", "Of", "Thrones"}, 1)) + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignMiddle.Apply([]string{"Game", "Of", "Thrones"}, 3)) + assert.Equal(t, []string{"", "Game", "Of", "Thrones", ""}, + VAlignMiddle.Apply([]string{"Game", "Of", "Thrones"}, 5)) + + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignBottom.Apply([]string{"Game", "Of", "Thrones"}, 1)) + assert.Equal(t, []string{"Game", "Of", "Thrones"}, + VAlignBottom.Apply([]string{"Game", "Of", "Thrones"}, 3)) + assert.Equal(t, []string{"", "", "Game", "Of", "Thrones"}, + VAlignBottom.Apply([]string{"Game", "Of", "Thrones"}, 5)) +} + +func ExampleVAlign_ApplyStr() { + str := "Game\nOf\nThrones" + maxLines := 5 + fmt.Printf("VAlignDefault: %#v\n", VAlignDefault.ApplyStr(str, maxLines)) + fmt.Printf("VAlignTop : %#v\n", VAlignTop.ApplyStr(str, maxLines)) + fmt.Printf("VAlignMiddle : %#v\n", VAlignMiddle.ApplyStr(str, maxLines)) + fmt.Printf("VAlignBottom : %#v\n", VAlignBottom.ApplyStr(str, maxLines)) + + // Output: VAlignDefault: []string{"Game", "Of", "Thrones", "", ""} + // VAlignTop : []string{"Game", "Of", "Thrones", "", ""} + // VAlignMiddle : []string{"", "Game", "Of", "Thrones", ""} + // VAlignBottom : []string{"", "", "Game", "Of", "Thrones"} +} + +func TestVAlign_ApplyStr(t *testing.T) { + assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""}, + VAlignDefault.ApplyStr("Game\nOf\nThrones", 5)) + assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""}, + VAlignTop.ApplyStr("Game\nOf\nThrones", 5)) + assert.Equal(t, []string{"", "Game", "Of", "Thrones", ""}, + VAlignMiddle.ApplyStr("Game\nOf\nThrones", 5)) + assert.Equal(t, []string{"", "", "Game", "Of", "Thrones"}, + VAlignBottom.ApplyStr("Game\nOf\nThrones", 5)) +} + +func ExampleVAlign_HTMLProperty() { + fmt.Printf("VAlignDefault: '%s'\n", VAlignDefault.HTMLProperty()) + fmt.Printf("VAlignTop : '%s'\n", VAlignTop.HTMLProperty()) + fmt.Printf("VAlignMiddle : '%s'\n", VAlignMiddle.HTMLProperty()) + fmt.Printf("VAlignBottom : '%s'\n", VAlignBottom.HTMLProperty()) + + // Output: VAlignDefault: '' + // VAlignTop : 'valign="top"' + // VAlignMiddle : 'valign="middle"' + // VAlignBottom : 'valign="bottom"' +} + +func TestVAlign_HTMLProperty(t *testing.T) { + vAligns := map[VAlign]string{ + VAlignDefault: "", + VAlignTop: "top", + VAlignMiddle: "middle", + VAlignBottom: "bottom", + } + for vAlign, htmlStyle := range vAligns { + assert.Contains(t, vAlign.HTMLProperty(), htmlStyle) + } +} diff --git a/helper/text/wrap.go b/helper/text/wrap.go new file mode 100644 index 0000000..a55cb51 --- /dev/null +++ b/helper/text/wrap.go @@ -0,0 +1,266 @@ +package text + +import ( + "strings" + "unicode/utf8" +) + +// WrapHard wraps a string to the given length using a newline. Handles strings +// with ANSI escape sequences (such as text color) without breaking the text +// formatting. Breaks all words that go beyond the line boundary. +// +// For examples, refer to the unit-tests or GoDoc examples. +func WrapHard(str string, wrapLen int) string { + if wrapLen <= 0 { + return "" + } + str = strings.Replace(str, "\t", " ", -1) + sLen := utf8.RuneCountInString(str) + if sLen <= wrapLen { + return str + } + + out := &strings.Builder{} + out.Grow(sLen + (sLen / wrapLen)) + for idx, paragraph := range strings.Split(str, "\n\n") { + if idx > 0 { + out.WriteString("\n\n") + } + wrapHard(paragraph, wrapLen, out) + } + + return out.String() +} + +// WrapSoft wraps a string to the given length using a newline. Handles strings +// with ANSI escape sequences (such as text color) without breaking the text +// formatting. Tries to move words that go beyond the line boundary to the next +// line. +// +// For examples, refer to the unit-tests or GoDoc examples. +func WrapSoft(str string, wrapLen int) string { + if wrapLen <= 0 { + return "" + } + str = strings.Replace(str, "\t", " ", -1) + sLen := utf8.RuneCountInString(str) + if sLen <= wrapLen { + return str + } + + out := &strings.Builder{} + out.Grow(sLen + (sLen / wrapLen)) + for idx, paragraph := range strings.Split(str, "\n\n") { + if idx > 0 { + out.WriteString("\n\n") + } + wrapSoft(paragraph, wrapLen, out) + } + + return out.String() +} + +// WrapText is very similar to WrapHard except for one minor difference. Unlike +// WrapHard which discards line-breaks and respects only paragraph-breaks, this +// function respects line-breaks too. +// +// For examples, refer to the unit-tests or GoDoc examples. +func WrapText(str string, wrapLen int) string { + if wrapLen <= 0 { + return "" + } + + var out strings.Builder + sLen := utf8.RuneCountInString(str) + out.Grow(sLen + (sLen / wrapLen)) + lineIdx, isEscSeq, lastEscSeq := 0, false, "" + for _, char := range str { + if char == EscapeStartRune { + isEscSeq = true + lastEscSeq = "" + } + if isEscSeq { + lastEscSeq += string(char) + } + + appendChar(char, wrapLen, &lineIdx, isEscSeq, lastEscSeq, &out) + + if isEscSeq && char == EscapeStopRune { + isEscSeq = false + } + if lastEscSeq == EscapeReset { + lastEscSeq = "" + } + } + if lastEscSeq != "" && lastEscSeq != EscapeReset { + out.WriteString(EscapeReset) + } + return out.String() +} + +func appendChar(char rune, wrapLen int, lineLen *int, inEscSeq bool, lastSeenEscSeq string, out *strings.Builder) { + // handle reaching the end of the line as dictated by wrapLen or by finding + // a newline character + if (*lineLen == wrapLen && !inEscSeq && char != '\n') || (char == '\n') { + if lastSeenEscSeq != "" { + // terminate escape sequence and the line; and restart the escape + // sequence in the next line + out.WriteString(EscapeReset) + out.WriteRune('\n') + out.WriteString(lastSeenEscSeq) + } else { + // just start a new line + out.WriteRune('\n') + } + // reset line index to 0th character + *lineLen = 0 + } + + // if the rune is not a new line, output it + if char != '\n' { + out.WriteRune(char) + + // increment the line index if not in the middle of an escape sequence + if !inEscSeq { + *lineLen++ + } + } +} + +func appendWord(word string, lineIdx *int, lastSeenEscSeq string, wrapLen int, out *strings.Builder) { + inEscSeq := false + for _, char := range word { + if char == EscapeStartRune { + inEscSeq = true + lastSeenEscSeq = "" + } + if inEscSeq { + lastSeenEscSeq += string(char) + } + + appendChar(char, wrapLen, lineIdx, inEscSeq, lastSeenEscSeq, out) + + if inEscSeq && char == EscapeStopRune { + inEscSeq = false + } + if lastSeenEscSeq == EscapeReset { + lastSeenEscSeq = "" + } + } +} + +func extractOpenEscapeSeq(str string) string { + escapeSeq, inEscSeq := "", false + for _, char := range str { + if char == EscapeStartRune { + inEscSeq = true + escapeSeq = "" + } + if inEscSeq { + escapeSeq += string(char) + } + if char == EscapeStopRune { + inEscSeq = false + } + } + if escapeSeq == EscapeReset { + escapeSeq = "" + } + return escapeSeq +} + +func terminateLine(wrapLen int, lineLen *int, lastSeenEscSeq string, out *strings.Builder) { + if *lineLen < wrapLen { + out.WriteString(strings.Repeat(" ", wrapLen-*lineLen)) + } + // something is already on the line; terminate it + if lastSeenEscSeq != "" { + out.WriteString(EscapeReset) + } + out.WriteRune('\n') + out.WriteString(lastSeenEscSeq) + *lineLen = 0 +} + +func terminateOutput(lastSeenEscSeq string, out *strings.Builder) { + if lastSeenEscSeq != "" && lastSeenEscSeq != EscapeReset && !strings.HasSuffix(out.String(), EscapeReset) { + out.WriteString(EscapeReset) + } +} + +func wrapHard(paragraph string, wrapLen int, out *strings.Builder) { + lineLen, lastSeenEscSeq := 0, "" + words := strings.Fields(paragraph) + for wordIdx, word := range words { + escSeq := extractOpenEscapeSeq(word) + if escSeq != "" { + lastSeenEscSeq = escSeq + } + if lineLen > 0 { + out.WriteRune(' ') + lineLen++ + } + + wordLen := RuneWidthWithoutEscSequences(word) + if lineLen+wordLen <= wrapLen { // word fits within the line + out.WriteString(word) + lineLen += wordLen + } else { // word doesn't fit within the line; hard-wrap + appendWord(word, &lineLen, lastSeenEscSeq, wrapLen, out) + } + + // end of line; but more words incoming + if lineLen == wrapLen && wordIdx < len(words)-1 { + terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out) + } + } + terminateOutput(lastSeenEscSeq, out) +} + +func wrapSoft(paragraph string, wrapLen int, out *strings.Builder) { + lineLen, lastSeenEscSeq := 0, "" + words := strings.Fields(paragraph) + for wordIdx, word := range words { + escSeq := extractOpenEscapeSeq(word) + if escSeq != "" { + lastSeenEscSeq = escSeq + } + + spacing, spacingLen := wrapSoftSpacing(lineLen) + wordLen := RuneWidthWithoutEscSequences(word) + if lineLen+spacingLen+wordLen <= wrapLen { // word fits within the line + out.WriteString(spacing) + out.WriteString(word) + lineLen += spacingLen + wordLen + } else { // word doesn't fit within the line + lineLen = wrapSoftLastWordInLine(wrapLen, lineLen, lastSeenEscSeq, wordLen, word, out) + } + + // end of line; but more words incoming + if lineLen == wrapLen && wordIdx < len(words)-1 { + terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out) + } + } + terminateOutput(lastSeenEscSeq, out) +} + +func wrapSoftLastWordInLine(wrapLen int, lineLen int, lastSeenEscSeq string, wordLen int, word string, out *strings.Builder) int { + if lineLen > 0 { // something is already on the line; terminate it + terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out) + } + if wordLen <= wrapLen { // word fits within a single line + out.WriteString(word) + lineLen = wordLen + } else { // word doesn't fit within a single line; hard-wrap + appendWord(word, &lineLen, lastSeenEscSeq, wrapLen, out) + } + return lineLen +} + +func wrapSoftSpacing(lineLen int) (string, int) { + spacing, spacingLen := "", 0 + if lineLen > 0 { + spacing, spacingLen = " ", 1 + } + return spacing, spacingLen +} diff --git a/helper/text/wrap_test.go b/helper/text/wrap_test.go new file mode 100644 index 0000000..cca619d --- /dev/null +++ b/helper/text/wrap_test.go @@ -0,0 +1,147 @@ +package text + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func ExampleWrapHard() { + str := `The quick brown fox jumped over the lazy dog. + +A big crocodile died empty-fanged, gulping horribly in jerking kicking little +motions. Nonchalant old Peter Quinn ruthlessly shot the under-water vermin with +Xavier yelling Zap!` + strWrapped := WrapHard(str, 30) + for idx, line := range strings.Split(strWrapped, "\n") { + fmt.Printf("Line #%02d: '%s'\n", idx+1, line) + } + + // Output: Line #01: 'The quick brown fox jumped ove' + // Line #02: 'r the lazy dog.' + // Line #03: '' + // Line #04: 'A big crocodile died empty-fan' + // Line #05: 'ged, gulping horribly in jerki' + // Line #06: 'ng kicking little motions. Non' + // Line #07: 'chalant old Peter Quinn ruthle' + // Line #08: 'ssly shot the under-water verm' + // Line #09: 'in with Xavier yelling Zap!' +} + +func TestWrapHard(t *testing.T) { + assert.Equal(t, "", WrapHard("Ghost", 0)) + assert.Equal(t, "G\nh\no\ns\nt", WrapHard("Ghost", 1)) + assert.Equal(t, "Gh\nos\nt", WrapHard("Ghost", 2)) + assert.Equal(t, "Gho\nst", WrapHard("Ghost", 3)) + assert.Equal(t, "Ghos\nt", WrapHard("Ghost", 4)) + assert.Equal(t, "Ghost", WrapHard("Ghost", 5)) + assert.Equal(t, "Ghost", WrapHard("Ghost", 6)) + assert.Equal(t, "Jo\nn \nSn\now", WrapHard("Jon\nSnow", 2)) + assert.Equal(t, "Jo\nn \nSn\now", WrapHard("Jon\nSnow\n", 2)) + assert.Equal(t, "Jon\nSno\nw", WrapHard("Jon\nSnow\n", 3)) + assert.Equal(t, "Jon i\ns a S\nnow", WrapHard("Jon is a Snow", 5)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapHard("\x1b[33mJon\x1b[0m\nSnow", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapHard("\x1b[33mJon\x1b[0m\nSnow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\x1b[0m", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw \x1b[0m", WrapHard("\x1b[33mJon Snow\n\x1b[0m", 3)) + + complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" + assert.Equal(t, complexIn, WrapHard(complexIn, 27)) +} + +func ExampleWrapSoft() { + str := `The quick brown fox jumped over the lazy dog. + +A big crocodile died empty-fanged, gulping horribly in jerking kicking little +motions. Nonchalant old Peter Quinn ruthlessly shot the under-water vermin with +Xavier yelling Zap!` + strWrapped := WrapSoft(str, 30) + for idx, line := range strings.Split(strWrapped, "\n") { + fmt.Printf("Line #%02d: '%s'\n", idx+1, line) + } + + // Output: Line #01: 'The quick brown fox jumped ' + // Line #02: 'over the lazy dog.' + // Line #03: '' + // Line #04: 'A big crocodile died ' + // Line #05: 'empty-fanged, gulping horribly' + // Line #06: 'in jerking kicking little ' + // Line #07: 'motions. Nonchalant old Peter ' + // Line #08: 'Quinn ruthlessly shot the ' + // Line #09: 'under-water vermin with Xavier' + // Line #10: 'yelling Zap!' +} + +func TestWrapSoft(t *testing.T) { + assert.Equal(t, "", WrapSoft("Ghost", 0)) + assert.Equal(t, "G\nh\no\ns\nt", WrapSoft("Ghost", 1)) + assert.Equal(t, "Gh\nos\nt", WrapSoft("Ghost", 2)) + assert.Equal(t, "Gho\nst", WrapSoft("Ghost", 3)) + assert.Equal(t, "Ghos\nt", WrapSoft("Ghost", 4)) + assert.Equal(t, "Ghost", WrapSoft("Ghost", 5)) + assert.Equal(t, "Ghost", WrapSoft("Ghost", 6)) + assert.Equal(t, "Jo\nn \nSn\now", WrapSoft("Jon\nSnow", 2)) + assert.Equal(t, "Jo\nn \nSn\now", WrapSoft("Jon\nSnow\n", 2)) + assert.Equal(t, "Jon\nSno\nw", WrapSoft("Jon\nSnow\n", 3)) + assert.Equal(t, "Jon \nSnow", WrapSoft("Jon\nSnow", 4)) + assert.Equal(t, "Jon \nSnow", WrapSoft("Jon\nSnow\n", 4)) + assert.Equal(t, "Jon \nis a \nSnow", WrapSoft("Jon is a Snow", 5)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapSoft("\x1b[33mJon\x1b[0m\nSnow", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapSoft("\x1b[33mJon\x1b[0m\nSnow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapSoft("\x1b[33mJon Snow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw \x1b[0m", WrapSoft("\x1b[33mJon Snow\n\x1b[0m", 3)) + + complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" + assert.Equal(t, complexIn, WrapSoft(complexIn, 27)) + + assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 4)) + assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m\n\x1b[33m???\x1b[0m", WrapSoft("\x1b[33mJon Snow???\x1b[0m", 4)) +} + +func ExampleWrapText() { + str := `The quick brown fox jumped over the lazy dog. + +A big crocodile died empty-fanged, gulping horribly in jerking kicking little +motions. Nonchalant old Peter Quinn ruthlessly shot the under-water vermin with +Xavier yelling Zap!` + strWrapped := WrapText(str, 30) + for idx, line := range strings.Split(strWrapped, "\n") { + fmt.Printf("Line #%02d: '%s'\n", idx+1, line) + } + + // Output: Line #01: 'The quick brown fox jumped ove' + // Line #02: 'r the lazy dog.' + // Line #03: '' + // Line #04: 'A big crocodile died empty-fan' + // Line #05: 'ged, gulping horribly in jerki' + // Line #06: 'ng kicking little' + // Line #07: 'motions. Nonchalant old Peter ' + // Line #08: 'Quinn ruthlessly shot the unde' + // Line #09: 'r-water vermin with' + // Line #10: 'Xavier yelling Zap!' +} + +func TestWrapText(t *testing.T) { + assert.Equal(t, "", WrapText("Ghost", 0)) + assert.Equal(t, "G\nh\no\ns\nt", WrapText("Ghost", 1)) + assert.Equal(t, "Gh\nos\nt", WrapText("Ghost", 2)) + assert.Equal(t, "Gho\nst", WrapText("Ghost", 3)) + assert.Equal(t, "Ghos\nt", WrapText("Ghost", 4)) + assert.Equal(t, "Ghost", WrapText("Ghost", 5)) + assert.Equal(t, "Ghost", WrapText("Ghost", 6)) + assert.Equal(t, "Jo\nn\nSn\now", WrapText("Jon\nSnow", 2)) + assert.Equal(t, "Jo\nn\nSn\now\n", WrapText("Jon\nSnow\n", 2)) + assert.Equal(t, "Jon\nSno\nw\n", WrapText("Jon\nSnow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapText("\x1b[33mJon\x1b[0m\nSnow", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw\n", WrapText("\x1b[33mJon\x1b[0m\nSnow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m", WrapText("\x1b[33mJon Snow\x1b[0m", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n\x1b[0m", 3)) + + complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" + assert.Equal(t, complexIn, WrapText(complexIn, 27)) +}