Repository: jedib0t/go-pretty

Issue:
SortBy with custom function
For my use case, it would be helpful if I could specify a custom sort function to sort the rows. With my problem (SNMP ifIndex/ifName sorting of various manufacturers that are neither purely numeric nor purely alphanumeric), I cannot do this with the SortMode options and therefore have to sort before filling the table. This makes the code less elegant.
What do you think about this? Is it easy to implement?


Hints from issue comments:
@jedib0t: Can you give me an example (sample data if possible) where the current Sorting mechanisms don't work? I'm trying to understand the issue better.@gaissmai: I'd be happy to give you an example. Please see this SNMP output from an existing network device:
```
$ snmpbulkwalk ocnos-xs1 IF-MIB::ifName
IF-MIB::ifName.3 = STRING: eth0
IF-MIB::ifName.5029 = STRING: ce48
IF-MIB::ifName.5030 = STRING: ce49
IF-MIB::ifName.5032 = STRING: xe29
IF-MIB::ifName.5048 = STRING: xe45
IF-MIB::ifName.5049 = STRING: xe46
IF-MIB::ifName.5050 = STRING: xe47
IF-MIB::ifName.5051 = STRING: ce50
IF-MIB::ifName.5052 = STRING: ce51
IF-MIB::ifName.5053 = STRING: ce52
IF-MIB::ifName.5054 = STRING: ce53
IF-MIB::ifName.10001 = STRING: xe0
IF-MIB::ifName.10002 = STRING: xe1
IF-MIB::ifName.10003 = STRING: xe2
IF-MIB::ifName.10004 = STRING: xe3
IF-MIB::ifName.10005 = STRING: xe4
IF-MIB::ifName.10006 = STRING: xe5
IF-MIB::ifName.10007 = STRING: xe6
IF-MIB::ifName.10008 = STRING: xe7
IF-MIB::ifName.10009 = STRING: xe8
IF-MIB::ifName.10010 = STRING: xe9
IF-MIB::ifName.10011 = STRING: xe10
IF-MIB::ifName.10012 = STRING: xe11
IF-MIB::ifName.10013 = STRING: xe12
IF-MIB::ifName.10014 = STRING: xe13
IF-MIB::ifName.10015 = STRING: xe14
IF-MIB::ifName.10016 = STRING: xe15
IF-MIB::ifName.10017 = STRING: xe16
IF-MIB::ifName.10018 = STRING: xe17
IF-MIB::ifName.10019 = STRING: xe18
IF-MIB::ifName.10020 = STRING: xe19
IF-MIB::ifName.10021 = STRING: xe20
IF-MIB::ifName.10022 = STRING: xe21
IF-MIB::ifName.10023 = STRING: xe22
IF-MIB::ifName.10024 = STRING: xe23
IF-MIB::ifName.10025 = STRING: xe24
IF-MIB::ifName.10026 = STRING: xe25
IF-MIB::ifName.10027 = STRING: xe26
IF-MIB::ifName.10028 = STRING: xe27
IF-MIB::ifName.10033 = STRING: xe28
IF-MIB::ifName.10035 = STRING: xe30
IF-MIB::ifName.10036 = STRING: xe31
IF-MIB::ifName.10037 = STRING: xe32
IF-MIB::ifName.10038 = STRING: xe33
IF-MIB::ifName.10039 = STRING: xe34
IF-MIB::ifName.10040 = STRING: xe35
IF-MIB::ifName.10041 = STRING: xe36
IF-MIB::ifName.10042 = STRING: xe37
IF-MIB::ifName.10043 = STRING: xe38
IF-MIB::ifName.10044 = STRING: xe39
IF-MIB::ifName.10045 = STRING: xe40
IF-MIB::ifName.10046 = STRING: xe41
IF-MIB::ifName.10047 = STRING: xe42
IF-MIB::ifName.10048 = STRING: xe43
IF-MIB::ifName.10049 = STRING: xe44
```
sorted by the ifIndex (integer), but the proper sortOrder for our usecase would be:
```

+-------+-------+
|     3 | eth0  |
| 10001 | xe0   |
| 10002 | xe1   |
| 10003 | xe2   |
| 10004 | xe3   |
| 10005 | xe4   |
| 10006 | xe5   |
| 10007 | xe6   |
| 10008 | xe7   |
| 10009 | xe8   |
| 10010 | xe9   |
| 10011 | xe10  |
| 10012 | xe11  |
| 10013 | xe12  |
| 10014 | xe13  |
| 10015 | xe14  |
| 10016 | xe15  |
| 10017 | xe16  |
| 10018 | xe17  |
| 10019 | xe18  |
| 10020 | xe19  |
| 10021 | xe20  |
| 10022 | xe21  |
| 10023 | xe22  |
| 10024 | xe23  |
| 10025 | xe24  |
| 10026 | xe25  |
| 10027 | xe26  |
| 10028 | xe27  |
| 10033 | xe28  |
|  5032 | xe29  |
| 10035 | xe30  |
| 10036 | xe31  |
| 10037 | xe32  |
| 10038 | xe33  |
| 10039 | xe34  |
| 10040 | xe35  |
| 10041 | xe36  |
| 10042 | xe37  |
| 10043 | xe38  |
| 10044 | xe39  |
| 10045 | xe40  |
| 10046 | xe41  |
| 10047 | xe42  |
| 10048 | xe43  |
| 10049 | xe44  |
|  5048 | xe45  |
|  5049 | xe46  |
|  5050 | xe47  |
|  5029 | ce48  |
|  5030 | ce49  |
|  5051 | ce50  |
|  5052 | ce51  |
|  5053 | ce52  |
|  5054 | ce53  |
+-------+-------+
```

It is neither numeric nor alphanumeric, but special. This can only be mapped with a custom function.@jedib0t: @gaissmai Take a look at the code in the `table-custom-sort` branch to see if it works for you. You will have to setup the SortBy.CustomLess function for the column with special sorting logic. Try it and give me feedback.@gaissmai: Perfect, works great, see my complex custom-less function, which couldn't be reproduced otherwise:
```go
func SortOcNosLess(a, b string) int {
	specialMap := map[string]int{
		"lo":            -10,
		"lo.management": -9,
	}

	var aNum int
	var bNum int

	aIsSet := false
	bIsSet := false

	// handle vlanX.vlanid, sort by vlanid but after all other interfaces
	if strings.HasPrefix(a, "vlan") {
		aNum, _ = strconv.Atoi(RxTrailingInt.FindString(a))
		aNum += 10_000
		aIsSet = true
	}
	if strings.HasPrefix(b, "vlan") {
		bNum, _ = strconv.Atoi(RxTrailingInt.FindString(b))
		bNum += 10_000
		bIsSet = true
	}

	// handle other special ifNames, sort before all other interfaces
	// if not already set
	if !aIsSet {
		if _, ok := specialMap[a]; ok {
			aNum = specialMap[a]
		} else {
			aNum, _ = strconv.Atoi(RxTrailingInt.FindString(a))
		}
	}
	if !bIsSet {
		if _, ok := specialMap[b]; ok {
			bNum = specialMap[b]
		} else {
			bNum, _ = strconv.Atoi(RxTrailingInt.FindString(b))
		}
	}

	// cmp(ce53, xe48) -> (xe48, ce53)
	// cmp(53, 48) -> 48, 53
	if c := cmp.Compare(aNum, bNum); c != 0 {
		return c
	}

	// numbers are equal -> plain string compare
	return cmp.Compare(a, b)
}
```

with this result:

```plain
 go run . -l ocnos-xs1

Hostname:  ocnos-xs1
SysObjId:  ".1.3.6.1.4.1.36673.100.1.2.1.1.2"
+-------+----------+---------------+----------------+-------------+-------+-----------------+-----------------+------------+----+
|     # | State    | Name          | PVID           |       Speed |   MTU | Alias           | Neighbor        | Type       |    |
+-------+----------+---------------+----------------+-------------+-------+-----------------+-----------------+------------+----+
|     1 | up       | lo            |                |   0  Bits/s | 16436 | lo              |                 | SoftLoopBk |  0 |
| 15002 | up       | lo.management |                |   0  Bits/s | 16436 | lo.management   |                 | SoftLoopBk |  1 |
|     3 | disabled | eth0          |                | 100 MBits/s |  1500 | eth0            |                 | ECsmacd    |  2 |
| 10001 | down     | xe0           |                |  25 GBits/s |  1500 | xe0             |                 | ECsmacd    |  3 |
| 10002 | down     | xe1           |                |  25 GBits/s |  1500 | xe1             |                 | ECsmacd    |  4 |
| 10003 | down     | xe2           |                |  25 GBits/s |  1500 | xe2             |                 | ECsmacd    |  5 |
| 10004 | down     | xe3           |                |  25 GBits/s |  1500 | xe3             |                 | ECsmacd    |  6 |
| 10005 | down     | xe4           |                |  25 GBits/s |  1500 | xe4             |                 | ECsmacd    |  7 |
| 10006 | down     | xe5           |                |  25 GBits/s |  1500 | xe5             |                 | ECsmacd    |  8 |
| 10007 | down     | xe6           |                |  25 GBits/s |  1500 | xe6             |                 | ECsmacd    |  9 |
| 10008 | down     | xe7           |                |  25 GBits/s |  1500 | xe7             |                 | ECsmacd    | 10 |
| 10009 | down     | xe8           |                |  25 GBits/s |  1500 | xe8             |                 | ECsmacd    | 11 |
| 10010 | down     | xe9           |                |  25 GBits/s |  1500 | xe9             |                 | ECsmacd    | 12 |
| 10011 | down     | xe10          |                |  25 GBits/s |  1500 | xe10            |                 | ECsmacd    | 13 |
| 10012 | down     | xe11          |                |  25 GBits/s |  1500 | xe11            |                 | ECsmacd    | 14 |
| 10013 | down     | xe12          |                |  25 GBits/s |  1500 | xe12            |                 | ECsmacd    | 15 |
| 10014 | down     | xe13          |                |  25 GBits/s |  1500 | xe13            |                 | ECsmacd    | 16 |
| 10015 | down     | xe14          |                |  25 GBits/s |  1500 | xe14            |                 | ECsmacd    | 17 |
| 10016 | down     | xe15          |                |  25 GBits/s |  1500 | xe15            |                 | ECsmacd    | 18 |
| 10017 | down     | xe16          |                |  25 GBits/s |  1500 | xe16            |                 | ECsmacd    | 19 |
| 10018 | down     | xe17          |                |  25 GBits/s |  1500 | xe17            |                 | ECsmacd    | 20 |
| 10019 | down     | xe18          |                |  25 GBits/s |  1500 | xe18            |                 | ECsmacd    | 21 |
| 10020 | down     | xe19          |                |  25 GBits/s |  1500 | xe19            |                 | ECsmacd    | 22 |
| 10021 | down     | xe20          |                |  25 GBits/s |  1500 | xe20            |                 | ECsmacd    | 23 |
| 10022 | down     | xe21          |                |  25 GBits/s |  1500 | xe21            |                 | ECsmacd    | 24 |
| 10023 | down     | xe22          |                |  25 GBits/s |  1500 | xe22            |                 | ECsmacd    | 25 |
| 10024 | down     | xe23          |                |  25 GBits/s |  1500 | xe23            |                 | ECsmacd    | 26 |
| 10025 | down     | xe24          |                |  25 GBits/s |  1500 | xe24            |                 | ECsmacd    | 27 |
| 10026 | down     | xe25          |                |  25 GBits/s |  1500 | xe25            |                 | ECsmacd    | 28 |
| 10027 | down     | xe26          |                |  25 GBits/s |  1500 | xe26            |                 | ECsmacd    | 29 |
| 10028 | down     | xe27          |                |  25 GBits/s |  1500 | xe27            |                 | ECsmacd    | 30 |
| 10033 | down     | xe28          |                |  25 GBits/s |  1500 | xe28            |                 | ECsmacd    | 31 |
| 10034 | down     | xe29          |                |  25 GBits/s |  1500 | xe29            |                 | ECsmacd    | 32 |
| 10035 | down     | xe30          |                |  25 GBits/s |  1500 | xe30            |                 | ECsmacd    | 33 |
| 10036 | down     | xe31          |                |  25 GBits/s |  1500 | xe31            |                 | ECsmacd    | 34 |
| 10037 | down     | xe32          |                |  25 GBits/s |  1500 | xe32            |                 | ECsmacd    | 35 |
| 10038 | down     | xe33          |                |  25 GBits/s |  1500 | xe33            |                 | ECsmacd    | 36 |
| 10039 | down     | xe34          |                |  25 GBits/s |  1500 | xe34            |                 | ECsmacd    | 37 |
| 10040 | down     | xe35          |                |  25 GBits/s |  1500 | xe35            |                 | ECsmacd    | 38 |
| 10041 | down     | xe36          |                |  25 GBits/s |  1500 | xe36            |                 | ECsmacd    | 39 |
| 10042 | down     | xe37          |                |  25 GBits/s |  1500 | xe37            |                 | ECsmacd    | 40 |
| 10043 | down     | xe38          |                |  25 GBits/s |  1500 | xe38            |                 | ECsmacd    | 41 |
| 10044 | down     | xe39          |                |  25 GBits/s |  1500 | xe39            |                 | ECsmacd    | 42 |
| 10045 | down     | xe40          |                |  25 GBits/s |  1500 | xe40            |                 | ECsmacd    | 43 |
| 10046 | down     | xe41          |                |  25 GBits/s |  1500 | xe41            |                 | ECsmacd    | 44 |
| 10047 | down     | xe42          |                |  25 GBits/s |  1500 | xe42            |                 | ECsmacd    | 45 |
| 10048 | down     | xe43          |                |  25 GBits/s |  1500 | xe43            |                 | ECsmacd    | 46 |
| 10049 | down     | xe44          |                |  10 GBits/s |  1500 | xe44            |                 | ECsmacd    | 47 |
|  5048 | up       | xe45          | priv-test(555) |   1 GBits/s |  1500 | HP-2530-8G-PoEP | HP-2530-8G-PoEP | ECsmacd    | 48 |
|  5049 | blocking | xe46          |                |  10 GBits/s |  1500 | loop            | o26-5121-d-xs2  | ECsmacd    | 49 |
|  5050 | blocking | xe47          |                |  10 GBits/s |  1500 | root-loop 10g   | o26-5121-d-xs1  | ECsmacd    | 50 |
|  5029 | down     | ce48          | default(1)     | 100 GBits/s |  1500 | ce48            |                 | ECsmacd    | 51 |
|  5030 | down     | ce49          | default(1)     | 100 GBits/s |  1500 | ce49            |                 | ECsmacd    | 52 |
|  5051 | down     | ce50          | default(1)     | 100 GBits/s |  1500 | ce50            |                 | ECsmacd    | 53 |
|  5052 | down     | ce51          | default(1)     | 100 GBits/s |  1500 | ce51            |                 | ECsmacd    | 54 |
|  5053 | down     | ce52          | default(1)     | 100 GBits/s |  1500 | ce52            |                 | ECsmacd    | 55 |
|  5054 | up       | ce53          |                |  40 GBits/s |  1500 | root-loop 40g   | o26-5121-d-xs1  | ECsmacd    | 56 |
| 10515 | up       | vlan1.248     |                |   0  Bits/s |  1500 | device-mgmt     |                 | L3ipvlan   | 57 |
+-------+----------+---------------+----------------+-------------+-------+-----------------+-----------------+------------+----+
```@jedib0t: Here you go: https://github.com/jedib0t/go-pretty/releases/tag/v6.7.2@gaissmai: thanks a lot!

Solution patch:
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
+
+<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:
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)



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