Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

table: mixed-mode alignment and sorting #306

Merged
merged 4 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 28 additions & 0 deletions table/render_test.go
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
}