Repository: Evertras/bubble-table Branch: main Commit: ef37988ad03a Files: 86 Total size: 267.0 KB Directory structure: gitextract_ij2y60qv/ ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── build.yml │ ├── codeql-analysis.yml │ ├── coverage.yml │ └── lint.yml ├── .gitignore ├── .go-version ├── .golangci.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── examples/ │ ├── dimensions/ │ │ ├── README.md │ │ └── main.go │ ├── events/ │ │ ├── README.md │ │ └── main.go │ ├── features/ │ │ ├── README.md │ │ └── main.go │ ├── filter/ │ │ ├── README.md │ │ └── main.go │ ├── filterapi/ │ │ ├── README.md │ │ └── main.go │ ├── flex/ │ │ ├── README.md │ │ └── main.go │ ├── metadata/ │ │ ├── README.md │ │ └── main.go │ ├── multiline/ │ │ ├── README.md │ │ └── main.go │ ├── pagination/ │ │ ├── README.md │ │ └── main.go │ ├── pokemon/ │ │ ├── README.md │ │ └── main.go │ ├── scrolling/ │ │ ├── README.md │ │ └── main.go │ ├── simplest/ │ │ ├── README.md │ │ └── main.go │ ├── sorting/ │ │ ├── README.md │ │ └── main.go │ └── updates/ │ ├── README.md │ ├── data.go │ └── main.go ├── flake.nix ├── go.mod ├── go.sum └── table/ ├── benchmarks_test.go ├── border.go ├── calc.go ├── calc_test.go ├── cell.go ├── column.go ├── column_test.go ├── data.go ├── data_test.go ├── dimensions.go ├── dimensions_test.go ├── doc.go ├── events.go ├── events_test.go ├── filter.go ├── filter_test.go ├── footer.go ├── header.go ├── keys.go ├── keys_test.go ├── model.go ├── model_test.go ├── options.go ├── options_test.go ├── overflow.go ├── pagination.go ├── pagination_test.go ├── query.go ├── query_test.go ├── row.go ├── scrolling.go ├── scrolling_fuzz_test.go ├── scrolling_test.go ├── sort.go ├── sort_test.go ├── strlimit.go ├── strlimit_test.go ├── update.go ├── update_test.go ├── view.go ├── view_selectable_test.go └── view_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_size = 2 indent_style = space insert_final_newline = true trim_trailing_whitespace = true end_of_line = lf [{*.go,go.*}] indent_size = 4 indent_style = tab [Makefile] indent_style = tab ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: push: tags: - v* branches: - main pull_request: jobs: buildandtest: name: Build and test strategy: matrix: go-version: [~1.18, ^1] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - name: Install Go uses: actions/setup-go@v4 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v4 - name: Download Go modules run: go mod download - name: Build run: go build -v ./table - name: Test run: go test -race ./table ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ name: "CodeQL" on: push: branches: [ main ] pull_request: # The branches below must be a subset of the branches above branches: [ main ] schedule: - cron: '29 4 * * 4' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] steps: - name: Checkout repository uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 ================================================ FILE: .github/workflows/coverage.yml ================================================ name: coverage on: push: tags: - v* branches: - main pull_request: jobs: coverage: name: Report Coverage runs-on: ubuntu-latest steps: - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.18.10" - name: Check out code uses: actions/checkout@v4 - name: Install deps run: | go mod download - name: Run tests with coverage output run: | go test -race -covermode atomic -coverprofile=covprofile ./... - name: Install goveralls run: go install github.com/mattn/goveralls@latest - name: Send coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: goveralls -coverprofile=covprofile -service=github ================================================ FILE: .github/workflows/lint.yml ================================================ name: golangci-lint on: push: tags: - v* branches: - main pull_request: permissions: contents: read jobs: golangci: name: lint runs-on: ubuntu-latest steps: # For whatever reason, 1.21.9 blows up because it can't # find 'max' in some go lib... pinning to 1.21.4 fixes this - name: Install Go uses: actions/setup-go@v4 with: go-version: 1.21.4 - name: Checkout code uses: actions/checkout@v4 - name: Lint run: make lint ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib /bin # Test binary, built with `go test -c` *.test # Profiling outputs *.prof # Outputs of the go coverage tool, specifically when used with LiteIDE *.out *.coverage # Go vendor folder vendor/ # Mac stuff .DS_Store # Envrc with direnv .envrc # Sandbox area for experimenting without worrying about git sandbox ================================================ FILE: .go-version ================================================ 1.17.6 ================================================ FILE: .golangci.yaml ================================================ version: "2" linters: enable: - asciicheck - bidichk - bodyclose - contextcheck - cyclop - depguard - dogsled - dupl - durationcheck - err113 - errname - errorlint - exhaustive - forbidigo - forcetypeassert - funlen - gocognit - goconst - gocyclo - godot - goheader - gomoddirectives - gomodguard - goprintffuncname - gosec - importas - ireturn - lll - makezero - misspell - mnd - nakedret - nestif - nilerr - nilnil - nlreturn - noctx - nolintlint - prealloc - predeclared - promlinter - revive - rowserrcheck - sqlclosecheck - staticcheck - tagliatelle - thelper - tparallel - unconvert - unparam - varnamelen - wastedassign - whitespace - wrapcheck settings: depguard: rules: main: list-mode: lax allow: - github.com/stretchr/testify/assert - github.com/charmbracelet/lipgloss - github.com/muesli/reflow/wordwrap exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ formatters: enable: - gci - gofmt - goimports exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Thanks for your interest in contributing! ## Contributing issues Please feel free to open an issue if you think something is working incorrectly, if you have a feature request, or if you just have any questions. No templates are in place, all I ask is that you provide relevant information if you believe something is working incorrectly so we can sort it out quickly. ## Contributing code All contributions should have an associated issue. If you are at all unsure about how to solve the issue, please ask! I'd rather chat about possible solutions than have someone spend hours on a PR that requires a lot of major changes. Test coverage is important. If you end up with a small (<1%) drop, I'm happy to help cover the gap, but generally any new features or changes should have some tests as well. If you're not sure how to test something, feel free to ask! Linting can be done with `make lint`. Running `fmt` can be done with `make fmt`. Tests can be run with `make test`. Doing all these before submitting the PR can help you pass the existing gatekeeping tests without having to wait for them to run every time. The name of the PR, commit messages, and branch names aren't very important, there are no special triggers or filters or anything in place that depend on names. The name of the PR should at least reasonably describe the change, because this is what people will see in the commit history and what goes into the change logs. Exported functions should generally follow the pattern of returning a `Model` and not use a pointer receiver. This matches more closely with the flow of Bubble Tea (and Elm), and discourages users from making mutable changes with unintended side effects. Unexported functions are free to use pointer receivers for both simplicity and performance reasons. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Brandon Fulljames Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ ifeq ($(OS), Windows_NT) EXE_EXT=.exe else EXE_EXT= endif .PHONY: example-pokemon 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 .PHONY: example-events example-events: @go run ./examples/events/main.go .PHONY: example-features example-features: @go run ./examples/features/main.go .PHONY: example-multiline example-multiline: @go run ./examples/multiline/main.go .PHONY: example-filter example-filter: @go run ./examples/filter/*.go .PHONY: example-filterapi example-filterapi: @go run ./examples/filterapi/*.go .PHONY: example-flex example-flex: @go run ./examples/flex/*.go .PHONY: example-pagination example-pagination: @go run ./examples/pagination/*.go .PHONY: example-simplest example-simplest: @go run ./examples/simplest/*.go .PHONY: example-scrolling example-scrolling: @go run ./examples/scrolling/*.go .PHONY: example-sorting example-sorting: @go run ./examples/sorting/*.go .PHONY: example-updates example-updates: @go run ./examples/updates/*.go .PHONY: test test: @go test -race -cover ./table .PHONY: test-coverage test-coverage: coverage.out @go tool cover -html=coverage.out .PHONY: benchmark benchmark: @go test -run=XXX -bench=. -benchmem ./table .PHONY: lint lint: ./bin/golangci-lint$(EXE_EXT) @./bin/golangci-lint$(EXE_EXT) run ./table coverage.out: table/*.go go.* @go test -coverprofile=coverage.out ./table .PHONY: fmt fmt: ./bin/gci$(EXE_EXT) @go fmt ./... @./bin/gci$(EXE_EXT) write --skip-generated ./table/*.go ./bin/golangci-lint$(EXE_EXT): curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ./bin v2.3.1 ./bin/gci$(EXE_EXT): GOBIN=$(shell pwd)/bin go install github.com/daixiang0/gci@v0.9.1 ================================================ FILE: README.md ================================================ # Bubble-table
A customizable, interactive table component for the [Bubble Tea framework](https://github.com/charmbracelet/bubbletea).  [View above sample source code](./examples/pokemon) ## Contributing Contributions welcome, please [check the contributions doc](./CONTRIBUTING.md) for a few helpful tips! ## Features For a code reference of most available features, please see the [full feature example](./examples/features). If you want to get started with a simple default table, [check the simplest example](./examples/simplest). Displays a table with a header, rows, footer, and borders. The header can be hidden, and the footer can be set to automatically show page information, use custom text, or be hidden by default. Columns can be fixed-width [or flexible width](./examples/flex). A maximum width can be specified which enables [horizontal scrolling](./examples/scrolling), and left-most columns can be frozen for easier reference. Border shape is customizable with a basic thick square default. The color can be modified by applying a base style with `lipgloss.NewStyle().BorderForeground(...)`. Styles can be applied globally and to columns, rows, and individual cells. The base style is applied first, then column, then row, then cell when determining overrides. The default base style is a basic right-alignment. [See the main feature example](./examples/features) to see styles and how they override each other. Styles can also be applied via a style function which can be used to apply zebra striping, data-specific formatting, etc. Can be focused to highlight a row and navigate with up/down (and j/k). These keys can be customized with a KeyMap. Can make rows selectable, and fetch the current selections. Events can be checked for user interactions. Pagination can be set with a given page size, which automatically generates a simple footer to show the current page and total pages. Built-in filtering can be enabled by setting any columns as filterable, using a text box in the footer and `/` (customizable by keybind) to start filtering. A missing indicator can be supplied to show missing data in rows. Columns can be sorted in either ascending or descending order. Multiple columns can be specified in a row. If multiple columns are specified, first the table is sorted by the first specified column, then each group within that column is sorted in smaller and smaller groups. [See the sorting example](examples/sorting) for more information. If a column contains numbers (either ints or floats), the numbers will be sorted by numeric value. Otherwise rendered string values will be compared. If a feature is confusing to use or could use a better example, please feel free to open an issue. ## Defining table data A table is defined by a list of `Column` values that define the columns in the table. Each `Column` is associated with a unique string key. A table contains a list of `Row`s. Each `Row` contains a `RowData` object which is simply a map of string column IDs to arbitrary `any` data values. When the table is rendered, each `Row` is checked for each `Column` key. If the key exists in the `Row`'s `RowData`, it is rendered with `fmt.Sprintf("%v")`. 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 (see the [metadata example](./examples/metadata) for an example of using this). An example is given below. For more detailed examples, see [the examples directory](./examples). ```golang // This makes it easier/safer to match against values, but isn't necessary const ( // This value isn't visible anywhere, so a simple lowercase is fine columnKeyID = "id" // It's just a string, so it can be whatever, really! They only must be unique columnKeyName = "何?!" ) // Note that there's nothing special about "ID" or "Name", these are completely // arbitrary columns columns := []table.Column{ table.NewColumn(columnKeyID, "ID", 5), table.NewColumn(columnKeyName, "Name", 10), } rows := []table.Row{ // This row contains both an ID and a name table.NewRow(table.RowData{ columnKeyID: "abc", columnKeyName: "Hello", }), table.NewRow(table.RowData{ columnKeyID: "123", columnKeyName: "Oh no", // This field exists in the row data but won't be visible "somethingelse": "Super bold!", }), table.NewRow(table.RowData{ columnKeyID: "def", // This row is missing the Name column, so it will use the supplied missing // indicator if supplied when creating the table using the following option: // .WithMissingDataIndicator("<ない>") (or .WithMissingDataIndicatorStyled!) }), // We can also apply styling to the row or to individual cells // This row has individual styling to make it bold table.NewRow(table.RowData{ columnKeyID: "bold", columnKeyName: "Bolded", }).WithStyle(lipgloss.NewStyle().Bold(true). , // This row also has individual styling to make it bold table.NewRow(table.RowData{ columnKeyID: "alert", // This cell has styling applied on top of the bold columnKeyName: table.NewStyledCell("Alert", lipgloss.NewStyle().Foreground(lipgloss.Color("#f88"))), }).WithStyle(lipgloss.NewStyle().Bold(true), } ``` ### A note on 'metadata' There may be cases where you wish to reference some kind of data object in the table. For example, a table of users may display a user name, ID, etc., and you may wish to retrieve data about the user when the row is selected. This can be accomplished by attaching hidden 'metadata' to the row in the same way as any other data. ```golang const ( columnKeyID = "id" columnKeyName = "名前" columnKeyUserData = "userstuff" ) // Notice there is no "userstuff" column, so it won't be displayed columns := []table.Column{ table.NewColumn(columnKeyID, "ID", 5), table.NewColumn(columnKeyName, "Name", 10), } // Just one user for this quick snippet, check the example for more user := &SomeUser{ ID: 3, Name: "Evertras", } rows := []table.Row{ // This row contains both an ID and a name table.NewRow(table.RowData{ columnKeyID: user.ID, columnKeyName: user.Name, // This isn't displayed, but it remains attached to the row columnKeyUserData: user, }), } ``` For a more detailed demonstration of this idea in action, please see the [metadata example](./examples/metadata). ## Demos Code examples are located in [the examples directory](./examples). Run commands are added to the [Makefile](Makefile) for convenience but they should be as simple as `go run ./examples/features/main.go`, etc. You can also view what they look like by checking the example's directory in each README here on Github. To run the examples, clone this repo and run: ```bash # Run the pokemon demo for a general feel of common useful features make # Run dimensions example to see multiple sizes of simple tables in action make example-dimensions # Or run any of them directly go run ./examples/pagination/main.go ``` ================================================ FILE: examples/dimensions/README.md ================================================ # Dimensions Shows some simple tables with various dimensions.
================================================
FILE: examples/dimensions/main.go
================================================
package main
import (
"fmt"
"log"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
type Model struct {
table3x3 table.Model
table1x3 table.Model
table3x1 table.Model
table1x1 table.Model
table5x5 table.Model
}
func genTable(columnCount int, rowCount int) table.Model {
columns := []table.Column{}
for column := 0; column < columnCount; column++ {
columnStr := fmt.Sprintf("%d", column+1)
columns = append(columns, table.NewColumn(columnStr, columnStr, 4))
}
rows := []table.Row{}
for row := 1; row < rowCount; row++ {
rowData := table.RowData{}
for column := 0; column < columnCount; column++ {
columnStr := fmt.Sprintf("%d", column+1)
rowData[columnStr] = fmt.Sprintf("%d,%d", column+1, row+1)
}
rows = append(rows, table.NewRow(rowData))
}
return table.New(columns).WithRows(rows).HeaderStyle(lipgloss.NewStyle().Bold(true))
}
func NewModel() Model {
return Model{
table1x1: genTable(1, 1),
table3x1: genTable(3, 1),
table1x3: genTable(1, 3),
table3x3: genTable(3, 3),
table5x5: genTable(5, 5),
}
}
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.table1x1, cmd = m.table1x1.Update(msg)
cmds = append(cmds, cmd)
m.table3x1, cmd = m.table3x1.Update(msg)
cmds = append(cmds, cmd)
m.table1x3, cmd = m.table1x3.Update(msg)
cmds = append(cmds, cmd)
m.table3x3, cmd = m.table3x3.Update(msg)
cmds = append(cmds, cmd)
m.table5x5, cmd = m.table5x5.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("Table demo with various sized tables!\nPress q or ctrl+c to quit\n")
pad := lipgloss.NewStyle().Padding(1)
tablesSmall := lipgloss.JoinHorizontal(
lipgloss.Top,
pad.Render(m.table1x1.View()),
pad.Render(m.table1x3.View()),
pad.Render(m.table3x1.View()),
pad.Render(m.table3x3.View()),
)
tableBig := pad.Render(m.table5x5.View())
body.WriteString(lipgloss.JoinVertical(lipgloss.Center, tablesSmall, tableBig))
return body.String()
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/events/README.md
================================================
# Events example
This example shows how to use events to handle triggers for data retrieval or
other desired behavior. This example in particular shows how to use events to
trigger data retrieval.
================================================
FILE: examples/events/main.go
================================================
package main
import (
"fmt"
"log"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
type Element string
const (
columnKeyName = "name"
columnKeyElement = "element"
// 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",
}
)
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))),
// 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
currentPokemonData Pokemon
lastSelectedEvent table.UserEventRowSelectToggled
}
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),
}).WithRows(rows).
BorderRounded().
WithBaseStyle(styleBase).
WithPageSize(4).
Focused(true).
SelectableRows(true),
currentPokemonData: pokemon[0],
}
}
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)
for _, e := range m.pokeTable.GetLastUpdateUserEvents() {
switch e := e.(type) {
case table.UserEventHighlightedIndexChanged:
// We can pretend this is an async data retrieval, but really we already
// have the data, so just return it after some fake delay. Also note
// that the event has some data attached to it, but we're ignoring
// that for this example as we just want the current highlighted row.
selectedPokemon := m.pokeTable.HighlightedRow().Data[columnKeyPokemonData].(Pokemon)
cmds = append(cmds, func() tea.Msg {
time.Sleep(time.Millisecond * 200)
return selectedPokemon
})
case table.UserEventRowSelectToggled:
m.lastSelectedEvent = e
}
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
cmds = append(cmds, tea.Quit)
}
case Pokemon:
m.currentPokemonData = msg
}
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
view := lipgloss.JoinVertical(
lipgloss.Left,
styleSubtle.Render("Press q or ctrl+c to quit"),
fmt.Sprintf("Highlighted (200 ms delay): %s (%s)", m.currentPokemonData.Name, m.currentPokemonData.Element),
fmt.Sprintf("Last selected event: %d (%v)", m.lastSelectedEvent.RowIndex, m.lastSelectedEvent.IsSelected),
lipgloss.NewStyle().Foreground(lipgloss.Color("#8c8")).
Render(":D %"+fmt.Sprintf("%.1f", m.currentPokemonData.PositiveSentimentPercent)),
lipgloss.NewStyle().Foreground(lipgloss.Color("#c88")).
Render(":( %"+fmt.Sprintf("%.1f", m.currentPokemonData.NegativeSentimentPercent)),
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)
}
}
================================================
FILE: examples/features/README.md
================================================
# Full feature example
This table contains most of the implemented features, but as more features have
been added some have not made it into this demo for practical purposes. This
can be a useful reference for various features even if it's not a very
appealing final product.
================================================
FILE: examples/features/main.go
================================================
// This file contains a full demo of most available features, for both testing
// and for reference
package main
import (
"fmt"
"log"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyID = "id"
columnKeyName = "name"
columnKeyDescription = "description"
columnKeyCount = "count"
)
var (
customBorder = table.Border{
Top: "─",
Left: "│",
Right: "│",
Bottom: "─",
TopRight: "╮",
TopLeft: "╭",
BottomRight: "╯",
BottomLeft: "╰",
TopJunction: "╥",
LeftJunction: "├",
RightJunction: "┤",
BottomJunction: "╨",
InnerJunction: "╫",
InnerDivider: "║",
}
)
type Model struct {
tableModel table.Model
}
func NewModel() Model {
columns := []table.Column{
table.NewColumn(columnKeyID, "ID", 5).WithStyle(
lipgloss.NewStyle().
Faint(true).
Foreground(lipgloss.Color("#88f")).
Align(lipgloss.Center)),
table.NewColumn(columnKeyName, "Name", 10),
table.NewColumn(columnKeyDescription, "Description", 30),
table.NewColumn(columnKeyCount, "#", 5),
}
rows := []table.Row{
table.NewRow(table.RowData{
columnKeyID: "abc",
// Missing name
columnKeyDescription: "The first table entry, ever",
columnKeyCount: 4,
}),
table.NewRow(table.RowData{
columnKeyID: "123",
columnKeyName: "Oh no",
columnKeyDescription: "Super bold!",
columnKeyCount: 17,
}).WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)),
table.NewRow(table.RowData{
columnKeyID: "def",
// Apply a style to this cell
columnKeyName: table.NewStyledCell("Styled", lipgloss.NewStyle().Foreground(lipgloss.Color("#8ff"))),
columnKeyDescription: "This is a really, really, really long description that will get cut off",
columnKeyCount: table.NewStyledCell(0, lipgloss.NewStyle().Faint(true)),
}),
table.NewRow(table.RowData{
columnKeyID: "spg",
columnKeyName: "Page 2",
columnKeyDescription: "Second page",
columnKeyCount: 2,
}),
table.NewRow(table.RowData{
columnKeyID: "spg2",
columnKeyName: "Page 2.1",
columnKeyDescription: "Second page again",
columnKeyCount: 4,
}),
}
// Start with the default key map and change it slightly, just for demoing
keys := table.DefaultKeyMap()
keys.RowDown.SetKeys("j", "down", "s")
keys.RowUp.SetKeys("k", "up", "w")
model := Model{
// Throw features in... the point is not to look good, it's just reference!
tableModel: table.New(columns).
WithRows(rows).
HeaderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)).
SelectableRows(true).
Focused(true).
Border(customBorder).
WithKeyMap(keys).
WithStaticFooter("Footer!").
WithPageSize(3).
WithSelectedText(" ", "✓").
WithBaseStyle(
lipgloss.NewStyle().
BorderForeground(lipgloss.Color("#a38")).
Foreground(lipgloss.Color("#a7a")).
Align(lipgloss.Left),
).
SortByAsc(columnKeyID).
WithMissingDataIndicatorStyled(table.StyledCell{
Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#faa")),
Data: "<ない>",
}),
}
model.updateFooter()
return model
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m *Model) updateFooter() {
highlightedRow := m.tableModel.HighlightedRow()
footerText := fmt.Sprintf(
"Pg. %d/%d - Currently looking at ID: %s",
m.tableModel.CurrentPage(),
m.tableModel.MaxPages(),
highlightedRow.Data[columnKeyID],
)
m.tableModel = m.tableModel.WithStaticFooter(footerText)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
m.tableModel, cmd = m.tableModel.Update(msg)
cmds = append(cmds, cmd)
// We control the footer text, so make sure to update it
m.updateFooter()
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
cmds = append(cmds, tea.Quit)
case "i":
m.tableModel = m.tableModel.WithHeaderVisibility(!m.tableModel.GetHeaderVisibility())
}
}
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
body := strings.Builder{}
body.WriteString("A (chaotic) table demo with all features enabled!\n")
body.WriteString("Press left/right or page up/down to move pages\n")
body.WriteString("Press 'i' to toggle the header visibility\n")
body.WriteString("Press space/enter to select a row, q or ctrl+c to quit\n")
selectedIDs := []string{}
for _, row := range m.tableModel.SelectedRows() {
// Slightly dangerous type assumption but fine for demo
selectedIDs = append(selectedIDs, row.Data[columnKeyID].(string))
}
body.WriteString(fmt.Sprintf("SelectedIDs: %s\n", strings.Join(selectedIDs, ", ")))
body.WriteString(m.tableModel.View())
body.WriteString("\n")
return body.String()
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/filter/README.md
================================================
# Filter example
Shows how the table can use a built-in filter to filter results.
================================================
FILE: examples/filter/main.go
================================================
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).
SelectableRows(true).
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", "q":
if !m.table.GetIsFilterInputFocused() {
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 / + letters to start filtering, and escape to clear 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)
}
}
================================================
FILE: examples/filterapi/README.md
================================================
# Filter API example
Similar to the [regular filter example](../filter/), but uses an external text
box control for filtering. This demonstrates how you can use your own text
controls to perform filtering for more flexible UIs.
================================================
FILE: examples/filterapi/main.go
================================================
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, _ = 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.\n" +
"Press 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)
}
}
================================================
FILE: examples/flex/README.md
================================================
# Flex example
This example shows how to use flexible-width columns. The example stretches to
fill the full width of the terminal whenever it's resized.
================================================
FILE: examples/flex/main.go
================================================
package main
import (
"fmt"
"log"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyName = "name"
columnKeyElement = "element"
columnKeyDescription = "description"
minWidth = 30
minHeight = 8
// Add a fixed margin to account for description & instructions
fixedVerticalMargin = 4
)
type Model struct {
flexTable table.Model
// Window dimensions
totalWidth int
totalHeight int
// Table dimensions
horizontalMargin int
verticalMargin int
}
func NewModel() Model {
return Model{
flexTable: table.New([]table.Column{
table.NewColumn(columnKeyName, "Name", 10),
// This table uses flex columns, but it will still need a target
// width in order to know what width it should fill. In this example
// the target width is set below in `recalculateTable`, which sets
// the table to the width of the screen to demonstrate resizing
// with flex columns.
table.NewFlexColumn(columnKeyElement, "Element", 1),
table.NewFlexColumn(columnKeyDescription, "Description", 3),
}).WithRows([]table.Row{
table.NewRow(table.RowData{
columnKeyName: "Pikachu",
columnKeyElement: "Electric",
columnKeyDescription: "Super zappy mouse, handle with care",
}),
table.NewRow(table.RowData{
columnKeyName: "Charmander",
columnKeyElement: "Fire",
columnKeyDescription: "直立した恐竜のような身体と、尻尾の先端に常に燃えている炎が特徴。",
}),
}).WithStaticFooter("A footer!"),
}
}
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.flexTable, cmd = m.flexTable.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)
case "left":
if m.calculateWidth() > minWidth {
m.horizontalMargin++
m.recalculateTable()
}
case "right":
if m.horizontalMargin > 0 {
m.horizontalMargin--
m.recalculateTable()
}
case "up":
if m.calculateHeight() > minHeight {
m.verticalMargin++
m.recalculateTable()
}
case "down":
if m.verticalMargin > 0 {
m.verticalMargin--
m.recalculateTable()
}
}
case tea.WindowSizeMsg:
m.totalWidth = msg.Width
m.totalHeight = msg.Height
m.recalculateTable()
}
return m, tea.Batch(cmds...)
}
func (m *Model) recalculateTable() {
m.flexTable = m.flexTable.
WithTargetWidth(m.calculateWidth()).
WithMinimumHeight(m.calculateHeight())
}
func (m Model) calculateWidth() int {
return m.totalWidth - m.horizontalMargin
}
func (m Model) calculateHeight() int {
return m.totalHeight - m.verticalMargin - fixedVerticalMargin
}
func (m Model) View() string {
strs := []string{
"A flexible table that fills available space (Name column is fixed-width)",
fmt.Sprintf("Target size: %d W ⨉ %d H (arrow keys to adjust)",
m.calculateWidth(), m.calculateHeight()),
"Press q or ctrl+c to quit",
m.flexTable.View(),
}
return lipgloss.JoinVertical(lipgloss.Left, strs...) + "\n"
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/metadata/README.md
================================================
# Metadata example
This is the [pokemon example](../pokemon) with metadata attached to the rows in
order to retrieve data, instead of retrieving the data directly from the row.
This can be a useful technique to make more natural data transformations.

================================================
FILE: examples/metadata/main.go
================================================
// 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",
}
)
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).
BorderRounded().
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)
}
}
================================================
FILE: examples/multiline/README.md
================================================
# Multiline Example
This example code showcases the implementation of a multiline feature. The feature enables users to input and display content spanning multiple lines within the row. The provided code allows you to integrate the multiline feature seamlessly into your project. Feel free to experiment and adapt the code based on your specific requirements.
================================================
FILE: examples/pagination/main.go
================================================
package main
import (
"fmt"
"log"
"math/rand"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
type Model struct {
tableDefault table.Model
tableWithRowIndices table.Model
rowCount int
}
func genRows(columnCount int, rowCount int) []table.Row {
rows := []table.Row{}
for row := 1; row <= rowCount; row++ {
rowData := table.RowData{}
for column := 0; column < columnCount; column++ {
columnStr := fmt.Sprintf("%d", column+1)
rowData[columnStr] = fmt.Sprintf("%d - %d", column+1, row)
}
rows = append(rows, table.NewRow(rowData))
}
return rows
}
func genTable(columnCount int, rowCount int) table.Model {
columns := []table.Column{}
for column := 0; column < columnCount; column++ {
columnStr := fmt.Sprintf("%d", column+1)
columns = append(columns, table.NewColumn(columnStr, columnStr, 8))
}
rows := genRows(columnCount, rowCount)
return table.New(columns).WithRows(rows).HeaderStyle(lipgloss.NewStyle().Bold(true))
}
func NewModel() Model {
const startingRowCount = 105
m := Model{
rowCount: startingRowCount,
tableDefault: genTable(3, startingRowCount).WithPageSize(10).Focused(true),
tableWithRowIndices: genTable(3, startingRowCount).WithPageSize(10).Focused(false),
}
m.regenTableRows()
return m
}
func (m *Model) regenTableRows() {
m.tableDefault = m.tableDefault.WithRows(genRows(3, m.rowCount))
m.tableWithRowIndices = m.tableWithRowIndices.WithRows(genRows(3, m.rowCount))
}
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:
switch msg.String() {
case "ctrl+c", "esc", "q":
cmds = append(cmds, tea.Quit)
case "a":
m.tableDefault = m.tableDefault.Focused(true)
m.tableWithRowIndices = m.tableWithRowIndices.Focused(false)
case "b":
m.tableDefault = m.tableDefault.Focused(false)
m.tableWithRowIndices = m.tableWithRowIndices.Focused(true)
case "u":
m.tableDefault = m.tableDefault.WithPageSize(m.tableDefault.PageSize() - 1)
m.tableWithRowIndices = m.tableWithRowIndices.WithPageSize(m.tableWithRowIndices.PageSize() - 1)
case "i":
m.tableDefault = m.tableDefault.WithPageSize(m.tableDefault.PageSize() + 1)
m.tableWithRowIndices = m.tableWithRowIndices.WithPageSize(m.tableWithRowIndices.PageSize() + 1)
case "r":
m.tableDefault = m.tableDefault.WithCurrentPage(rand.Intn(m.tableDefault.MaxPages()) + 1)
m.tableWithRowIndices = m.tableWithRowIndices.WithCurrentPage(rand.Intn(m.tableWithRowIndices.MaxPages()) + 1)
case "z":
if m.rowCount < 10 {
break
}
m.rowCount -= 10
m.regenTableRows()
case "x":
m.rowCount += 10
m.regenTableRows()
}
}
m.tableDefault, cmd = m.tableDefault.Update(msg)
cmds = append(cmds, cmd)
m.tableWithRowIndices, cmd = m.tableWithRowIndices.Update(msg)
cmds = append(cmds, cmd)
// Write a custom footer
start, end := m.tableWithRowIndices.VisibleIndices()
m.tableWithRowIndices = m.tableWithRowIndices.WithStaticFooter(
fmt.Sprintf("%d-%d of %d", start+1, end+1, m.tableWithRowIndices.TotalRows()),
)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
body := strings.Builder{}
body.WriteString("Table demo with pagination! Press left/right to move pages, or use page up/down, or 'r' to jump to a random page\nPress 'a' for left table, 'b' for right table\nPress 'z' to reduce rows by 10, 'y' to increase rows by 10\nPress 'u' to decrease page size by 1, 'i' to increase page size by 1\nPress q or ctrl+c to quit\n\n")
pad := lipgloss.NewStyle().Padding(1)
tables := []string{
lipgloss.JoinVertical(lipgloss.Center, "A", pad.Render(m.tableDefault.View())),
lipgloss.JoinVertical(lipgloss.Center, "B", pad.Render(m.tableWithRowIndices.View())),
}
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, tables...))
return body.String()
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/pokemon/README.md
================================================
# Pokemon example
This example is a general use of the table using various features to nicely
display a table of Pokemon and some interesting statistics about them.

================================================
FILE: examples/pokemon/main.go
================================================
package main
import (
"log"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyName = "name"
columnKeyElement = "element"
columnKeyConversations = "convos"
columnKeyPositiveSentiment = "positive"
columnKeyNegativeSentiment = "negative"
colorNormal = "#fa0"
colorElectric = "#ff0"
colorFire = "#f64"
colorPlant = "#8b8"
colorWater = "#44f"
)
var (
styleSubtle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888"))
styleBase = lipgloss.NewStyle().
Foreground(lipgloss.Color("#a7a")).
BorderForeground(lipgloss.Color("#a38")).
Align(lipgloss.Right)
)
type Model struct {
pokeTable table.Model
favoriteElementIndex int
}
var elementList = []string{
"Normal",
"Electric",
"Fire",
"Plant",
"Water",
}
var colorMap = map[any]string{
"Electric": colorElectric,
"Fire": colorFire,
"Plant": colorPlant,
"Water": colorWater,
}
func makeRow(name, element string, numConversations int, positiveSentiment, negativeSentiment float32) table.Row {
elementStyleFunc := func(input table.StyledCellFuncInput) lipgloss.Style {
color := colorNormal
if val, ok := colorMap[input.Data]; ok {
color = val
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color(color))
if input.GlobalMetadata["favoriteElement"] == input.Data {
style = style.Italic(true)
}
return style
}
return table.NewRow(table.RowData{
columnKeyName: name,
columnKeyElement: table.NewStyledCellWithStyleFunc(element, elementStyleFunc),
columnKeyConversations: numConversations,
columnKeyPositiveSentiment: positiveSentiment,
columnKeyNegativeSentiment: negativeSentiment,
})
}
func genMetadata(favoriteElementIndex int) map[string]any {
return map[string]any{
"favoriteElement": elementList[favoriteElementIndex],
}
}
func NewModel() Model {
initialFavoriteElementIndex := 0
return Model{
favoriteElementIndex: initialFavoriteElementIndex,
pokeTable: table.New([]table.Column{
table.NewColumn(columnKeyName, "Name", 13),
table.NewColumn(columnKeyElement, "Element", 10),
table.NewColumn(columnKeyConversations, "# Conversations", 15),
table.NewColumn(columnKeyPositiveSentiment, ":D %", 6).
WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#8c8"))).
WithFormatString("%.1f%%"),
table.NewColumn(columnKeyNegativeSentiment, ":( %", 6).
WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#c88"))).
WithFormatString("%.1f%%"),
}).WithRows([]table.Row{
makeRow("Pikachu", "Electric", 2300648, 21.9, 8.54),
makeRow("Eevee", "Normal", 636373, 26.4, 7.37),
makeRow("Bulbasaur", "Plant", 352190, 25.7, 9.02),
makeRow("Squirtle", "Water", 241259, 25.6, 5.96),
makeRow("Blastoise", "Water", 162794, 19.5, 6.04),
makeRow("Charmander", "Fire", 265760, 31.2, 5.25),
makeRow("Charizard", "Fire", 567763, 25.6, 7.56),
}).
BorderRounded().
WithBaseStyle(styleBase).
WithPageSize(6).
SortByDesc(columnKeyConversations).
Focused(true).
WithGlobalMetadata(genMetadata(initialFavoriteElementIndex)),
}
}
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)
case "e":
m.favoriteElementIndex++
if m.favoriteElementIndex >= len(elementList) {
m.favoriteElementIndex = 0
}
m.pokeTable = m.pokeTable.WithGlobalMetadata(genMetadata(m.favoriteElementIndex))
}
}
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
selected := m.pokeTable.HighlightedRow().Data[columnKeyName].(string)
view := lipgloss.JoinVertical(
lipgloss.Left,
styleSubtle.Render("Press q or ctrl+c to quit - Sorted by # Conversations"),
styleSubtle.Render("Highlighted: "+selected),
styleSubtle.Render("Favorite element: "+elementList[m.favoriteElementIndex]),
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)
}
}
================================================
FILE: examples/scrolling/README.md
================================================
# Scrolling example
This example shows how to use scrolling to navigate particularly large tables
that may not fit nicely onto the screen.
================================================
FILE: examples/scrolling/main.go
================================================
package main
import (
"fmt"
"log"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyID = "id"
numCols = 100
numRows = 10
idWidth = 5
colWidth = 3
maxWidth = 30
)
type Model struct {
scrollableTable table.Model
}
func colKey(colNum int) string {
return fmt.Sprintf("%d", colNum)
}
func genRow(id int) table.Row {
data := table.RowData{
columnKeyID: fmt.Sprintf("ID %d", id),
}
for i := 0; i < numCols; i++ {
data[colKey(i)] = colWidth
}
return table.NewRow(data)
}
func NewModel() Model {
rows := []table.Row{}
for i := 0; i < numRows; i++ {
rows = append(rows, genRow(i))
}
cols := []table.Column{
table.NewColumn(columnKeyID, "ID", idWidth),
}
for i := 0; i < numCols; i++ {
cols = append(cols, table.NewColumn(colKey(i), colKey(i+1), colWidth))
}
t := table.New(cols).
WithRows(rows).
WithMaxTotalWidth(maxWidth).
WithHorizontalFreezeColumnCount(1).
WithStaticFooter("A footer").
Focused(true)
return Model{
scrollableTable: t,
}
}
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.scrollableTable, cmd = m.scrollableTable.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 scrollable table\nPress shift+left or shift+right to scroll\nPress q or ctrl+c to quit\n\n")
body.WriteString(m.scrollableTable.View())
return body.String()
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/simplest/README.md
================================================
# Simplest example
This is a bare bones example of how to get started with the table component. It
uses all the defaults to simply present data, and is a good starting point for
other use cases.
================================================
FILE: examples/simplest/main.go
================================================
package main
import (
"log"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyName = "name"
columnKeyElement = "element"
)
type Model struct {
simpleTable table.Model
}
func NewModel() Model {
return Model{
simpleTable: table.New([]table.Column{
table.NewColumn(columnKeyName, "Name", 13),
table.NewColumn(columnKeyElement, "Element", 10),
}).WithRows([]table.Row{
table.NewRow(table.RowData{
columnKeyName: "Pikachu",
columnKeyElement: "Electric",
}),
table.NewRow(table.RowData{
columnKeyName: "Charmander",
columnKeyElement: "Fire",
}),
}),
}
}
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.simpleTable, cmd = m.simpleTable.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 very simple default table (non-interactive)\nPress q or ctrl+c to quit\n\n")
body.WriteString(m.simpleTable.View())
return body.String()
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/sorting/README.md
================================================
# Sorting example
This example shows how to use the sorting feature, which sorts columns. It
demonstrates how numbers are numerically sorted while strings are alphabetically
sorted. It also demonstrates multi-column sorting to group different kinds of
data together and sort within them.
================================================
FILE: examples/sorting/main.go
================================================
package main
import (
"log"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyName = "name"
columnKeyType = "type"
columnKeyWins = "wins"
)
type Model struct {
simpleTable table.Model
columnSortKey string
sortDirection string
}
func NewModel() Model {
return Model{
simpleTable: table.New([]table.Column{
table.NewColumn(columnKeyName, "Name", 13),
table.NewColumn(columnKeyType, "Type", 13),
table.NewColumn(columnKeyWins, "Win %", 8).
WithFormatString("%.1f%%"),
}).WithRows([]table.Row{
table.NewRow(table.RowData{
columnKeyName: "ピカピカ",
columnKeyType: "Pikachu",
columnKeyWins: 78.3,
}),
table.NewRow(table.RowData{
columnKeyName: "Zapmouse",
columnKeyType: "Pikachu",
columnKeyWins: 3.3,
}),
table.NewRow(table.RowData{
columnKeyName: "Burninator",
columnKeyType: "Charmander",
columnKeyWins: 32.1,
}),
table.NewRow(table.RowData{
columnKeyName: "Alphonse",
columnKeyType: "Pikachu",
columnKeyWins: 13.8,
}),
table.NewRow(table.RowData{
columnKeyName: "Trogdor",
columnKeyType: "Charmander",
columnKeyWins: 99.9,
}),
table.NewRow(table.RowData{
columnKeyName: "Dihydrogen Monoxide",
columnKeyType: "Squirtle",
columnKeyWins: 31.348,
}),
}),
}
}
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.simpleTable, cmd = m.simpleTable.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)
case "n":
m.columnSortKey = columnKeyName
m.simpleTable = m.simpleTable.SortByAsc(m.columnSortKey)
case "t":
m.columnSortKey = columnKeyType
// Within the same type, order each by wins
m.simpleTable = m.simpleTable.SortByAsc(m.columnSortKey).ThenSortByDesc(columnKeyWins)
case "w":
m.columnSortKey = columnKeyWins
m.simpleTable = m.simpleTable.SortByDesc(m.columnSortKey)
}
}
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
body := strings.Builder{}
body.WriteString("A sorted simple default table\nSort by (n)ame, (t)ype->wins combo, or (w)ins\nCurrently sorting by: " + m.columnSortKey + "\nPress q or ctrl+c to quit\n\n")
body.WriteString(m.simpleTable.View())
return body.String()
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: examples/updates/README.md
================================================
# Update example
Shows how to update data in the table from an external API returning data.

================================================
FILE: examples/updates/data.go
================================================
package main
import "math/rand"
// SomeData represent some real data of some sort, unaware of tables
type SomeData struct {
ID string
Score int
Status string
}
// NewSomeData creates SomeData that has an ID and randomized values
func NewSomeData(id string) *SomeData {
s := &SomeData{
ID: id,
}
// Start with some random data
s.RandomizeScoreAndStatus()
return s
}
// RandomizeScoreAndStatus does an in-place update to simulate some data being
// updated by some other process
func (s *SomeData) RandomizeScoreAndStatus() {
s.Score = rand.Intn(100) + 1
if s.Score < 30 {
s.Status = "Critical"
} else if s.Score < 80 {
s.Status = "Stable"
} else {
s.Status = "Good"
}
}
================================================
FILE: examples/updates/main.go
================================================
package main
import (
"fmt"
"log"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
)
const (
columnKeyID = "id"
columnKeyScore = "score"
columnKeyStatus = "status"
)
var (
styleCritical = lipgloss.NewStyle().Foreground(lipgloss.Color("#f00"))
styleStable = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0"))
styleGood = lipgloss.NewStyle().Foreground(lipgloss.Color("#0f0"))
)
type Model struct {
table table.Model
updateDelay time.Duration
data []*SomeData
}
func rowStyleFunc(input table.RowStyleFuncInput) lipgloss.Style {
calculatedStyle := lipgloss.NewStyle()
switch input.Row.Data[columnKeyStatus] {
case "Critical":
calculatedStyle = styleCritical.Copy()
case "Stable":
calculatedStyle = styleStable.Copy()
case "Good":
calculatedStyle = styleGood.Copy()
}
if input.Index%2 == 0 {
calculatedStyle = calculatedStyle.Background(lipgloss.Color("#222"))
} else {
calculatedStyle = calculatedStyle.Background(lipgloss.Color("#444"))
}
return calculatedStyle
}
func NewModel() Model {
return Model{
table: table.New(generateColumns(0)).WithRowStyleFunc(rowStyleFunc),
updateDelay: time.Second,
}
}
// This data is stored somewhere else, maybe on a client or some other thing
func refreshDataCmd() tea.Msg {
// This could come from some API or something
return []*SomeData{
NewSomeData("abc"),
NewSomeData("def"),
NewSomeData("123"),
NewSomeData("ok"),
NewSomeData("another"),
NewSomeData("yay"),
NewSomeData("more"),
}
}
// Generate columns based on how many are critical to show some summary
func generateColumns(numCritical int) []table.Column {
// Show how many critical there are
statusStr := fmt.Sprintf("Score (%d)", numCritical)
statusCol := table.NewColumn(columnKeyStatus, statusStr, 10)
if numCritical > 3 {
// This normally applies the critical style to everything in the column,
// but in this case we apply a row style which overrides it anyway.
statusCol = statusCol.WithStyle(styleCritical)
}
return []table.Column{
table.NewColumn(columnKeyID, "ID", 10),
table.NewColumn(columnKeyScore, "Score", 8),
statusCol,
}
}
func (m Model) Init() tea.Cmd {
return refreshDataCmd
}
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)
case "up":
if m.updateDelay < time.Second {
m.updateDelay *= 10
}
case "down":
if m.updateDelay > time.Millisecond*1 {
m.updateDelay /= 10
}
}
case []*SomeData:
m.data = msg
numCritical := 0
for _, d := range msg {
if d.Status == "Critical" {
numCritical++
}
}
// Reapply the new data and the new columns based on critical count
m.table = m.table.WithRows(generateRowsFromData(m.data)).WithColumns(generateColumns(numCritical))
// This can be from any source, but for demo purposes let's party!
delay := m.updateDelay
cmds = append(cmds, func() tea.Msg {
time.Sleep(delay)
return refreshDataCmd()
})
}
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
body := strings.Builder{}
body.WriteString(
fmt.Sprintf(
"Table demo with updating data! Updating every %v\nPress up/down to update faster/slower\nPress q or ctrl+c to quit\n",
m.updateDelay,
))
pad := lipgloss.NewStyle().Padding(1)
body.WriteString(pad.Render(m.table.View()))
return body.String()
}
func generateRowsFromData(data []*SomeData) []table.Row {
rows := []table.Row{}
for _, entry := range data {
row := table.NewRow(table.RowData{
columnKeyID: entry.ID,
columnKeyScore: entry.Score,
columnKeyStatus: entry.Status,
})
rows = append(rows, row)
}
return rows
}
func main() {
p := tea.NewProgram(NewModel())
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: flake.nix
================================================
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-23.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
# Dev tools
go
];
};
});
}
================================================
FILE: go.mod
================================================
module github.com/evertras/bubble-table
go 1.18
require (
github.com/charmbracelet/bubbles v0.11.0
github.com/charmbracelet/bubbletea v0.21.0
github.com/charmbracelet/lipgloss v0.5.0
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/stretchr/testify v1.7.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.0 // indirect
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)
================================================
FILE: go.sum
================================================
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.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: table/benchmarks_test.go
================================================
package table
import (
"fmt"
"testing"
)
var benchView string
func benchTable(numColumns, numDataRows int) Model {
columns := []Column{}
for i := 0; i < numColumns; i++ {
iStr := fmt.Sprintf("%d", i)
columns = append(columns, NewColumn(iStr, iStr, 6))
}
rows := []Row{}
for i := 0; i < numDataRows; i++ {
rowData := RowData{}
for columnIndex, column := range columns {
rowData[column.key] = fmt.Sprintf("%d", columnIndex)
}
rows = append(rows, NewRow(rowData))
}
return New(columns).WithRows(rows)
}
func BenchmarkPlain3x3TableView(b *testing.B) {
makeRow := func(id, name string, score int) Row {
return NewRow(RowData{
"id": id,
"name": name,
"score": score,
})
}
model := New([]Column{
NewColumn("id", "ID", 3),
NewColumn("name", "Name", 8),
NewColumn("score", "Score", 6),
}).WithRows([]Row{
makeRow("abc", "First", 17),
makeRow("def", "Second", 1034),
makeRow("123", "Third", 841),
})
b.ResetTimer()
for n := 0; n < b.N; n++ {
benchView = model.View()
}
}
func BenchmarkPlainTableViews(b *testing.B) {
sizes := []struct {
numColumns int
numRows int
}{
{
numColumns: 1,
numRows: 0,
},
{
numColumns: 10,
numRows: 0,
},
{
numColumns: 1,
numRows: 4,
},
{
numColumns: 1,
numRows: 19,
},
{
numColumns: 9,
numRows: 19,
},
{
numColumns: 9,
numRows: 49,
},
}
for _, size := range sizes {
b.Run(fmt.Sprintf("%dx%d", size.numColumns, size.numRows+1), func(b *testing.B) {
model := benchTable(size.numColumns, size.numRows)
b.ResetTimer()
for n := 0; n < b.N; n++ {
benchView = model.View()
}
})
}
}
================================================
FILE: table/border.go
================================================
package table
import "github.com/charmbracelet/lipgloss"
// Border defines the borders in and around the table.
type Border struct {
Top string
Left string
Right string
Bottom string
TopRight string
TopLeft string
BottomRight string
BottomLeft string
TopJunction string
LeftJunction string
RightJunction string
BottomJunction string
InnerJunction string
InnerDivider string
// Styles for 2x2 tables and larger
styleMultiTopLeft lipgloss.Style
styleMultiTop lipgloss.Style
styleMultiTopRight lipgloss.Style
styleMultiRight lipgloss.Style
styleMultiBottomRight lipgloss.Style
styleMultiBottom lipgloss.Style
styleMultiBottomLeft lipgloss.Style
styleMultiLeft lipgloss.Style
styleMultiInner lipgloss.Style
// Styles for a single column table
styleSingleColumnTop lipgloss.Style
styleSingleColumnInner lipgloss.Style
styleSingleColumnBottom lipgloss.Style
// Styles for a single row table
styleSingleRowLeft lipgloss.Style
styleSingleRowInner lipgloss.Style
styleSingleRowRight lipgloss.Style
// Style for a table with only one cell
styleSingleCell lipgloss.Style
// Style for the footer
styleFooter lipgloss.Style
}
var (
// https://www.w3.org/TR/xml-entity-names/025.html
borderDefault = Border{
Top: "━",
Left: "┃",
Right: "┃",
Bottom: "━",
TopRight: "┓",
TopLeft: "┏",
BottomRight: "┛",
BottomLeft: "┗",
TopJunction: "┳",
LeftJunction: "┣",
RightJunction: "┫",
BottomJunction: "┻",
InnerJunction: "╋",
InnerDivider: "┃",
}
borderRounded = Border{
Top: "─",
Left: "│",
Right: "│",
Bottom: "─",
TopRight: "╮",
TopLeft: "╭",
BottomRight: "╯",
BottomLeft: "╰",
TopJunction: "┬",
LeftJunction: "├",
RightJunction: "┤",
BottomJunction: "┴",
InnerJunction: "┼",
InnerDivider: "│",
}
)
func init() {
borderDefault.generateStyles()
borderRounded.generateStyles()
}
func (b *Border) generateStyles() {
b.generateMultiStyles()
b.generateSingleColumnStyles()
b.generateSingleRowStyles()
b.generateSingleCellStyle()
// The footer is a single cell with the top taken off... usually. We can
// re-enable the top if needed this way for certain format configurations.
b.styleFooter = b.styleSingleCell.Copy().
Align(lipgloss.Right).
BorderBottom(true).
BorderRight(true).
BorderLeft(true)
}
func (b *Border) styleLeftWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomLeft = b.LeftJunction
return original.Copy().BorderStyle(border)
}
func (b *Border) styleRightWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomRight = b.RightJunction
return original.Copy().BorderStyle(border)
}
func (b *Border) styleBothWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomLeft = b.LeftJunction
border.BottomRight = b.RightJunction
return original.Copy().BorderStyle(border)
}
// This function is long, but it's just repetitive...
//
//nolint:funlen
func (b *Border) generateMultiStyles() {
b.styleMultiTopLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
TopLeft: b.TopLeft,
Top: b.Top,
TopRight: b.TopJunction,
Right: b.InnerDivider,
BottomRight: b.InnerJunction,
Bottom: b.Bottom,
BottomLeft: b.LeftJunction,
Left: b.Left,
},
)
b.styleMultiTop = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.InnerDivider,
Bottom: b.Bottom,
TopRight: b.TopJunction,
BottomRight: b.InnerJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleMultiTopRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.Right,
Bottom: b.Bottom,
TopRight: b.TopRight,
BottomRight: b.RightJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleMultiLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.InnerDivider,
},
).BorderRight(true).BorderLeft(true)
b.styleMultiRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.Right,
},
).BorderRight(true)
b.styleMultiInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.InnerDivider,
},
).BorderRight(true)
b.styleMultiBottomLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomJunction,
},
).BorderLeft(true).BorderBottom(true).BorderRight(true)
b.styleMultiBottom = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomRight: b.BottomJunction,
},
).BorderBottom(true).BorderRight(true)
b.styleMultiBottomRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.Right,
Bottom: b.Bottom,
BottomRight: b.BottomRight,
},
).BorderBottom(true).BorderRight(true)
}
func (b *Border) generateSingleColumnStyles() {
b.styleSingleColumnTop = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
TopLeft: b.TopLeft,
TopRight: b.TopRight,
BottomLeft: b.LeftJunction,
BottomRight: b.RightJunction,
},
)
b.styleSingleColumnInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.Right,
},
).BorderRight(true).BorderLeft(true)
b.styleSingleColumnBottom = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomRight,
},
).BorderRight(true).BorderLeft(true).BorderBottom(true)
}
func (b *Border) generateSingleRowStyles() {
b.styleSingleRowLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomJunction,
TopRight: b.TopJunction,
TopLeft: b.TopLeft,
},
)
b.styleSingleRowInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomRight: b.BottomJunction,
TopRight: b.TopJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleSingleRowRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.Right,
Bottom: b.Bottom,
BottomRight: b.BottomRight,
TopRight: b.TopRight,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
}
func (b *Border) generateSingleCellStyle() {
b.styleSingleCell = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomRight,
TopRight: b.TopRight,
TopLeft: b.TopLeft,
},
)
}
// BorderDefault uses the basic square border, useful to reset the border if
// it was changed somehow.
func (m Model) BorderDefault() Model {
// Already generated styles
m.border = borderDefault
return m
}
// BorderRounded uses a thin, rounded border.
func (m Model) BorderRounded() Model {
// Already generated styles
m.border = borderRounded
return m
}
// Border uses the given border components to render the table.
func (m Model) Border(border Border) Model {
border.generateStyles()
m.border = border
return m
}
type borderStyleRow struct {
left lipgloss.Style
inner lipgloss.Style
right lipgloss.Style
}
func (b *borderStyleRow) inherit(s lipgloss.Style) {
b.left = b.left.Copy().Inherit(s)
b.inner = b.inner.Copy().Inherit(s)
b.right = b.right.Copy().Inherit(s)
}
// There's a lot of branches here, but splitting it up further would make it
// 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.GetVisibleRows()) > 0 || m.calculatePadding(0) > 0
singleColumn := len(m.columns) == 1
styles := borderStyleRow{}
// Possible configurations:
// - Single cell
// - Single row
// - Single column
// - Multi
if singleColumn {
if hasRows {
// Single column
styles.left = m.border.styleSingleColumnTop
styles.inner = styles.left
styles.right = styles.left
} else {
// Single cell
styles.left = m.border.styleSingleCell
styles.inner = styles.left
styles.right = styles.left
if m.hasFooter() {
styles.left = m.border.styleBothWithFooter(styles.left)
}
}
} else if !hasRows {
// Single row
styles.left = m.border.styleSingleRowLeft
styles.inner = m.border.styleSingleRowInner
styles.right = m.border.styleSingleRowRight
if m.hasFooter() {
styles.left = m.border.styleLeftWithFooter(styles.left)
styles.right = m.border.styleRightWithFooter(styles.right)
}
} else {
// Multi
styles.left = m.border.styleMultiTopLeft
styles.inner = m.border.styleMultiTop
styles.right = m.border.styleMultiTopRight
}
styles.inherit(m.headerStyle)
return styles
}
func (m Model) styleRows() (inner borderStyleRow, last borderStyleRow) {
if len(m.columns) == 1 {
inner.left = m.border.styleSingleColumnInner
inner.inner = inner.left
inner.right = inner.left
last.left = m.border.styleSingleColumnBottom
if m.hasFooter() {
last.left = m.border.styleBothWithFooter(last.left)
}
last.inner = last.left
last.right = last.left
} else {
inner.left = m.border.styleMultiLeft
inner.inner = m.border.styleMultiInner
inner.right = m.border.styleMultiRight
last.left = m.border.styleMultiBottomLeft
last.inner = m.border.styleMultiBottom
last.right = m.border.styleMultiBottomRight
if m.hasFooter() {
last.left = m.border.styleLeftWithFooter(last.left)
last.right = m.border.styleRightWithFooter(last.right)
}
}
return inner, last
}
================================================
FILE: table/calc.go
================================================
package table
// Keep compatibility with Go 1.21 by re-declaring min.
//
//nolint:predeclared
func min(x, y int) int {
if x < y {
return x
}
return y
}
// Keep compatibility with Go 1.21 by re-declaring max.
//
//nolint:predeclared
func max(x, y int) int {
if x > y {
return x
}
return y
}
// These var names are fine for this little function
//
//nolint:varnamelen
func gcd(x, y int) int {
if x == 0 {
return y
} else if y == 0 {
return x
}
return gcd(y%x, x)
}
================================================
FILE: table/calc_test.go
================================================
package table
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// A bit overkill but let's be thorough!
func TestMin(t *testing.T) {
tests := []struct {
x int
y int
expected int
}{
{
x: 3,
y: 4,
expected: 3,
},
{
x: 3,
y: 3,
expected: 3,
},
{
x: -4,
y: 3,
expected: -4,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%d and %d gives %d", test.x, test.y, test.expected), func(t *testing.T) {
result := min(test.x, test.y)
assert.Equal(t, test.expected, result)
})
}
}
func TestMax(t *testing.T) {
tests := []struct {
x int
y int
expected int
}{
{
x: 3,
y: 4,
expected: 4,
},
{
x: 3,
y: 3,
expected: 3,
},
{
x: -4,
y: 3,
expected: 3,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%d and %d gives %d", test.x, test.y, test.expected), func(t *testing.T) {
result := max(test.x, test.y)
assert.Equal(t, test.expected, result)
})
}
}
func TestGCD(t *testing.T) {
tests := []struct {
x int
y int
expected int
}{
{
x: 3,
y: 4,
expected: 1,
},
{
x: 3,
y: 6,
expected: 3,
},
{
x: 4,
y: 6,
expected: 2,
},
{
x: 0,
y: 6,
expected: 6,
},
{
x: 12,
y: 0,
expected: 12,
},
{
x: 1000,
y: 100000,
expected: 1000,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%d and %d has GCD %d", test.x, test.y, test.expected), func(t *testing.T) {
result := gcd(test.x, test.y)
assert.Equal(t, test.expected, result)
})
}
}
================================================
FILE: table/cell.go
================================================
package table
import "github.com/charmbracelet/lipgloss"
// StyledCell represents a cell in the table that has a particular style applied.
// The cell style takes highest precedence and will overwrite more general styles
// from the row, column, or table as a whole. This style should be generally
// limited to colors, font style, and alignments - spacing style such as margin
// will break the table format.
type StyledCell struct {
// Data is the content of the cell.
Data any
// Style is the specific style to apply. This is ignored if StyleFunc is not nil.
Style lipgloss.Style
// StyleFunc is a function that takes the row/column of the cell and
// returns a lipgloss.Style allowing for dynamic styling based on the cell's
// content or position. Overrides Style if set.
StyleFunc StyledCellFunc
}
// StyledCellFuncInput is the input to the StyledCellFunc. Sent as a struct
// to allow for future additions without breaking changes.
type StyledCellFuncInput struct {
// Data is the data in the cell.
Data any
// Column is the column that the cell belongs to.
Column Column
// Row is the row that the cell belongs to.
Row Row
// GlobalMetadata is the global table metadata that's been set by WithGlobalMetadata
GlobalMetadata map[string]any
}
// StyledCellFunc is a function that takes various information about the cell and
// returns a lipgloss.Style allowing for easier dynamic styling based on the cell's
// content or position.
type StyledCellFunc = func(input StyledCellFuncInput) lipgloss.Style
// NewStyledCell creates an entry that can be set in the row data and show as
// styled with the given style.
func NewStyledCell(data any, style lipgloss.Style) StyledCell {
return StyledCell{
Data: data,
Style: style,
}
}
// NewStyledCellWithStyleFunc creates an entry that can be set in the row data and show as
// styled with the given style function.
func NewStyledCellWithStyleFunc(data any, styleFunc StyledCellFunc) StyledCell {
return StyledCell{
Data: data,
StyleFunc: styleFunc,
}
}
================================================
FILE: table/column.go
================================================
package table
import (
"github.com/charmbracelet/lipgloss"
)
// Column is a column in the table.
type Column struct {
title string
key string
width int
flexFactor int
filterable bool
style lipgloss.Style
fmtString string
}
// NewColumn creates a new fixed-width column with the given information.
func NewColumn(key, title string, width int) Column {
return Column{
key: key,
title: title,
width: width,
filterable: false,
}
}
// NewFlexColumn creates a new flexible width column that tries to fill in the
// total table width. If multiple flex columns exist, each will measure against
// each other depending on their flexFactor. For example, if both have a flexFactor
// of 1, they will have equal width. If one has a flexFactor of 1 and the other
// has a flexFactor of 3, the second will be 3 times larger than the first. You
// must use WithTargetWidth if you have any flex columns, so that the table knows
// how much width it should fill.
func NewFlexColumn(key, title string, flexFactor int) Column {
return Column{
key: key,
title: title,
flexFactor: max(flexFactor, 1),
}
}
// WithStyle applies a style to the column as a whole.
func (c Column) WithStyle(style lipgloss.Style) Column {
c.style = style.Copy().Width(c.width)
return c
}
// WithFiltered sets whether the column should be considered for filtering (true)
// or not (false).
func (c Column) WithFiltered(filterable bool) Column {
c.filterable = filterable
return c
}
// WithFormatString sets the format string used by fmt.Sprintf to display the data.
// If not set, the default is "%v" for all data types. Intended mainly for
// numeric formatting.
//
// Since data is of the any type, make sure that all data in the column
// is of the expected type or the format may fail. For example, hardcoding '3'
// instead of '3.0' and using '%.2f' will fail because '3' is an integer.
func (c Column) WithFormatString(fmtString string) Column {
c.fmtString = fmtString
return c
}
func (c *Column) isFlex() bool {
return c.flexFactor != 0
}
// Title returns the title of the column.
func (c Column) Title() string {
return c.title
}
// Key returns the key of the column.
func (c Column) Key() string {
return c.key
}
// Width returns the width of the column.
func (c Column) Width() int {
return c.width
}
// FlexFactor returns the flex factor of the column.
func (c Column) FlexFactor() int {
return c.flexFactor
}
// IsFlex returns whether the column is a flex column.
func (c Column) IsFlex() bool {
return c.isFlex()
}
// Filterable returns whether the column is filterable.
func (c Column) Filterable() bool {
return c.filterable
}
// Style returns the style of the column.
func (c Column) Style() lipgloss.Style {
return c.style
}
// FmtString returns the format string of the column.
func (c Column) FmtString() string {
return c.fmtString
}
================================================
FILE: table/column_test.go
================================================
package table
import (
"fmt"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/assert"
)
func TestColumnTitle(t *testing.T) {
tests := []struct {
title string
expected string
}{
{
title: "foo",
expected: "foo",
},
{
title: "bar",
expected: "bar",
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("title %s gives %s", test.title, test.expected), func(t *testing.T) {
col := NewColumn("key", test.title, 10)
assert.Equal(t, test.expected, col.Title())
})
}
}
func TestColumnKey(t *testing.T) {
tests := []struct {
key string
expected string
}{
{
key: "foo",
expected: "foo",
},
{
key: "bar",
expected: "bar",
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("key %s gives %s", test.key, test.expected), func(t *testing.T) {
col := NewColumn(test.key, "title", 10)
assert.Equal(t, test.expected, col.Key())
})
}
}
func TestColumnWidth(t *testing.T) {
tests := []struct {
width int
expected int
}{
{
width: 3,
expected: 3,
},
{
width: 4,
expected: 4,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("width %d gives %d", test.width, test.expected), func(t *testing.T) {
col := NewColumn("key", "title", test.width)
assert.Equal(t, test.expected, col.Width())
})
}
}
func TestColumnFlexFactor(t *testing.T) {
tests := []struct {
flexFactor int
expected int
}{
{
flexFactor: 3,
expected: 3,
},
{
flexFactor: 4,
expected: 4,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("flexFactor %d gives %d", test.flexFactor, test.expected), func(t *testing.T) {
col := NewFlexColumn("key", "title", test.flexFactor)
assert.Equal(t, test.expected, col.FlexFactor())
})
}
}
func TestColumnIsFlex(t *testing.T) {
testsFlexColumn := []struct {
flexFactor int
expected bool
}{
{
flexFactor: 3,
expected: true,
},
{
flexFactor: 0,
expected: true,
},
}
for _, test := range testsFlexColumn {
t.Run(fmt.Sprintf("flexFactor %d gives %t", test.flexFactor, test.expected), func(t *testing.T) {
col := NewFlexColumn("key", "title", test.flexFactor)
assert.Equal(t, test.expected, col.IsFlex())
})
}
testsRegularColumn := []struct {
width int
expected bool
}{
{
width: 3,
expected: false,
},
{
width: 0,
expected: false,
},
}
for _, test := range testsRegularColumn {
t.Run(fmt.Sprintf("width %d gives %t", test.width, test.expected), func(t *testing.T) {
col := NewColumn("key", "title", test.width)
assert.Equal(t, test.expected, col.IsFlex())
})
}
}
func TestColumnFilterable(t *testing.T) {
tests := []struct {
filterable bool
expected bool
}{
{
filterable: true,
expected: true,
},
{
filterable: false,
expected: false,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("filterable %t gives %t", test.filterable, test.expected), func(t *testing.T) {
col := NewColumn("key", "title", 10)
col = col.WithFiltered(test.filterable)
assert.Equal(t, test.expected, col.Filterable())
})
}
}
func TestColumnStyle(t *testing.T) {
width := 10
tests := []struct {
style lipgloss.Style
expected lipgloss.Style
}{
{
style: lipgloss.NewStyle(),
expected: lipgloss.NewStyle().Width(width),
},
{
style: lipgloss.NewStyle().Bold(true),
expected: lipgloss.NewStyle().Bold(true).Width(width),
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("style %v gives %v", test.style, test.expected), func(t *testing.T) {
col := NewColumn("key", "title", width).WithStyle(test.style)
assert.Equal(t, test.expected, col.Style())
})
}
}
func TestColumnFormatString(t *testing.T) {
tests := []struct {
fmtString string
expected string
}{
{
fmtString: "%v",
expected: "%v",
},
{
fmtString: "%.2f",
expected: "%.2f",
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("fmtString %s gives %s", test.fmtString, test.expected), func(t *testing.T) {
col := NewColumn("key", "title", 10)
col = col.WithFormatString(test.fmtString)
assert.Equal(t, test.expected, col.FmtString())
})
}
}
================================================
FILE: table/data.go
================================================
package table
import "time"
// This is just a bunch of data type checks, so... no linting here
//
//nolint:cyclop
func asInt(data any) (int64, bool) {
switch val := data.(type) {
case int:
return int64(val), true
case int8:
return int64(val), true
case int16:
return int64(val), true
case int32:
return int64(val), true
case int64:
return val, true
case uint:
// #nosec: G115
return int64(val), true
case uint8:
return int64(val), true
case uint16:
return int64(val), true
case uint32:
return int64(val), true
case uint64:
// #nosec: G115
return int64(val), true
case time.Duration:
return int64(val), true
case StyledCell:
return asInt(val.Data)
}
return 0, false
}
func asNumber(data any) (float64, bool) {
switch val := data.(type) {
case float32:
return float64(val), true
case float64:
return val, true
case StyledCell:
return asNumber(val.Data)
}
intVal, isInt := asInt(data)
return float64(intVal), isInt
}
================================================
FILE: table/data_test.go
================================================
package table
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAsInt(t *testing.T) {
check := func(data any, isInt bool, expectedValue int64) {
val, ok := asInt(data)
assert.Equal(t, isInt, ok)
assert.Equal(t, expectedValue, val)
}
check(3, true, 3)
check(3.3, false, 0)
check(int8(3), true, 3)
check(int16(3), true, 3)
check(int32(3), true, 3)
check(int64(3), true, 3)
check(uint(3), true, 3)
check(uint8(3), true, 3)
check(uint16(3), true, 3)
check(uint32(3), true, 3)
check(uint64(3), true, 3)
check(StyledCell{Data: 3}, true, 3)
check(time.Duration(3), true, 3)
}
func TestAsNumber(t *testing.T) {
check := func(data any, isFloat bool, expectedValue float64) {
val, ok := asNumber(data)
assert.Equal(t, isFloat, ok)
assert.InDelta(t, expectedValue, val, 0.001)
}
check(uint32(3), true, 3)
check(3.3, true, 3.3)
check(float32(3.3), true, 3.3)
check(StyledCell{Data: 3.3}, true, 3.3)
}
================================================
FILE: table/dimensions.go
================================================
package table
import (
"github.com/charmbracelet/lipgloss"
)
func (m *Model) recalculateWidth() {
if m.targetTotalWidth != 0 {
m.totalWidth = m.targetTotalWidth
} else {
total := 0
for _, column := range m.columns {
total += column.width
}
m.totalWidth = total + len(m.columns) + 1
}
updateColumnWidths(m.columns, m.targetTotalWidth)
m.recalculateLastHorizontalColumn()
}
// Updates column width in-place. This could be optimized but should be called
// very rarely so we prioritize simplicity over performance here.
func updateColumnWidths(cols []Column, totalWidth int) {
totalFlexWidth := totalWidth - len(cols) - 1
totalFlexFactor := 0
flexGCD := 0
for index, col := range cols {
if !col.isFlex() {
totalFlexWidth -= col.width
cols[index].style = col.style.Width(col.width)
} else {
totalFlexFactor += col.flexFactor
flexGCD = gcd(flexGCD, col.flexFactor)
}
}
if totalFlexFactor == 0 {
return
}
// We use the GCD here because otherwise very large values won't divide
// nicely as ints
totalFlexFactor /= flexGCD
flexUnit := totalFlexWidth / totalFlexFactor
leftoverWidth := totalFlexWidth % totalFlexFactor
for index := range cols {
if !cols[index].isFlex() {
continue
}
width := flexUnit * (cols[index].flexFactor / flexGCD)
if leftoverWidth > 0 {
width++
leftoverWidth--
}
if index == len(cols)-1 {
width += leftoverWidth
leftoverWidth = 0
}
width = max(width, 1)
cols[index].width = width
// Take borders into account for the actual style
cols[index].style = cols[index].style.Width(width)
}
}
func (m *Model) recalculateHeight() {
header := m.renderHeaders()
headerHeight := 1 // Header always has the top border
if m.headerVisible {
headerHeight = lipgloss.Height(header)
}
footer := m.renderFooter(lipgloss.Width(header), false)
var footerHeight int
if footer != "" {
footerHeight = lipgloss.Height(footer)
}
m.metaHeight = headerHeight + footerHeight
}
func (m *Model) calculatePadding(numRows int) int {
if m.minimumHeight == 0 {
return 0
}
padding := m.minimumHeight - m.metaHeight - numRows - 1 // additional 1 for bottom border
if padding == 0 && numRows == 0 {
// This is an edge case where we want to add 1 additional line of height, i.e.
// add a border without an empty row. However, this is not possible, so we need
// to add an extra row which will result in the table being 1 row taller than
// the requested minimum height.
return 1
}
if padding < 0 {
// Table is already larger than minimum height, do nothing.
return 0
}
return padding
}
================================================
FILE: table/dimensions_test.go
================================================
package table
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// This function is only long because of repetitive test definitions, this is fine
//
//nolint:funlen
func TestColumnUpdateWidths(t *testing.T) {
tests := []struct {
name string
columns []Column
totalWidth int
expectedWidths []int
}{
{
name: "Static",
columns: []Column{
NewColumn("abc", "a", 4),
NewColumn("sdf", "b", 7),
NewColumn("xyz", "c", 2),
},
totalWidth: 13,
expectedWidths: []int{
4, 7, 2,
},
},
{
name: "Even half",
columns: []Column{
NewFlexColumn("abc", "a", 1),
NewFlexColumn("sdf", "b", 1),
},
totalWidth: 11,
expectedWidths: []int{
4, 4,
},
},
{
name: "Odd half increases first",
columns: []Column{
NewFlexColumn("abc", "a", 1),
NewFlexColumn("sdf", "b", 1),
},
totalWidth: 12,
expectedWidths: []int{
5, 4,
},
},
{
name: "Even fourths",
columns: []Column{
NewFlexColumn("abc", "a", 1),
NewFlexColumn("sdf", "b", 1),
NewFlexColumn("xyz", "c", 1),
NewFlexColumn("123", "d", 1),
},
totalWidth: 17,
expectedWidths: []int{
3, 3, 3, 3,
},
},
{
name: "Odd fourths",
columns: []Column{
NewFlexColumn("abc", "a", 1),
NewFlexColumn("sdf", "b", 1),
NewFlexColumn("xyz", "c", 1),
NewFlexColumn("123", "d", 1),
},
totalWidth: 20,
expectedWidths: []int{
4, 4, 4, 3,
},
},
{
name: "Simple mix static and flex",
columns: []Column{
NewColumn("abc", "a", 5),
NewFlexColumn("flex", "flex", 1),
},
totalWidth: 18,
expectedWidths: []int{
5, 10,
},
},
{
name: "Static and flex with high flex factor",
columns: []Column{
NewColumn("abc", "a", 5),
NewFlexColumn("flex", "flex", 1000),
},
totalWidth: 18,
expectedWidths: []int{
5, 10,
},
},
{
name: "Static and multiple flexes with high flex factor",
columns: []Column{
NewColumn("abc", "a", 5),
NewFlexColumn("flex", "flex", 1000),
NewFlexColumn("flex", "flex", 1000),
NewFlexColumn("flex", "flex", 1000),
},
totalWidth: 22,
expectedWidths: []int{
5, 4, 4, 4,
},
},
{
name: "Static and multiple flexes of different sizes",
columns: []Column{
NewFlexColumn("flex", "flex", 1),
NewColumn("abc", "a", 5),
NewFlexColumn("flex", "flex", 2),
NewFlexColumn("flex", "flex", 1),
},
totalWidth: 22,
expectedWidths: []int{
3, 5, 6, 3,
},
},
{
name: "Width is too small",
columns: []Column{
NewColumn("abc", "a", 5),
NewFlexColumn("flex", "flex", 2),
NewFlexColumn("flex", "flex", 1),
},
totalWidth: 3,
expectedWidths: []int{
5, 1, 1,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
updateColumnWidths(test.columns, test.totalWidth)
for i, col := range test.columns {
assert.Equal(t, test.expectedWidths[i], col.width, fmt.Sprintf("index %d", i))
}
})
}
}
// This function is long because it has many test cases
//
//nolint:funlen
func TestRecalculateHeight(t *testing.T) {
columns := []Column{
NewColumn("ka", "a", 3),
NewColumn("kb", "b", 4),
NewColumn("kc", "c", 5),
}
rows := []Row{
NewRow(RowData{"ka": 1, "kb": 23, "kc": "zyx"}),
NewRow(RowData{"ka": 3, "kb": 34, "kc": "wvu"}),
NewRow(RowData{"ka": 5, "kb": 45, "kc": "zyx"}),
NewRow(RowData{"ka": 7, "kb": 56, "kc": "wvu"}),
}
tests := []struct {
name string
model Model
expectedHeight int
}{
{
name: "Default header",
model: New(columns).WithRows(rows),
expectedHeight: 3,
},
{
name: "Empty page with default header",
model: New(columns),
expectedHeight: 3,
},
{
name: "Filtered with default header",
model: New(columns).WithRows(rows).Filtered(true),
expectedHeight: 5,
},
{
name: "Static footer one line",
model: New(columns).WithRows(rows).WithStaticFooter("single line"),
expectedHeight: 5,
},
{
name: "Static footer overflow",
model: New(columns).WithRows(rows).
WithStaticFooter("single line but it's long"),
expectedHeight: 6,
},
{
name: "Static footer multi-line",
model: New(columns).WithRows(rows).
WithStaticFooter("footer with\nmultiple lines"),
expectedHeight: 6,
},
{
name: "Paginated",
model: New(columns).WithRows(rows).WithPageSize(2),
expectedHeight: 5,
},
{
name: "No pagination",
model: New(columns).WithRows(rows).WithPageSize(2).WithNoPagination(),
expectedHeight: 3,
},
{
name: "Footer not visible",
model: New(columns).WithRows(rows).Filtered(true).WithFooterVisibility(false),
expectedHeight: 3,
},
{
name: "Header not visible",
model: New(columns).WithRows(rows).WithHeaderVisibility(false),
expectedHeight: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.model.recalculateHeight()
assert.Equal(t, test.expectedHeight, test.model.metaHeight)
})
}
}
================================================
FILE: table/doc.go
================================================
/*
Package table contains a Bubble Tea component for an interactive and customizable
table.
The simplest useful table can be created with table.New(...).WithRows(...). Row
data should map to the column keys, as shown below. Note that extra data will
simply not be shown, while missing data will be safely blank in the row's cell.
const (
// This is not necessary, but recommended to avoid typos
columnKeyName = "name"
columnKeyCount = "count"
)
// Define the columns and how they appear
columns := []table.Column{
table.NewColumn(columnKeyName, "Name", 10),
table.NewColumn(columnKeyCount, "Count", 6),
}
// Define the data that will be in the table, mapping to the column keys
rows := []table.Row{
table.NewRow(table.RowData{
columnKeyName: "Cheeseburger",
columnKeyCount: 3,
}),
table.NewRow(table.RowData{
columnKeyName: "Fries",
columnKeyCount: 2,
}),
}
// Create the table
tbl := table.New(columns).WithRows(rows)
// Use it like any Bubble Tea component in your view
tbl.View()
*/
package table
================================================
FILE: table/events.go
================================================
package table
// UserEvent is some state change that has occurred due to user input. These will
// ONLY be generated when a user has interacted directly with the table. These
// will NOT be generated when code programmatically changes values in the table.
type UserEvent any
func (m *Model) appendUserEvent(e UserEvent) {
m.lastUpdateUserEvents = append(m.lastUpdateUserEvents, e)
}
func (m *Model) clearUserEvents() {
m.lastUpdateUserEvents = nil
}
// GetLastUpdateUserEvents returns a list of events that happened due to user
// input in the last Update call. This is useful to look for triggers such as
// whether the user moved to a new highlighted row.
func (m *Model) GetLastUpdateUserEvents() []UserEvent {
// Most common case
if len(m.lastUpdateUserEvents) == 0 {
return nil
}
returned := make([]UserEvent, len(m.lastUpdateUserEvents))
// Slightly wasteful but helps guarantee immutability, and this should only
// have data very rarely so this is fine
copy(returned, m.lastUpdateUserEvents)
return returned
}
// UserEventHighlightedIndexChanged indicates that the user has scrolled to a new
// row.
type UserEventHighlightedIndexChanged struct {
// PreviousRow is the row that was selected before the change.
PreviousRowIndex int
// SelectedRow is the row index that is now selected
SelectedRowIndex int
}
// UserEventRowSelectToggled indicates that the user has either selected or
// deselected a row by toggling the selection. The event contains information
// about which row index was selected and whether it was selected or deselected.
type UserEventRowSelectToggled struct {
RowIndex int
IsSelected bool
}
// UserEventFilterInputFocused indicates that the user has focused the filter
// text input, so that any other typing will type into the filter field. Only
// activates for the built-in filter text box.
type UserEventFilterInputFocused struct{}
// UserEventFilterInputUnfocused indicates that the user has unfocused the filter
// text input, which means the user is done typing into the filter field. Only
// activates for the built-in filter text box.
type UserEventFilterInputUnfocused struct{}
================================================
FILE: table/events_test.go
================================================
package table
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
)
func TestUserEventsEmptyWhenNothingHappens(t *testing.T) {
model := New([]Column{})
events := model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "Should be empty when nothing has happened")
model, _ = model.Update(nil)
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "Should be empty when no changes made in Update")
}
//nolint:funlen // This is a bunch of checks in a row, this is fine
func TestUserEventHighlightedIndexChanged(t *testing.T) {
// Don't need any actual row data for this
empty := RowData{}
model := New([]Column{}).
Focused(true).
WithRows(
[]Row{
NewRow(empty),
NewRow(empty),
NewRow(empty),
NewRow(empty),
},
)
hitDown := func() {
model, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
}
hitUp := func() {
model, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp})
}
checkEvent := func(events []UserEvent, expectedPreviousIndex, expectedCurrentIndex int) {
if len(events) != 1 {
assert.FailNow(t, "Asked to check events with len of not 1, test is bad")
}
switch event := events[0].(type) {
case UserEventHighlightedIndexChanged:
assert.Equal(t, expectedPreviousIndex, event.PreviousRowIndex)
assert.Equal(t, expectedCurrentIndex, event.SelectedRowIndex)
default:
assert.Failf(t, "Event is not expected type UserEventHighlightedIndexChanged", "%+v", event)
}
}
events := model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "Should be empty when nothing has happened")
// Hit down to change row down by one
hitDown()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Missing event for scrolling down")
checkEvent(events, 0, 1)
// Do some no-op
model, _ = model.Update(nil)
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "Events not cleared between Updates")
// Hit up to go back to top
hitUp()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Missing event to scroll back up")
checkEvent(events, 1, 0)
// Hit up to scroll around to bottom
hitUp()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Missing event to scroll up with wrap")
checkEvent(events, 0, 3)
// Now check to make sure it doesn't trigger when row index doesn't change
model = model.WithRows([]Row{NewRow(empty)})
hitDown()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "There's no row to change to for single row table, event shouldn't exist")
model = model.WithRows([]Row{})
hitDown()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "There's no row to change to for an empty table, event shouldn't exist")
}
//nolint:funlen // This is a bunch of checks in a row, this is fine
func TestUserEventRowSelectToggled(t *testing.T) {
// Don't need any actual row data for this
empty := RowData{}
model := New([]Column{}).
Focused(true).
WithRows(
[]Row{
NewRow(empty),
NewRow(empty),
NewRow(empty),
NewRow(empty),
},
).
SelectableRows(true)
hitDown := func() {
model, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
}
hitSelectToggle := func() {
model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
}
checkEvent := func(events []UserEvent, expectedRowIndex int, expectedSelectionState bool) {
if len(events) != 1 {
assert.FailNow(t, "Asked to check events with len of not 1, test is bad")
}
switch event := events[0].(type) {
case UserEventRowSelectToggled:
assert.Equal(t, expectedRowIndex, event.RowIndex, "Row index wrong")
assert.Equal(t, expectedSelectionState, event.IsSelected, "Selection state wrong")
default:
assert.Failf(t, "Event is not expected type UserEventRowSelectToggled", "%+v", event)
}
}
events := model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "Should be empty when nothing has happened")
// Try initial selection
hitSelectToggle()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Missing event for selection toggle")
checkEvent(events, 0, true)
// Do some no-op
model, _ = model.Update(nil)
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "Events not cleared between Updates")
// Check deselection
hitSelectToggle()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Missing event to toggle select for second time")
checkEvent(events, 0, false)
// Try one row down... note that the row change event should clear after the
// first keypress
hitDown()
hitSelectToggle()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Missing event after scrolling down")
checkEvent(events, 1, true)
// Check edge case of empty table
model = model.WithRows([]Row{})
hitSelectToggle()
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 0, "There's no row to select for an empty table, event shouldn't exist")
}
func TestFilterFocusEvents(t *testing.T) {
model := New([]Column{}).Filtered(true).Focused(true)
events := model.GetLastUpdateUserEvents()
assert.Empty(t, events, "Unexpected events to start")
// Start filter
model, _ = model.Update(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'/'},
})
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Only expected one event")
switch events[0].(type) {
case UserEventFilterInputFocused:
default:
assert.FailNow(t, "Unexpected event type")
}
// Stop filter
model, _ = model.Update(tea.KeyMsg{
Type: tea.KeyEnter,
})
events = model.GetLastUpdateUserEvents()
assert.Len(t, events, 1, "Only expected one event")
switch events[0].(type) {
case UserEventFilterInputUnfocused:
default:
assert.FailNow(t, "Unexpected event type")
}
}
================================================
FILE: table/filter.go
================================================
package table
import (
"fmt"
"strings"
)
// FilterFuncInput is the input to a FilterFunc. It's a struct so we can add more things later
// without breaking compatibility.
type FilterFuncInput struct {
// Columns is a list of the columns of the table
Columns []Column
// Row is the row that's being considered for filtering
Row Row
// GlobalMetadata is an arbitrary set of metadata from the table set by WithGlobalMetadata
GlobalMetadata map[string]any
// Filter is the filter string input to consider
Filter string
}
// FilterFunc takes a FilterFuncInput and returns true if the row should be visible,
// or false if the row should be hidden.
type FilterFunc func(FilterFuncInput) bool
func (m Model) getFilteredRows(rows []Row) []Row {
filterInputValue := m.filterTextInput.Value()
if !m.filtered || filterInputValue == "" {
return rows
}
filteredRows := make([]Row, 0)
for _, row := range rows {
var availableFilterFunc FilterFunc
if m.filterFunc != nil {
availableFilterFunc = m.filterFunc
} else {
availableFilterFunc = filterFuncContains
}
if availableFilterFunc(FilterFuncInput{
Columns: m.columns,
Row: row,
Filter: filterInputValue,
GlobalMetadata: m.metadata,
}) {
filteredRows = append(filteredRows, row)
}
}
return filteredRows
}
// filterFuncContains returns a filterFunc that performs case-insensitive
// "contains" matching over all filterable columns in a row.
func filterFuncContains(input FilterFuncInput) bool {
if input.Filter == "" {
return true
}
checkedAny := false
filterLower := strings.ToLower(input.Filter)
for _, column := range input.Columns {
if !column.filterable {
continue
}
checkedAny = true
data, ok := input.Row.Data[column.key]
if !ok {
continue
}
// Extract internal StyledCell data
switch dataV := data.(type) {
case StyledCell:
data = dataV.Data
}
var target string
switch dataV := data.(type) {
case string:
target = dataV
case fmt.Stringer:
target = dataV.String()
default:
target = fmt.Sprintf("%v", data)
}
if strings.Contains(strings.ToLower(target), filterLower) {
return true
}
}
return !checkedAny
}
// filterFuncFuzzy returns a filterFunc that performs case-insensitive fuzzy
// matching (subsequence) over the concatenation of all filterable column values.
func filterFuncFuzzy(input FilterFuncInput) bool {
filter := strings.TrimSpace(input.Filter)
if filter == "" {
return true
}
var builder strings.Builder
for _, col := range input.Columns {
if !col.filterable {
continue
}
value, ok := input.Row.Data[col.key]
if !ok {
continue
}
if sc, ok := value.(StyledCell); ok {
value = sc.Data
}
builder.WriteString(fmt.Sprint(value)) // uses Stringer if implemented
builder.WriteByte(' ')
}
haystack := strings.ToLower(builder.String())
if haystack == "" {
return false
}
for _, token := range strings.Fields(strings.ToLower(filter)) {
if !fuzzySubsequenceMatch(haystack, token) {
return false
}
}
return true
}
// fuzzySubsequenceMatch returns true if all runes in needle appear in order
// within haystack (not necessarily contiguously). Case must be normalized by caller.
func fuzzySubsequenceMatch(haystack, needle string) bool {
if needle == "" {
return true
}
haystackIndex, needleIndex := 0, 0
haystackRunes := []rune(haystack)
needleRunes := []rune(needle)
for haystackIndex < len(haystackRunes) && needleIndex < len(needleRunes) {
if haystackRunes[haystackIndex] == needleRunes[needleIndex] {
needleIndex++
}
haystackIndex++
}
return needleIndex == len(needleRunes)
}
================================================
FILE: table/filter_test.go
================================================
package table
import (
"fmt"
"testing"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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, filterFuncContains(FilterFuncInput{
Columns: columns,
Row: NewRow(RowData{
"title": "AAA",
"description": "",
}),
Filter: "",
}))
type testCase struct {
name string
filter string
title any
description any
shouldMatch bool
}
timeFrom2020 := time.Date(2020, time.July, 1, 1, 1, 1, 1, time.UTC)
cases := []testCase{
{"empty filter matches all", "", "AAA", "", true},
{"exact match", "AAA", "AAA", "", true},
{"partial match start", "A", "AAA", "", true},
{"partial match middle", "AA", "AAA", "", true},
{"too long", "AAAA", "AAA", "", false},
{"lowercase", "aaa", "AAA", "", true},
{"mixed case", "AaA", "AAA", "", true},
{"wrong input", "B", "AAA", "", false},
{"ignore description", "BBB", "AAA", "BBB", false},
{"time filterable success", "2020", timeFrom2020, "", true},
{"time filterable wrong input", "2021", timeFrom2020, "", false},
{"styled cell", "AAA", NewStyledCell("AAA", lipgloss.NewStyle()), "", true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.shouldMatch, filterFuncContains(FilterFuncInput{
Columns: columns,
Row: NewRow(RowData{
"title": testCase.title,
"description": testCase.description,
}),
Filter: testCase.filter,
}))
})
}
// Styled check
}
func TestIsRowMatchedForNonStringer(t *testing.T) {
columns := []Column{
NewColumn("val", "val", 10).WithFiltered(true),
}
type testCase struct {
name string
filter string
val any
shouldMatch bool
}
cases := []testCase{
{"exact match", "12", 12, true},
{"partial match", "1", 12, true},
{"partial match end", "2", 12, true},
{"wrong input", "3", 12, false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.shouldMatch, filterFuncContains(FilterFuncInput{
Columns: columns,
Row: NewRow(RowData{
"val": testCase.val,
}),
Filter: testCase.filter,
}))
})
}
}
func TestGetFilteredRowsNoColumnFiltered(t *testing.T) {
columns := []Column{NewColumn("title", "title", 10)}
rows := []Row{
NewRow(RowData{
"title": "AAA",
"description": "",
}),
NewRow(RowData{
"title": "BBB",
"description": "",
}),
NewRow(RowData{
"title": "CCC",
"description": "",
}),
}
model := New(columns).WithRows(rows).Filtered(true)
model.filterTextInput.SetValue("AAA")
filteredRows := model.getFilteredRows(rows)
assert.Len(t, filteredRows, len(rows))
}
func TestGetFilteredRowsUnfiltered(t *testing.T) {
columns := []Column{NewColumn("title", "title", 10)}
rows := []Row{
NewRow(RowData{
"title": "AAA",
}),
NewRow(RowData{
"title": "BBB",
}),
}
model := New(columns).WithRows(rows)
filteredRows := model.getFilteredRows(rows)
assert.Len(t, filteredRows, len(rows))
}
func TestGetFilteredRowsFiltered(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)
model.filterTextInput.SetValue("AaA")
filteredRows := model.getFilteredRows(rows)
assert.Len(t, filteredRows, 1)
}
func TestGetFilteredRowsRefocusAfterFilter(t *testing.T) {
columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
rows := []Row{
NewRow(RowData{
"title": "a",
"description": "",
}),
NewRow(RowData{
"title": "b",
"description": "",
}),
NewRow(RowData{
"title": "c",
"description": "",
}),
NewRow(RowData{
"title": "d1",
"description": "",
}),
NewRow(RowData{
"title": "d2",
"description": "",
}),
}
model := New(columns).WithRows(rows).Filtered(true).WithPageSize(1)
model = model.PageDown()
assert.Len(t, model.GetVisibleRows(), 5)
assert.Equal(t, 1, model.PageSize())
assert.Equal(t, 2, model.CurrentPage())
assert.Equal(t, 5, model.MaxPages())
assert.Equal(t, 5, model.TotalRows())
model.filterTextInput.SetValue("c")
model, _ = model.updateFilterTextInput(tea.KeyMsg{})
assert.Len(t, model.GetVisibleRows(), 1)
assert.Equal(t, 1, model.PageSize())
assert.Equal(t, 1, model.CurrentPage())
assert.Equal(t, 1, model.MaxPages())
assert.Equal(t, 1, model.TotalRows())
model.filterTextInput.SetValue("not-exist")
model, _ = model.updateFilterTextInput(tea.KeyMsg{})
assert.Len(t, model.GetVisibleRows(), 0)
assert.Equal(t, 1, model.PageSize())
assert.Equal(t, 1, model.CurrentPage())
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{}),
}
// Page size 1 to test scrolling back if input changes
model := New(columns).WithRows(rows).Filtered(true).WithPageSize(1)
model.pageDown()
assert.Equal(t, 2, model.CurrentPage(), "Should start on second page for test")
input := textinput.New()
input.SetValue("AaA")
model = model.WithFilterInput(input)
assert.Equal(t, 1, model.CurrentPage(), "Did not go back to first page")
filteredRows := model.getFilteredRows(rows)
assert.Len(t, filteredRows, 1)
}
func TestFilterWithSetValue(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{}),
}
// Page size 1 to make sure we scroll back correctly
model := New(columns).WithRows(rows).Filtered(true).WithPageSize(1)
model.pageDown()
assert.Equal(t, 2, model.CurrentPage(), "Should start on second page for test")
model = model.WithFilterInputValue("AaA")
assert.Equal(t, 1, model.CurrentPage(), "Did not go back to first page")
filteredRows := model.getFilteredRows(rows)
assert.Len(t, filteredRows, 1)
// Make sure it holds true after an update
model, _ = model.Update(tea.KeyRight)
filteredRows = model.getFilteredRows(rows)
assert.Len(t, filteredRows, 1)
// Remove filter
model = model.WithFilterInputValue("")
filteredRows = model.getFilteredRows(rows)
assert.Len(t, filteredRows, 3)
}
func TestFilterFunc(t *testing.T) {
const (
colTitle = "title"
colDesc = "description"
)
columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
rows := []Row{
NewRow(RowData{
colTitle: "AAA",
colDesc: "",
}),
NewRow(RowData{
colTitle: "BBB",
colDesc: "",
}),
// Empty
NewRow(RowData{}),
}
filterFunc := func(input FilterFuncInput) bool {
// Completely arbitrary check for testing purposes
title := fmt.Sprintf("%v", input.Row.Data["title"])
return title == "AAA" && input.Filter == "x" && input.GlobalMetadata["testValue"] == 3
}
// First check that the table won't match with different case
model := New(columns).WithRows(rows).Filtered(true).WithGlobalMetadata(map[string]any{
"testValue": 3,
})
model = model.WithFilterInputValue("x")
filteredRows := model.getFilteredRows(rows)
assert.Len(t, filteredRows, 0)
// The filter func should then match the one row
model = model.WithFilterFunc(filterFunc)
filteredRows = model.getFilteredRows(rows)
assert.Len(t, filteredRows, 1)
// Remove filter
model = model.WithFilterInputValue("")
filteredRows = model.getFilteredRows(rows)
assert.Len(t, filteredRows, 3)
}
func BenchmarkFilteredScrolling(b *testing.B) {
// Scrolling through a filtered table with many rows should be quick
// https://github.com/Evertras/bubble-table/issues/135
const rowCount = 40000
columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
rows := make([]Row, rowCount)
for i := 0; i < rowCount; i++ {
rows[i] = NewRow(RowData{
"title": fmt.Sprintf("%d", i),
})
}
model := New(columns).WithRows(rows).Filtered(true)
model = model.WithFilterInputValue("1")
hitKey := func(key rune) {
model, _ = model.Update(
tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{key},
})
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
hitKey('j')
}
}
func BenchmarkFilteredScrollingPaged(b *testing.B) {
// Scrolling through a filtered table with many rows should be quick
// https://github.com/Evertras/bubble-table/issues/135
const rowCount = 40000
columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
rows := make([]Row, rowCount)
for i := 0; i < rowCount; i++ {
rows[i] = NewRow(RowData{
"title": fmt.Sprintf("%d", i),
})
}
model := New(columns).WithRows(rows).Filtered(true).WithPageSize(50)
model = model.WithFilterInputValue("1")
b.ResetTimer()
for i := 0; i < b.N; i++ {
model, _ = model.Update(
tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'j'},
})
}
}
func BenchmarkFilteredRenders(b *testing.B) {
// Rendering a filtered table should be fast
// https://github.com/Evertras/bubble-table/issues/135
const rowCount = 40000
columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
rows := make([]Row, rowCount)
for i := 0; i < rowCount; i++ {
rows[i] = NewRow(RowData{
"title": fmt.Sprintf("%d", i),
})
}
model := New(columns).WithRows(rows).Filtered(true).WithPageSize(50)
model = model.WithFilterInputValue("1")
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Don't care about result, just rendering
_ = model.View()
}
}
func TestFuzzyFilter_EmptyFilterMatchesAll(t *testing.T) {
cols := []Column{
NewColumn("name", "Name", 10).WithFiltered(true),
}
rows := []Row{
NewRow(RowData{"name": "Acme Steel"}),
NewRow(RowData{"name": "Globex"}),
}
for index, row := range rows {
if !filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "",
}) {
t.Fatalf("row %d should match empty filter", index)
}
}
}
func TestFuzzyFilter_SubsequenceAcrossColumns(t *testing.T) {
cols := []Column{
NewColumn("name", "Name", 10).WithFiltered(true),
NewColumn("city", "City", 10).WithFiltered(true),
}
row := NewRow(RowData{
"name": "Acme",
"city": "Stuttgart",
})
type testCase struct {
name string
filter string
shouldMatch bool
}
testCases := []testCase{
{"subsequence match", "agt", true},
{"case-insensitive match", "ACM", true},
{"not a subsequence", "zzt", false},
}
for _, tc := range testCases {
assert.Equal(t, tc.shouldMatch, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: tc.filter,
}))
}
}
func TestFuzzyFilter_ColumnNotInRow(t *testing.T) {
cols := []Column{
NewColumn("column_name_doesnt_match", "Name", 10).WithFiltered(true),
}
row := NewRow(RowData{
"name": "Acme Steel",
})
assert.False(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "steel",
}), "Shouldn't match")
}
func TestFuzzyFilter_RowHasEmptyHaystack(t *testing.T) {
cols := []Column{
NewColumn("name", "Name", 10).WithFiltered(true),
}
row := NewRow(RowData{"name": ""})
// literally any value other than an empty string
// should not match
assert.False(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "a",
}), "Shouldn't match")
}
func TestFuzzyFilter_MultiToken_AND(t *testing.T) {
cols := []Column{
NewColumn("name", "Name", 10).WithFiltered(true),
NewColumn("dept", "Dept", 10).WithFiltered(true),
}
row := NewRow(RowData{
"name": "Wayne Enterprises",
"dept": "R&D",
})
// Both tokens must match as subsequences somewhere in the concatenated haystack
assert.True(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "wy ent",
}), "Should match wy ent") // "wy" in Wayne, "ent" in Enterprises
assert.False(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "wy zzz",
}), "Shouldn't match wy zzz")
}
func TestFuzzyFilter_IgnoresNonFilterableColumns(t *testing.T) {
cols := []Column{
NewColumn("name", "Name", 10).WithFiltered(true),
NewColumn("secret", "Secret", 10).WithFiltered(false), // should be ignored
}
row := NewRow(RowData{
"name": "Acme",
"secret": "topsecretpattern",
})
assert.False(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "topsecret",
}), "Shouldn't match on non-filterable")
}
func TestFuzzyFilter_UnwrapsStyledCell(t *testing.T) {
cols := []Column{
NewColumn("name", "Name", 10).WithFiltered(true),
}
row := NewRow(RowData{
"name": NewStyledCell("Nakatomi Plaza", lipgloss.NewStyle()),
})
assert.True(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "nak plz",
}), "Expected fuzzy subsequence to match within StyledCell data")
}
func TestFuzzyFilter_NonStringValuesFormatted(t *testing.T) {
cols := []Column{
NewColumn("id", "ID", 6).WithFiltered(true),
}
row := NewRow(RowData{
"id": 12345, // should be formatted via fmt.Sprintf("%v", v)
})
assert.True(t, filterFuncFuzzy(FilterFuncInput{
Columns: cols,
Row: row,
Filter: "245", // subsequence of "12345"
}), "expected matcher to format non-strings and match subsequence")
}
func TestFuzzySubSequenceMatch_EmptyString(t *testing.T) {
assert.True(t, fuzzySubsequenceMatch("anything", ""), "empty needle should match anything")
assert.False(t, fuzzySubsequenceMatch("", "a"), "non-empty needle should not match empty haystack")
assert.True(t, fuzzySubsequenceMatch("", ""), "empty needle should match empty haystack")
}
================================================
FILE: table/footer.go
================================================
package table
import (
"fmt"
"strings"
)
func (m Model) hasFooter() bool {
return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered)
}
func (m Model) renderFooter(width int, includeTop bool) string {
if !m.hasFooter() {
return ""
}
const borderAdjustment = 2
styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(width - borderAdjustment)
if includeTop {
styleFooter = styleFooter.BorderTop(true)
}
if m.staticFooter != "" {
return styleFooter.Render(m.staticFooter)
}
sections := []string{}
if m.filtered && (m.filterTextInput.Focused() || m.filterTextInput.Value() != "") {
sections = append(sections, m.filterTextInput.View())
}
// paged feature enabled
if m.pageSize != 0 {
str := fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages())
if m.filtered && m.filterTextInput.Focused() {
// Need to apply inline style here in case of filter input cursor, because
// the input cursor resets the style after rendering. Note that Inline(true)
// creates a copy, so it's safe to use here without mutating the underlying
// base style.
str = m.baseStyle.Inline(true).Render(str)
}
sections = append(sections, str)
}
footerText := strings.Join(sections, " ")
return styleFooter.Render(footerText)
}
================================================
FILE: table/header.go
================================================
package table
import "github.com/charmbracelet/lipgloss"
// This is long and could use some refactoring in the future, but unsure of how
// to pick it apart right now.
//
//nolint:funlen,cyclop
func (m Model) renderHeaders() string {
headerStrings := []string{}
totalRenderedWidth := 0
headerStyles := m.styleHeaders()
renderHeader := func(column Column, borderStyle lipgloss.Style) string {
borderStyle = borderStyle.Inherit(column.style).Inherit(m.baseStyle)
headerSection := limitStr(column.title, column.width)
return borderStyle.Render(headerSection)
}
for columnIndex, column := range m.columns {
var borderStyle lipgloss.Style
if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
if columnIndex == 0 {
borderStyle = headerStyles.left.Copy()
} else {
borderStyle = headerStyles.inner.Copy()
}
rendered := renderHeader(genOverflowColumnLeft(1), borderStyle)
totalRenderedWidth += lipgloss.Width(rendered)
headerStrings = append(headerStrings, rendered)
}
if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
continue
}
if len(headerStrings) == 0 {
borderStyle = headerStyles.left.Copy()
} else if columnIndex < len(m.columns)-1 {
borderStyle = headerStyles.inner.Copy()
} else {
borderStyle = headerStyles.right.Copy()
}
rendered := renderHeader(column, borderStyle)
if m.maxTotalWidth != 0 {
renderedWidth := lipgloss.Width(rendered)
const (
borderAdjustment = 1
overflowColWidth = 2
)
targetWidth := m.maxTotalWidth - overflowColWidth
if columnIndex == len(m.columns)-1 {
// If this is the last header, we don't need to account for the
// overflow arrow column
targetWidth = m.maxTotalWidth
}
if totalRenderedWidth+renderedWidth > targetWidth {
overflowWidth := m.maxTotalWidth - totalRenderedWidth - borderAdjustment
overflowStyle := genOverflowStyle(headerStyles.right, overflowWidth)
overflowColumn := genOverflowColumnRight(overflowWidth)
overflowStr := renderHeader(overflowColumn, overflowStyle)
headerStrings = append(headerStrings, overflowStr)
break
}
totalRenderedWidth += renderedWidth
}
headerStrings = append(headerStrings, rendered)
}
headerBlock := lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...)
return headerBlock
}
================================================
FILE: table/keys.go
================================================
package table
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines the keybindings for the table when it's focused.
type KeyMap struct {
RowDown key.Binding
RowUp key.Binding
RowSelectToggle key.Binding
PageDown key.Binding
PageUp key.Binding
PageFirst key.Binding
PageLast key.Binding
// Filter allows the user to start typing and filter the rows.
Filter key.Binding
// FilterBlur is the key that stops the user's input from typing into the filter.
FilterBlur key.Binding
// FilterClear will clear the filter while it's blurred.
FilterClear key.Binding
// ScrollRight will move one column to the right when overflow occurs.
ScrollRight key.Binding
// ScrollLeft will move one column to the left when overflow occurs.
ScrollLeft key.Binding
}
// DefaultKeyMap returns a set of sensible defaults for controlling a focused table with help text.
func DefaultKeyMap() KeyMap {
return KeyMap{
RowDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
RowUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
RowSelectToggle: key.NewBinding(
key.WithKeys(" ", "enter"),
key.WithHelp("