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()) + }) +}