Skip to content

Commit b553082

Browse files
author
IAL32
committed
feat: Add WithFuzzyFilter modifier to enable fuzzy filter matching
1 parent 7de6bce commit b553082

File tree

3 files changed

+189
-0
lines changed

3 files changed

+189
-0
lines changed

table/filter.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,74 @@ func isRowMatched(columns []Column, row Row, filter string) bool {
7575

7676
return !checkedAny
7777
}
78+
79+
// NewFuzzyFilter returns a filterFunc that performs case-insensitive fuzzy
80+
// matching (subsequence) over the concatenation of all filterable column values.
81+
// Example wiring:
82+
//
83+
// m.filterFunc = NewFuzzyFilter(m.columns)
84+
func NewFuzzyFilter(columns []Column) func(Row, string) bool {
85+
return func(row Row, filter string) bool {
86+
filter = strings.TrimSpace(filter)
87+
if filter == "" {
88+
return true
89+
}
90+
91+
// Concatenate all filterable values for this row into one string
92+
var b strings.Builder
93+
for _, col := range columns {
94+
if !col.filterable {
95+
continue
96+
}
97+
if v, ok := row.Data[col.key]; ok {
98+
// Unwrap StyledCell if present
99+
switch vv := v.(type) {
100+
case StyledCell:
101+
v = vv.Data
102+
}
103+
104+
switch vv := v.(type) {
105+
case string:
106+
b.WriteString(vv)
107+
case fmt.Stringer:
108+
b.WriteString(vv.String())
109+
default:
110+
b.WriteString(fmt.Sprintf("%v", v))
111+
}
112+
b.WriteByte(' ')
113+
}
114+
}
115+
116+
haystack := strings.ToLower(b.String())
117+
if haystack == "" {
118+
return false
119+
}
120+
121+
// Support multi-token filters: "acme stl" must fuzzy-match both tokens
122+
for _, token := range strings.Fields(strings.ToLower(filter)) {
123+
if !fuzzySubsequenceMatch(haystack, token) {
124+
return false
125+
}
126+
}
127+
return true
128+
}
129+
}
130+
131+
// fuzzySubsequenceMatch returns true if all runes in needle appear in order
132+
// within haystack (not necessarily contiguously). Case must be normalized by caller.
133+
func fuzzySubsequenceMatch(haystack, needle string) bool {
134+
if needle == "" {
135+
return true
136+
}
137+
hi, ni := 0, 0
138+
hr := []rune(haystack)
139+
nr := []rune(needle)
140+
141+
for hi < len(hr) && ni < len(nr) {
142+
if hr[hi] == nr[ni] {
143+
ni++
144+
}
145+
hi++
146+
}
147+
return ni == len(nr)
148+
}

table/filter_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,115 @@ 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+
ff := NewFuzzyFilter(cols)
434+
435+
for i, r := range rows {
436+
if !ff(r, "") {
437+
t.Fatalf("row %d should match empty filter", i)
438+
}
439+
}
440+
}
441+
442+
func TestFuzzyFilter_SubsequenceAcrossColumns(t *testing.T) {
443+
cols := []Column{
444+
NewColumn("name", "Name", 10).WithFiltered(true),
445+
NewColumn("city", "City", 10).WithFiltered(true),
446+
}
447+
row := NewRow(RowData{
448+
"name": "Acme",
449+
"city": "Stuttgart",
450+
})
451+
452+
ff := NewFuzzyFilter(cols)
453+
454+
// subsequence match: "agt" appears in order inside "stuttgart"
455+
if !ff(row, "agt") {
456+
t.Fatalf("expected subsequence 'agt' to match 'Stuttgart'")
457+
}
458+
// case-insensitive
459+
if !ff(row, "ACM") {
460+
t.Fatalf("expected case-insensitive subsequence to match 'Acme'")
461+
}
462+
// not a subsequence
463+
if ff(row, "zzt") {
464+
t.Fatalf("did not expect 'zzt' to match")
465+
}
466+
}
467+
468+
func TestFuzzyFilter_MultiToken_AND(t *testing.T) {
469+
cols := []Column{
470+
NewColumn("name", "Name", 10).WithFiltered(true),
471+
NewColumn("dept", "Dept", 10).WithFiltered(true),
472+
}
473+
row := NewRow(RowData{
474+
"name": "Wayne Enterprises",
475+
"dept": "R&D",
476+
})
477+
478+
ff := NewFuzzyFilter(cols)
479+
480+
// Both tokens must match as subsequences somewhere in the concatenated haystack
481+
if !ff(row, "wy ent") { // "wy" in Wayne, "ent" in Enterprises
482+
t.Fatalf("expected multi-token AND to match")
483+
}
484+
if ff(row, "wy zzz") {
485+
t.Fatalf("expected multi-token AND to fail when a token doesn't match")
486+
}
487+
}
488+
489+
func TestFuzzyFilter_IgnoresNonFilterableColumns(t *testing.T) {
490+
cols := []Column{
491+
NewColumn("name", "Name", 10).WithFiltered(true),
492+
NewColumn("secret", "Secret", 10).WithFiltered(false), // should be ignored
493+
}
494+
row := NewRow(RowData{
495+
"name": "Acme",
496+
"secret": "topsecretpattern",
497+
})
498+
499+
ff := NewFuzzyFilter(cols)
500+
501+
if ff(row, "topsecret") {
502+
t.Fatalf("should not match on non-filterable column content")
503+
}
504+
}
505+
506+
func TestFuzzyFilter_UnwrapsStyledCell(t *testing.T) {
507+
cols := []Column{
508+
NewColumn("name", "Name", 10).WithFiltered(true),
509+
}
510+
row := NewRow(RowData{
511+
"name": NewStyledCell("Nakatomi Plaza", lipgloss.NewStyle()),
512+
})
513+
514+
ff := NewFuzzyFilter(cols)
515+
516+
if !ff(row, "nak plz") {
517+
t.Fatalf("expected fuzzy subsequence to match within StyledCell data")
518+
}
519+
}
520+
521+
func TestFuzzyFilter_NonStringValuesFormatted(t *testing.T) {
522+
cols := []Column{
523+
NewColumn("id", "ID", 6).WithFiltered(true),
524+
}
525+
row := NewRow(RowData{
526+
"id": 12345, // should be formatted via fmt.Sprintf("%v", v)
527+
})
528+
529+
ff := NewFuzzyFilter(cols)
530+
531+
if !ff(row, "245") { // subsequence of "12345"
532+
t.Fatalf("expected matcher to format non-strings and match subsequence")
533+
}
534+
}

table/options.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,12 @@ func (m Model) WithFilterFunc(shouldInclude func(row Row, filterInput string) bo
376376
return m
377377
}
378378

379+
func (m Model) WithFuzzyFilter() Model {
380+
m.filterFunc = NewFuzzyFilter(m.columns)
381+
382+
return m
383+
}
384+
379385
// WithFooterVisibility sets the visibility of the footer.
380386
func (m Model) WithFooterVisibility(visibility bool) Model {
381387
m.footerVisible = visibility

0 commit comments

Comments
 (0)