diff --git a/.gitignore b/.gitignore
index 0788a16..22c42ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/.idea/
+/.vscode/
/demo*
/profile/
coverage.*
diff --git a/table/README.md b/table/README.md
index cf75954..bf9df7f 100644
--- a/table/README.md
+++ b/table/README.md
@@ -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`)
@@ -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
+
+
+Example: Custom numeric sorting that handles string numbers correctly
+
+```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
+ },
+ },
+ })
+```
+
+
+
+
+Example: Custom case-insensitive sorting with fallback to case-sensitive
+
+```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
+ },
+ },
+ })
+```
+
+
+
+
+Example: Combining custom sorting with default sorting modes
+
+```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
+ })
+```
+
+
+
### Wrapping (or) Row/Column Width restrictions
You can restrict the maximum (text) width for a Row:
diff --git a/table/sort.go b/table/sort.go
index 7356f51..dd1d217 100644
--- a/table/sort.go
+++ b/table/sort.go
@@ -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.
@@ -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 {
@@ -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
})
}
@@ -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)
@@ -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)
diff --git a/table/sort_test.go b/table/sort_test.go
index c6c05c5..c7e7d5d 100644
--- a/table/sort_test.go
+++ b/table/sort_test.go
@@ -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())
+ })
+}