Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.idea/
/.vscode/
/demo*
/profile/
coverage.*
Expand Down
121 changes: 120 additions & 1 deletion table/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ If you want very specific examples, look at the [Examples](#examples) section.
### Sorting & Filtering

- Sort by one or more Columns (`SortBy`)
- Ascending or Descending mode per column
- Multiple column sorting support
- Various sort modes: alphabetical, numeric, alphanumeric, numeric-alpha
- Case-insensitive sorting option (`IgnoreCase`)
- Custom sorting functions (`CustomLess`) for advanced sorting logic
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
- Hide specific columns (`ColumnConfig.Hidden`)
- Suppress trailing spaces in the last column (`SuppressTrailingSpaces`)
Expand Down Expand Up @@ -375,6 +377,123 @@ rows be sorted first by "First Name" and then by "Last Name" (in case of similar
})
```

#### Sort Modes

The `Mode` field in `SortBy` supports various sorting modes:
- `Asc` / `Dsc` - Alphabetical ascending/descending
- `AscNumeric` / `DscNumeric` - Numerical ascending/descending
- `AscAlphaNumeric` / `DscAlphaNumeric` - Alphabetical first, then numerical
- `AscNumericAlpha` / `DscNumericAlpha` - Numerical first, then alphabetical

You can also make sorting case-insensitive by setting `IgnoreCase: true`:
```golang
t.SortBy([]table.SortBy{
{Name: "First Name", Mode: table.Asc, IgnoreCase: true},
})
```

#### Custom Sorting

For advanced sorting requirements, you can provide a custom comparison function
using `CustomLess`. This function overrides the `Mode` and `IgnoreCase` settings
and gives you full control over the sorting logic.

The `CustomLess` function receives two string values (the cell contents converted
to strings) and must return:
- `-1` when the first value should come before the second
- `0` when the values are considered equal (sorting continues to the next column)
- `1` when the first value should come after the second

<details>
<summary>Example: Custom numeric sorting that handles string numbers correctly</summary>

```golang
t.SortBy([]table.SortBy{
{
Number: 1,
CustomLess: func(iStr string, jStr string) int {
iNum, iErr := strconv.Atoi(iStr)
jNum, jErr := strconv.Atoi(jStr)
if iErr != nil || jErr != nil {
// Fallback to string comparison if not numeric
if iStr < jStr {
return -1
}
if iStr > jStr {
return 1
}
return 0
}
if iNum < jNum {
return -1
}
if iNum > jNum {
return 1
}
return 0
},
},
})
```

</details>

<details>
<summary>Example: Custom case-insensitive sorting with fallback to case-sensitive</summary>

```golang
t.SortBy([]table.SortBy{
{
Name: "Name",
CustomLess: func(iStr string, jStr string) int {
iLower := strings.ToLower(iStr)
jLower := strings.ToLower(jStr)
if iLower < jLower {
return -1
}
if iLower > jLower {
return 1
}
// If case-insensitive equal, compare case-sensitive
if iStr < jStr {
return -1
}
if iStr > jStr {
return 1
}
return 0
},
},
})
```

</details>

<details>
<summary>Example: Combining custom sorting with default sorting modes</summary>

```golang
t.SortBy([]table.SortBy{
{
Number: 1,
CustomLess: func(iStr string, jStr string) int {
// Custom logic: "same" values come first
if iStr == "same" && jStr != "same" {
return -1
}
if iStr != "same" && jStr == "same" {
return 1
}
return 0 // Equal, continue to next column
},
},
{Number: 2, Mode: table.Asc}, // Default alphabetical sort
{Number: 3, Mode: table.AscNumeric}, // Default numeric sort
})
```

</details>

### Wrapping (or) Row/Column Width restrictions

You can restrict the maximum (text) width for a Row:
Expand Down
126 changes: 63 additions & 63 deletions table/sort.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ type SortBy struct {

// IgnoreCase makes sorting case-insensitive
IgnoreCase bool

// CustomLess is a function that can be used to sort the column in a custom
// manner. Note that:
// * This overrides and ignores the Mode and IgnoreCase settings
// * This is called after the column contents are converted to string form
// * This function is expected to return:
// * -1 => when iStr comes before jStr
// * 0 => when iStr and jStr are considered equal
// * 1 => when iStr comes after jStr
//
// Use this when the default sorting logic is not sufficient.
CustomLess func(iStr string, jStr string) int
}

// SortMode defines How to sort.
Expand Down Expand Up @@ -49,12 +61,6 @@ const (
DscNumericAlpha
)

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 {
Expand All @@ -64,10 +70,30 @@ func (t *Table) getSortedRowIndices() []int {
}

if len(t.sortBy) > 0 {
sort.Sort(rowsSorter{
rows: t.rows,
sortBy: t.parseSortBy(t.sortBy),
sortedIndices: sortedIndices,
parsedSortBy := t.parseSortBy(t.sortBy)
sort.Slice(sortedIndices, func(i, j int) bool {
isEqual, isLess := false, false
realI, realJ := sortedIndices[i], sortedIndices[j]
for _, sortBy := range parsedSortBy {
// extract the values/cells from the rows for comparison
rowI, rowJ, colIdx := t.rows[realI], t.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
isEqual, isLess = less(iVal, jVal, sortBy)
// if the values are not equal, return the result immediately
if !isEqual {
return isLess
}
// if the values are equal, continue to the next column
}
return isLess
})
}

Expand All @@ -94,48 +120,32 @@ func (t *Table) parseSortBy(sortBy []SortBy) []SortBy {
Number: colNum,
Mode: col.Mode,
IgnoreCase: col.IgnoreCase,
CustomLess: col.CustomLess,
})
}
}
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 {
shouldContinue, returnValue := false, false
realI, realJ := rs.sortedIndices[i], rs.sortedIndices[j]
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)
if !shouldContinue {
break
func less(iVal string, jVal string, sb SortBy) (bool, bool) {
if sb.CustomLess != nil {
// use the custom less function to compare the values
rc := sb.CustomLess(iVal, jVal)
if rc < 0 {
return false, true
} else if rc > 0 {
return false, false
} else { // rc == 0
return true, false
}
}
return returnValue
}

func less(iVal string, jVal string, sb SortBy) (bool, bool) {
// if the values are equal, return fast to continue to next column
if iVal == jVal {
return true, false
}

// otherwise, use the default sorting logic defined by Mode and IgnoreCase
switch sb.Mode {
case Asc, Dsc:
return lessAlphabetic(iVal, jVal, sb)
Expand Down Expand Up @@ -168,37 +178,27 @@ func lessAlphabetic(iVal string, jVal string, sb SortBy) (bool, bool) {
}
}

func lessAlphaNumericI(sb SortBy) (bool, bool) {
// i == "abc"; j == 5
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, true
default: // AscNumericAlpha, DscNumericAlpha
return false, false
}
}

func lessAlphaNumericJ(sb SortBy) (bool, bool) {
// i == 5; j == "abc"
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, false
default: // AscNumericAlpha, DscNumericAlpha:
return false, true
}
}

func lessMixedMode(iVal string, jVal string, sb SortBy) (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, sb)
}
if iErr != nil { // iVal is alphabetic, jVal is numeric
return lessAlphaNumericI(sb)
if iErr != nil { // iVal == "abc"; jVal == 5
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, true
default: // AscNumericAlpha, DscNumericAlpha
return false, false
}
}
if jErr != nil { // iVal is numeric, jVal is alphabetic
return lessAlphaNumericJ(sb)
if jErr != nil { // iVal == 5; jVal == "abc"
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, false
default: // AscNumericAlpha, DscNumericAlpha:
return false, true
}
}
// both values numeric
return lessNumericVal(iNumVal, jNumVal, sb)
Expand Down
70 changes: 70 additions & 0 deletions table/sort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,73 @@ func TestTable_sortRows_WithoutName(t *testing.T) {
table.SortBy(nil)
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
}

func TestTable_sortRows_CustomLess(t *testing.T) {
t.Run("AllReturnValues", func(t *testing.T) {
table := Table{}
table.AppendRows([]Row{
{"c", "zebra"},
{"a", "apple"},
{"b", "banana"},
{"b", "broccoli"},
})
table.initForRenderRows()
table.SortBy([]SortBy{{
Number: 1,
CustomLess: func(iVal string, jVal string) int {
if iVal < jVal {
return -1
}
if iVal > jVal {
return 1
}
return 0
},
}})
assert.Equal(t, []int{1, 2, 3, 0}, table.getSortedRowIndices())
})

t.Run("EqualValuesContinueToNextColumn", func(t *testing.T) {
table := Table{}
table.AppendRows([]Row{
{"same", "zebra"},
{"same", "apple"},
{"same", "banana"},
})
table.initForRenderRows()
table.SortBy([]SortBy{
{
Number: 1,
CustomLess: func(iVal string, jVal string) int {
return 0 // All equal, continue to next column
},
},
{Number: 2, Mode: Asc},
})
assert.Equal(t, []int{1, 2, 0}, table.getSortedRowIndices())
})

t.Run("WithColumnName", func(t *testing.T) {
table := Table{}
table.AppendHeader(Row{"Value", "Other"})
table.AppendRows([]Row{
{"zebra", "item"},
{"apple", "item"},
{"banana", "item"},
})
table.initForRenderRows()
table.SortBy([]SortBy{{
Name: "Value",
CustomLess: func(iVal string, jVal string) int {
if iVal < jVal {
return -1
}
if iVal > jVal {
return 1
}
return 0
},
}})
assert.Equal(t, []int{1, 2, 0}, table.getSortedRowIndices())
})
}