Repository: Evertras/bubble-table
Pull request #73 (2022-05-04 16:25:05)

enable filter works with table api(not in the footer)
We already have keyword filtering, but this can only be displayed in the footer of the form. If I want a form without a footer, or if I want to have the ability to manually control the filtering feature, I need an API to connect to the form to manipulate it.


Before:

```
A filtered simple default table
Currently filter by Title and Author, press / + letters to start filtering, and escape to clear filter.
Press q or ctrl+c to quit

┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃        Title┃       Author┃                                       Description┃
┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃Computer Sys…┃Randal E. Br…┃This book explains the important and enduring con…┃
┃Structure an…┃Harold Abels…┃Structure and Interpretation of Computer Programs…┃
┣━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃                                                                     /com  1/1┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
```

New sytle:

```
A filtered simple default table
Currently filter by Title and Author, press / + letters to start filtering, and escape to clear filter.
Press q or ctrl+c to quit

> com 
┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃        Title┃       Author┃                                       Description┃
┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃Computer Sys…┃Randal E. Br…┃This book explains the important and enduring con…┃
┃Structure an…┃Harold Abels…┃Structure and Interpretation of Computer Programs…┃
┗━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

```
```


Patch:
diff --git a/Makefile b/Makefile
index d7e75d0..2d7a012 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,10 @@
 example-pokemon:
 	@go run ./examples/pokemon/*.go
 
+.PHONY: example-metadata
+example-metadata:
+	@go run ./examples/metadata/*.go
+
 .PHONY: example-dimensions
 example-dimensions:
 	@go run ./examples/dimensions/main.go
diff --git a/README.md b/README.md
index ab28b74..e6802bc 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ for a few helpful tips!
 
 ## Features
 
-For a code reference of all available features, please see the [full feature example](./examples/features/main.go).
+For a code reference of most available features, please see the [full feature example](./examples/features/main.go).
 If you want to get started with a simple default table, [check the simplest example](./examples/simplest/main.go).
 
 Displays a table with a header, rows, footer, and borders.
@@ -71,7 +71,8 @@ If it does not exist, nothing is rendered.
 
 Extra data in the `RowData` object is ignored.  This can be helpful to simply
 dump data into `RowData` and create columns that select what is interesting to
-view, or to generate different columns based on view options on the fly.
+view, or to generate different columns based on view options on the fly (see the
+[metadata example](./examples/metadata/main.go) for an example of using this).
 
 An example is given below.  For more detailed examples, see
 [the examples directory](./examples).
diff --git a/examples/filterapi/main.go b/examples/filterapi/main.go
new file mode 100644
index 0000000..66bd731
--- /dev/null
+++ b/examples/filterapi/main.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+	"github.com/charmbracelet/bubbles/textinput"
+	"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
+	filterTextInput textinput.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).
+			WithFooterVisibility(false).
+			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...",
+				}),
+			}),
+		filterTextInput: textinput.New(),
+	}
+}
+
+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
+	)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		// global
+		if msg.String() == "ctrl+c" {
+			cmds = append(cmds, tea.Quit)
+			return m, tea.Batch(cmds...)
+		}
+		// event to filter
+		if m.filterTextInput.Focused() {
+			if msg.String() == "enter" {
+				m.filterTextInput.Blur()
+			} else {
+				m.filterTextInput, cmd = m.filterTextInput.Update(msg)
+			}
+			m.table = m.table.WithFilterInput(m.filterTextInput)
+			return m, tea.Batch(cmds...)
+		}
+
+		// others component
+		switch msg.String() {
+		case "/":
+			m.filterTextInput.Focus()
+		case "q":
+			cmds = append(cmds, tea.Quit)
+			return m, tea.Batch(cmds...)
+		default:
+			m.table, cmd = m.table.Update(msg)
+			cmds = append(cmds, cmd)
+		}
+
+	}
+
+	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 / + letters to start filtering, and escape to clear filter.\nPress q or ctrl+c to quit\n\n")
+
+	body.WriteString(m.filterTextInput.View() + "\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/examples/metadata/main.go b/examples/metadata/main.go
new file mode 100644
index 0000000..87aba44
--- /dev/null
+++ b/examples/metadata/main.go
@@ -0,0 +1,189 @@
+// This is a more data-driven example of the Pokemon table
+package main
+
+import (
+	"fmt"
+	"log"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/evertras/bubble-table/table"
+)
+
+type Element string
+
+const (
+	columnKeyName              = "name"
+	columnKeyElement           = "element"
+	columnKeyConversations     = "convos"
+	columnKeyPositiveSentiment = "positive"
+	columnKeyNegativeSentiment = "negative"
+
+	// This is not a visible column, but is used to attach useful reference data
+	// to the row itself for easier retrieval
+	columnKeyPokemonData = "pokedata"
+
+	elementNormal   Element = "Normal"
+	elementFire     Element = "Fire"
+	elementElectric Element = "Electric"
+	elementWater    Element = "Water"
+	elementPlant    Element = "Plant"
+)
+
+var (
+	styleSubtle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888"))
+
+	styleBase = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("#a7a")).
+			BorderForeground(lipgloss.Color("#a38")).
+			Align(lipgloss.Right)
+
+	elementColors = map[Element]string{
+		elementNormal:   "#fa0",
+		elementFire:     "#f64",
+		elementElectric: "#ff0",
+		elementWater:    "#44f",
+		elementPlant:    "#8b8",
+	}
+
+	customBorder = table.Border{
+		Top:    "─",
+		Left:   "│",
+		Right:  "│",
+		Bottom: "─",
+
+		TopRight:    "╮",
+		TopLeft:     "╭",
+		BottomRight: "╯",
+		BottomLeft:  "╰",
+
+		TopJunction:    "┬",
+		LeftJunction:   "├",
+		RightJunction:  "┤",
+		BottomJunction: "┴",
+		InnerJunction:  "┼",
+
+		InnerDivider: "│",
+	}
+)
+
+type Pokemon struct {
+	Name                     string
+	Element                  Element
+	ConversationCount        int
+	PositiveSentimentPercent float32
+	NegativeSentimentPercent float32
+}
+
+func NewPokemon(name string, element Element, conversationCount int, positiveSentimentPercent float32, negativeSentimentPercent float32) Pokemon {
+	return Pokemon{
+		Name:                     name,
+		Element:                  element,
+		ConversationCount:        conversationCount,
+		PositiveSentimentPercent: positiveSentimentPercent,
+		NegativeSentimentPercent: negativeSentimentPercent,
+	}
+}
+
+func (p Pokemon) ToRow() table.Row {
+	color, exists := elementColors[p.Element]
+
+	if !exists {
+		color = elementColors[elementNormal]
+	}
+
+	return table.NewRow(table.RowData{
+		columnKeyName:              p.Name,
+		columnKeyElement:           table.NewStyledCell(p.Element, lipgloss.NewStyle().Foreground(lipgloss.Color(color))),
+		columnKeyConversations:     p.ConversationCount,
+		columnKeyPositiveSentiment: p.PositiveSentimentPercent,
+		columnKeyNegativeSentiment: p.NegativeSentimentPercent,
+
+		// This isn't a visible column, but we can add the data here anyway for later retrieval
+		columnKeyPokemonData: p,
+	})
+}
+
+type Model struct {
+	pokeTable table.Model
+}
+
+func NewModel() Model {
+	pokemon := []Pokemon{
+		NewPokemon("Pikachu", elementElectric, 2300648, 21.9, 8.54),
+		NewPokemon("Eevee", elementNormal, 636373, 26.4, 7.37),
+		NewPokemon("Bulbasaur", elementPlant, 352190, 25.7, 9.02),
+		NewPokemon("Squirtle", elementWater, 241259, 25.6, 5.96),
+		NewPokemon("Blastoise", elementWater, 162794, 19.5, 6.04),
+		NewPokemon("Charmander", elementFire, 265760, 31.2, 5.25),
+		NewPokemon("Charizard", elementFire, 567763, 25.6, 7.56),
+	}
+
+	rows := []table.Row{}
+
+	for _, p := range pokemon {
+		rows = append(rows, p.ToRow())
+	}
+
+	return Model{
+		pokeTable: table.New([]table.Column{
+			table.NewColumn(columnKeyName, "Name", 13),
+			table.NewColumn(columnKeyElement, "Element", 10),
+			table.NewColumn(columnKeyConversations, "# Conversations", 15),
+			table.NewColumn(columnKeyPositiveSentiment, ":D %", 5).WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#8c8"))),
+			table.NewColumn(columnKeyNegativeSentiment, ":( %", 5).WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#c88"))),
+		}).WithRows(rows).
+			Border(customBorder).
+			WithBaseStyle(styleBase).
+			WithPageSize(6).
+			SortByDesc(columnKeyConversations).
+			Focused(true),
+	}
+}
+
+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.pokeTable, cmd = m.pokeTable.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 {
+	// Get the metadata back out of the row
+	selected := m.pokeTable.HighlightedRow().Data[columnKeyPokemonData].(Pokemon)
+
+	view := lipgloss.JoinVertical(
+		lipgloss.Left,
+		styleSubtle.Render("Press q or ctrl+c to quit - Sorted by # Conversations"),
+		styleSubtle.Render("Highlighted: "+fmt.Sprintf("%s (%s)", selected.Name, selected.Element)),
+		styleSubtle.Render("https://www.nintendolife.com/news/2021/11/these-are-the-most-loved-and-most-hated-pokemon-according-to-a-new-study"),
+		m.pokeTable.View(),
+	) + "\n"
+
+	return lipgloss.NewStyle().MarginLeft(1).Render(view)
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/table/footer.go b/table/footer.go
index 2af09ac..4fd5547 100644
--- a/table/footer.go
+++ b/table/footer.go
@@ -6,7 +6,7 @@ import (
 )
 
 func (m Model) hasFooter() bool {
-	return m.staticFooter != "" || m.pageSize != 0 || m.filtered
+	return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered)
 }
 
 func (m Model) renderFooter() string {
@@ -25,7 +25,7 @@ func (m Model) renderFooter() string {
 	sections := []string{}
 
 	if m.filtered && (m.filterTextInput.Focused() || m.filterTextInput.Value() != "") {
-		sections = append(sections, fmt.Sprintf("/%s", m.filterTextInput.View()))
+		sections = append(sections, m.filterTextInput.View())
 	}
 
 	// paged feature enabled
diff --git a/table/model.go b/table/model.go
index 6cfdd2c..5ef9e90 100644
--- a/table/model.go
+++ b/table/model.go
@@ -36,7 +36,8 @@ type Model struct {
 	unselectedText string
 
 	// Footers
-	staticFooter string
+	footerVisible bool
+	staticFooter  string
 
 	// Pagination
 	pageSize    int
@@ -60,11 +61,12 @@ type Model struct {
 // New creates a new table ready for further modifications.
 func New(columns []Column) Model {
 	filterInput := textinput.New()
-	filterInput.Prompt = ""
+	filterInput.Prompt = "/"
 	model := Model{
 		columns:        make([]Column, len(columns)),
 		highlightStyle: defaultHighlightStyle.Copy(),
 		border:         borderDefault,
+		footerVisible:  true,
 		keyMap:         DefaultKeyMap(),
 
 		selectedText:   "[x]",
diff --git a/table/options.go b/table/options.go
index d40a49e..a02b397 100644
--- a/table/options.go
+++ b/table/options.go
@@ -1,6 +1,7 @@
 package table
 
 import (
+	"github.com/charmbracelet/bubbles/textinput"
 	"github.com/charmbracelet/lipgloss"
 )
 
@@ -249,3 +250,19 @@ func (m Model) WithColumns(columns []Column) Model {
 
 	return m
 }
+
+// WithFilterInput makes the table use the provided text input bubble for
+// filtering rather than using the built-in default.  This allows for external
+// text input controls to be used.
+func (m Model) WithFilterInput(input textinput.Model) Model {
+	m.filterTextInput = input
+
+	return m
+}
+
+// WithFooterVisibility sets the visibility of the footer.
+func (m Model) WithFooterVisibility(visibility bool) Model {
+	m.footerVisible = visibility
+
+	return m
+}


Test patch:
diff --git a/table/filter_test.go b/table/filter_test.go
index 56cc772..3eb987b 100644
--- a/table/filter_test.go
+++ b/table/filter_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/stretchr/testify/assert"
 )
@@ -179,3 +180,27 @@ func TestGetFilteredRowsRefocusAfterFilter(t *testing.T) {
 	assert.Equal(t, 1, model.MaxPages())
 	assert.Equal(t, 0, model.TotalRows())
 }
+
+func TestFilterWithExternalTextInput(t *testing.T) {
+	columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
+	rows := []Row{
+		NewRow(RowData{
+			"title":       "AAA",
+			"description": "",
+		}),
+		NewRow(RowData{
+			"title":       "BBB",
+			"description": "",
+		}),
+		// Empty
+		NewRow(RowData{}),
+	}
+	model := New(columns).WithRows(rows).Filtered(true)
+	input := textinput.New()
+	input.SetValue("AaA")
+	model = model.WithFilterInput(input)
+
+	filteredRows := model.getFilteredRows(rows)
+
+	assert.Len(t, filteredRows, 1)
+}
diff --git a/table/options_test.go b/table/options_test.go
index 1e173d9..a7b336b 100644
--- a/table/options_test.go
+++ b/table/options_test.go
@@ -129,4 +129,11 @@ func TestPageOptions(t *testing.T) {
 	model = model.WithCurrentPage(6)
 	assert.Equal(t, 6, model.CurrentPage())
 	assert.Equal(t, 26, model.rowCursorIndex)
+
+	model = model.WithFooterVisibility(false)
+	assert.Equal(t, "", model.renderFooter())
+
+	model = model.WithFooterVisibility(true)
+	assert.Greater(t, len(model.renderFooter()), 10)
+	assert.Contains(t, model.renderFooter(), "6/6")
 }