Repository: Evertras/bubble-table
Pull request #29 (2022-02-24 16:14:36)

feat: filter feature works
Request https://github.com/Evertras/bubble-table/issues/5 is done.

demo `go run examples/filter/main.go`.

```
A filtered simple default table
Currently filter by:
Press q or ctrl+c to quit

┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━┓
┃        Title┃       Author┃Desc…┃
┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━┫
┃Computer Sys…┃Randal E. Br…┃This…┃
┃Effective Ja…┃ Joshua Bloch┃The …┃
┃Structure an…┃Harold Abels…┃Stru…┃
┃Game Program…┃Robert Nystr…┃The …┃
┣━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━┫
┃                              1/1┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

A filtered simple default table
Currently filter by:
Press q or ctrl+c to quit

┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━┓
┃        Title┃       Author┃Desc…┃
┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━┫
┃Computer Sys…┃Randal E. Br…┃This…┃
┃Structure an…┃Harold Abels…┃Stru…┃
┃Game Program…┃Robert Nystr…┃The …┃
┣━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━┫
┃                        /pro  1/1┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━┓
┃        Title┃       Author┃Desc…┃
┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━┫
┃Game Program…┃Robert Nystr…┃The …┃
┣━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━┫
┃                       /game  1/1┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
```

Evertras: Just a note, we also need to make sure to fix the linting/testing coverage.  I unfortunately do want to stay strict with that, as it's caught many of my own mistakes even in this project.
alswl: > Just a note, we also need to make sure to fix the linting/testing coverage. I unfortunately do want to stay strict with that, as it's caught many of my own mistakes even in this project.

Sure.
Evertras: Hey there, just checking if you were able to add some coverage.  If not, I'm willing to help out here.  There appear to be some bugs regarding the selectable rows and another issue that would collide with the keymap added in this PR, so I'd like to unblock this if you find the test coverage requirements painful.
alswl: @Evertras I added as more unit tests as I can. And I merged with upstream main branch(solved some conflicts).

If the coverage is still under the required, help is wanted~
Evertras: Thanks!  Just a few linting things snuck in... a quick `make lint` locally should point them out as well.  Once those are fixed I'm happy to merge!
alswl: @Evertras thanks for point out. Style issues is fixed.
Evertras: Thank you for the patience, I'll take care of the coverage gap; I'll just not make a release until I fix that.  Appreciate the work!

Patch:
diff --git a/examples/filter/main.go b/examples/filter/main.go
new file mode 100644
index 0000000..71efab1
--- /dev/null
+++ b/examples/filter/main.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"log"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/evertras/bubble-table/table"
+)
+
+const (
+	columnKeyTitle       = "title"
+	columnKeyAuthor      = "author"
+	columnKeyDescription = "description"
+)
+
+type Model struct {
+	table table.Model
+}
+
+func NewModel() Model {
+	columns := []table.Column{
+		table.NewColumn(columnKeyTitle, "Title", 13).WithFiltered(true),
+		table.NewColumn(columnKeyAuthor, "Author", 13).WithFiltered(true),
+		table.NewColumn(columnKeyDescription, "Description", 50),
+	}
+	return Model{
+		table: table.
+			New(columns).
+			Filtered(true).
+			Focused(true).
+			WithPageSize(10).
+			WithRows([]table.Row{
+				table.NewRow(table.RowData{
+					columnKeyTitle:       "Computer Systems : A Programmer's Perspective",
+					columnKeyAuthor:      "Randal E. Bryant、David R. O'Hallaron / Prentice Hall ",
+					columnKeyDescription: "This book explains the important and enduring concepts underlying all computer...",
+				}),
+				table.NewRow(table.RowData{
+					columnKeyTitle:       "Effective Java : 3rd Edition",
+					columnKeyAuthor:      "Joshua Bloch",
+					columnKeyDescription: "The Definitive Guide to Java Platform Best Practices—Updated for Java 9 Java ...",
+				}),
+				table.NewRow(table.RowData{
+					columnKeyTitle:       "Structure and Interpretation of Computer Programs - 2nd Edition (MIT)",
+					columnKeyAuthor:      "Harold Abelson、Gerald Jay Sussman",
+					columnKeyDescription: "Structure and Interpretation of Computer Programs has had a dramatic impact on...",
+				}),
+				table.NewRow(table.RowData{
+					columnKeyTitle:       "Game Programming Patterns",
+					columnKeyAuthor:      "Robert Nystrom / Genever Benning",
+					columnKeyDescription: "The biggest challenge facing many game programmers is completing their game. M...",
+				}),
+			}),
+	}
+}
+
+func (m Model) Init() tea.Cmd {
+	return nil
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		cmd  tea.Cmd
+		cmds []tea.Cmd
+	)
+
+	m.table, cmd = m.table.Update(msg)
+	cmds = append(cmds, cmd)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc", "q":
+			cmds = append(cmds, tea.Quit)
+		}
+
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m Model) View() string {
+	body := strings.Builder{}
+
+	body.WriteString("A filtered simple default table\n" +
+		"Currently filter by Title and Author, press / to filter.\nPress q or ctrl+c to quit\n\n")
+
+	body.WriteString(m.table.View())
+
+	return body.String()
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/go.mod b/go.mod
index 66286b1..0857ffe 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
 )
 
 require (
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/containerd/console v1.0.2 // indirect
 	github.com/davecgh/go-spew v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
diff --git a/go.sum b/go.sum
index 044ef31..faa8ac7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,4 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
 github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
diff --git a/table/border.go b/table/border.go
index 51fa8ac..2f106ed 100644
--- a/table/border.go
+++ b/table/border.go
@@ -322,7 +322,7 @@ func (b *borderStyleRow) inherit(s lipgloss.Style) {
 // harder to follow.  So just be careful with comments and make sure it's tested!
 // nolint:nestif
 func (m Model) styleHeaders() borderStyleRow {
-	hasRows := len(m.rows) > 0
+	hasRows := len(m.GetVisibleRows()) > 0
 	singleColumn := len(m.columns) == 1
 	styles := borderStyleRow{}
 
diff --git a/table/column.go b/table/column.go
index 005d81e..73acd58 100644
--- a/table/column.go
+++ b/table/column.go
@@ -10,7 +10,8 @@ type Column struct {
 	Key   string
 	Width int
 
-	style lipgloss.Style
+	filterable bool
+	style      lipgloss.Style
 }
 
 // NewColumn creates a new column with the given information.
@@ -29,3 +30,9 @@ func (c Column) WithStyle(style lipgloss.Style) Column {
 
 	return c
 }
+
+func (c Column) WithFiltered(filterable bool) Column {
+	c.filterable = filterable
+
+	return c
+}
diff --git a/table/filter.go b/table/filter.go
new file mode 100644
index 0000000..240e26b
--- /dev/null
+++ b/table/filter.go
@@ -0,0 +1,55 @@
+package table
+
+import (
+	"fmt"
+	"strings"
+)
+
+func (m Model) getFilteredRows(rows []Row) []Row {
+	if !m.filtered || m.filterTextInput.Value() == "" {
+		return rows
+	}
+
+	filteredRows := make([]Row, 0)
+
+	for _, row := range rows {
+		if isRowMatched(m.columns, row, m.filterTextInput.Value()) {
+			filteredRows = append(filteredRows, row)
+		}
+	}
+
+	return filteredRows
+}
+
+func isRowMatched(columns []Column, row Row, filter string) bool {
+	if filter == "" {
+		return true
+	}
+
+	for _, column := range columns {
+		if !column.filterable {
+			continue
+		}
+		data, ok := row.Data[column.Key]
+		if !ok {
+			continue
+		}
+		switch dataV := data.(type) {
+		case string:
+			if strings.Contains(strings.ToLower(dataV), strings.ToLower(filter)) {
+				return true
+			}
+		case fmt.Stringer:
+			if strings.Contains(strings.ToLower(dataV.String()), strings.ToLower(filter)) {
+				return true
+			}
+
+			return false
+		default:
+
+			return false
+		}
+	}
+
+	return false
+}
diff --git a/table/footer.go b/table/footer.go
index 6cc5d04..1e939d8 100644
--- a/table/footer.go
+++ b/table/footer.go
@@ -10,16 +10,27 @@ func (m Model) renderFooter() string {
 	if !m.hasFooter() {
 		return ""
 	}
-
+	if m.staticFooter != "" {
+		return m.border.styleFooter.Width(m.totalWidth).Render(m.staticFooter)
+	}
 	var footerText string
-
-	switch {
-	case m.staticFooter != "":
-		footerText = m.staticFooter
-
-	case m.pageSize != 0:
-		footerText = fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages())
+	var pagination string
+	var filter string
+	// paged feature enabled
+	if m.pageSize != 0 {
+		pagination = fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages())
+	}
+	if m.filtered {
+		// filter pressing
+		if m.filterTextInput.Focused() {
+			filter = fmt.Sprintf("/%s", m.filterTextInput.View())
+		} else {
+			if m.filterTextInput.Value() != "" {
+				filter = fmt.Sprintf("/%s", m.filterTextInput.Value())
+			}
+		}
 	}
+	footerText = fmt.Sprintf("%s %s", filter, pagination)
 
 	styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(m.totalWidth)
 
diff --git a/table/keys.go b/table/keys.go
index c4131e7..ccbee58 100644
--- a/table/keys.go
+++ b/table/keys.go
@@ -11,6 +11,8 @@ type KeyMap struct {
 
 	PageDown key.Binding
 	PageUp   key.Binding
+
+	Filter key.Binding
 }
 
 // DefaultKeyMap returns a set of sensible defaults for controlling a focused table.
@@ -31,5 +33,8 @@ func DefaultKeyMap() KeyMap {
 		PageUp: key.NewBinding(
 			key.WithKeys("left", "h", "pgup"),
 		),
+		Filter: key.NewBinding(
+			key.WithKeys("/"),
+		),
 	}
 }
diff --git a/table/model.go b/table/model.go
index 30256be..9442212 100644
--- a/table/model.go
+++ b/table/model.go
@@ -1,6 +1,7 @@
 package table
 
 import (
+	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 )
@@ -41,9 +42,12 @@ type Model struct {
 	pageSize    int
 	currentPage int
 
-	// Sorting
-	sortOrder  []sortColumn
-	sortedRows []Row
+	// Sorting, first sortColum will be last sorted
+	sortOrder []sortColumn
+
+	// Filter
+	filtered        bool
+	filterTextInput textinput.Model
 
 	// Internal cached calculations for reference
 	totalWidth int
@@ -51,6 +55,8 @@ type Model struct {
 
 // New creates a new table ready for further modifications.
 func New(columns []Column) Model {
+	filterInput := textinput.New()
+	filterInput.Prompt = ""
 	model := Model{
 		columns:        make([]Column, len(columns)),
 		highlightStyle: defaultHighlightStyle.Copy(),
@@ -60,7 +66,8 @@ func New(columns []Column) Model {
 		selectedText:   "[x]",
 		unselectedText: "[ ]",
 
-		baseStyle: lipgloss.NewStyle().Align(lipgloss.Right),
+		filterTextInput: filterInput,
+		baseStyle:       lipgloss.NewStyle().Align(lipgloss.Right),
 	}
 
 	// Do a full deep copy to avoid unexpected edits
@@ -75,3 +82,15 @@ func New(columns []Column) Model {
 func (m Model) Init() tea.Cmd {
 	return nil
 }
+
+// GetVisibleRows return sorted and filtered rows.
+func (m Model) GetVisibleRows() []Row {
+	rows := make([]Row, len(m.rows))
+	copy(rows, m.rows)
+	if m.filtered {
+		rows = m.getFilteredRows(rows)
+	}
+	rows = getSortedRows(m.sortOrder, rows)
+
+	return rows
+}
diff --git a/table/options.go b/table/options.go
index 7f297b8..230ca30 100644
--- a/table/options.go
+++ b/table/options.go
@@ -14,7 +14,6 @@ func (m Model) HeaderStyle(style lipgloss.Style) Model {
 // WithRows sets the rows to show as data in the table.
 func (m Model) WithRows(rows []Row) Model {
 	m.rows = rows
-	m.updateSortedRows()
 
 	return m
 }
@@ -55,8 +54,8 @@ func (m Model) SelectableRows(selectable bool) Model {
 
 // HighlightedRow returns the full Row that's currently highlighted by the user.
 func (m Model) HighlightedRow() Row {
-	if len(m.rows) > 0 {
-		return m.rows[m.rowCursorIndex]
+	if len(m.GetVisibleRows()) > 0 {
+		return m.GetVisibleRows()[m.rowCursorIndex]
 	}
 
 	// TODO: Better way to do this without pointers/nil?  Or should it be nil?
@@ -84,6 +83,19 @@ func (m Model) Focused(focused bool) Model {
 	return m
 }
 
+// Filtered allows the table to show rows that match the filter.
+func (m Model) Filtered(filtered bool) Model {
+	m.filtered = filtered
+
+	return m
+}
+
+func (m Model) FilterMode() Model {
+	m.filterTextInput.Focus()
+
+	return m
+}
+
 // WithStaticFooter adds a footer that only displays the given text.
 func (m Model) WithStaticFooter(footer string) Model {
 	m.staticFooter = footer
diff --git a/table/pagination.go b/table/pagination.go
index e31fb50..f6418be 100644
--- a/table/pagination.go
+++ b/table/pagination.go
@@ -14,23 +14,23 @@ func (m *Model) CurrentPage() int {
 
 // MaxPages returns the maximum number of pages that are visible.
 func (m *Model) MaxPages() int {
-	if m.pageSize == 0 || len(m.rows) == 0 {
+	if m.pageSize == 0 || len(m.GetVisibleRows()) == 0 {
 		return 1
 	}
 
-	return (len(m.rows)-1)/m.pageSize + 1
+	return (len(m.GetVisibleRows())-1)/m.pageSize + 1
 }
 
 // TotalRows returns the current total row count of the table.  If the table is
 // paginated, this is the total number of rows across all pages.
 func (m *Model) TotalRows() int {
-	return len(m.rows)
+	return len(m.GetVisibleRows())
 }
 
 // VisibleIndices returns the current visible rows by their 0 based index.
 // Useful for custom pagination footers.
 func (m *Model) VisibleIndices() (start, end int) {
-	totalRows := len(m.rows)
+	totalRows := len(m.GetVisibleRows())
 
 	if m.pageSize == 0 {
 		start = 0
@@ -50,7 +50,7 @@ func (m *Model) VisibleIndices() (start, end int) {
 }
 
 func (m *Model) pageDown() {
-	if m.pageSize == 0 || len(m.rows) <= m.pageSize {
+	if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize {
 		return
 	}
 
@@ -66,7 +66,7 @@ func (m *Model) pageDown() {
 }
 
 func (m *Model) pageUp() {
-	if m.pageSize == 0 || len(m.rows) <= m.pageSize {
+	if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize {
 		return
 	}
 
diff --git a/table/row.go b/table/row.go
index b615dc6..1166faf 100644
--- a/table/row.go
+++ b/table/row.go
@@ -44,7 +44,7 @@ func (r Row) WithStyle(style lipgloss.Style) Row {
 // nolint: cyclop
 func (m Model) renderRow(rowIndex int, last bool) string {
 	numColumns := len(m.columns)
-	row := m.sortedRows[rowIndex]
+	row := m.GetVisibleRows()[rowIndex]
 	highlighted := rowIndex == m.rowCursorIndex
 
 	columnStrings := []string{}
diff --git a/table/sort.go b/table/sort.go
index 88c5be0..f10855d 100644
--- a/table/sort.go
+++ b/table/sort.go
@@ -29,8 +29,6 @@ func (m Model) SortByAsc(columnKey string) Model {
 		},
 	}
 
-	m.updateSortedRows()
-
 	return m
 }
 
@@ -46,8 +44,6 @@ func (m Model) SortByDesc(columnKey string) Model {
 		},
 	}
 
-	m.updateSortedRows()
-
 	return m
 }
 
@@ -61,8 +57,6 @@ func (m Model) ThenSortByAsc(columnKey string) Model {
 		},
 	}, m.sortOrder...)
 
-	m.updateSortedRows()
-
 	return m
 }
 
@@ -76,8 +70,6 @@ func (m Model) ThenSortByDesc(columnKey string) Model {
 		},
 	}, m.sortOrder...)
 
-	m.updateSortedRows()
-
 	return m
 }
 
@@ -147,24 +139,27 @@ func (s *sortableTable) Less(first, second int) bool {
 	return firstVal > secondVal
 }
 
-func (m *Model) updateSortedRows() {
-	if len(m.sortOrder) == 0 {
-		m.sortedRows = m.rows
+func getSortedRows(sortOrder []sortColumn, rows []Row) []Row {
+	var sortedRows []Row
+	if len(sortOrder) == 0 {
+		sortedRows = rows
 
-		return
+		return sortedRows
 	}
 
-	m.sortedRows = make([]Row, len(m.rows))
-	copy(m.sortedRows, m.rows)
+	sortedRows = make([]Row, len(rows))
+	copy(sortedRows, rows)
 
-	for _, byColumn := range m.sortOrder {
+	for _, byColumn := range sortOrder {
 		sorted := &sortableTable{
-			rows:     m.sortedRows,
+			rows:     sortedRows,
 			byColumn: byColumn,
 		}
 
 		sort.Stable(sorted)
 
-		m.sortedRows = sorted.rows
+		sortedRows = sorted.rows
 	}
+
+	return sortedRows
 }
diff --git a/table/update.go b/table/update.go
index 2127c03..674a9a4 100644
--- a/table/update.go
+++ b/table/update.go
@@ -9,7 +9,7 @@ func (m *Model) moveHighlightUp() {
 	m.rowCursorIndex--
 
 	if m.rowCursorIndex < 0 {
-		m.rowCursorIndex = len(m.rows) - 1
+		m.rowCursorIndex = len(m.GetVisibleRows()) - 1
 	}
 
 	m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
@@ -18,7 +18,7 @@ func (m *Model) moveHighlightUp() {
 func (m *Model) moveHighlightDown() {
 	m.rowCursorIndex++
 
-	if m.rowCursorIndex >= len(m.rows) {
+	if m.rowCursorIndex >= len(m.GetVisibleRows()) {
 		m.rowCursorIndex = 0
 	}
 
@@ -26,32 +26,49 @@ func (m *Model) moveHighlightDown() {
 }
 
 func (m *Model) toggleSelect() {
-	if !m.selectableRows || len(m.rows) == 0 {
+	if !m.selectableRows || len(m.GetVisibleRows()) == 0 {
 		return
 	}
 
-	rows := make([]Row, len(m.sortedRows))
-	copy(rows, m.sortedRows)
+	rows := make([]Row, len(m.GetVisibleRows()))
+	copy(rows, m.GetVisibleRows())
 
 	rows[m.rowCursorIndex].selected = !rows[m.rowCursorIndex].selected
 
-	m.sortedRows = rows
+	m.rows = rows
 
 	m.selectedRows = []Row{}
 
-	for _, row := range m.sortedRows {
+	for _, row := range m.GetVisibleRows() {
 		if row.selected {
 			m.selectedRows = append(m.selectedRows, row)
 		}
 	}
 }
 
+func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) {
+	var cmd tea.Cmd
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		if msg.Type == tea.KeyEnter {
+			m.filterTextInput.Blur()
+		}
+	}
+	m.filterTextInput, cmd = m.filterTextInput.Update(msg)
+
+	return m, cmd
+}
+
 // Update responds to input from the user or other messages from Bubble Tea.
 func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
 	if !m.focused {
 		return m, nil
 	}
 
+	if m.filterTextInput.Focused() {
+		return m.updateFilterTextInput(msg)
+	}
+
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		switch {
@@ -69,6 +86,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
 
 		case key.Matches(msg, m.keyMap.PageUp):
 			m.pageUp()
+
+		case key.Matches(msg, m.keyMap.Filter):
+			m.filterTextInput.Focus()
 		}
 	}
 


Test patch:
diff --git a/table/filter_test.go b/table/filter_test.go
new file mode 100644
index 0000000..6f1c81b
--- /dev/null
+++ b/table/filter_test.go
@@ -0,0 +1,96 @@
+package table
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/bubbles/textinput"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsRowMatched(t *testing.T) {
+	columns := []Column{
+		NewColumn("title", "title", 10).WithFiltered(true),
+		NewColumn("description", "description", 10)}
+
+	assert.True(t, isRowMatched(columns,
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}), ""))
+
+	assert.True(t, isRowMatched(columns,
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}), "AA"))
+
+	assert.True(t, isRowMatched(columns,
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}), "A"))
+
+	assert.True(t, isRowMatched(columns,
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}), "a"))
+
+	assert.False(t, isRowMatched(columns,
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}), "B"))
+
+	assert.False(t, isRowMatched(columns,
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "BBB",
+		}), "BBB"))
+}
+
+func TestGetFilteredRows(t *testing.T) {
+	input := textinput.Model{}
+	input.SetValue("AAA")
+	columns := []Column{NewColumn("title", "title", 10)}
+	model := Model{filtered: true, filterTextInput: input, columns: columns}
+	rows := []Row{
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}),
+		NewRow(map[string]interface{}{
+			"title":       "BBB",
+			"description": "",
+		}),
+		NewRow(map[string]interface{}{
+			"title":       "CCC",
+			"description": "",
+		}),
+	}
+	filteredRows := model.getFilteredRows(rows)
+	assert.Equal(t, 0, len(filteredRows))
+}
+
+func TestGetFilteredRowsFiltered(t *testing.T) {
+	input := textinput.Model{}
+	input.SetValue("AAA")
+	columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
+	model := Model{filtered: true, filterTextInput: input, columns: columns}
+	rows := []Row{
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}),
+		NewRow(map[string]interface{}{
+			"title":       "BBB",
+			"description": "",
+		}),
+		NewRow(map[string]interface{}{
+			"title":       "CCC",
+			"description": "",
+		}),
+	}
+	filteredRows := model.getFilteredRows(rows)
+	assert.Equal(t, 1, len(filteredRows))
+}
diff --git a/table/model_test.go b/table/model_test.go
index 531526e..8fc982f 100644
--- a/table/model_test.go
+++ b/table/model_test.go
@@ -3,6 +3,7 @@ package table
 import (
 	"testing"
 
+	"github.com/charmbracelet/bubbles/textinput"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -13,3 +14,26 @@ func TestModelInitReturnsNil(t *testing.T) {
 
 	assert.Nil(t, cmd)
 }
+
+func TestGetVisibleRows(t *testing.T) {
+	input := textinput.Model{}
+	input.SetValue("AAA")
+	columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
+	rows := []Row{
+		NewRow(map[string]interface{}{
+			"title":       "AAA",
+			"description": "",
+		}),
+		NewRow(map[string]interface{}{
+			"title":       "BBB",
+			"description": "",
+		}),
+		NewRow(map[string]interface{}{
+			"title":       "CCC",
+			"description": "",
+		}),
+	}
+	m := Model{filtered: true, filterTextInput: input, columns: columns, rows: rows}
+	visibleRows := m.GetVisibleRows()
+	assert.Len(t, visibleRows, 1)
+}
diff --git a/table/pagination_test.go b/table/pagination_test.go
index 5a38c06..bbc181d 100644
--- a/table/pagination_test.go
+++ b/table/pagination_test.go
@@ -35,7 +35,7 @@ func paginationRowID(row Row) int {
 func getVisibleRows(m *Model) []Row {
 	start, end := m.VisibleIndices()
 
-	return m.rows[start : end+1]
+	return m.GetVisibleRows()[start : end+1]
 }
 
 func TestPaginationAccessors(t *testing.T) {
diff --git a/table/sort_test.go b/table/sort_test.go
index 3631951..ac39f83 100644
--- a/table/sort_test.go
+++ b/table/sort_test.go
@@ -24,7 +24,7 @@ func TestSortSingleColumnAscAndDesc(t *testing.T) {
 
 	assertOrder := func(expectedList []string) {
 		for index, expected := range expectedList {
-			idVal, ok := model.sortedRows[index].Data[idColKey]
+			idVal, ok := model.GetVisibleRows()[index].Data[idColKey]
 
 			if expected != "" {
 				assert.True(t, ok)
@@ -47,7 +47,7 @@ func TestSortSingleColumnAscAndDesc(t *testing.T) {
 		}
 	}
 
-	assert.Len(t, model.sortedRows, len(rows))
+	assert.Len(t, model.GetVisibleRows(), len(rows))
 	assertOrder([]string{"", "a", "b", "c"})
 
 	model = model.SortByDesc(idColKey)
@@ -70,7 +70,7 @@ func TestSortSingleColumnIntsAsc(t *testing.T) {
 
 	assertOrder := func(expectedList []int) {
 		for index, expected := range expectedList {
-			idVal, ok := model.sortedRows[index].Data[idColKey]
+			idVal, ok := model.GetVisibleRows()[index].Data[idColKey]
 
 			assert.True(t, ok)
 
@@ -87,7 +87,7 @@ func TestSortSingleColumnIntsAsc(t *testing.T) {
 		}
 	}
 
-	assert.Len(t, model.sortedRows, len(rows))
+	assert.Len(t, model.GetVisibleRows(), len(rows))
 	assertOrder([]int{1, 2, 13})
 }
 
@@ -115,17 +115,17 @@ func TestSortTwoColumnsAscDescMix(t *testing.T) {
 	}).SortByAsc(nameKey).ThenSortByDesc(scoreKey)
 
 	assertVals := func(index int, name string, score int) {
-		actualName, ok := model.sortedRows[index].Data[nameKey].(string)
+		actualName, ok := model.GetVisibleRows()[index].Data[nameKey].(string)
 		assert.True(t, ok)
 
-		actualScore, ok := model.sortedRows[index].Data[scoreKey].(int)
+		actualScore, ok := model.GetVisibleRows()[index].Data[scoreKey].(int)
 		assert.True(t, ok)
 
 		assert.Equal(t, name, actualName)
 		assert.Equal(t, score, actualScore)
 	}
 
-	assert.Len(t, model.sortedRows, 4)
+	assert.Len(t, model.GetVisibleRows(), 4)
 
 	assertVals(0, "a", 100)
 	assertVals(1, "a", 75)
@@ -139,3 +139,44 @@ func TestSortTwoColumnsAscDescMix(t *testing.T) {
 	assertVals(2, "a", 75)
 	assertVals(3, "a", 100)
 }
+
+func TestGetSortedRows(t *testing.T) {
+	sortColumns := []sortColumn{
+		{
+			columnKey: "cb",
+			direction: 1,
+		},
+		{
+			columnKey: "ca",
+			direction: 0,
+		},
+	}
+	rows := getSortedRows(sortColumns, []Row{
+		NewRow(map[string]interface{}{
+			"ca": "2",
+			"cb": "t-1",
+		}),
+		NewRow(map[string]interface{}{
+			"ca": "1",
+			"cb": "t-2",
+		}),
+		NewRow(map[string]interface{}{
+			"ca": "3",
+			"cb": "t-3",
+		}),
+		NewRow(map[string]interface{}{
+			"ca": "3",
+			"cb": "t-2",
+		}),
+	})
+	assert.Len(t, rows, 4)
+	assert.Equal(t, "1", rows[0].Data["ca"])
+	assert.Equal(t, "2", rows[1].Data["ca"])
+	assert.Equal(t, "3", rows[2].Data["ca"])
+	assert.Equal(t, "3", rows[3].Data["ca"])
+
+	assert.Equal(t, "t-2", rows[0].Data["cb"])
+	assert.Equal(t, "t-1", rows[1].Data["cb"])
+	assert.Equal(t, "t-3", rows[2].Data["cb"])
+	assert.Equal(t, "t-2", rows[3].Data["cb"])
+}
diff --git a/table/update_test.go b/table/update_test.go
index 605e529..ea4ffe6 100644
--- a/table/update_test.go
+++ b/table/update_test.go
@@ -165,11 +165,11 @@ func TestSelectingRowWhenTableUnselectableDoesNothing(t *testing.T) {
 		}),
 	}).Focused(true)
 
-	assert.False(t, model.rows[0].selected, "Row shouldn't be selected to start")
+	assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected to start")
 
 	model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
 
-	assert.False(t, model.rows[0].selected, "Row shouldn't be selected after key press")
+	assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected after key press")
 }
 
 func TestSelectingRowToggles(t *testing.T) {
@@ -192,15 +192,15 @@ func TestSelectingRowToggles(t *testing.T) {
 	keyEnter := tea.KeyMsg{Type: tea.KeyEnter}
 	keyDown := tea.KeyMsg{Type: tea.KeyDown}
 
-	assert.False(t, model.sortedRows[0].selected, "Row shouldn't be selected to start")
+	assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected to start")
 
 	model, _ = model.Update(keyEnter)
-	assert.True(t, model.sortedRows[0].selected, "Row should be selected after first toggle")
+	assert.True(t, model.GetVisibleRows()[0].selected, "Row should be selected after first toggle")
 
 	model, _ = model.Update(keyEnter)
-	assert.False(t, model.sortedRows[0].selected, "Row should not be selected after second toggle")
+	assert.False(t, model.GetVisibleRows()[0].selected, "Row should not be selected after second toggle")
 
 	model, _ = model.Update(keyDown)
 	model, _ = model.Update(keyEnter)
-	assert.True(t, model.sortedRows[1].selected, "Second row should be selected after moving and toggling")
+	assert.True(t, model.GetVisibleRows()[1].selected, "Second row should be selected after moving and toggling")
 }