Skip to content

Commit

Permalink
table: mixed-mode alignment and sorting (#306)
Browse files Browse the repository at this point in the history
- table: Sort should now work on missing/empty cells
- table: Sort supports `AlphaNumeric` modes
- text: `AlignAuto` to align numbers Right and everything else Left
  • Loading branch information
jedib0t committed Mar 14, 2024
1 parent 699cbbf commit 142bdf4
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 63 deletions.
28 changes: 28 additions & 0 deletions table/render_test.go
Expand Up @@ -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 | <nil> | |
+------+------------+-----------+------------+-----------------------------+
| | | TOTAL | 10000 | |
+------+------------+-----------+------------+-----------------------------+`)
}

func TestTable_Render_AutoIndex(t *testing.T) {
tw := NewWriter()
for rowIdx := 0; rowIdx < 10; rowIdx++ {
Expand Down
126 changes: 104 additions & 22 deletions table/sort.go
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
82 changes: 66 additions & 16 deletions table/sort_test.go
Expand Up @@ -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"})
Expand Down Expand Up @@ -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())
}
68 changes: 43 additions & 25 deletions text/align.go
Expand Up @@ -2,6 +2,7 @@ package text

import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode/utf8"
Expand All @@ -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.:
Expand All @@ -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:
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

0 comments on commit 142bdf4

Please sign in to comment.