Skip to content

Commit 1dbd3e0

Browse files
authored
feat: Add WithFuzzyFilter to enable fuzzy filter matching (#196)
* feat: Add `WithFuzzyFilter` modifier to enable fuzzy filter matching * Address feedback from @Evertras, satisfy lint gods * API proposal change: add `columns []Column` as filterFunc argument * chore: rename `isRowsMatched` to `newContainsFilter` * feedback: rename functions * feat: satisfy coverage gods, add more tests
1 parent a25ee37 commit 1dbd3e0

File tree

4 files changed

+230
-25
lines changed

4 files changed

+230
-25
lines changed

table/filter.go

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,25 @@ func (m Model) getFilteredRows(rows []Row) []Row {
1414
filteredRows := make([]Row, 0)
1515

1616
for _, row := range rows {
17+
var availableFilterFunc func([]Column, Row, string) bool
18+
1719
if m.filterFunc != nil {
18-
if m.filterFunc(row, filterInputValue) {
19-
filteredRows = append(filteredRows, row)
20-
}
20+
availableFilterFunc = m.filterFunc
2121
} else {
22-
if isRowMatched(m.columns, row, filterInputValue) {
23-
filteredRows = append(filteredRows, row)
24-
}
22+
availableFilterFunc = filterFuncContains
23+
}
24+
25+
if availableFilterFunc(m.columns, row, filterInputValue) {
26+
filteredRows = append(filteredRows, row)
2527
}
2628
}
2729

2830
return filteredRows
2931
}
3032

31-
func isRowMatched(columns []Column, row Row, filter string) bool {
33+
// filterFuncContains returns a filterFunc that performs case-insensitive
34+
// "contains" matching over all filterable columns in a row.
35+
func filterFuncContains(columns []Column, row Row, filter string) bool {
3236
if filter == "" {
3337
return true
3438
}
@@ -75,3 +79,61 @@ func isRowMatched(columns []Column, row Row, filter string) bool {
7579

7680
return !checkedAny
7781
}
82+
83+
// filterFuncFuzzy returns a filterFunc that performs case-insensitive fuzzy
84+
// matching (subsequence) over the concatenation of all filterable column values.
85+
func filterFuncFuzzy(columns []Column, row Row, filter string) bool {
86+
filter = strings.TrimSpace(filter)
87+
if filter == "" {
88+
return true
89+
}
90+
91+
var builder strings.Builder
92+
for _, col := range columns {
93+
if !col.filterable {
94+
continue
95+
}
96+
value, ok := row.Data[col.key]
97+
if !ok {
98+
continue
99+
}
100+
if sc, ok := value.(StyledCell); ok {
101+
value = sc.Data
102+
}
103+
builder.WriteString(fmt.Sprint(value)) // uses Stringer if implemented
104+
builder.WriteByte(' ')
105+
}
106+
107+
haystack := strings.ToLower(builder.String())
108+
if haystack == "" {
109+
return false
110+
}
111+
112+
for _, token := range strings.Fields(strings.ToLower(filter)) {
113+
if !fuzzySubsequenceMatch(haystack, token) {
114+
return false
115+
}
116+
}
117+
118+
return true
119+
}
120+
121+
// fuzzySubsequenceMatch returns true if all runes in needle appear in order
122+
// within haystack (not necessarily contiguously). Case must be normalized by caller.
123+
func fuzzySubsequenceMatch(haystack, needle string) bool {
124+
if needle == "" {
125+
return true
126+
}
127+
haystackIndex, needleIndex := 0, 0
128+
haystackRunes := []rune(haystack)
129+
needleRunes := []rune(needle)
130+
131+
for haystackIndex < len(haystackRunes) && needleIndex < len(needleRunes) {
132+
if haystackRunes[haystackIndex] == needleRunes[needleIndex] {
133+
needleIndex++
134+
}
135+
haystackIndex++
136+
}
137+
138+
return needleIndex == len(needleRunes)
139+
}

table/filter_test.go

Lines changed: 153 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,52 +16,52 @@ func TestIsRowMatched(t *testing.T) {
1616
NewColumn("title", "title", 10).WithFiltered(true),
1717
NewColumn("description", "description", 10)}
1818

19-
assert.True(t, isRowMatched(columns,
19+
assert.True(t, filterFuncContains(columns,
2020
NewRow(RowData{
2121
"title": "AAA",
2222
"description": "",
2323
}), ""))
2424

25-
assert.True(t, isRowMatched(columns,
25+
assert.True(t, filterFuncContains(columns,
2626
NewRow(RowData{
2727
"title": "AAA",
2828
"description": "",
2929
}), "AA"))
3030

31-
assert.True(t, isRowMatched(columns,
31+
assert.True(t, filterFuncContains(columns,
3232
NewRow(RowData{
3333
"title": "AAA",
3434
"description": "",
3535
}), "A"))
3636

37-
assert.True(t, isRowMatched(columns,
37+
assert.True(t, filterFuncContains(columns,
3838
NewRow(RowData{
3939
"title": "AAA",
4040
"description": "",
4141
}), "a"))
4242

43-
assert.False(t, isRowMatched(columns,
43+
assert.False(t, filterFuncContains(columns,
4444
NewRow(RowData{
4545
"title": "AAA",
4646
"description": "",
4747
}), "B"))
4848

49-
assert.False(t, isRowMatched(columns,
49+
assert.False(t, filterFuncContains(columns,
5050
NewRow(RowData{
5151
"title": "AAA",
5252
"description": "BBB",
5353
}), "BBB"))
5454

5555
timeFrom2020 := time.Date(2020, time.July, 1, 1, 1, 1, 1, time.UTC)
5656

57-
assert.True(t, isRowMatched(columns,
57+
assert.True(t, filterFuncContains(columns,
5858
NewRow(RowData{
5959
"title": timeFrom2020,
6060
}),
6161
"2020",
6262
))
6363

64-
assert.False(t, isRowMatched(columns,
64+
assert.False(t, filterFuncContains(columns,
6565
NewRow(RowData{
6666
"title": timeFrom2020,
6767
}),
@@ -75,13 +75,13 @@ func TestIsRowMatchedForStyled(t *testing.T) {
7575
NewColumn("description", "description", 10),
7676
}
7777

78-
assert.True(t, isRowMatched(columns,
78+
assert.True(t, filterFuncContains(columns,
7979
NewRow(RowData{
8080
"title": "AAA",
8181
"description": "",
8282
}), "AA"))
8383

84-
assert.True(t, isRowMatched(columns,
84+
assert.True(t, filterFuncContains(columns,
8585
NewRow(RowData{
8686
"title": NewStyledCell("AAA", lipgloss.NewStyle()),
8787
"description": "",
@@ -93,22 +93,22 @@ func TestIsRowMatchedForNonStringer(t *testing.T) {
9393
NewColumn("val", "val", 10).WithFiltered(true),
9494
}
9595

96-
assert.True(t, isRowMatched(columns,
96+
assert.True(t, filterFuncContains(columns,
9797
NewRow(RowData{
9898
"val": 12,
9999
}), "12"))
100100

101-
assert.True(t, isRowMatched(columns,
101+
assert.True(t, filterFuncContains(columns,
102102
NewRow(RowData{
103103
"val": 12,
104104
}), "1"))
105105

106-
assert.True(t, isRowMatched(columns,
106+
assert.True(t, filterFuncContains(columns,
107107
NewRow(RowData{
108108
"val": 12,
109109
}), "2"))
110110

111-
assert.False(t, isRowMatched(columns,
111+
assert.False(t, filterFuncContains(columns,
112112
NewRow(RowData{
113113
"val": 12,
114114
}), "3"))
@@ -314,7 +314,7 @@ func TestFilterFunc(t *testing.T) {
314314
NewRow(RowData{}),
315315
}
316316

317-
filterFunc := func(r Row, s string) bool {
317+
filterFunc := func(_ []Column, r Row, s string) bool {
318318
// Completely arbitrary check for testing purposes
319319
title := fmt.Sprintf("%v", r.Data["title"])
320320

@@ -420,3 +420,141 @@ func BenchmarkFilteredRenders(b *testing.B) {
420420
_ = model.View()
421421
}
422422
}
423+
424+
func TestFuzzyFilter_EmptyFilterMatchesAll(t *testing.T) {
425+
cols := []Column{
426+
NewColumn("name", "Name", 10).WithFiltered(true),
427+
}
428+
rows := []Row{
429+
NewRow(RowData{"name": "Acme Steel"}),
430+
NewRow(RowData{"name": "Globex"}),
431+
}
432+
433+
for i, r := range rows {
434+
if !filterFuncFuzzy(cols, r, "") {
435+
t.Fatalf("row %d should match empty filter", i)
436+
}
437+
}
438+
}
439+
440+
func TestFuzzyFilter_SubsequenceAcrossColumns(t *testing.T) {
441+
cols := []Column{
442+
NewColumn("name", "Name", 10).WithFiltered(true),
443+
NewColumn("city", "City", 10).WithFiltered(true),
444+
}
445+
row := NewRow(RowData{
446+
"name": "Acme",
447+
"city": "Stuttgart",
448+
})
449+
450+
// subsequence match: "agt" appears in order inside "stuttgart"
451+
if !filterFuncFuzzy(cols, row, "agt") {
452+
t.Fatalf("expected subsequence 'agt' to match 'Stuttgart'")
453+
}
454+
// case-insensitive
455+
if !filterFuncFuzzy(cols, row, "ACM") {
456+
t.Fatalf("expected case-insensitive subsequence to match 'Acme'")
457+
}
458+
// not a subsequence
459+
if filterFuncFuzzy(cols, row, "zzt") {
460+
t.Fatalf("did not expect 'zzt' to match")
461+
}
462+
}
463+
464+
func TestFuzzyFilter_ColumnNotInRow(t *testing.T) {
465+
cols := []Column{
466+
NewColumn("column_name_doesnt_match", "Name", 10).WithFiltered(true),
467+
}
468+
row := NewRow(RowData{
469+
"name": "Acme Steel",
470+
})
471+
472+
if filterFuncFuzzy(cols, row, "steel") {
473+
t.Fatalf("did not expect 'steel' to match")
474+
}
475+
}
476+
477+
func TestFuzzyFilter_RowHasEmptyHaystack(t *testing.T) {
478+
cols := []Column{
479+
NewColumn("name", "Name", 10).WithFiltered(true),
480+
}
481+
row := NewRow(RowData{"name": ""})
482+
483+
// literally any value other than an empty string
484+
// should not match
485+
if filterFuncFuzzy(cols, row, "a") {
486+
t.Fatalf("did not expect 'a' to match")
487+
}
488+
}
489+
490+
func TestFuzzyFilter_MultiToken_AND(t *testing.T) {
491+
cols := []Column{
492+
NewColumn("name", "Name", 10).WithFiltered(true),
493+
NewColumn("dept", "Dept", 10).WithFiltered(true),
494+
}
495+
row := NewRow(RowData{
496+
"name": "Wayne Enterprises",
497+
"dept": "R&D",
498+
})
499+
500+
// Both tokens must match as subsequences somewhere in the concatenated haystack
501+
if !filterFuncFuzzy(cols, row, "wy ent") { // "wy" in Wayne, "ent" in Enterprises
502+
t.Fatalf("expected multi-token AND to match")
503+
}
504+
if filterFuncFuzzy(cols, row, "wy zzz") {
505+
t.Fatalf("expected multi-token AND to fail when a token doesn't match")
506+
}
507+
}
508+
509+
func TestFuzzyFilter_IgnoresNonFilterableColumns(t *testing.T) {
510+
cols := []Column{
511+
NewColumn("name", "Name", 10).WithFiltered(true),
512+
NewColumn("secret", "Secret", 10).WithFiltered(false), // should be ignored
513+
}
514+
row := NewRow(RowData{
515+
"name": "Acme",
516+
"secret": "topsecretpattern",
517+
})
518+
519+
if filterFuncFuzzy(cols, row, "topsecret") {
520+
t.Fatalf("should not match on non-filterable column content")
521+
}
522+
}
523+
524+
func TestFuzzyFilter_UnwrapsStyledCell(t *testing.T) {
525+
cols := []Column{
526+
NewColumn("name", "Name", 10).WithFiltered(true),
527+
}
528+
row := NewRow(RowData{
529+
"name": NewStyledCell("Nakatomi Plaza", lipgloss.NewStyle()),
530+
})
531+
532+
if !filterFuncFuzzy(cols, row, "nak plz") {
533+
t.Fatalf("expected fuzzy subsequence to match within StyledCell data")
534+
}
535+
}
536+
537+
func TestFuzzyFilter_NonStringValuesFormatted(t *testing.T) {
538+
cols := []Column{
539+
NewColumn("id", "ID", 6).WithFiltered(true),
540+
}
541+
row := NewRow(RowData{
542+
"id": 12345, // should be formatted via fmt.Sprintf("%v", v)
543+
})
544+
545+
if !filterFuncFuzzy(cols, row, "245") { // subsequence of "12345"
546+
t.Fatalf("expected matcher to format non-strings and match subsequence")
547+
}
548+
}
549+
550+
func TestFuzzySubSequenceMatch_EmptyString(t *testing.T) {
551+
if !fuzzySubsequenceMatch("anything", "") {
552+
t.Fatalf("empty needle should match anything")
553+
}
554+
if fuzzySubsequenceMatch("", "a") {
555+
t.Fatalf("non-empty needle should not match empty haystack")
556+
}
557+
if !fuzzySubsequenceMatch("", "") {
558+
t.Fatalf("empty needle should match empty haystack")
559+
}
560+
}

table/model.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ type Model struct {
7878
// Filter
7979
filtered bool
8080
filterTextInput textinput.Model
81-
filterFunc func(Row, string) bool
81+
filterFunc func([]Column, Row, string) bool
8282

8383
// For flex columns
8484
targetTotalWidth int
@@ -126,7 +126,7 @@ func New(columns []Column) Model {
126126
unselectedText: "[ ]",
127127

128128
filterTextInput: filterInput,
129-
filterFunc: nil,
129+
filterFunc: filterFuncContains,
130130
baseStyle: lipgloss.NewStyle().Align(lipgloss.Right),
131131

132132
paginationWrapping: true,

table/options.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,14 +368,19 @@ func (m Model) WithFilterInputValue(value string) Model {
368368
// true, the row will be included in the filtered results. If the function
369369
// is nil, the function won't be used. The filter input is passed as the second
370370
// argument to the function.
371-
func (m Model) WithFilterFunc(shouldInclude func(row Row, filterInput string) bool) Model {
371+
func (m Model) WithFilterFunc(shouldInclude func(columns []Column, row Row, filterInput string) bool) Model {
372372
m.filterFunc = shouldInclude
373373

374374
m.visibleRowCacheUpdated = false
375375

376376
return m
377377
}
378378

379+
// WithFuzzyFilter enables fuzzy filtering for the table.
380+
func (m Model) WithFuzzyFilter() Model {
381+
return m.WithFilterFunc(filterFuncFuzzy)
382+
}
383+
379384
// WithFooterVisibility sets the visibility of the footer.
380385
func (m Model) WithFooterVisibility(visibility bool) Model {
381386
m.footerVisible = visibility

0 commit comments

Comments
 (0)