diff --git a/table/render_test.go b/table/render_test.go index d5969eb..4dc2e26 100644 --- a/table/render_test.go +++ b/table/render_test.go @@ -111,6 +111,34 @@ func TestTable_Render(t *testing.T) { A Song of Ice and Fire`) } +func TestTable_Render_Align(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(Row{500, "Jamie", "Lannister", "Kingslayer", "The things I do for love."}) + tw.AppendRow(Row{1000, "Tywin", "Lannister", nil}) + tw.AppendFooter(testFooter) + tw.SetColumnConfigs([]ColumnConfig{ + {Name: "First Name", Align: text.AlignLeft, AlignHeader: text.AlignLeft, AlignFooter: text.AlignLeft}, + {Name: "Last Name", Align: text.AlignRight, AlignHeader: text.AlignRight, AlignFooter: text.AlignRight}, + {Name: "Salary", Align: text.AlignAuto, AlignHeader: text.AlignRight, AlignFooter: text.AlignAuto}, + {Number: 5, Align: text.AlignJustify, AlignHeader: text.AlignJustify, AlignFooter: text.AlignJustify}, + }) + + 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 | | +| 500 | Jamie | Lannister | Kingslayer | The things I do for love. | +| 1000 | Tywin | Lannister | | | ++------+------------+-----------+------------+-----------------------------+ +| | | TOTAL | 10000 | | ++------+------------+-----------+------------+-----------------------------+`) +} + func TestTable_Render_AutoIndex(t *testing.T) { tw := NewWriter() for rowIdx := 0; rowIdx < 10; rowIdx++ { diff --git a/table/sort.go b/table/sort.go index ae55f30..ad229d6 100644 --- a/table/sort.go +++ b/table/sort.go @@ -25,12 +25,24 @@ type SortMode int const ( // Asc sorts the column in Ascending order alphabetically. Asc SortMode = iota + // AscAlphaNumeric sorts the column in Ascending order alphabetically and + // then numerically. + AscAlphaNumeric // AscNumeric sorts the column in Ascending order numerically. AscNumeric + // AscNumericAlpha sorts the column in Ascending order numerically and + // then alphabetically. + AscNumericAlpha // Dsc sorts the column in Descending order alphabetically. Dsc + // DscAlphaNumeric sorts the column in Descending order alphabetically and + // then numerically. + DscAlphaNumeric // DscNumeric sorts the column in Descending order numerically. DscNumeric + // DscNumericAlpha sorts the column in Descending order numerically and + // then alphabetically. + DscNumericAlpha ) type rowsSorter struct { @@ -93,35 +105,105 @@ func (rs rowsSorter) Swap(i, j int) { 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 - } + for _, sortBy := range rs.sortBy { + // extract the values/cells from the rows for comparison + rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], sortBy.Number-1 + iVal, jVal := "", "" + if colIdx < len(rowI) { + iVal = rowI[colIdx] + } + if colIdx < len(rowJ) { + jVal = rowJ[colIdx] + } + + // compare and choose whether to continue + shouldContinue, returnValue := less(iVal, jVal, sortBy.Mode) + 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] { +func less(iVal string, jVal string, mode SortMode) (bool, bool) { + if iVal == jVal { 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 - } + switch mode { + case Asc, Dsc: + return lessAlphabetic(iVal, jVal, mode) + case AscNumeric, DscNumeric: + return lessNumeric(iVal, jVal, mode) + default: // AscAlphaNumeric, AscNumericAlpha, DscAlphaNumeric, DscNumericAlpha + return lessMixedMode(iVal, jVal, mode) + } +} + +func lessAlphabetic(iVal string, jVal string, mode SortMode) (bool, bool) { + switch mode { + case Asc, AscAlphaNumeric, AscNumericAlpha: + return false, iVal < jVal + default: // Dsc, DscAlphaNumeric, DscNumericAlpha + return false, iVal > jVal + } +} + +func lessAlphaNumericI(mode SortMode) (bool, bool) { + // i == "abc"; j == 5 + switch mode { + case AscAlphaNumeric, DscAlphaNumeric: + return false, true + default: // AscNumericAlpha, DscNumericAlpha + return false, false + } +} + +func lessAlphaNumericJ(mode SortMode) (bool, bool) { + // i == 5; j == "abc" + switch mode { + case AscAlphaNumeric, DscAlphaNumeric: + return false, false + default: // AscNumericAlpha, DscNumericAlpha: + return false, true + } +} + +func lessMixedMode(iVal string, jVal string, mode SortMode) (bool, bool) { + iNumVal, iErr := strconv.ParseFloat(iVal, 64) + jNumVal, jErr := strconv.ParseFloat(jVal, 64) + if iErr != nil && jErr != nil { // both are alphanumeric + return lessAlphabetic(iVal, jVal, mode) + } + if iErr != nil { // iVal is alphabetic, jVal is numeric + return lessAlphaNumericI(mode) + } + if jErr != nil { // iVal is numeric, jVal is alphabetic + return lessAlphaNumericJ(mode) + } + // both values numeric + return lessNumericVal(iNumVal, jNumVal, mode) +} + +func lessNumeric(iVal string, jVal string, mode SortMode) (bool, bool) { + iNumVal, iErr := strconv.ParseFloat(iVal, 64) + jNumVal, jErr := strconv.ParseFloat(jVal, 64) + if iErr != nil || jErr != nil { + return false, false + } + + return lessNumericVal(iNumVal, jNumVal, mode) +} + +func lessNumericVal(iVal float64, jVal float64, mode SortMode) (bool, bool) { + if iVal == jVal { + return true, false + } + + switch mode { + case AscNumeric, AscAlphaNumeric, AscNumericAlpha: + return false, iVal < jVal + default: // DscNumeric, DscAlphaNumeric, DscNumericAlpha + return false, iVal > jVal } - return true, false } diff --git a/table/sort_test.go b/table/sort_test.go index d63164d..c6c05c5 100644 --- a/table/sort_test.go +++ b/table/sort_test.go @@ -6,6 +6,72 @@ import ( "github.com/stretchr/testify/assert" ) +func TestTable_sortRows_MissingCells(t *testing.T) { + table := Table{} + table.AppendRows([]Row{ + {1, "Arya", "Stark", 3000, 9}, + {11, "Sansa", "Stark", 3000}, + {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + {300, "Tyrion", "Lannister", 5000, 7}, + }) + table.SetStyle(StyleDefault) + table.initForRenderRows() + + // sort by "First Name" + table.SortBy([]SortBy{{Number: 5, Mode: Asc}}) + assert.Equal(t, []int{1, 3, 0, 2}, 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()) +} + +func TestTable_sortRows_MixedMode(t *testing.T) { + table := Table{} + table.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"}) + table.AppendRows([]Row{ + /* 0 */ {1, "Arya", "Stark", 3000, 4}, + /* 1 */ {11, "Sansa", "Stark", 3000}, + /* 2 */ {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, + /* 3 */ {300, "Tyrion", "Lannister", 5000, -7.54}, + /* 4 */ {400, "Jamie", "Lannister", 5000, nil}, + /* 5 */ {500, "Tywin", "Lannister", 5000, "-7.540"}, + }) + table.SetStyle(StyleDefault) + table.initForRenderRows() + + // sort by nothing + assert.Equal(t, []int{0, 1, 2, 3, 4, 5}, table.getSortedRowIndices()) + + // sort column #5 in Ascending order alphabetically and then numerically + table.SortBy([]SortBy{{Number: 5, Mode: AscAlphaNumeric}, {Number: 1, Mode: AscNumeric}}) + assert.Equal(t, []int{1, 4, 2, 3, 5, 0}, table.getSortedRowIndices()) + + // sort column #5 in Ascending order numerically and then alphabetically + table.SortBy([]SortBy{{Number: 5, Mode: AscNumericAlpha}, {Number: 1, Mode: AscNumeric}}) + assert.Equal(t, []int{3, 5, 0, 1, 4, 2}, table.getSortedRowIndices()) + + // sort column #5 in Descending order alphabetically and then numerically + table.SortBy([]SortBy{{Number: 5, Mode: DscAlphaNumeric}, {Number: 1, Mode: AscNumeric}}) + assert.Equal(t, []int{2, 4, 1, 0, 3, 5}, table.getSortedRowIndices()) + + // sort column #5 in Descending order numerically and then alphabetically + table.SortBy([]SortBy{{Number: 5, Mode: DscNumericAlpha}, {Number: 1, Mode: AscNumeric}}) + assert.Equal(t, []int{0, 3, 5, 2, 4, 1}, table.getSortedRowIndices()) +} + func TestTable_sortRows_WithName(t *testing.T) { table := Table{} table.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"}) @@ -130,19 +196,3 @@ func TestTable_sortRows_WithoutName(t *testing.T) { 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/text/align.go b/text/align.go index 7a6d92c..c11b511 100644 --- a/text/align.go +++ b/text/align.go @@ -2,6 +2,7 @@ package text import ( "fmt" + "regexp" "strconv" "strings" "unicode/utf8" @@ -17,6 +18,12 @@ const ( AlignCenter // " center " AlignJustify // "justify it" AlignRight // " right" + AlignAuto // AlignRight for numbers, AlignLeft for the rest +) + +var ( + // reNumericText - Regular Expression to match numbers. + reNumericText = regexp.MustCompile(`^\s*[+\-]?\d*[.]?\d+\s*$`) ) // Apply aligns the text as directed. For ex.: @@ -25,14 +32,25 @@ const ( // - AlignCenter.Apply("Jon Snow", 12) returns " Jon Snow " // - AlignJustify.Apply("Jon Snow", 12) returns "Jon Snow" // - AlignRight.Apply("Jon Snow", 12) returns " Jon Snow" +// - AlignAuto.Apply("Jon Snow", 12) returns "Jon Snow " func (a Align) Apply(text string, maxLength int) string { - text = a.trimString(text) + aComputed := a + if aComputed == AlignAuto { + _, err := strconv.ParseFloat(text, 64) + if err == nil { // was able to parse a number out of the string + aComputed = AlignRight + } else { + aComputed = AlignLeft + } + } + + text = aComputed.trimString(text) sLen := utf8.RuneCountInString(text) sLenWoE := RuneWidthWithoutEscSequences(text) numEscChars := sLen - sLenWoE // now, align the text - switch a { + switch aComputed { case AlignDefault, AlignLeft: return fmt.Sprintf("%-"+strconv.Itoa(maxLength+numEscChars)+"s", text) case AlignCenter: @@ -42,7 +60,7 @@ func (a Align) Apply(text string, maxLength int) string { text+strings.Repeat(" ", (maxLength-sLenWoE)/2)) } case AlignJustify: - return a.justifyText(text, sLenWoE, maxLength) + return justifyText(text, sLenWoE, maxLength) } return fmt.Sprintf("%"+strconv.Itoa(maxLength+numEscChars)+"s", text) } @@ -77,16 +95,34 @@ func (a Align) MarkdownProperty() string { } } -func (a Align) justifyText(text string, textLength int, maxLength int) 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 +} + +func 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 { + words := Filter(strings.Split(text, " "), func(item string) bool { return item != "" }) - // empty string implies spaces for maxLength + // empty string implies result is just 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 @@ -117,21 +153,3 @@ func (a Align) justifyText(text string, textLength int, maxLength int) string { } 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/text/align_test.go b/text/align_test.go index b3250d1..a148845 100644 --- a/text/align_test.go +++ b/text/align_test.go @@ -13,12 +13,16 @@ func ExampleAlign_Apply() { 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)) + fmt.Printf("AlignAuto : '%s'\n", AlignAuto.Apply("Jon Snow", 12)) + fmt.Printf("AlignAuto : '%s'\n", AlignAuto.Apply("-5.43", 12)) // Output: AlignDefault: 'Jon Snow ' // AlignLeft : 'Jon Snow ' // AlignCenter : ' Jon Snow ' // AlignJustify: 'Jon Snow' // AlignRight : ' Jon Snow' + // AlignAuto : 'Jon Snow ' + // AlignAuto : ' -5.43' } func TestAlign_Apply(t *testing.T) { @@ -50,6 +54,17 @@ func TestAlign_Apply(t *testing.T) { 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)) + + // Align Auto + assert.Equal(t, "Jon Snow ", AlignAuto.Apply("Jon Snow", 12)) + assert.Equal(t, "Jon Snow ", AlignAuto.Apply("Jon Snow ", 12)) + assert.Equal(t, " Jon Snow ", AlignAuto.Apply(" Jon Snow ", 12)) + assert.Equal(t, " ", AlignAuto.Apply("", 12)) + assert.Equal(t, " 13", AlignAuto.Apply("13", 12)) + assert.Equal(t, " -5.43", AlignAuto.Apply("-5.43", 12)) + assert.Equal(t, " +.43", AlignAuto.Apply("+.43", 12)) + assert.Equal(t, " +5.43", AlignAuto.Apply("+5.43", 12)) + assert.Equal(t, "+5.43x ", AlignAuto.Apply("+5.43x", 12)) } func TestAlign_Apply_ColoredText(t *testing.T) {