Skip to content

Commit 142bdf4

Browse files
authored
table: mixed-mode alignment and sorting (#306)
- table: Sort should now work on missing/empty cells - table: Sort supports `AlphaNumeric` modes - text: `AlignAuto` to align numbers Right and everything else Left
1 parent 699cbbf commit 142bdf4

File tree

5 files changed

+256
-63
lines changed

5 files changed

+256
-63
lines changed

table/render_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,34 @@ func TestTable_Render(t *testing.T) {
111111
A Song of Ice and Fire`)
112112
}
113113

114+
func TestTable_Render_Align(t *testing.T) {
115+
tw := NewWriter()
116+
tw.AppendHeader(testHeader)
117+
tw.AppendRows(testRows)
118+
tw.AppendRow(Row{500, "Jamie", "Lannister", "Kingslayer", "The things I do for love."})
119+
tw.AppendRow(Row{1000, "Tywin", "Lannister", nil})
120+
tw.AppendFooter(testFooter)
121+
tw.SetColumnConfigs([]ColumnConfig{
122+
{Name: "First Name", Align: text.AlignLeft, AlignHeader: text.AlignLeft, AlignFooter: text.AlignLeft},
123+
{Name: "Last Name", Align: text.AlignRight, AlignHeader: text.AlignRight, AlignFooter: text.AlignRight},
124+
{Name: "Salary", Align: text.AlignAuto, AlignHeader: text.AlignRight, AlignFooter: text.AlignAuto},
125+
{Number: 5, Align: text.AlignJustify, AlignHeader: text.AlignJustify, AlignFooter: text.AlignJustify},
126+
})
127+
128+
compareOutput(t, tw.Render(), `
129+
+------+------------+-----------+------------+-----------------------------+
130+
| # | FIRST NAME | LAST NAME | SALARY | |
131+
+------+------------+-----------+------------+-----------------------------+
132+
| 1 | Arya | Stark | 3000 | |
133+
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
134+
| 300 | Tyrion | Lannister | 5000 | |
135+
| 500 | Jamie | Lannister | Kingslayer | The things I do for love. |
136+
| 1000 | Tywin | Lannister | <nil> | |
137+
+------+------------+-----------+------------+-----------------------------+
138+
| | | TOTAL | 10000 | |
139+
+------+------------+-----------+------------+-----------------------------+`)
140+
}
141+
114142
func TestTable_Render_AutoIndex(t *testing.T) {
115143
tw := NewWriter()
116144
for rowIdx := 0; rowIdx < 10; rowIdx++ {

table/sort.go

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,24 @@ type SortMode int
2525
const (
2626
// Asc sorts the column in Ascending order alphabetically.
2727
Asc SortMode = iota
28+
// AscAlphaNumeric sorts the column in Ascending order alphabetically and
29+
// then numerically.
30+
AscAlphaNumeric
2831
// AscNumeric sorts the column in Ascending order numerically.
2932
AscNumeric
33+
// AscNumericAlpha sorts the column in Ascending order numerically and
34+
// then alphabetically.
35+
AscNumericAlpha
3036
// Dsc sorts the column in Descending order alphabetically.
3137
Dsc
38+
// DscAlphaNumeric sorts the column in Descending order alphabetically and
39+
// then numerically.
40+
DscAlphaNumeric
3241
// DscNumeric sorts the column in Descending order numerically.
3342
DscNumeric
43+
// DscNumericAlpha sorts the column in Descending order numerically and
44+
// then alphabetically.
45+
DscNumericAlpha
3446
)
3547

3648
type rowsSorter struct {
@@ -93,35 +105,105 @@ func (rs rowsSorter) Swap(i, j int) {
93105

94106
func (rs rowsSorter) Less(i, j int) bool {
95107
realI, realJ := rs.sortedIndices[i], rs.sortedIndices[j]
96-
for _, col := range rs.sortBy {
97-
rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], col.Number-1
98-
if colIdx < len(rowI) && colIdx < len(rowJ) {
99-
shouldContinue, returnValue := rs.lessColumns(rowI, rowJ, colIdx, col)
100-
if !shouldContinue {
101-
return returnValue
102-
}
108+
for _, sortBy := range rs.sortBy {
109+
// extract the values/cells from the rows for comparison
110+
rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], sortBy.Number-1
111+
iVal, jVal := "", ""
112+
if colIdx < len(rowI) {
113+
iVal = rowI[colIdx]
114+
}
115+
if colIdx < len(rowJ) {
116+
jVal = rowJ[colIdx]
117+
}
118+
119+
// compare and choose whether to continue
120+
shouldContinue, returnValue := less(iVal, jVal, sortBy.Mode)
121+
if !shouldContinue {
122+
return returnValue
103123
}
104124
}
105125
return false
106126
}
107127

108-
func (rs rowsSorter) lessColumns(rowI rowStr, rowJ rowStr, colIdx int, col SortBy) (bool, bool) {
109-
if rowI[colIdx] == rowJ[colIdx] {
128+
func less(iVal string, jVal string, mode SortMode) (bool, bool) {
129+
if iVal == jVal {
110130
return true, false
111-
} else if col.Mode == Asc {
112-
return false, rowI[colIdx] < rowJ[colIdx]
113-
} else if col.Mode == Dsc {
114-
return false, rowI[colIdx] > rowJ[colIdx]
115131
}
116132

117-
iVal, iErr := strconv.ParseFloat(rowI[colIdx], 64)
118-
jVal, jErr := strconv.ParseFloat(rowJ[colIdx], 64)
119-
if iErr == nil && jErr == nil {
120-
if col.Mode == AscNumeric {
121-
return false, iVal < jVal
122-
} else if col.Mode == DscNumeric {
123-
return false, jVal < iVal
124-
}
133+
switch mode {
134+
case Asc, Dsc:
135+
return lessAlphabetic(iVal, jVal, mode)
136+
case AscNumeric, DscNumeric:
137+
return lessNumeric(iVal, jVal, mode)
138+
default: // AscAlphaNumeric, AscNumericAlpha, DscAlphaNumeric, DscNumericAlpha
139+
return lessMixedMode(iVal, jVal, mode)
140+
}
141+
}
142+
143+
func lessAlphabetic(iVal string, jVal string, mode SortMode) (bool, bool) {
144+
switch mode {
145+
case Asc, AscAlphaNumeric, AscNumericAlpha:
146+
return false, iVal < jVal
147+
default: // Dsc, DscAlphaNumeric, DscNumericAlpha
148+
return false, iVal > jVal
149+
}
150+
}
151+
152+
func lessAlphaNumericI(mode SortMode) (bool, bool) {
153+
// i == "abc"; j == 5
154+
switch mode {
155+
case AscAlphaNumeric, DscAlphaNumeric:
156+
return false, true
157+
default: // AscNumericAlpha, DscNumericAlpha
158+
return false, false
159+
}
160+
}
161+
162+
func lessAlphaNumericJ(mode SortMode) (bool, bool) {
163+
// i == 5; j == "abc"
164+
switch mode {
165+
case AscAlphaNumeric, DscAlphaNumeric:
166+
return false, false
167+
default: // AscNumericAlpha, DscNumericAlpha:
168+
return false, true
169+
}
170+
}
171+
172+
func lessMixedMode(iVal string, jVal string, mode SortMode) (bool, bool) {
173+
iNumVal, iErr := strconv.ParseFloat(iVal, 64)
174+
jNumVal, jErr := strconv.ParseFloat(jVal, 64)
175+
if iErr != nil && jErr != nil { // both are alphanumeric
176+
return lessAlphabetic(iVal, jVal, mode)
177+
}
178+
if iErr != nil { // iVal is alphabetic, jVal is numeric
179+
return lessAlphaNumericI(mode)
180+
}
181+
if jErr != nil { // iVal is numeric, jVal is alphabetic
182+
return lessAlphaNumericJ(mode)
183+
}
184+
// both values numeric
185+
return lessNumericVal(iNumVal, jNumVal, mode)
186+
}
187+
188+
func lessNumeric(iVal string, jVal string, mode SortMode) (bool, bool) {
189+
iNumVal, iErr := strconv.ParseFloat(iVal, 64)
190+
jNumVal, jErr := strconv.ParseFloat(jVal, 64)
191+
if iErr != nil || jErr != nil {
192+
return false, false
193+
}
194+
195+
return lessNumericVal(iNumVal, jNumVal, mode)
196+
}
197+
198+
func lessNumericVal(iVal float64, jVal float64, mode SortMode) (bool, bool) {
199+
if iVal == jVal {
200+
return true, false
201+
}
202+
203+
switch mode {
204+
case AscNumeric, AscAlphaNumeric, AscNumericAlpha:
205+
return false, iVal < jVal
206+
default: // DscNumeric, DscAlphaNumeric, DscNumericAlpha
207+
return false, iVal > jVal
125208
}
126-
return true, false
127209
}

table/sort_test.go

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,72 @@ import (
66
"github.com/stretchr/testify/assert"
77
)
88

9+
func TestTable_sortRows_MissingCells(t *testing.T) {
10+
table := Table{}
11+
table.AppendRows([]Row{
12+
{1, "Arya", "Stark", 3000, 9},
13+
{11, "Sansa", "Stark", 3000},
14+
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
15+
{300, "Tyrion", "Lannister", 5000, 7},
16+
})
17+
table.SetStyle(StyleDefault)
18+
table.initForRenderRows()
19+
20+
// sort by "First Name"
21+
table.SortBy([]SortBy{{Number: 5, Mode: Asc}})
22+
assert.Equal(t, []int{1, 3, 0, 2}, table.getSortedRowIndices())
23+
}
24+
25+
func TestTable_sortRows_InvalidMode(t *testing.T) {
26+
table := Table{}
27+
table.AppendRows([]Row{
28+
{1, "Arya", "Stark", 3000},
29+
{11, "Sansa", "Stark", 3000},
30+
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
31+
{300, "Tyrion", "Lannister", 5000},
32+
})
33+
table.SetStyle(StyleDefault)
34+
table.initForRenderRows()
35+
36+
// sort by "First Name"
37+
table.SortBy([]SortBy{{Number: 2, Mode: AscNumeric}})
38+
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
39+
}
40+
41+
func TestTable_sortRows_MixedMode(t *testing.T) {
42+
table := Table{}
43+
table.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"})
44+
table.AppendRows([]Row{
45+
/* 0 */ {1, "Arya", "Stark", 3000, 4},
46+
/* 1 */ {11, "Sansa", "Stark", 3000},
47+
/* 2 */ {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
48+
/* 3 */ {300, "Tyrion", "Lannister", 5000, -7.54},
49+
/* 4 */ {400, "Jamie", "Lannister", 5000, nil},
50+
/* 5 */ {500, "Tywin", "Lannister", 5000, "-7.540"},
51+
})
52+
table.SetStyle(StyleDefault)
53+
table.initForRenderRows()
54+
55+
// sort by nothing
56+
assert.Equal(t, []int{0, 1, 2, 3, 4, 5}, table.getSortedRowIndices())
57+
58+
// sort column #5 in Ascending order alphabetically and then numerically
59+
table.SortBy([]SortBy{{Number: 5, Mode: AscAlphaNumeric}, {Number: 1, Mode: AscNumeric}})
60+
assert.Equal(t, []int{1, 4, 2, 3, 5, 0}, table.getSortedRowIndices())
61+
62+
// sort column #5 in Ascending order numerically and then alphabetically
63+
table.SortBy([]SortBy{{Number: 5, Mode: AscNumericAlpha}, {Number: 1, Mode: AscNumeric}})
64+
assert.Equal(t, []int{3, 5, 0, 1, 4, 2}, table.getSortedRowIndices())
65+
66+
// sort column #5 in Descending order alphabetically and then numerically
67+
table.SortBy([]SortBy{{Number: 5, Mode: DscAlphaNumeric}, {Number: 1, Mode: AscNumeric}})
68+
assert.Equal(t, []int{2, 4, 1, 0, 3, 5}, table.getSortedRowIndices())
69+
70+
// sort column #5 in Descending order numerically and then alphabetically
71+
table.SortBy([]SortBy{{Number: 5, Mode: DscNumericAlpha}, {Number: 1, Mode: AscNumeric}})
72+
assert.Equal(t, []int{0, 3, 5, 2, 4, 1}, table.getSortedRowIndices())
73+
}
74+
975
func TestTable_sortRows_WithName(t *testing.T) {
1076
table := Table{}
1177
table.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"})
@@ -130,19 +196,3 @@ func TestTable_sortRows_WithoutName(t *testing.T) {
130196
table.SortBy(nil)
131197
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
132198
}
133-
134-
func TestTable_sortRows_InvalidMode(t *testing.T) {
135-
table := Table{}
136-
table.AppendRows([]Row{
137-
{1, "Arya", "Stark", 3000},
138-
{11, "Sansa", "Stark", 3000},
139-
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
140-
{300, "Tyrion", "Lannister", 5000},
141-
})
142-
table.SetStyle(StyleDefault)
143-
table.initForRenderRows()
144-
145-
// sort by "First Name"
146-
table.SortBy([]SortBy{{Number: 2, Mode: AscNumeric}})
147-
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
148-
}

text/align.go

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package text
22

33
import (
44
"fmt"
5+
"regexp"
56
"strconv"
67
"strings"
78
"unicode/utf8"
@@ -17,6 +18,12 @@ const (
1718
AlignCenter // " center "
1819
AlignJustify // "justify it"
1920
AlignRight // " right"
21+
AlignAuto // AlignRight for numbers, AlignLeft for the rest
22+
)
23+
24+
var (
25+
// reNumericText - Regular Expression to match numbers.
26+
reNumericText = regexp.MustCompile(`^\s*[+\-]?\d*[.]?\d+\s*$`)
2027
)
2128

2229
// Apply aligns the text as directed. For ex.:
@@ -25,14 +32,25 @@ const (
2532
// - AlignCenter.Apply("Jon Snow", 12) returns " Jon Snow "
2633
// - AlignJustify.Apply("Jon Snow", 12) returns "Jon Snow"
2734
// - AlignRight.Apply("Jon Snow", 12) returns " Jon Snow"
35+
// - AlignAuto.Apply("Jon Snow", 12) returns "Jon Snow "
2836
func (a Align) Apply(text string, maxLength int) string {
29-
text = a.trimString(text)
37+
aComputed := a
38+
if aComputed == AlignAuto {
39+
_, err := strconv.ParseFloat(text, 64)
40+
if err == nil { // was able to parse a number out of the string
41+
aComputed = AlignRight
42+
} else {
43+
aComputed = AlignLeft
44+
}
45+
}
46+
47+
text = aComputed.trimString(text)
3048
sLen := utf8.RuneCountInString(text)
3149
sLenWoE := RuneWidthWithoutEscSequences(text)
3250
numEscChars := sLen - sLenWoE
3351

3452
// now, align the text
35-
switch a {
53+
switch aComputed {
3654
case AlignDefault, AlignLeft:
3755
return fmt.Sprintf("%-"+strconv.Itoa(maxLength+numEscChars)+"s", text)
3856
case AlignCenter:
@@ -42,7 +60,7 @@ func (a Align) Apply(text string, maxLength int) string {
4260
text+strings.Repeat(" ", (maxLength-sLenWoE)/2))
4361
}
4462
case AlignJustify:
45-
return a.justifyText(text, sLenWoE, maxLength)
63+
return justifyText(text, sLenWoE, maxLength)
4664
}
4765
return fmt.Sprintf("%"+strconv.Itoa(maxLength+numEscChars)+"s", text)
4866
}
@@ -77,16 +95,34 @@ func (a Align) MarkdownProperty() string {
7795
}
7896
}
7997

80-
func (a Align) justifyText(text string, textLength int, maxLength int) string {
98+
func (a Align) trimString(text string) string {
99+
switch a {
100+
case AlignDefault, AlignLeft:
101+
if strings.HasSuffix(text, " ") {
102+
return strings.TrimRight(text, " ")
103+
}
104+
case AlignRight:
105+
if strings.HasPrefix(text, " ") {
106+
return strings.TrimLeft(text, " ")
107+
}
108+
default:
109+
if strings.HasPrefix(text, " ") || strings.HasSuffix(text, " ") {
110+
return strings.Trim(text, " ")
111+
}
112+
}
113+
return text
114+
}
115+
116+
func justifyText(text string, textLength int, maxLength int) string {
81117
// split the text into individual words
82-
wordsUnfiltered := strings.Split(text, " ")
83-
words := Filter(wordsUnfiltered, func(item string) bool {
118+
words := Filter(strings.Split(text, " "), func(item string) bool {
84119
return item != ""
85120
})
86-
// empty string implies spaces for maxLength
121+
// empty string implies result is just spaces for maxLength
87122
if len(words) == 0 {
88123
return strings.Repeat(" ", maxLength)
89124
}
125+
90126
// get the number of spaces to insert into the text
91127
numSpacesNeeded := maxLength - textLength + strings.Count(text, " ")
92128
numSpacesNeededBetweenWords := 0
@@ -117,21 +153,3 @@ func (a Align) justifyText(text string, textLength int, maxLength int) string {
117153
}
118154
return outText.String()
119155
}
120-
121-
func (a Align) trimString(text string) string {
122-
switch a {
123-
case AlignDefault, AlignLeft:
124-
if strings.HasSuffix(text, " ") {
125-
return strings.TrimRight(text, " ")
126-
}
127-
case AlignRight:
128-
if strings.HasPrefix(text, " ") {
129-
return strings.TrimLeft(text, " ")
130-
}
131-
default:
132-
if strings.HasPrefix(text, " ") || strings.HasSuffix(text, " ") {
133-
return strings.Trim(text, " ")
134-
}
135-
}
136-
return text
137-
}

0 commit comments

Comments
 (0)