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

Latest Release GoDoc Coverage Status Go Report Card

A customizable, interactive table component for the [Bubble Tea framework](https://github.com/charmbracelet/bubbletea). ![Styled table](https://user-images.githubusercontent.com/5923958/188168029-0de392c8-dbb0-47da-93a0-d2a6e3d46838.png) [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. image ================================================ 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. image ================================================ 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. image ================================================ 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. image ================================================ 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. image ================================================ 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. image ================================================ 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. ![Styled table](https://user-images.githubusercontent.com/5923958/156778142-cc1a32e1-1b1e-4a65-b699-187f39f0f946.png) ================================================ 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. image ================================================ FILE: examples/multiline/main.go ================================================ package main import ( "log" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/evertras/bubble-table/table" ) const ( columnKeyName = "name" columnKeyCountry = "country" columnKeyCurrency = "crurrency" ) type Model struct { tableModel table.Model } func NewModel() Model { columns := []table.Column{ table.NewColumn(columnKeyName, "Name", 10).WithStyle( lipgloss.NewStyle(). Foreground(lipgloss.Color("#88f")), ), table.NewColumn(columnKeyCountry, "Country", 20), table.NewColumn(columnKeyCurrency, "Currency", 10), } rows := []table.Row{ table.NewRow( table.RowData{ columnKeyName: "Talon Stokes", columnKeyCountry: "Mexico", columnKeyCurrency: "$23.17", }), table.NewRow( table.RowData{ columnKeyName: "Sonia Shepard", columnKeyCountry: "United States", columnKeyCurrency: "$76.47", }), table.NewRow( table.RowData{ columnKeyName: "Shad Reed", columnKeyCountry: "Turkey", columnKeyCurrency: "$62.99", }), table.NewRow( table.RowData{ columnKeyName: "Kibo Clay", columnKeyCountry: "Philippines", columnKeyCurrency: "$29.82", }), table.NewRow( table.RowData{ columnKeyName: "Leslie Kerr", columnKeyCountry: "Singapore", columnKeyCurrency: "$70.54", }), table.NewRow( table.RowData{ columnKeyName: "Micah Hurst", columnKeyCountry: "Pakistan", columnKeyCurrency: "$80.84", }), table.NewRow( table.RowData{ columnKeyName: "Dora Miranda", columnKeyCountry: "Colombia", columnKeyCurrency: "$34.75", }), table.NewRow( table.RowData{ columnKeyName: "Keefe Walters", columnKeyCountry: "China", columnKeyCurrency: "$56.82", }), table.NewRow( table.RowData{ columnKeyName: "Fujimoto Tarokizaemon no shoutokinori", columnKeyCountry: "Japan", columnKeyCurrency: "$89.31", }), table.NewRow( table.RowData{ columnKeyName: "Keefe Walters", columnKeyCountry: "China", columnKeyCurrency: "$56.82", }), table.NewRow( table.RowData{ columnKeyName: "Vincent Sanchez", columnKeyCountry: "Peru", columnKeyCurrency: "$71.60", }), table.NewRow( table.RowData{ columnKeyName: "Lani Figueroa", columnKeyCountry: "United Kingdom", columnKeyCurrency: "$90.67", }), } model := Model{ tableModel: table.New(columns). WithRows(rows). HeaderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)). Focused(true). WithBaseStyle( lipgloss.NewStyle(). BorderForeground(lipgloss.Color("#a38")). Foreground(lipgloss.Color("#a7a")). Align(lipgloss.Left), ). WithMultiline(true), } return model } 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.tableModel, cmd = m.tableModel.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 table demo with multiline feature enabled!\n") body.WriteString("Press up/down or j/k to move around\n") 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/pagination/README.md ================================================ # Pagination example This example shows how to paginate data that would be too long to show in a single screen. It also shows how to do this with multiple tables and to navigate between them. image ================================================ 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. ![Styled table](https://user-images.githubusercontent.com/5923958/156778142-cc1a32e1-1b1e-4a65-b699-187f39f0f946.png) ================================================ 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. image ================================================ 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. image ================================================ 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. image ================================================ 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. ![table-gif](https://user-images.githubusercontent.com/5923958/170802479-a4395407-f286-42d6-9165-0580db6030db.gif) ================================================ 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("/enter", "select row"), ), PageDown: key.NewBinding( key.WithKeys("right", "l", "pgdown"), key.WithHelp("→/h/page down", "next page"), ), PageUp: key.NewBinding( key.WithKeys("left", "h", "pgup"), key.WithHelp("←/h/page up", "previous page"), ), PageFirst: key.NewBinding( key.WithKeys("home", "g"), key.WithHelp("home/g", "first page"), ), PageLast: key.NewBinding( key.WithKeys("end", "G"), key.WithHelp("end/G", "last page"), ), Filter: key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "filter"), ), FilterBlur: key.NewBinding( key.WithKeys("enter", "esc"), key.WithHelp("enter/esc", "unfocus"), ), FilterClear: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), ), ScrollRight: key.NewBinding( key.WithKeys("shift+right"), key.WithHelp("shift+→", "scroll right"), ), ScrollLeft: key.NewBinding( key.WithKeys("shift+left"), key.WithHelp("shift+←", "scroll left"), ), } } // FullHelp returns a multi row view of all the helpkeys that are defined. Needed to fullfil the 'help.Model' interface. // Also appends all user defined extra keys to the help. func (m Model) FullHelp() [][]key.Binding { keyBinds := [][]key.Binding{ {m.keyMap.RowDown, m.keyMap.RowUp, m.keyMap.RowSelectToggle}, {m.keyMap.PageDown, m.keyMap.PageUp, m.keyMap.PageFirst, m.keyMap.PageLast}, {m.keyMap.Filter, m.keyMap.FilterBlur, m.keyMap.FilterClear, m.keyMap.ScrollRight, m.keyMap.ScrollLeft}, } if m.additionalFullHelpKeys != nil { keyBinds = append(keyBinds, m.additionalFullHelpKeys()) } return keyBinds } // ShortHelp just returns a single row of help views. Needed to fullfil the 'help.Model' interface. // Also appends all user defined extra keys to the help. func (m Model) ShortHelp() []key.Binding { keyBinds := []key.Binding{ m.keyMap.RowDown, m.keyMap.RowUp, m.keyMap.RowSelectToggle, m.keyMap.PageDown, m.keyMap.PageUp, m.keyMap.Filter, m.keyMap.FilterBlur, m.keyMap.FilterClear, } if m.additionalShortHelpKeys != nil { keyBinds = append(keyBinds, m.additionalShortHelpKeys()...) } return keyBinds } ================================================ FILE: table/keys_test.go ================================================ package table import ( "testing" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/stretchr/testify/assert" ) func TestKeyMapShortHelp(t *testing.T) { columns := []Column{NewColumn("c1", "Column1", 10)} model := New(columns) km := DefaultKeyMap() model.WithKeyMap(km) assert.Nil(t, model.additionalShortHelpKeys) assert.Equal(t, model.ShortHelp(), []key.Binding{ model.keyMap.RowDown, model.keyMap.RowUp, model.keyMap.RowSelectToggle, model.keyMap.PageDown, model.keyMap.PageUp, model.keyMap.Filter, model.keyMap.FilterBlur, model.keyMap.FilterClear}, ) // Testing if the 'adding of keys' works too. keys := []key.Binding{key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "Testing additional keybinds"))} model = model.WithAdditionalShortHelpKeys(keys) assert.NotNil(t, model.additionalShortHelpKeys) assert.Equal(t, model.ShortHelp(), []key.Binding{ model.keyMap.RowDown, model.keyMap.RowUp, model.keyMap.RowSelectToggle, model.keyMap.PageDown, model.keyMap.PageUp, model.keyMap.Filter, model.keyMap.FilterBlur, model.keyMap.FilterClear, key.NewBinding( key.WithKeys("t"), key.WithHelp("t", "Testing additional keybinds"), ), }) } func TestKeyMapFullHelp(t *testing.T) { columns := []Column{NewColumn("c1", "Column1", 10)} model := New(columns) km := DefaultKeyMap() model.WithKeyMap(km) assert.Nil(t, model.additionalFullHelpKeys) assert.Equal(t, model.FullHelp(), [][]key.Binding{ {model.keyMap.RowDown, model.keyMap.RowUp, model.keyMap.RowSelectToggle}, {model.keyMap.PageDown, model.keyMap.PageUp, model.keyMap.PageFirst, model.keyMap.PageLast}, { model.keyMap.Filter, model.keyMap.FilterBlur, model.keyMap.FilterClear, model.keyMap.ScrollRight, model.keyMap.ScrollLeft, }, }, ) // Testing if the 'adding of keys' works too. keys := []key.Binding{key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "Testing additional keybinds"))} model = model.WithAdditionalFullHelpKeys(keys) assert.NotNil(t, model.additionalFullHelpKeys) assert.Equal(t, model.FullHelp(), [][]key.Binding{ {model.keyMap.RowDown, model.keyMap.RowUp, model.keyMap.RowSelectToggle}, {model.keyMap.PageDown, model.keyMap.PageUp, model.keyMap.PageFirst, model.keyMap.PageLast}, {model.keyMap.Filter, model.keyMap.FilterBlur, model.keyMap.FilterClear, model.keyMap.ScrollRight, model.keyMap.ScrollLeft}, {key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "Testing additional keybinds"))}}, ) } // Testing if Model actually implements the 'help.KeyMap' interface. func TestKeyMapInterface(t *testing.T) { model := New(nil) assert.Implements(t, (*help.KeyMap)(nil), model) } ================================================ FILE: table/model.go ================================================ package table import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( columnKeySelect = "___select___" ) var ( defaultHighlightStyle = lipgloss.NewStyle().Background(lipgloss.Color("#334")) ) // Model is the main table model. Create using New(). type Model struct { // Data columns []Column rows []Row metadata map[string]any // Caches for optimizations visibleRowCacheUpdated bool visibleRowCache []Row // Shown when data is missing from a row missingDataIndicator any // Interaction focused bool keyMap KeyMap // Taken from: 'Bubbles/List' // Additional key mappings for the short and full help views. This allows // you to add additional key mappings to the help menu without // re-implementing the help component. Of course, you can also disable the // list's help component and implement a new one if you need more // flexibility. // You have to supply a keybinding like this: // key.NewBinding( key.WithKeys("shift+left"), key.WithHelp("shift+←", "scroll left")) // It needs both 'WithKeys' and 'WithHelp' additionalShortHelpKeys func() []key.Binding additionalFullHelpKeys func() []key.Binding selectableRows bool rowCursorIndex int // Events lastUpdateUserEvents []UserEvent // Styles baseStyle lipgloss.Style highlightStyle lipgloss.Style headerStyle lipgloss.Style rowStyleFunc func(RowStyleFuncInput) lipgloss.Style border Border selectedText string unselectedText string // Header headerVisible bool // Footers footerVisible bool staticFooter string // Pagination pageSize int currentPage int paginationWrapping bool // Sorting, where a stable sort is applied from first element to last so // that elements are grouped by the later elements. sortOrder []SortColumn // Filter filtered bool filterTextInput textinput.Model filterFunc FilterFunc // For flex columns targetTotalWidth int // The maximum total width for overflow/scrolling maxTotalWidth int // Internal cached calculations for reference, may be higher than // maxTotalWidth. If this is the case, we need to adjust the view totalWidth int // How far to scroll to the right, in columns horizontalScrollOffsetCol int // How many columns to freeze when scrolling horizontally horizontalScrollFreezeColumnsCount int // Calculated maximum column we can scroll to before the last is displayed maxHorizontalColumnIndex int // Minimum total height of the table minimumHeight int // Internal cached calculation, the height of the header and footer // including borders. Used to determine how many padding rows to add. metaHeight int // If true, the table will be multiline multiline bool } // New creates a new table ready for further modifications. func New(columns []Column) Model { filterInput := textinput.New() filterInput.Prompt = "/" model := Model{ columns: make([]Column, len(columns)), metadata: make(map[string]any), highlightStyle: defaultHighlightStyle.Copy(), border: borderDefault, headerVisible: true, footerVisible: true, keyMap: DefaultKeyMap(), selectedText: "[x]", unselectedText: "[ ]", filterTextInput: filterInput, filterFunc: filterFuncContains, baseStyle: lipgloss.NewStyle().Align(lipgloss.Right), paginationWrapping: true, } // Do a full deep copy to avoid unexpected edits copy(model.columns, columns) model.recalculateWidth() return model } // Init initializes the table per the Bubble Tea architecture. func (m Model) Init() tea.Cmd { return nil } ================================================ FILE: table/model_test.go ================================================ package table import ( "testing" "github.com/stretchr/testify/assert" ) func TestModelInitReturnsNil(t *testing.T) { model := New(nil) cmd := model.Init() assert.Nil(t, cmd) } ================================================ FILE: table/options.go ================================================ package table import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/lipgloss" ) // RowStyleFuncInput is the input to the style function that can // be applied to each row. This is useful for things like zebra // striping or other data-based styles. // // Note that we use a struct here to allow for future expansion // while keeping backwards compatibility. type RowStyleFuncInput struct { // Index is the index of the row, starting at 0. Index int // Row is the full row data. Row Row // IsHighlighted is true if the row is currently highlighted. IsHighlighted bool } // WithRowStyleFunc sets a function that can be used to apply a style to each row // based on the row data. This is useful for things like zebra striping or other // data-based styles. It can be safely set to nil to remove it later. // This style is applied after the base style and before individual row styles. // This will override any HighlightStyle settings. func (m Model) WithRowStyleFunc(f func(RowStyleFuncInput) lipgloss.Style) Model { m.rowStyleFunc = f return m } // WithHighlightedRow sets the highlighted row to the given index. func (m Model) WithHighlightedRow(index int) Model { m.rowCursorIndex = index if m.rowCursorIndex >= len(m.GetVisibleRows()) { m.rowCursorIndex = len(m.GetVisibleRows()) - 1 } if m.rowCursorIndex < 0 { m.rowCursorIndex = 0 } m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex) return m } // HeaderStyle sets the style to apply to the header text, such as color or bold. func (m Model) HeaderStyle(style lipgloss.Style) Model { m.headerStyle = style.Copy() return m } // WithRows sets the rows to show as data in the table. func (m Model) WithRows(rows []Row) Model { m.rows = rows m.visibleRowCacheUpdated = false if m.rowCursorIndex >= len(m.rows) { m.rowCursorIndex = len(m.rows) - 1 } if m.rowCursorIndex < 0 { m.rowCursorIndex = 0 } if m.pageSize != 0 { maxPage := m.MaxPages() // MaxPages is 1-index, currentPage is 0 index if maxPage <= m.currentPage { m.pageLast() } } return m } // WithKeyMap sets the key map to use for controls when focused. func (m Model) WithKeyMap(keyMap KeyMap) Model { m.keyMap = keyMap return m } // KeyMap returns a copy of the current key map in use. func (m Model) KeyMap() KeyMap { return m.keyMap } // SelectableRows sets whether or not rows are selectable. If set, adds a column // in the front that acts as a checkbox and responds to controls if Focused. func (m Model) SelectableRows(selectable bool) Model { m.selectableRows = selectable hasSelectColumn := len(m.columns) > 0 && m.columns[0].key == columnKeySelect if hasSelectColumn != selectable { if selectable { m.columns = append([]Column{ NewColumn(columnKeySelect, m.selectedText, len([]rune(m.selectedText))), }, m.columns...) } else { m.columns = m.columns[1:] } } m.recalculateWidth() return m } // HighlightedRow returns the full Row that's currently highlighted by the user. func (m Model) HighlightedRow() Row { if len(m.GetVisibleRows()) > 0 { return m.GetVisibleRows()[m.rowCursorIndex] } // TODO: Better way to do this without pointers/nil? Or should it be nil? return Row{} } // SelectedRows returns all rows that have been set as selected by the user. func (m Model) SelectedRows() []Row { selectedRows := []Row{} for _, row := range m.GetVisibleRows() { if row.selected { selectedRows = append(selectedRows, row) } } return selectedRows } // HighlightStyle sets a custom style to use when the row is being highlighted // by the cursor. This should not be used with WithRowStyleFunc. Instead, use // the IsHighlighted field in the style function. func (m Model) HighlightStyle(style lipgloss.Style) Model { m.highlightStyle = style return m } // Focused allows the table to show highlighted rows and take in controls of // up/down/space/etc to let the user navigate the table and interact with it. func (m Model) Focused(focused bool) Model { m.focused = focused return m } // Filtered allows the table to show rows that match the filter. func (m Model) Filtered(filtered bool) Model { m.filtered = filtered m.visibleRowCacheUpdated = false if m.minimumHeight > 0 { m.recalculateHeight() } return m } // StartFilterTyping focuses the text input to allow user typing to filter. func (m Model) StartFilterTyping() Model { m.filterTextInput.Focus() return m } // WithStaticFooter adds a footer that only displays the given text. func (m Model) WithStaticFooter(footer string) Model { m.staticFooter = footer if m.minimumHeight > 0 { m.recalculateHeight() } return m } // WithPageSize enables pagination using the given page size. This can be called // again at any point to resize the height of the table. func (m Model) WithPageSize(pageSize int) Model { m.pageSize = pageSize maxPages := m.MaxPages() if m.currentPage >= maxPages { m.currentPage = maxPages - 1 } if m.minimumHeight > 0 { m.recalculateHeight() } return m } // WithNoPagination disables pagination in the table. func (m Model) WithNoPagination() Model { m.pageSize = 0 if m.minimumHeight > 0 { m.recalculateHeight() } return m } // WithPaginationWrapping sets whether to wrap around from the beginning to the // end when navigating through pages. Defaults to true. func (m Model) WithPaginationWrapping(wrapping bool) Model { m.paginationWrapping = wrapping return m } // WithSelectedText describes what text to show when selectable rows are enabled. // The selectable column header will use the selected text string. func (m Model) WithSelectedText(unselected, selected string) Model { m.selectedText = selected m.unselectedText = unselected if len(m.columns) > 0 && m.columns[0].key == columnKeySelect { m.columns[0] = NewColumn(columnKeySelect, m.selectedText, len([]rune(m.selectedText))) m.recalculateWidth() } return m } // WithBaseStyle applies a base style as the default for everything in the table. // This is useful for border colors, default alignment, default color, etc. func (m Model) WithBaseStyle(style lipgloss.Style) Model { m.baseStyle = style return m } // WithTargetWidth sets the total target width of the table, including borders. // This only takes effect when using flex columns. When using flex columns, // columns will stretch to fill out to the total width given here. func (m Model) WithTargetWidth(totalWidth int) Model { m.targetTotalWidth = totalWidth m.recalculateWidth() return m } // WithMinimumHeight sets the minimum total height of the table, including borders. func (m Model) WithMinimumHeight(minimumHeight int) Model { m.minimumHeight = minimumHeight m.recalculateHeight() return m } // PageDown goes to the next page of a paginated table, wrapping to the first // page if the table is already on the last page. func (m Model) PageDown() Model { m.pageDown() return m } // PageUp goes to the previous page of a paginated table, wrapping to the // last page if the table is already on the first page. func (m Model) PageUp() Model { m.pageUp() return m } // PageLast goes to the last page of a paginated table. func (m Model) PageLast() Model { m.pageLast() return m } // PageFirst goes to the first page of a paginated table. func (m Model) PageFirst() Model { m.pageFirst() return m } // WithCurrentPage sets the current page (1 as the first page) of a paginated // table, bounded to the total number of pages. The current selected row will // be set to the top row of the page if the page changed. func (m Model) WithCurrentPage(currentPage int) Model { if m.pageSize == 0 || currentPage == m.CurrentPage() { return m } if currentPage < 1 { currentPage = 1 } else { maxPages := m.MaxPages() if currentPage > maxPages { currentPage = maxPages } } m.currentPage = currentPage - 1 m.rowCursorIndex = m.currentPage * m.pageSize return m } // WithColumns sets the visible columns for the table, so that columns can be // added/removed/resized or headers rewritten. func (m Model) WithColumns(columns []Column) Model { // Deep copy to avoid edits m.columns = make([]Column, len(columns)) copy(m.columns, columns) m.recalculateWidth() if m.selectableRows { // Re-add the selectable column m = m.SelectableRows(true) } return m } // WithFilterInput makes the table use the provided text input bubble for // filtering rather than using the built-in default. This allows for external // text input controls to be used. func (m Model) WithFilterInput(input textinput.Model) Model { if m.filterTextInput.Value() != input.Value() { m.pageFirst() } m.filterTextInput = input m.visibleRowCacheUpdated = false return m } // WithFilterInputValue sets the filter value to the given string, immediately // applying it as if the user had typed it in. Useful for external filter inputs // that are not necessarily a text input. func (m Model) WithFilterInputValue(value string) Model { if m.filterTextInput.Value() != value { m.pageFirst() } m.filterTextInput.SetValue(value) m.filterTextInput.Blur() m.visibleRowCacheUpdated = false return m } // WithFilterFunc adds a filter function to the model. If the function returns // true, the row will be included in the filtered results. If the function // is nil, the function won't be used and instead the default filtering will be applied, // if any. func (m Model) WithFilterFunc(shouldInclude FilterFunc) Model { m.filterFunc = shouldInclude m.visibleRowCacheUpdated = false return m } // WithFuzzyFilter enables fuzzy filtering for the table. func (m Model) WithFuzzyFilter() Model { return m.WithFilterFunc(filterFuncFuzzy) } // WithFooterVisibility sets the visibility of the footer. func (m Model) WithFooterVisibility(visibility bool) Model { m.footerVisible = visibility if m.minimumHeight > 0 { m.recalculateHeight() } return m } // WithHeaderVisibility sets the visibility of the header. func (m Model) WithHeaderVisibility(visibility bool) Model { m.headerVisible = visibility if m.minimumHeight > 0 { m.recalculateHeight() } return m } // WithMaxTotalWidth sets the maximum total width that the table should render. // If this width is exceeded by either the target width or by the total width // of all the columns (including borders!), anything extra will be treated as // overflow and horizontal scrolling will be enabled to see the rest. func (m Model) WithMaxTotalWidth(maxTotalWidth int) Model { m.maxTotalWidth = maxTotalWidth m.recalculateWidth() return m } // WithHorizontalFreezeColumnCount freezes the given number of columns to the // left side. This is useful for things like ID or Name columns that should // always be visible even when scrolling. func (m Model) WithHorizontalFreezeColumnCount(columnsToFreeze int) Model { m.horizontalScrollFreezeColumnsCount = columnsToFreeze m.recalculateWidth() return m } // ScrollRight moves one column to the right. Use with WithMaxTotalWidth. func (m Model) ScrollRight() Model { m.scrollRight() return m } // ScrollLeft moves one column to the left. Use with WithMaxTotalWidth. func (m Model) ScrollLeft() Model { m.scrollLeft() return m } // WithMissingDataIndicator sets an indicator to use when data for a column is // not found in a given row. Note that this is for completely missing data, // an empty string or other zero value that is explicitly set is not considered // to be missing. func (m Model) WithMissingDataIndicator(str string) Model { m.missingDataIndicator = str return m } // WithMissingDataIndicatorStyled sets a styled indicator to use when data for // a column is not found in a given row. Note that this is for completely // missing data, an empty string or other zero value that is explicitly set is // not considered to be missing. func (m Model) WithMissingDataIndicatorStyled(styled StyledCell) Model { m.missingDataIndicator = styled return m } // WithAllRowsDeselected deselects any rows that are currently selected. func (m Model) WithAllRowsDeselected() Model { rows := m.GetVisibleRows() for i, row := range rows { if row.selected { rows[i] = row.Selected(false) } } m.rows = rows return m } // WithMultiline sets whether or not to wrap text in cells to multiple lines. func (m Model) WithMultiline(multiline bool) Model { m.multiline = multiline return m } // WithAdditionalShortHelpKeys enables you to add more keybindings to the 'short help' view. func (m Model) WithAdditionalShortHelpKeys(keys []key.Binding) Model { m.additionalShortHelpKeys = func() []key.Binding { return keys } return m } // WithAdditionalFullHelpKeys enables you to add more keybindings to the 'full help' view. func (m Model) WithAdditionalFullHelpKeys(keys []key.Binding) Model { m.additionalFullHelpKeys = func() []key.Binding { return keys } return m } // WithGlobalMetadata applies the given metadata to the table. This metadata is passed to // some functions in FilterFuncInput and StyleFuncInput to enable more advanced decisions, // such as setting some global theme variable to reference, etc. Has no effect otherwise. func (m Model) WithGlobalMetadata(metadata map[string]any) Model { m.metadata = metadata return m } ================================================ FILE: table/options_test.go ================================================ package table import ( "testing" "github.com/stretchr/testify/assert" ) func TestWithHighlightedRowSet(t *testing.T) { highlightedIndex := 1 cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), }).WithHighlightedRow(highlightedIndex) assert.Equal(t, model.rows[highlightedIndex], model.HighlightedRow()) } func TestWithHighlightedRowSetNegative(t *testing.T) { highlightedIndex := -1 cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), }).WithHighlightedRow(highlightedIndex) assert.Equal(t, model.rows[0], model.HighlightedRow()) } func TestWithHighlightedRowSetTooHigh(t *testing.T) { highlightedIndex := 2 cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), }).WithHighlightedRow(highlightedIndex) assert.Equal(t, model.rows[1], model.HighlightedRow()) } // This is long only because it's a lot of repetitive test cases // //nolint:funlen func TestPageOptions(t *testing.T) { const ( pageSize = 5 rowCount = 30 ) cols := []Column{ NewColumn("id", "ID", 3), } rows := make([]Row, rowCount) model := New(cols).WithRows(rows).WithPageSize(pageSize) assert.Equal(t, 1, model.CurrentPage()) model = model.PageDown() assert.Equal(t, 2, model.CurrentPage()) model = model.PageDown() model = model.PageUp() assert.Equal(t, 2, model.CurrentPage()) model = model.PageLast() assert.Equal(t, 6, model.CurrentPage()) model = model.PageLast() model = model.PageLast() assert.Equal(t, 6, model.CurrentPage()) model = model.PageFirst() assert.Equal(t, 1, model.CurrentPage()) model = model.PageFirst() model = model.PageFirst() assert.Equal(t, 1, model.CurrentPage()) model = model.PageUp() assert.Equal(t, 6, model.CurrentPage()) model = model.PageDown() assert.Equal(t, 1, model.CurrentPage()) model = model.WithCurrentPage(3) model = model.WithCurrentPage(3) model = model.WithCurrentPage(3) assert.Equal(t, 3, model.CurrentPage()) assert.Equal(t, 10, model.rowCursorIndex) model = model.WithCurrentPage(-1) assert.Equal(t, 1, model.CurrentPage()) assert.Equal(t, 0, model.rowCursorIndex) model = model.WithCurrentPage(0) assert.Equal(t, 1, model.CurrentPage()) assert.Equal(t, 0, model.rowCursorIndex) model = model.WithCurrentPage(7) assert.Equal(t, 6, model.CurrentPage()) assert.Equal(t, 25, model.rowCursorIndex) model.rowCursorIndex = 26 model = model.WithCurrentPage(6) assert.Equal(t, 6, model.CurrentPage()) assert.Equal(t, 26, model.rowCursorIndex) model = model.WithFooterVisibility(false) assert.Equal(t, "", model.renderFooter(10, false)) model = model.WithFooterVisibility(true) assert.Greater(t, len(model.renderFooter(10, false)), 10) assert.Contains(t, model.renderFooter(10, false), "6/6") } func TestMinimumHeightOptions(t *testing.T) { columns := []Column{ NewColumn("ka", "a", 3), NewColumn("kb", "b", 4), NewColumn("kc", "c", 5), } model := New(columns).WithMinimumHeight(10) assert.Equal(t, 10, model.minimumHeight) assert.Equal(t, 3, model.metaHeight) model = model.WithPageSize(2) assert.Equal(t, 5, model.metaHeight) model = model.WithNoPagination() assert.Equal(t, 3, model.metaHeight) model = model.WithStaticFooter("footer with\nmultiple lines") assert.Equal(t, 6, model.metaHeight) model = model.WithStaticFooter("").Filtered(true) assert.Equal(t, 5, model.metaHeight) model = model.WithFooterVisibility(false) assert.Equal(t, 3, model.metaHeight) model = model.WithHeaderVisibility(false) assert.Equal(t, 1, model.metaHeight) } // This is long only because the test cases are larger // //nolint:funlen func TestSelectRowsProgramatically(t *testing.T) { const col = "id" tests := map[string]struct { rows []Row selectedIDs []int }{ "no rows selected": { []Row{ NewRow(RowData{col: 1}), NewRow(RowData{col: 2}), NewRow(RowData{col: 3}), }, []int{}, }, "all rows selected": { []Row{ NewRow(RowData{col: 1}).Selected(true), NewRow(RowData{col: 2}).Selected(true), NewRow(RowData{col: 3}).Selected(true), }, []int{1, 2, 3}, }, "first row selected": { []Row{ NewRow(RowData{col: 1}).Selected(true), NewRow(RowData{col: 2}), NewRow(RowData{col: 3}), }, []int{1}, }, "last row selected": { []Row{ NewRow(RowData{col: 1}), NewRow(RowData{col: 2}), NewRow(RowData{col: 3}).Selected(true), }, []int{3}, }, } baseModel := New([]Column{ NewColumn(col, col, 1), }) for name, test := range tests { t.Run(name, func(t *testing.T) { model := baseModel.WithRows(test.rows) sel := model.SelectedRows() assert.Equal(t, len(test.selectedIDs), len(sel)) for i, id := range test.selectedIDs { assert.Equal(t, id, sel[i].Data[col], "expecting row %d to have same %s column value", i) } model = model.WithAllRowsDeselected() assert.Len(t, model.SelectedRows(), 0, "Did not deselect all rows") }) } } func TestDefaultBorderIsDefault(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 1), }).WithRows([]Row{ NewRow(RowData{"id": 1}), NewRow(RowData{"id": 2}), NewRow(RowData{"id": 3}), }) renderedInitial := model.View() model = model.BorderRounded() renderedRounded := model.View() model = model.BorderDefault() renderedDefault := model.View() assert.NotEqual(t, renderedInitial, renderedRounded, "Rounded border should differ from default") assert.Equal(t, renderedInitial, renderedDefault, "Default border should match initial state") } func BenchmarkSelectedRows(b *testing.B) { const N = 1000 b.ReportAllocs() rows := make([]Row, 0, N) for i := 0; i < N; i++ { rows = append(rows, NewRow(RowData{"row": i}).Selected(i%2 == 0)) } model := New([]Column{ NewColumn("row", "Row", 4), }).WithRows(rows) var sel []Row b.ResetTimer() for i := 0; i < b.N; i++ { sel = model.SelectedRows() } Rows = sel } var Rows []Row ================================================ FILE: table/overflow.go ================================================ package table import "github.com/charmbracelet/lipgloss" const columnKeyOverflowRight = "___overflow_r___" const columnKeyOverflowLeft = "___overflow_l__" func genOverflowStyle(base lipgloss.Style, width int) lipgloss.Style { return base.Width(width).Align(lipgloss.Right) } func genOverflowColumnRight(width int) Column { return NewColumn(columnKeyOverflowRight, ">", width) } func genOverflowColumnLeft(width int) Column { return NewColumn(columnKeyOverflowLeft, "<", width) } ================================================ FILE: table/pagination.go ================================================ package table // PageSize returns the current page size for the table, or 0 if there is no // pagination enabled. func (m *Model) PageSize() int { return m.pageSize } // CurrentPage returns the current page that the table is on, starting from an // index of 1. func (m *Model) CurrentPage() int { return m.currentPage + 1 } // MaxPages returns the maximum number of pages that are visible. func (m *Model) MaxPages() int { totalRows := len(m.GetVisibleRows()) if m.pageSize == 0 || totalRows == 0 { return 1 } return (totalRows-1)/m.pageSize + 1 } // TotalRows returns the current total row count of the table. If the table is // paginated, this is the total number of rows across all pages. func (m *Model) TotalRows() int { return len(m.GetVisibleRows()) } // VisibleIndices returns the current visible rows by their 0 based index. // Useful for custom pagination footers. func (m *Model) VisibleIndices() (start, end int) { totalRows := len(m.GetVisibleRows()) if m.pageSize == 0 { start = 0 end = totalRows - 1 return start, end } start = m.pageSize * m.currentPage end = start + m.pageSize - 1 if end >= totalRows { end = totalRows - 1 } return start, end } func (m *Model) pageDown() { if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize { return } m.currentPage++ maxPageIndex := m.MaxPages() - 1 if m.currentPage > maxPageIndex { if m.paginationWrapping { m.currentPage = 0 } else { m.currentPage = maxPageIndex } } m.rowCursorIndex = m.currentPage * m.pageSize } func (m *Model) pageUp() { if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize { return } m.currentPage-- maxPageIndex := m.MaxPages() - 1 if m.currentPage < 0 { if m.paginationWrapping { m.currentPage = maxPageIndex } else { m.currentPage = 0 } } m.rowCursorIndex = m.currentPage * m.pageSize } func (m *Model) pageFirst() { m.currentPage = 0 m.rowCursorIndex = 0 } func (m *Model) pageLast() { m.currentPage = m.MaxPages() - 1 m.rowCursorIndex = m.currentPage * m.pageSize } func (m *Model) expectedPageForRowIndex(rowIndex int) int { if m.pageSize == 0 { return 0 } expectedPage := rowIndex / m.pageSize return expectedPage } ================================================ FILE: table/pagination_test.go ================================================ package table import ( "testing" "github.com/stretchr/testify/assert" ) func genPaginationTable(count, pageSize int) Model { model := New([]Column{ NewColumn("id", "ID", 3), }) rows := []Row{} for i := 1; i <= count; i++ { rows = append(rows, NewRow(RowData{ "id": i, })) } return model.WithRows(rows).WithPageSize(pageSize) } func paginationRowID(row Row) int { rowID, ok := row.Data["id"].(int) if !ok { panic("id not int, bad test") } return rowID } func getVisibleRows(m *Model) []Row { start, end := m.VisibleIndices() return m.GetVisibleRows()[start : end+1] } func TestPaginationAccessors(t *testing.T) { const ( numRows = 100 pageSize = 20 ) model := genPaginationTable(numRows, pageSize) assert.Equal(t, numRows, model.TotalRows()) assert.Equal(t, pageSize, model.PageSize()) } func TestPaginationNoPageSizeReturnsAll(t *testing.T) { const ( numRows = 100 pageSize = 0 ) model := genPaginationTable(numRows, pageSize) paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows) assert.Equal(t, 1, model.MaxPages()) } func TestPaginationEmptyTableReturnsNoRows(t *testing.T) { const ( numRows = 0 pageSize = 10 ) model := genPaginationTable(numRows, pageSize) paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows) } func TestPaginationDefaultsToAllRows(t *testing.T) { const numRows = 100 model := genPaginationTable(numRows, 0) paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows) } func TestPaginationReturnsPartialFirstPage(t *testing.T) { const ( numRows = 10 pageSize = 20 ) model := genPaginationTable(numRows, pageSize) paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows) } func TestPaginationReturnsFirstFullPage(t *testing.T) { const ( pageSize = 10 numRows = 20 ) model := genPaginationTable(numRows, pageSize) paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, pageSize) for i, row := range paginatedRows { assert.Equal(t, i+1, paginationRowID(row)) } } func TestPaginationReturnsSecondFullPageAfterMoving(t *testing.T) { const ( pageSize = 10 numRows = 30 ) model := genPaginationTable(numRows, pageSize) model.pageDown() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, pageSize) for i, row := range paginatedRows { assert.Equal(t, i+11, paginationRowID(row)) } } func TestPaginationReturnsPartialFinalPage(t *testing.T) { const ( pageSize = 10 numRows = 15 ) model := genPaginationTable(numRows, pageSize) model.pageDown() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows-pageSize) for i, row := range paginatedRows { assert.Equal(t, i+11, paginationRowID(row)) } } func TestPaginationWrapsUpPartial(t *testing.T) { const ( pageSize = 10 numRows = 15 ) model := genPaginationTable(numRows, pageSize) model.pageUp() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows-pageSize) for i, row := range paginatedRows { assert.Equal(t, i+11, paginationRowID(row)) } } func TestPaginationWrapsUpFull(t *testing.T) { const ( pageSize = 10 numRows = 20 ) model := genPaginationTable(numRows, pageSize) model.pageUp() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows-pageSize) for i, row := range paginatedRows { assert.Equal(t, i+11, paginationRowID(row)) } } func TestPaginationWrapsUpSelf(t *testing.T) { const ( pageSize = 10 numRows = 10 ) model := genPaginationTable(numRows, pageSize) model.pageUp() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, numRows) for i, row := range paginatedRows { assert.Equal(t, i+1, paginationRowID(row)) } } func TestPaginationWrapsDown(t *testing.T) { const ( pageSize = 10 numRows = 15 ) model := genPaginationTable(numRows, pageSize) model.pageDown() model.pageDown() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, pageSize) for i, row := range paginatedRows { assert.Equal(t, i+1, paginationRowID(row)) } } func TestPaginationWrapsDownSelf(t *testing.T) { const ( pageSize = 10 numRows = 10 ) model := genPaginationTable(numRows, pageSize) model.pageDown() model.pageDown() paginatedRows := getVisibleRows(&model) assert.Len(t, paginatedRows, pageSize) for i, row := range paginatedRows { assert.Equal(t, i+1, paginationRowID(row)) } } func TestPaginationHighlightFirstOnPageDown(t *testing.T) { const ( pageSize = 10 numRows = 20 ) model := genPaginationTable(numRows, pageSize) assert.Equal(t, 1, paginationRowID(model.HighlightedRow()), "Initial test setup wrong, test code may be bad") model.pageDown() assert.Equal(t, 11, paginationRowID(model.HighlightedRow()), "Did not highlight expected row") } // This is long because of various test cases, not because of logic // //nolint:funlen func TestExpectedPageForRowIndex(t *testing.T) { tests := []struct { name string totalRows int pageSize int rowIndex int expectedPage int }{ { name: "Empty", }, { name: "No pages", totalRows: 50, pageSize: 0, rowIndex: 37, expectedPage: 0, }, { name: "One page", totalRows: 50, pageSize: 50, rowIndex: 37, expectedPage: 0, }, { name: "First page", totalRows: 50, pageSize: 30, rowIndex: 17, expectedPage: 0, }, { name: "Second page", totalRows: 50, pageSize: 30, rowIndex: 37, expectedPage: 1, }, { name: "First page first row", totalRows: 50, pageSize: 30, rowIndex: 0, expectedPage: 0, }, { name: "First page last row", totalRows: 50, pageSize: 30, rowIndex: 29, expectedPage: 0, }, { name: "Second page first row", totalRows: 50, pageSize: 30, rowIndex: 30, expectedPage: 1, }, { name: "Second page last row", totalRows: 50, pageSize: 30, rowIndex: 49, expectedPage: 1, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { model := genPaginationTable(test.totalRows, test.pageSize) page := model.expectedPageForRowIndex(test.rowIndex) assert.Equal(t, test.expectedPage, page) }) } } func TestClearPagination(t *testing.T) { const ( pageSize = 10 numRows = 20 ) model := genPaginationTable(numRows, pageSize) assert.Equal(t, 1, model.expectedPageForRowIndex(11)) model = model.WithNoPagination() assert.Equal(t, 0, model.expectedPageForRowIndex(11)) } func TestPaginationSetsLastPageWithFewerRows(t *testing.T) { const ( pageSize = 10 numRowsOriginal = 30 numRowsAfter = 18 ) model := genPaginationTable(numRowsOriginal, pageSize) model.pageUp() assert.Equal(t, 3, model.CurrentPage()) rows := []Row{} for i := 1; i <= numRowsAfter; i++ { rows = append(rows, NewRow(RowData{ "id": i, })) } model = model.WithRows(rows) assert.Equal(t, 2, model.CurrentPage()) } func TestPaginationBoundsToMaxPageOnResize(t *testing.T) { const ( pageSize = 5 numRows = 20 ) model := genPaginationTable(numRows, pageSize) assert.Equal(t, model.CurrentPage(), 1) model.pageUp() assert.Equal(t, model.CurrentPage(), 4) model = model.WithPageSize(10) assert.Equal(t, model.CurrentPage(), 2) assert.Equal(t, model.MaxPages(), 2) } ================================================ FILE: table/query.go ================================================ package table // GetColumnSorting returns the current sorting rules for the table as a list of // SortColumns, which are applied from first to last. This means that data will // be grouped by the later elements in the list. The returned list is a copy // and modifications will have no effect. func (m *Model) GetColumnSorting() []SortColumn { c := make([]SortColumn, len(m.sortOrder)) copy(c, m.sortOrder) return c } // GetCanFilter returns true if the table enables filtering at all. This does // not say whether a filter is currently active, only that the feature is enabled. func (m *Model) GetCanFilter() bool { return m.filtered } // GetIsFilterActive returns true if the table is currently being filtered. This // does not say whether the table CAN be filtered, only whether or not a filter // is actually currently being applied. func (m *Model) GetIsFilterActive() bool { return m.filterTextInput.Value() != "" } // GetIsFilterInputFocused returns true if the table's built-in filter input is // currently focused. func (m *Model) GetIsFilterInputFocused() bool { return m.filterTextInput.Focused() } // GetCurrentFilter returns the current filter text being applied, or an empty // string if none is applied. func (m *Model) GetCurrentFilter() string { return m.filterTextInput.Value() } // GetVisibleRows returns sorted and filtered rows. func (m *Model) GetVisibleRows() []Row { if m.visibleRowCacheUpdated { return m.visibleRowCache } rows := make([]Row, len(m.rows)) copy(rows, m.rows) if m.filtered { rows = m.getFilteredRows(rows) } rows = getSortedRows(m.sortOrder, rows) m.visibleRowCache = rows m.visibleRowCacheUpdated = true return rows } // GetHighlightedRowIndex returns the index of the Row that's currently highlighted // by the user. func (m *Model) GetHighlightedRowIndex() int { return m.rowCursorIndex } // GetFocused returns whether or not the table is focused and is receiving inputs. func (m *Model) GetFocused() bool { return m.focused } // GetHorizontalScrollColumnOffset returns how many columns to the right the table // has been scrolled. 0 means the table is all the way to the left, which is // the starting default. func (m *Model) GetHorizontalScrollColumnOffset() int { return m.horizontalScrollOffsetCol } // GetHeaderVisibility returns true if the header has been set to visible (default) // or false if the header has been set to hidden. func (m *Model) GetHeaderVisibility() bool { return m.headerVisible } // GetFooterVisibility returns true if the footer has been set to // visible (default) or false if the footer has been set to hidden. // Note that even if the footer is visible it will only be rendered if // it has contents. func (m *Model) GetFooterVisibility() bool { return m.footerVisible } // GetPaginationWrapping returns true if pagination wrapping is enabled, or false // if disabled. If disabled, navigating through pages will stop at the first // and last pages. func (m *Model) GetPaginationWrapping() bool { return m.paginationWrapping } ================================================ FILE: table/query_test.go ================================================ package table import ( "testing" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) func TestGetColumnSorting(t *testing.T) { cols := []Column{ NewColumn("a", "a", 3), NewColumn("b", "b", 3), NewColumn("c", "c", 3), } model := New(cols).SortByAsc("b") sorted := model.GetColumnSorting() assert.Len(t, sorted, 1, "Should only have one column") assert.Equal(t, sorted[0].ColumnKey, "b", "Should sort column b") assert.Equal(t, sorted[0].Direction, SortDirectionAsc, "Should be ascending") sorted[0].Direction = SortDirectionDesc assert.NotEqual( t, model.sortOrder[0].Direction, sorted[0].Direction, "Should not have been able to modify actual values", ) } func TestGetFilterData(t *testing.T) { model := New([]Column{}) assert.False(t, model.GetIsFilterActive(), "Should not start with filter active") assert.False(t, model.GetCanFilter(), "Should not start with filter ability") assert.Equal(t, model.GetCurrentFilter(), "", "Filter string should be empty") model = model.Filtered(true) assert.False(t, model.GetIsFilterActive(), "Should not be filtered just because the ability was activated") assert.True(t, model.GetCanFilter(), "Filter feature should be enabled") assert.Equal(t, model.GetCurrentFilter(), "", "Filter string should be empty") model.filterTextInput.SetValue("a") assert.True(t, model.GetIsFilterActive(), "Typing anything into box should mark as filtered") assert.True(t, model.GetCanFilter(), "Filter feature should be enabled") assert.Equal(t, model.GetCurrentFilter(), "a", "Filter string should be what was typed") } func TestGetVisibleRows(t *testing.T) { input := textinput.Model{} input.SetValue("AAA") columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)} rows := []Row{ NewRow(RowData{ "title": "AAA", "description": "", }), NewRow(RowData{ "title": "BBB", "description": "", }), NewRow(RowData{ "title": "CCC", "description": "", }), } m := Model{filtered: true, filterTextInput: input, columns: columns, rows: rows} visibleRows := m.GetVisibleRows() assert.Len(t, visibleRows, 1) } func TestGetHighlightedRowIndex(t *testing.T) { model := New([]Column{}) assert.Equal(t, 0, model.GetHighlightedRowIndex(), "Empty table should still safely have 0 index highlighted") // We don't actually need data to test this empty := RowData{} model = model.WithRows([]Row{NewRow(empty), NewRow(empty)}) assert.Equal(t, 0, model.GetHighlightedRowIndex(), "Unfocused table should start with 0 index") model = model.WithHighlightedRow(1) assert.Equal(t, 1, model.GetHighlightedRowIndex(), "Table with set highlighted row should return same highlighted row") } func TestGetFocused(t *testing.T) { model := New([]Column{}) assert.Equal(t, false, model.GetFocused(), "Table should not be focused by default") model = model.Focused(true) assert.Equal(t, true, model.GetFocused(), "Table should be focused after being set") } func TestGetHorizontalScrollColumnOffset(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithMaxTotalWidth(18). Focused(true) hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } hitScrollLeft := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftLeft}) } assert.Equal( t, 0, model.GetHorizontalScrollColumnOffset(), "Should start to left", ) hitScrollRight() assert.Equal( t, 1, model.GetHorizontalScrollColumnOffset(), "Should be 1 after scrolling to the right once", ) hitScrollLeft() assert.Equal( t, 0, model.GetHorizontalScrollColumnOffset(), "Should be back to 0 after moving to the left", ) hitScrollLeft() assert.Equal( t, 0, model.GetHorizontalScrollColumnOffset(), "Should still be 0 after trying to go left again", ) } func TestGetHeaderVisibility(t *testing.T) { model := New([]Column{}) assert.True(t, model.GetHeaderVisibility(), "Header should be visible by default") model = model.WithHeaderVisibility(false) assert.False(t, model.GetHeaderVisibility(), "Header was not set to hidden") } func TestGetFooterVisibility(t *testing.T) { model := New([]Column{}) assert.True(t, model.GetFooterVisibility(), "Footer should be visible by default") model = model.WithFooterVisibility(false) assert.False(t, model.GetFooterVisibility(), "Footer was not set to hidden") } func TestGetPaginationWrapping(t *testing.T) { model := New([]Column{}) assert.True(t, model.GetPaginationWrapping(), "Pagination wrapping should default to true") model = model.WithPaginationWrapping(false) assert.False(t, model.GetPaginationWrapping(), "Pagination wrapping setting did not update after setting option") } func TestGetIsFilterInputFocused(t *testing.T) { model := New([]Column{}).Filtered(true).Focused(true) assert.False(t, model.GetIsFilterInputFocused(), "Text input shouldn't start focused") model, _ = model.Update(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune{'/'}, }) assert.True(t, model.GetIsFilterInputFocused(), "Did not trigger text input") model, _ = model.Update(tea.KeyMsg{ Type: tea.KeyEnter, }) assert.False(t, model.GetIsFilterInputFocused(), "Should no longer be focused after hitting enter") } ================================================ FILE: table/row.go ================================================ package table import ( "fmt" "sync/atomic" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" ) // RowData is a map of string column keys to arbitrary data. Data with a key // that matches a column key will be displayed. Data with a key that does not // match a column key will not be displayed, but will remain attached to the Row. // This can be useful for attaching hidden metadata for future reference when // retrieving rows. type RowData map[string]any // Row represents a row in the table with some data keyed to the table columns> // Can have a style applied to it such as color/bold. Create using NewRow(). type Row struct { Style lipgloss.Style Data RowData selected bool // id is an internal unique ID to match rows after they're copied id uint32 } var lastRowID uint32 = 1 // NewRow creates a new row and copies the given row data. func NewRow(data RowData) Row { row := Row{ Data: make(map[string]any), id: lastRowID, } atomic.AddUint32(&lastRowID, 1) for key, val := range data { // Doesn't deep copy val, but close enough for now... row.Data[key] = val } return row } // WithStyle uses the given style for the text in the row. func (r Row) WithStyle(style lipgloss.Style) Row { r.Style = style.Copy() return r } //nolint:cyclop,funlen // Breaking this up will be more complicated than it's worth for now func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Style, borderStyle lipgloss.Style) string { cellStyle := rowStyle.Copy().Inherit(column.style).Inherit(m.baseStyle) var str string switch column.key { case columnKeySelect: if row.selected { str = m.selectedText } else { str = m.unselectedText } case columnKeyOverflowRight: cellStyle = cellStyle.Align(lipgloss.Right) str = ">" case columnKeyOverflowLeft: str = "<" default: fmtString := "%v" var data any if entry, exists := row.Data[column.key]; exists { data = entry if column.fmtString != "" { fmtString = column.fmtString } } else if m.missingDataIndicator != nil { data = m.missingDataIndicator } else { data = "" } switch entry := data.(type) { case StyledCell: str = fmt.Sprintf(fmtString, entry.Data) if entry.StyleFunc != nil { cellStyle = entry.StyleFunc(StyledCellFuncInput{ Column: column, Data: entry.Data, Row: row, GlobalMetadata: m.metadata, }).Copy().Inherit(cellStyle) } else { cellStyle = entry.Style.Copy().Inherit(cellStyle) } default: str = fmt.Sprintf(fmtString, entry) } } if m.multiline { str = wordwrap.String(str, column.width) cellStyle = cellStyle.Align(lipgloss.Top) } else { str = limitStr(str, column.width) } cellStyle = cellStyle.Inherit(borderStyle) cellStr := cellStyle.Render(str) return cellStr } func (m Model) renderRow(rowIndex int, last bool) string { row := m.GetVisibleRows()[rowIndex] highlighted := rowIndex == m.rowCursorIndex rowStyle := row.Style.Copy() if m.rowStyleFunc != nil { styleResult := m.rowStyleFunc(RowStyleFuncInput{ Index: rowIndex, Row: row, IsHighlighted: m.focused && highlighted, }) rowStyle = rowStyle.Inherit(styleResult) } else if m.focused && highlighted { rowStyle = rowStyle.Inherit(m.highlightStyle) } return m.renderRowData(row, rowStyle, last) } func (m Model) renderBlankRow(last bool) string { return m.renderRowData(NewRow(nil), lipgloss.NewStyle(), last) } // This is long and could use some refactoring in the future, but not quite sure // how to pick it apart yet. // //nolint:funlen, cyclop func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string { numColumns := len(m.columns) columnStrings := []string{} totalRenderedWidth := 0 stylesInner, stylesLast := m.styleRows() maxCellHeight := 1 if m.multiline { for _, column := range m.columns { cellStr := m.renderRowColumnData(row, column, rowStyle, lipgloss.NewStyle()) maxCellHeight = max(maxCellHeight, lipgloss.Height(cellStr)) } } for columnIndex, column := range m.columns { var borderStyle lipgloss.Style var rowStyles borderStyleRow if !last { rowStyles = stylesInner } else { rowStyles = stylesLast } rowStyle = rowStyle.Copy().Height(maxCellHeight) if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount { var borderStyle lipgloss.Style if columnIndex == 0 { borderStyle = rowStyles.left.Copy() } else { borderStyle = rowStyles.inner.Copy() } rendered := m.renderRowColumnData(row, genOverflowColumnLeft(1), rowStyle, borderStyle) totalRenderedWidth += lipgloss.Width(rendered) columnStrings = append(columnStrings, rendered) } if columnIndex >= m.horizontalScrollFreezeColumnsCount && columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount { continue } if len(columnStrings) == 0 { borderStyle = rowStyles.left } else if columnIndex < numColumns-1 { borderStyle = rowStyles.inner } else { borderStyle = rowStyles.right } cellStr := m.renderRowColumnData(row, column, rowStyle, borderStyle) if m.maxTotalWidth != 0 { renderedWidth := lipgloss.Width(cellStr) 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(rowStyles.right, overflowWidth) overflowColumn := genOverflowColumnRight(overflowWidth) overflowStr := m.renderRowColumnData(row, overflowColumn, rowStyle, overflowStyle) columnStrings = append(columnStrings, overflowStr) break } totalRenderedWidth += renderedWidth } columnStrings = append(columnStrings, cellStr) } return lipgloss.JoinHorizontal(lipgloss.Bottom, columnStrings...) } // Selected returns a copy of the row that's set to be selected or deselected. // The old row is not changed in-place. func (r Row) Selected(selected bool) Row { r.selected = selected return r } ================================================ FILE: table/scrolling.go ================================================ package table func (m *Model) scrollRight() { if m.horizontalScrollOffsetCol < m.maxHorizontalColumnIndex { m.horizontalScrollOffsetCol++ } } func (m *Model) scrollLeft() { if m.horizontalScrollOffsetCol > 0 { m.horizontalScrollOffsetCol-- } } func (m *Model) recalculateLastHorizontalColumn() { if m.horizontalScrollFreezeColumnsCount >= len(m.columns) { m.maxHorizontalColumnIndex = 0 return } if m.totalWidth <= m.maxTotalWidth { m.maxHorizontalColumnIndex = 0 return } const ( leftOverflowWidth = 2 borderAdjustment = 1 ) // Always have left border visibleWidth := borderAdjustment + leftOverflowWidth for i := 0; i < m.horizontalScrollFreezeColumnsCount; i++ { visibleWidth += m.columns[i].width + borderAdjustment } m.maxHorizontalColumnIndex = len(m.columns) - 1 // Work backwards from the right for i := len(m.columns) - 1; i >= m.horizontalScrollFreezeColumnsCount && visibleWidth <= m.maxTotalWidth; i-- { visibleWidth += m.columns[i].width + borderAdjustment if visibleWidth <= m.maxTotalWidth { m.maxHorizontalColumnIndex = i - m.horizontalScrollFreezeColumnsCount } } } ================================================ FILE: table/scrolling_fuzz_test.go ================================================ //go:build go1.18 // +build go1.18 package table import ( "fmt" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) // This is long because of test cases // //nolint:funlen,cyclop func FuzzHorizontalScrollingStopEdgeCases(f *testing.F) { const ( minNameWidth = 2 maxNameWidth = 50 minColWidth = 4 maxColWidth = 50 minNumCols = 1 maxNumCols = 500 minMaxWidth = 5 maxMaxWidth = 200 borderBuffer = 4 ) f.Add(5, 3, 5, 30) f.Fuzz(func(t *testing.T, nameWidth, colWidth, numCols, maxWidth int) { if nameWidth < minNameWidth || nameWidth > maxNameWidth || nameWidth > maxWidth-colWidth || nameWidth+colWidth+borderBuffer >= maxWidth { return } if colWidth < minColWidth || colWidth > maxColWidth || colWidth >= maxWidth { return } if numCols < minNumCols || numCols > maxNumCols { return } if maxWidth < minMaxWidth || maxWidth > maxMaxWidth { return } cols := []Column{NewColumn("Name", "Name", nameWidth)} for i := 0; i < numCols; i++ { s := fmt.Sprintf("%d", i+1) cols = append(cols, NewColumn(s, s, colWidth)) } rowData := RowData{"Name": "A"} for i := 0; i < numCols; i++ { s := fmt.Sprintf("%d", i+1) rowData[s] = s } rows := []Row{NewRow(rowData)} model := New(cols). WithRows(rows). WithStaticFooter("Footer"). WithMaxTotalWidth(maxWidth). WithHorizontalFreezeColumnCount(1). Focused(true) hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } // Excessive scrolling attempts to be sure for i := 0; i < numCols*2; i++ { hitScrollRight() } rendered := model.View() assert.NotContains(t, rendered, ">") if !strings.Contains(rendered, "…") { assert.Contains(t, rendered, fmt.Sprintf("%d", numCols)) } }) } ================================================ FILE: table/scrolling_test.go ================================================ package table import ( "fmt" "testing" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) func TestHorizontalScrolling(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithMaxTotalWidth(18). Focused(true) const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━┓ ┃ 1┃ 2┃ 3┃>┃ ┣━━━━╋━━━━╋━━━━╋━┫ ┃ x1┃ x2┃ x3┃>┃ ┗━━━━┻━━━━┻━━━━┻━┛` const expectedTableAfter = `┏━┳━━━━┳━━━━┳━━━━┓ ┃<┃ 2┃ 3┃ 4┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃<┃ x2┃ x3┃ x4┃ ┗━┻━━━━┻━━━━┻━━━━┛` hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } hitScrollLeft := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftLeft}) } assert.Equal(t, expectedTableOriginal, model.View()) hitScrollRight() assert.Equal(t, expectedTableAfter, model.View()) hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) // Try it again, should do nothing hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) } func TestHorizontalScrollWithFooter(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithStaticFooter("Footer"). WithMaxTotalWidth(18). Focused(true) const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━┓ ┃ 1┃ 2┃ 3┃>┃ ┣━━━━╋━━━━╋━━━━╋━┫ ┃ x1┃ x2┃ x3┃>┃ ┣━━━━┻━━━━┻━━━━┻━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━┛` const expectedTableAfter = `┏━┳━━━━┳━━━━┳━━━━┓ ┃<┃ 2┃ 3┃ 4┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃<┃ x2┃ x3┃ x4┃ ┣━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━┛` hitScrollRight := func() { // Try the programmatic API model = model.ScrollRight() } hitScrollLeft := func() { model = model.ScrollLeft() } assert.Equal(t, expectedTableOriginal, model.View()) hitScrollRight() assert.Equal(t, expectedTableAfter, model.View()) hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) // Try it again, should do nothing hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) } func TestHorizontalScrollingWithFooterAndFrozenCols(t *testing.T) { model := New([]Column{ NewColumn("Name", "Name", 4), NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "Name": "A", "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithStaticFooter("Footer"). WithMaxTotalWidth(21). WithHorizontalFreezeColumnCount(1). Focused(true) const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃Name┃ 1┃ 2┃ >┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ A┃ x1┃ x2┃ >┃ ┣━━━━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` const expectedTableAfter = `┏━━━━┳━┳━━━━┳━━━━┳━━┓ ┃Name┃<┃ 2┃ 3┃ >┃ ┣━━━━╋━╋━━━━╋━━━━╋━━┫ ┃ A┃<┃ x2┃ x3┃ >┃ ┣━━━━┻━┻━━━━┻━━━━┻━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } hitScrollLeft := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftLeft}) } assert.Equal(t, expectedTableOriginal, model.View()) hitScrollRight() assert.Equal(t, expectedTableAfter, model.View()) hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) // Try it again, should do nothing hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) } // This is long due to literal strings. func TestHorizontalScrollStopsAtLastColumnBeingVisible(t *testing.T) { model := New([]Column{ NewColumn("Name", "Name", 4), NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "Name": "A", "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithStaticFooter("Footer"). WithMaxTotalWidth(21). WithHorizontalFreezeColumnCount(1). Focused(true) const expectedTableLeft = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃Name┃ 1┃ 2┃ >┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ A┃ x1┃ x2┃ >┃ ┣━━━━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` const expectedTableMiddle = `┏━━━━┳━┳━━━━┳━━━━┳━━┓ ┃Name┃<┃ 2┃ 3┃ >┃ ┣━━━━╋━╋━━━━╋━━━━╋━━┫ ┃ A┃<┃ x2┃ x3┃ >┃ ┣━━━━┻━┻━━━━┻━━━━┻━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` const expectedTableRight = `┏━━━━┳━┳━━━━┳━━━━┓ ┃Name┃<┃ 3┃ 4┃ ┣━━━━╋━╋━━━━╋━━━━┫ ┃ A┃<┃ x3┃ x4┃ ┣━━━━┻━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━┛` hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } assert.Equal(t, expectedTableLeft, model.View()) hitScrollRight() assert.Equal(t, expectedTableMiddle, model.View()) hitScrollRight() assert.Equal(t, expectedTableRight, model.View()) // Should no longer scroll hitScrollRight() assert.Equal(t, expectedTableRight, model.View()) } func TestNoScrollingWhenEntireTableIsVisible(t *testing.T) { model := New([]Column{ NewColumn("Name", "Name", 4), NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }). WithRows([]Row{ NewRow(RowData{ "Name": "A", "1": "x1", "2": "x2", "3": "x3", }), }). WithStaticFooter("Footer"). WithMaxTotalWidth(21). WithHorizontalFreezeColumnCount(1). Focused(true) const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃Name┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ A┃ x1┃ x2┃ x3┃ ┣━━━━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } assert.Equal(t, expectedTable, model.View()) hitScrollRight() assert.Equal(t, expectedTable, model.View()) } // This is long because of test cases // //nolint:funlen func TestHorizontalScrollingStopEdgeCases(t *testing.T) { tests := []struct { numCols int nameWidth int colWidth int maxWidth int expectedCols []int }{ { numCols: 8, nameWidth: 5, colWidth: 3, maxWidth: 30, }, { numCols: 8, nameWidth: 5, colWidth: 3, maxWidth: 20, expectedCols: []int{7, 8}, }, { numCols: 6, nameWidth: 5, colWidth: 3, maxWidth: 30, }, { numCols: 50, nameWidth: 20, colWidth: 6, maxWidth: 31, }, } for i, test := range tests { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { cols := []Column{NewColumn("Name", "Name", test.nameWidth)} for i := 0; i < test.numCols; i++ { s := fmt.Sprintf("%d", i+1) cols = append(cols, NewColumn(s, s, test.colWidth)) } rowData := RowData{"Name": "A"} for i := 0; i < test.numCols; i++ { s := fmt.Sprintf("%d", i+1) rowData[s] = s } rows := []Row{NewRow(rowData)} model := New(cols). WithRows(rows). WithStaticFooter("Footer"). WithMaxTotalWidth(test.maxWidth). WithHorizontalFreezeColumnCount(1). Focused(true) hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight}) } // Excessive scrolling attempts to be sure for i := 0; i < test.numCols*2; i++ { hitScrollRight() } rendered := model.View() assert.NotContains(t, rendered, ">") assert.Contains(t, rendered, fmt.Sprintf("%d", test.numCols)) for _, expected := range test.expectedCols { assert.Contains(t, rendered, fmt.Sprintf("%d", expected), "Missing expected column") } }) } } func TestHorizontalScrollingWithCustomKeybind(t *testing.T) { keymap := DefaultKeyMap() // These intentionally overlap with the keybinds for paging, to ensure // that conflicts can live together keymap.ScrollRight = key.NewBinding(key.WithKeys("right")) keymap.ScrollLeft = key.NewBinding(key.WithKeys("left")) model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithKeyMap(keymap). WithMaxTotalWidth(18). Focused(true) const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━┓ ┃ 1┃ 2┃ 3┃>┃ ┣━━━━╋━━━━╋━━━━╋━┫ ┃ x1┃ x2┃ x3┃>┃ ┗━━━━┻━━━━┻━━━━┻━┛` const expectedTableAfter = `┏━┳━━━━┳━━━━┳━━━━┓ ┃<┃ 2┃ 3┃ 4┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃<┃ x2┃ x3┃ x4┃ ┗━┻━━━━┻━━━━┻━━━━┛` hitScrollRight := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) } hitScrollLeft := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) } assert.Equal(t, expectedTableOriginal, model.View()) hitScrollRight() assert.Equal(t, expectedTableAfter, model.View()) hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) // Try it again, should do nothing hitScrollLeft() assert.Equal(t, expectedTableOriginal, model.View()) } ================================================ FILE: table/sort.go ================================================ package table import ( "fmt" "sort" ) // SortDirection indicates whether a column should sort by ascending or descending. type SortDirection int const ( // SortDirectionAsc indicates the column should be in ascending order. SortDirectionAsc SortDirection = iota // SortDirectionDesc indicates the column should be in descending order. SortDirectionDesc ) // SortColumn describes which column should be sorted and how. type SortColumn struct { ColumnKey string Direction SortDirection } // SortByAsc sets the main sorting column to the given key, in ascending order. // If a previous sort was used, it is replaced by the given column each time // this function is called. Values are sorted as numbers if possible, or just // as simple string comparisons if not numbers. func (m Model) SortByAsc(columnKey string) Model { m.sortOrder = []SortColumn{ { ColumnKey: columnKey, Direction: SortDirectionAsc, }, } m.visibleRowCacheUpdated = false return m } // SortByDesc sets the main sorting column to the given key, in descending order. // If a previous sort was used, it is replaced by the given column each time // this function is called. Values are sorted as numbers if possible, or just // as simple string comparisons if not numbers. func (m Model) SortByDesc(columnKey string) Model { m.sortOrder = []SortColumn{ { ColumnKey: columnKey, Direction: SortDirectionDesc, }, } m.visibleRowCacheUpdated = false return m } // ThenSortByAsc provides a secondary sort after the first, in ascending order. // Can be chained multiple times, applying to smaller subgroups each time. func (m Model) ThenSortByAsc(columnKey string) Model { m.sortOrder = append([]SortColumn{ { ColumnKey: columnKey, Direction: SortDirectionAsc, }, }, m.sortOrder...) m.visibleRowCacheUpdated = false return m } // ThenSortByDesc provides a secondary sort after the first, in descending order. // Can be chained multiple times, applying to smaller subgroups each time. func (m Model) ThenSortByDesc(columnKey string) Model { m.sortOrder = append([]SortColumn{ { ColumnKey: columnKey, Direction: SortDirectionDesc, }, }, m.sortOrder...) m.visibleRowCacheUpdated = false return m } type sortableTable struct { rows []Row byColumn SortColumn } func (s *sortableTable) Len() int { return len(s.rows) } func (s *sortableTable) Swap(i, j int) { old := s.rows[i] s.rows[i] = s.rows[j] s.rows[j] = old } func (s *sortableTable) extractString(i int, column string) string { iData, exists := s.rows[i].Data[column] if !exists { return "" } switch iData := iData.(type) { case StyledCell: return fmt.Sprintf("%v", iData.Data) case string: return iData default: return fmt.Sprintf("%v", iData) } } func (s *sortableTable) extractNumber(i int, column string) (float64, bool) { iData, exists := s.rows[i].Data[column] if !exists { return 0, false } return asNumber(iData) } func (s *sortableTable) Less(first, second int) bool { firstNum, firstNumIsValid := s.extractNumber(first, s.byColumn.ColumnKey) secondNum, secondNumIsValid := s.extractNumber(second, s.byColumn.ColumnKey) if firstNumIsValid && secondNumIsValid { if s.byColumn.Direction == SortDirectionAsc { return firstNum < secondNum } return firstNum > secondNum } firstVal := s.extractString(first, s.byColumn.ColumnKey) secondVal := s.extractString(second, s.byColumn.ColumnKey) if s.byColumn.Direction == SortDirectionAsc { return firstVal < secondVal } return firstVal > secondVal } func getSortedRows(sortOrder []SortColumn, rows []Row) []Row { var sortedRows []Row if len(sortOrder) == 0 { sortedRows = rows return sortedRows } sortedRows = make([]Row, len(rows)) copy(sortedRows, rows) for _, byColumn := range sortOrder { sorted := &sortableTable{ rows: sortedRows, byColumn: byColumn, } sort.Stable(sorted) sortedRows = sorted.rows } return sortedRows } ================================================ FILE: table/sort_test.go ================================================ package table import ( "testing" "github.com/charmbracelet/lipgloss" "github.com/stretchr/testify/assert" ) func TestSortSingleColumnAscAndDesc(t *testing.T) { const idColKey = "id" // Check mixing types type someType string rows := []Row{ NewRow(RowData{idColKey: someType("b")}), NewRow(RowData{idColKey: NewStyledCell("c", lipgloss.NewStyle().Bold(true))}), NewRow(RowData{idColKey: "a"}), // Missing data NewRow(RowData{}), } model := New([]Column{ NewColumn(idColKey, "ID", 3), }).WithRows(rows).SortByAsc(idColKey) assertOrder := func(expectedList []string) { for index, expected := range expectedList { idVal, ok := model.GetVisibleRows()[index].Data[idColKey] if expected != "" { assert.True(t, ok) } else { assert.False(t, ok) continue } switch idVal := idVal.(type) { case string: assert.Equal(t, expected, idVal) case someType: assert.Equal(t, expected, string(idVal)) case StyledCell: assert.Equal(t, expected, idVal.Data) default: assert.Fail(t, "Unknown type") } } } assert.Len(t, model.GetVisibleRows(), len(rows)) assertOrder([]string{"", "a", "b", "c"}) model = model.SortByDesc(idColKey) assertOrder([]string{"c", "b", "a", ""}) } func TestSortSingleColumnIntsAsc(t *testing.T) { const idColKey = "id" rows := []Row{ NewRow(RowData{idColKey: 13}), NewRow(RowData{idColKey: NewStyledCell(1, lipgloss.NewStyle().Bold(true))}), NewRow(RowData{idColKey: 2}), } model := New([]Column{ NewColumn(idColKey, "ID", 3), }).WithRows(rows).SortByAsc(idColKey) assertOrder := func(expectedList []int) { for index, expected := range expectedList { idVal, ok := model.GetVisibleRows()[index].Data[idColKey] assert.True(t, ok) switch idVal := idVal.(type) { case int: assert.Equal(t, expected, idVal) case StyledCell: assert.Equal(t, expected, idVal.Data) default: assert.Fail(t, "Unknown type") } } } assert.Len(t, model.GetVisibleRows(), len(rows)) assertOrder([]int{1, 2, 13}) } func TestSortTwoColumnsAscDescMix(t *testing.T) { const ( nameKey = "name" scoreKey = "score" ) makeRow := func(name string, score int) Row { return NewRow(RowData{ nameKey: name, scoreKey: score, }) } model := New([]Column{ NewColumn(nameKey, "Name", 8), NewColumn(scoreKey, "Score", 8), }).WithRows([]Row{ makeRow("c", 50), makeRow("a", 75), makeRow("b", 101), makeRow("a", 100), }).SortByAsc(nameKey).ThenSortByDesc(scoreKey) assertVals := func(index int, name string, score int) { actualName, ok := model.GetVisibleRows()[index].Data[nameKey].(string) assert.True(t, ok) actualScore, ok := model.GetVisibleRows()[index].Data[scoreKey].(int) assert.True(t, ok) assert.Equal(t, name, actualName) assert.Equal(t, score, actualScore) } assert.Len(t, model.GetVisibleRows(), 4) assertVals(0, "a", 100) assertVals(1, "a", 75) assertVals(2, "b", 101) assertVals(3, "c", 50) model = model.SortByDesc(nameKey).ThenSortByAsc(scoreKey) assertVals(0, "c", 50) assertVals(1, "b", 101) assertVals(2, "a", 75) assertVals(3, "a", 100) } func TestGetSortedRows(t *testing.T) { sortColumns := []SortColumn{ { ColumnKey: "cb", Direction: SortDirectionDesc, }, { ColumnKey: "ca", Direction: SortDirectionAsc, }, } rows := getSortedRows(sortColumns, []Row{ NewRow(RowData{ "ca": "2", "cb": "t-1", }), NewRow(RowData{ "ca": "1", "cb": "t-2", }), NewRow(RowData{ "ca": "3", "cb": "t-3", }), NewRow(RowData{ "ca": "3", "cb": "t-2", }), }) assert.Len(t, rows, 4) assert.Equal(t, "1", rows[0].Data["ca"]) assert.Equal(t, "2", rows[1].Data["ca"]) assert.Equal(t, "3", rows[2].Data["ca"]) assert.Equal(t, "3", rows[3].Data["ca"]) assert.Equal(t, "t-2", rows[0].Data["cb"]) assert.Equal(t, "t-1", rows[1].Data["cb"]) assert.Equal(t, "t-3", rows[2].Data["cb"]) assert.Equal(t, "t-2", rows[3].Data["cb"]) } ================================================ FILE: table/strlimit.go ================================================ package table import ( "strings" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" ) func limitStr(str string, maxLen int) string { if maxLen == 0 { return "" } newLineIndex := strings.Index(str, "\n") if newLineIndex > -1 { str = str[:newLineIndex] + "…" } if ansi.PrintableRuneWidth(str) > maxLen { // #nosec: G115 return truncate.StringWithTail(str, uint(maxLen), "…") } return str } ================================================ FILE: table/strlimit_test.go ================================================ package table import ( "testing" "github.com/muesli/reflow/ansi" "github.com/stretchr/testify/assert" ) // This function is only long because of repetitive test definitions, this is fine // //nolint:funlen func TestLimitStr(t *testing.T) { tests := []struct { name string input string max int expected string }{ { name: "Short", input: "Hello", max: 50, expected: "Hello", }, { name: "Close", input: "Hello", max: 6, expected: "Hello", }, { name: "Equal", input: "Hello", max: 5, expected: "Hello", }, { name: "Shorter", input: "Hello this is a really long string", max: 8, expected: "Hello t…", }, { name: "Zero max", input: "Hello", max: 0, expected: "", }, { name: "Unicode width", input: "✓", max: 1, expected: "✓", }, { name: "Unicode truncated", input: "✓✓✓", max: 2, expected: "✓…", }, { name: "Unicode japenese equal", input: "直立", max: 5, expected: "直立", }, { name: "Unicode japenese truncated", input: "直立した恐", max: 5, expected: "直立…", }, { name: "Multiline truncated", input: "hi\nall", max: 5, expected: "hi…", }, { name: "Multiline with exact max width", input: "hello\nall", max: 5, expected: "hell…", }, { name: "Embedded ANSI control sequences with exact max width", input: "\x1b[31;41mtest\x1b[0m", max: 4, expected: "\x1b[31;41mtest\x1b[0m", }, { name: "Embedded ANSI control sequences with truncation", input: "\x1b[31;41mte\x1b[0m\x1b[0m\x1b[0mst", max: 3, expected: "\x1b[31;41mte\x1b[0m\x1b[0m\x1b[0m…", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { output := limitStr(test.input, test.max) assert.Equal(t, test.expected, output) assert.LessOrEqual(t, ansi.PrintableRuneWidth(output), test.max) }) } } ================================================ FILE: table/update.go ================================================ package table import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) func (m *Model) moveHighlightUp() { m.rowCursorIndex-- if m.rowCursorIndex < 0 { m.rowCursorIndex = len(m.GetVisibleRows()) - 1 } m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex) } func (m *Model) moveHighlightDown() { m.rowCursorIndex++ if m.rowCursorIndex >= len(m.GetVisibleRows()) { m.rowCursorIndex = 0 } m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex) } func (m *Model) toggleSelect() { if !m.selectableRows || len(m.GetVisibleRows()) == 0 { return } rows := m.GetVisibleRows() rowID := rows[m.rowCursorIndex].id currentSelectedState := false for i := range m.rows { if m.rows[i].id == rowID { currentSelectedState = m.rows[i].selected m.rows[i].selected = !m.rows[i].selected } } m.visibleRowCacheUpdated = false m.appendUserEvent(UserEventRowSelectToggled{ RowIndex: m.rowCursorIndex, IsSelected: !currentSelectedState, }) } func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: if key.Matches(msg, m.keyMap.FilterBlur) { m.filterTextInput.Blur() } } m.filterTextInput, cmd = m.filterTextInput.Update(msg) m.pageFirst() m.visibleRowCacheUpdated = false return m, cmd } // This is a series of Matches tests with minimal logic // //nolint:cyclop func (m *Model) handleKeypress(msg tea.KeyMsg) { previousRowIndex := m.rowCursorIndex if key.Matches(msg, m.keyMap.RowDown) { m.moveHighlightDown() } if key.Matches(msg, m.keyMap.RowUp) { m.moveHighlightUp() } if key.Matches(msg, m.keyMap.RowSelectToggle) { m.toggleSelect() } if key.Matches(msg, m.keyMap.PageDown) { m.pageDown() } if key.Matches(msg, m.keyMap.PageUp) { m.pageUp() } if key.Matches(msg, m.keyMap.PageFirst) { m.pageFirst() } if key.Matches(msg, m.keyMap.PageLast) { m.pageLast() } if key.Matches(msg, m.keyMap.Filter) { m.filterTextInput.Focus() m.appendUserEvent(UserEventFilterInputFocused{}) } if key.Matches(msg, m.keyMap.FilterClear) { m.visibleRowCacheUpdated = false m.filterTextInput.Reset() } if key.Matches(msg, m.keyMap.ScrollRight) { m.scrollRight() } if key.Matches(msg, m.keyMap.ScrollLeft) { m.scrollLeft() } if m.rowCursorIndex != previousRowIndex { m.appendUserEvent(UserEventHighlightedIndexChanged{ PreviousRowIndex: previousRowIndex, SelectedRowIndex: m.rowCursorIndex, }) } } // Update responds to input from the user or other messages from Bubble Tea. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.clearUserEvents() if !m.focused { return m, nil } if m.filterTextInput.Focused() { var cmd tea.Cmd m, cmd = m.updateFilterTextInput(msg) if !m.filterTextInput.Focused() { m.appendUserEvent(UserEventFilterInputUnfocused{}) } return m, cmd } switch msg := msg.(type) { case tea.KeyMsg: m.handleKeypress(msg) } return m, nil } ================================================ FILE: table/update_test.go ================================================ package table import ( "testing" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) func TestUnfocusedDoesntMove(t *testing.T) { cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), }) model, _ = model.Update(tea.KeyMsg{ Type: tea.KeyUp, }) highlighted := model.HighlightedRow() id, ok := highlighted.Data["id"].(string) assert.True(t, ok, "Failed to convert to string") assert.Equal(t, "first", id, "Should still be on first row") } func TestPageKeysDoNothingWhenNoPages(t *testing.T) { cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), NewRow(RowData{ "id": "third", }), }).Focused(true) pageMoveKeys := []tea.Msg{ tea.KeyMsg{Type: tea.KeyLeft}, tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyHome}, tea.KeyMsg{Type: tea.KeyEnd}, } checkNoMove := func() string { str, ok := model.HighlightedRow().Data["id"].(string) assert.True(t, ok, "Failed to convert to string") assert.Equal(t, "first", str, "Shouldn't move") return str } for _, msg := range pageMoveKeys { model, _ = model.Update(msg) checkNoMove() } } // This is a long test with a lot of movement keys pressed, that's okay because // it's simply repetitive and tracking the same kind of state change many times // //nolint:funlen func TestFocusedMovesWhenMoveKeysPressedPaged(t *testing.T) { cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), NewRow(RowData{ "id": "third", }), }).Focused(true).WithPageSize(2) // Note that this is assuming default keymap keyUp := tea.KeyMsg{Type: tea.KeyUp} keyDown := tea.KeyMsg{Type: tea.KeyDown} keyLeft := tea.KeyMsg{Type: tea.KeyLeft} keyRight := tea.KeyMsg{Type: tea.KeyRight} keyHome := tea.KeyMsg{Type: tea.KeyHome} keyEnd := tea.KeyMsg{Type: tea.KeyEnd} curID := func() string { str, ok := model.HighlightedRow().Data["id"].(string) assert.True(t, ok, "Failed to convert to string") return str } assert.Equal(t, "first", curID(), "Should start on first row") model, _ = model.Update(keyDown) assert.Equal(t, "second", curID(), "Default key down should move down a row") model, _ = model.Update(keyUp) assert.Equal(t, "first", curID(), "Should move back up") model, _ = model.Update(keyUp) assert.Equal(t, "third", curID(), "Moving up from top should wrap to bottom") model, _ = model.Update(keyDown) assert.Equal(t, "first", curID(), "Moving down from bottom should wrap to top") model, _ = model.Update(keyRight) assert.Equal(t, "third", curID(), "Moving right should move to second page") model, _ = model.Update(keyRight) assert.Equal(t, "first", curID(), "Moving right again should move to first page") model, _ = model.Update(keyLeft) assert.Equal(t, "third", curID(), "Moving left should move to last page") model, _ = model.Update(keyLeft) assert.Equal(t, "first", curID(), "Moving left should move back to first page") model, _ = model.Update(keyDown) assert.Equal(t, "second", curID(), "Should be back down to second row") model, _ = model.Update(keyHome) assert.Equal(t, "first", curID(), "Hitting home should go to first page and select first row") model, _ = model.Update(keyHome) assert.Equal(t, "first", curID(), "Hitting home a second time should not move pages") model, _ = model.Update(keyEnd) assert.Equal(t, "third", curID(), "Hitting end should move to last page") model, _ = model.Update(keyEnd) assert.Equal(t, "third", curID(), "Hitting end a second time should not move pages") // Disable pagination wrapping and ensure it sticks model = model.WithPaginationWrapping(false) model, _ = model.Update(keyRight) assert.Equal(t, "third", curID(), "Did not stay on last page, may have wrapped") model, _ = model.Update(keyHome) model, _ = model.Update(keyLeft) assert.Equal(t, "first", curID(), "Did not stay on first page, may have wrapped") } func TestFocusedMovesWithCustomKeyMap(t *testing.T) { cols := []Column{ NewColumn("id", "ID", 3), } customKeys := KeyMap{ RowUp: key.NewBinding(key.WithKeys("ctrl+a")), RowDown: key.NewBinding(key.WithKeys("ctrl+b")), RowSelectToggle: key.NewBinding(key.WithKeys("ctrl+c")), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), NewRow(RowData{ "id": "third", }), }).Focused(true).WithKeyMap(customKeys) keyUp := tea.KeyMsg{Type: tea.KeyUp} keyDown := tea.KeyMsg{Type: tea.KeyDown} keyCtrlA := tea.KeyMsg{Type: tea.KeyCtrlA} keyCtrlB := tea.KeyMsg{Type: tea.KeyCtrlB} assert.Equal(t, "ctrl+a", keyCtrlA.String(), "Test sanity check failed for ctrl+a") assert.Equal(t, "ctrl+b", keyCtrlB.String(), "Test sanity check failed for ctrl+b") curID := func() string { str, ok := model.HighlightedRow().Data["id"].(string) assert.True(t, ok, "Failed to convert to string") return str } assert.Equal(t, "first", curID(), "Should start on first row") model, _ = model.Update(keyDown) assert.Equal(t, "first", curID(), "Down arrow should do nothing") model, _ = model.Update(keyCtrlB) assert.Equal(t, "second", curID(), "Custom key map for down failed") model, _ = model.Update(keyUp) assert.Equal(t, "second", curID(), "Up arrow should do nothing") model, _ = model.Update(keyCtrlA) assert.Equal(t, "first", curID(), "Custom key map for up failed") } func TestSelectingRowWhenTableUnselectableDoesNothing(t *testing.T) { cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), NewRow(RowData{ "id": "third", }), }).Focused(true) assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected to start") model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected after key press") } func TestSelectingRowToggles(t *testing.T) { cols := []Column{ NewColumn("id", "ID", 3), } model := New(cols).WithRows([]Row{ NewRow(RowData{ "id": "first", }), NewRow(RowData{ "id": "second", }), NewRow(RowData{ "id": "third", }), }).Focused(true).SelectableRows(true) keyEnter := tea.KeyMsg{Type: tea.KeyEnter} keyDown := tea.KeyMsg{Type: tea.KeyDown} assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected to start") assert.Len(t, model.SelectedRows(), 0) model, _ = model.Update(keyEnter) assert.True(t, model.GetVisibleRows()[0].selected, "Row should be selected after first toggle") assert.Len(t, model.SelectedRows(), 1) model, _ = model.Update(keyEnter) assert.False(t, model.GetVisibleRows()[0].selected, "Row should not be selected after second toggle") assert.Len(t, model.SelectedRows(), 0) model, _ = model.Update(keyDown) model, _ = model.Update(keyEnter) assert.True(t, model.GetVisibleRows()[1].selected, "Second row should be selected after moving and toggling") } func TestFilterWithKeypresses(t *testing.T) { cols := []Column{ NewColumn("name", "Name", 10).WithFiltered(true), } model := New(cols).WithRows([]Row{ NewRow(RowData{"name": "Pikachu"}), NewRow(RowData{"name": "Charmander"}), }).Focused(true).Filtered(true) hitKey := func(key rune) { model, _ = model.Update(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune{key}, }) } hitEnter := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) } hitEscape := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) } visible := model.GetVisibleRows() assert.Len(t, visible, 2) hitKey(rune(model.KeyMap().Filter.Keys()[0][0])) assert.Len(t, visible, 2) hitKey('p') hitKey('i') hitKey('k') visible = model.GetVisibleRows() assert.Len(t, visible, 1) hitEnter() hitKey('x') visible = model.GetVisibleRows() assert.Len(t, visible, 1) hitEscape() visible = model.GetVisibleRows() assert.Len(t, visible, 2) } // This is a long test with a lot of movement keys pressed, that's okay because // it's simply repetitive and tracking the same kind of state change many times // //nolint:funlen func TestSelectOnFilteredTableDoesntLoseRows(t *testing.T) { // Issue: https://github.com/Evertras/bubble-table/issues/170 // // Basically, if you filter a table and then select a row, then // clear the filter, then all the other rows should still exist. cols := []Column{ NewColumn("name", "Name", 10).WithFiltered(true), } model := New(cols).WithRows([]Row{ NewRow(RowData{"name": "Charmander"}), NewRow(RowData{"name": "Pikachu"}), }).Focused(true).Filtered(true).SelectableRows(true) hitKey := func(key rune) { model, _ = model.Update(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune{key}, }) } hitEnter := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) } hitEscape := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) } hitSpacebar := func() { model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) } // First, apply the filter // // Note that we try and filter for the second row, "Pikachu" // so that we can better ensure everything is stably intact visible := model.GetVisibleRows() assert.Len(t, visible, 2) hitKey(rune(model.KeyMap().Filter.Keys()[0][0])) assert.Len(t, visible, 2) hitKey('p') hitKey('i') hitKey('k') visible = model.GetVisibleRows() assert.Len(t, visible, 1) hitEnter() // Now apply the selection toggle hitSpacebar() visible = model.GetVisibleRows() assert.Len(t, visible, 1) assert.True(t, visible[0].selected) // Now clear the filter and make sure everything is intact hitEscape() visible = model.GetVisibleRows() assert.Len(t, visible, 2) if t.Failed() { return } assert.False(t, visible[0].selected) assert.True(t, visible[1].selected) } ================================================ FILE: table/view.go ================================================ package table import ( "strings" "github.com/charmbracelet/lipgloss" ) // View renders the table. It does not end in a newline, so that it can be // composed with other elements more consistently. // //nolint:cyclop func (m Model) View() string { // Safety valve for empty tables if len(m.columns) == 0 { return "" } body := strings.Builder{} rowStrs := make([]string, 0, 1) headers := m.renderHeaders() startRowIndex, endRowIndex := m.VisibleIndices() numRows := endRowIndex - startRowIndex + 1 padding := m.calculatePadding(numRows) if m.headerVisible { rowStrs = append(rowStrs, headers) } else if numRows > 0 || padding > 0 { //nolint: mnd // This is just getting the first newlined substring split := strings.SplitN(headers, "\n", 2) rowStrs = append(rowStrs, split[0]) } for i := startRowIndex; i <= endRowIndex; i++ { rowStrs = append(rowStrs, m.renderRow(i, padding == 0 && i == endRowIndex)) } for i := 1; i <= padding; i++ { rowStrs = append(rowStrs, m.renderBlankRow(i == padding)) } var footer string if len(rowStrs) > 0 { footer = m.renderFooter(lipgloss.Width(rowStrs[0]), false) } else { footer = m.renderFooter(lipgloss.Width(headers), true) } if footer != "" { rowStrs = append(rowStrs, footer) } if len(rowStrs) == 0 { return "" } body.WriteString(lipgloss.JoinVertical(lipgloss.Left, rowStrs...)) return body.String() } ================================================ FILE: table/view_selectable_test.go ================================================ package table import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestSimple3x3WithSelectableDefaults(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).SelectableRows(true) const expectedTable = `┏━━━┳━━━━┳━━━━┳━━━━┓ ┃[x]┃ 1┃ 2┃ 3┃ ┣━━━╋━━━━╋━━━━╋━━━━┫ ┃[ ]┃ 1,1┃ 2,1┃ 3,1┃ ┃[ ]┃ 1,2┃ 2,2┃ 3,2┃ ┃[ ]┃ 1,3┃ 2,3┃ 3,3┃ ┗━━━┻━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithCustomSelectableText(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows). SelectableRows(true). WithSelectedText(" ", "✓") const expectedTable = `┏━┳━━━━┳━━━━┳━━━━┓ ┃✓┃ 1┃ 2┃ 3┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━┻━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithCustomSelectableTextAndFooter(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows). SelectableRows(true). WithSelectedText(" ", "✓"). WithStaticFooter("Footer") const expectedTable = `┏━┳━━━━┳━━━━┳━━━━┓ ┃✓┃ 1┃ 2┃ 3┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestRegeneratingColumnsKeepsSelectableText(t *testing.T) { columns := []Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), } model := New(columns) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows). SelectableRows(true). WithSelectedText(" ", "✓"). WithColumns(columns) const expectedTable = `┏━┳━━━━┳━━━━┳━━━━┓ ┃✓┃ 1┃ 2┃ 3┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━┻━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } ================================================ FILE: table/view_test.go ================================================ package table import ( "fmt" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" "github.com/stretchr/testify/assert" ) func TestBasicTableShowsAllHeaders(t *testing.T) { const ( firstKey = "first-key" firstTitle = "First Title" firstWidth = 13 secondKey = "second-key" secondTitle = "Second Title" secondWidth = 20 ) columns := []Column{ NewColumn(firstKey, firstTitle, firstWidth), NewColumn(secondKey, secondTitle, secondWidth), } model := New(columns) rendered := model.View() assert.Contains(t, rendered, firstTitle) assert.Contains(t, rendered, secondTitle) assert.False(t, strings.HasSuffix(rendered, "\n"), "Should not end in newline") } func TestBasicTableTruncatesLongHeaders(t *testing.T) { const ( firstKey = "first-key" firstTitle = "First Title" firstWidth = 3 secondKey = "second-key" secondTitle = "Second Title" secondWidth = 3 ) columns := []Column{ NewColumn(firstKey, firstTitle, firstWidth), NewColumn(secondKey, secondTitle, secondWidth), } model := New(columns) rendered := model.View() assert.Contains(t, rendered, "Fi…") assert.Contains(t, rendered, "Se…") assert.False(t, strings.HasSuffix(rendered, "\n"), "Should not end in newline") } func TestNilColumnsSafelyReturnsEmptyView(t *testing.T) { model := New(nil) assert.Equal(t, "", model.View()) } func TestSingleCellView(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }) const expectedTable = `┏━━━━┓ ┃ ID┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleColumnView(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 1┃ ┃ 2┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleColumnViewSorted(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }).SortByDesc("id") const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 2┃ ┃ 1┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleRowView(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleRowViewWithHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }). WithHeaderVisibility(false). WithRows([]Row{ NewRow(RowData{"1": "a", "2": "b", "3": "c"}), }) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ a┃ b┃ c┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestTableWithNoRowsAndHiddenHeaderHidesTable(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithHeaderVisibility(false) const expectedTable = "" rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithHeaderVisibility(false) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleHeaderWithFooter(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithStaticFooter("Foot") const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleColumnWithFooterAndHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }). WithStaticFooter("Foot"). WithHeaderVisibility(false) const expectedTable = `┏━━━━┓ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleRowWithFooterView(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleRowWithFooterViewAndBaseStyle(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithStaticFooter("Footer").WithBaseStyle(lipgloss.NewStyle().Align(lipgloss.Left)) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃1 ┃2 ┃3 ┃ ┣━━━━┻━━━━┻━━━━┫ ┃Footer ┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleRowWithFooterViewAndBaseStyleWithHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }). WithStaticFooter("Footer"). WithBaseStyle(lipgloss.NewStyle().Align(lipgloss.Left)). WithHeaderVisibility(false) const expectedTable = `┏━━━━━━━━━━━━━━┓ ┃Footer ┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleColumnWithFooterView(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }).WithStaticFooter("Foot") const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 1┃ ┃ 2┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleColumnWithFooterViewAndHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }). WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }). WithStaticFooter("Foot"). WithHeaderVisibility(false) const expectedTable = `┏━━━━┓ ┃ 1┃ ┃ 2┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithFooterView(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithMissingData(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { // Take out the center if rowIndex == 2 && columnIndex == 2 { continue } id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ ┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestFmtStringWithMissingData(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4).WithFormatString("%.1f"), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { // Take out the center if rowIndex == 2 && columnIndex == 2 { continue } id := fmt.Sprintf("%d", columnIndex) rowData[id] = float64(columnIndex) + float64(rowIndex)/10.0 } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1.1┃ 2.1┃ 3.1┃ ┃ 1.2┃ ┃ 3.2┃ ┃ 1.3┃ 2.3┃ 3.3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithMissingIndicator(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithMissingDataIndicator("XX") rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { // Take out the center if rowIndex == 2 && columnIndex == 2 { continue } columnKey := fmt.Sprintf("%d", columnIndex) if rowIndex == 2 && columnIndex == 3 { // Empty string to ensure that zero value data is not 'missing' rowData[columnKey] = "" continue } rowData[columnKey] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ XX┃ ┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestFmtStringWithMissingIndicator(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4).WithFormatString("%.1f"), NewColumn("3", "3", 4), }).WithMissingDataIndicator("XX") rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { // Take out the center if rowIndex == 2 && columnIndex == 2 { continue } id := fmt.Sprintf("%d", columnIndex) rowData[id] = float64(columnIndex) + float64(rowIndex)/10.0 } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1.1┃ 2.1┃ 3.1┃ ┃ 1.2┃ XX┃ 3.2┃ ┃ 1.3┃ 2.3┃ 3.3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3WithMissingIndicatorStyled(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithMissingDataIndicatorStyled(StyledCell{ Style: lipgloss.NewStyle().Align(lipgloss.Left), Data: "XX", }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { // Take out the center if rowIndex == 2 && columnIndex == 2 { continue } id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃XX ┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestFmtStringWithMissingIndicatorStyled(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4).WithFormatString("%.1f"), NewColumn("3", "3", 4), }).WithMissingDataIndicatorStyled(StyledCell{ Style: lipgloss.NewStyle().Align(lipgloss.Left), Data: "XX", }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { // Take out the center if rowIndex == 2 && columnIndex == 2 { continue } id := fmt.Sprintf("%d", columnIndex) rowData[id] = float64(columnIndex) + float64(rowIndex)/10.0 } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1.1┃ 2.1┃ 3.1┃ ┃ 1.2┃XX ┃ 3.2┃ ┃ 1.3┃ 2.3┃ 3.3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestPaged3x3WithNoSpecifiedFooter(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithPageSize(2) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┣━━━━┻━━━━┻━━━━┫ ┃ 1/2┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestPaged3x3WithStaticFooter(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithPageSize(2).WithStaticFooter("Override") const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Override┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3StyleOverridesAsBaseColumnRowCell(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 6), NewColumn("2", "2", 6).WithStyle(lipgloss.NewStyle().Align(lipgloss.Left)), NewColumn("3", "3", 6), }).WithBaseStyle(lipgloss.NewStyle().Align(lipgloss.Center)) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } // Test overrides with alignment because it's easy to check output string rows[0] = rows[0].WithStyle(lipgloss.NewStyle().Align(lipgloss.Left)) rows[0].Data["2"] = NewStyledCell("R", lipgloss.NewStyle().Align(lipgloss.Right)) rows[2] = rows[2].WithStyle(lipgloss.NewStyle().Align(lipgloss.Right)) model = model.WithRows(rows) const expectedTable = `┏━━━━━━┳━━━━━━┳━━━━━━┓ ┃ 1 ┃2 ┃ 3 ┃ ┣━━━━━━╋━━━━━━╋━━━━━━┫ ┃1,1 ┃ R┃3,1 ┃ ┃ 1,2 ┃2,2 ┃ 3,2 ┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━━━━━━┻━━━━━━┻━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimple3x3CellStyleFuncOverridesAsBaseColumnRowCell(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 6), NewColumn("2", "2", 6).WithStyle(lipgloss.NewStyle().Align(lipgloss.Left)), NewColumn("3", "3", 6), }).WithBaseStyle(lipgloss.NewStyle().Align(lipgloss.Center)). WithGlobalMetadata(map[string]any{ "testValue": 37, }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } // Test overrides with alignment because it's easy to check output string rows[0] = rows[0].WithStyle(lipgloss.NewStyle().Align(lipgloss.Left)) rows[0].Data["2"] = NewStyledCellWithStyleFunc("R", func(input StyledCellFuncInput) lipgloss.Style { // Do some checks to make sure we're given the right information as a bonus test assert.Equal(t, "2", input.Column.Key(), "Wrong column key given to style func") assert.Equal(t, "R", input.Data, "Wrong data given to style func") assert.Equal(t, "1,1", input.Row.Data["1"], "Wrong row given to style func") assert.Equal(t, 37, input.GlobalMetadata["testValue"], "Wrong table metadata given to style func") return lipgloss.NewStyle().Align(lipgloss.Right) }) rows[2] = rows[2].WithStyle(lipgloss.NewStyle().Align(lipgloss.Right)) model = model.WithRows(rows) const expectedTable = `┏━━━━━━┳━━━━━━┳━━━━━━┓ ┃ 1 ┃2 ┃ 3 ┃ ┣━━━━━━╋━━━━━━╋━━━━━━┫ ┃1,1 ┃ R┃3,1 ┃ ┃ 1,2 ┃2,2 ┃ 3,2 ┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━━━━━━┻━━━━━━┻━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestRowStyleFuncAppliesAfterBaseStyleAndColStylesAndBeforeRowStyle(t *testing.T) { styleFunc := func(input RowStyleFuncInput) lipgloss.Style { if input.Index%2 == 0 { return lipgloss.NewStyle().Align(lipgloss.Left) } return lipgloss.NewStyle() } model := New([]Column{ NewColumn("1", "1", 6), // This column style should be overridden by the style func NewColumn("2", "2", 6).WithStyle(lipgloss.NewStyle().Align(lipgloss.Right)), NewColumn("3", "3", 6), }). WithBaseStyle(lipgloss.NewStyle().Align(lipgloss.Center)). WithRowStyleFunc(styleFunc) rows := []Row{} for rowIndex := 1; rowIndex <= 5; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } rows[0] = rows[0].WithStyle(lipgloss.NewStyle().Align(lipgloss.Right)) model = model.WithRows(rows) const expectedTable = `┏━━━━━━┳━━━━━━┳━━━━━━┓ ┃ 1 ┃ 2┃ 3 ┃ ┣━━━━━━╋━━━━━━╋━━━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2 ┃ 2,2┃ 3,2 ┃ ┃1,3 ┃2,3 ┃3,3 ┃ ┃ 1,4 ┃ 2,4┃ 3,4 ┃ ┃1,5 ┃2,5 ┃3,5 ┃ ┗━━━━━━┻━━━━━━┻━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestRowStyleFuncAppliesHighlighted(t *testing.T) { styleFunc := func(input RowStyleFuncInput) lipgloss.Style { if input.IsHighlighted { return lipgloss.NewStyle().Align(lipgloss.Center) } if input.Index%2 == 0 { return lipgloss.NewStyle().Align(lipgloss.Right) } return lipgloss.NewStyle().Align(lipgloss.Left) } model := New([]Column{ NewColumn("1", "1", 6), NewColumn("2", "2", 6), NewColumn("3", "3", 6), }). WithRowStyleFunc(styleFunc). Focused(true) rows := []Row{} for rowIndex := 1; rowIndex <= 5; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows). WithHighlightedRow(2) const expectedTable = `┏━━━━━━┳━━━━━━┳━━━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━━━╋━━━━━━╋━━━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃1,2 ┃2,2 ┃3,2 ┃ ┃ 1,3 ┃ 2,3 ┃ 3,3 ┃ ┃1,4 ┃2,4 ┃3,4 ┃ ┃ 1,5┃ 2,5┃ 3,5┃ ┗━━━━━━┻━━━━━━┻━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } // This is a long test due to typing and multiple big table strings, that's okay // //nolint:funlen func Test3x3WithFilterFooter(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4).WithFiltered(true), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).Filtered(true).Focused(true) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ ┃ ┗━━━━━━━━━━━━━━┛` assert.Equal(t, expectedTable, model.View()) hitKey := func(key rune) { model, _ = model.Update( tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune{key}, }) } hitKey('/') hitKey('3') // The byte code near the bottom is a block cursor from the text box const expectedFilteredTypingTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ /3` + "\x1b[7m \x1b[0m" + `┃ ┗━━━━━━━━━━━━━━┛` assert.Equal(t, expectedFilteredTypingTable, model.View()) const expectedFilteredDoneTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ 1,3┃ 2,3┃ 3,3┃ ┣━━━━┻━━━━┻━━━━┫ ┃ /3┃ ┗━━━━━━━━━━━━━━┛` model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) assert.Equal(t, expectedFilteredDoneTable, model.View()) } func TestSingleCellFlexView(t *testing.T) { model := New([]Column{ NewFlexColumn("id", "ID", 1), }).WithTargetWidth(6) const expectedTable = `┏━━━━┓ ┃ ID┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimpleFlex3x3(t *testing.T) { model := New([]Column{ NewFlexColumn("1", "1", 1), NewFlexColumn("2", "2", 1), NewFlexColumn("3", "3", 2), }).WithTargetWidth(20) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows) const expectedTable = `┏━━━━┳━━━━┳━━━━━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━━━━━┫ ┃ 1,1┃ 2,1┃ 3,1┃ ┃ 1,2┃ 2,2┃ 3,2┃ ┃ 1,3┃ 2,3┃ 3,3┃ ┗━━━━┻━━━━┻━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSimpleFlex3x3AtAllTargetWidths(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewFlexColumn("2", "2", 1), NewFlexColumn("3", "3", 2), }).WithTargetWidth(20) rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows) for targetWidth := 15; targetWidth < 100; targetWidth++ { model = model.WithTargetWidth(targetWidth) rendered := model.View() firstLine := strings.Split(rendered, "\n")[0] assert.Equal(t, targetWidth, model.totalWidth) assert.Equal(t, targetWidth, runewidth.StringWidth(firstLine)) if t.Failed() { return } } } func TestViewResizesWhenColumnsChange(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1", "score": 3}), NewRow(RowData{"id": "2", "score": 4}), }) const expectedTableOriginal = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 1┃ ┃ 2┃ ┗━━━━┛` // Lowercased, resized, and new column added const expectedTableUpdated = `┏━━━━━┳━━━━━━┓ ┃ id┃ Score┃ ┣━━━━━╋━━━━━━┫ ┃ 1┃ 3┃ ┃ 2┃ 4┃ ┗━━━━━┻━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTableOriginal, rendered) model = model.WithColumns([]Column{ NewColumn("id", "id", 5), NewColumn("score", "Score", 6), }) rendered = model.View() assert.Equal(t, expectedTableUpdated, rendered) } func TestMaxWidthHidesOverflow(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithStaticFooter("Footer"). // This includes borders, so should cut off early WithMaxTotalWidth(19) const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━┓ ┃ 1┃ 2┃ 3┃ >┃ ┣━━━━╋━━━━╋━━━━╋━━┫ ┃ x1┃ x2┃ x3┃ >┃ ┣━━━━┻━━━━┻━━━━┻━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMaxWidthHasNoEffectForExactFit(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }) const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ 4┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ x1┃ x2┃ x3┃ x4┃ ┗━━━━┻━━━━┻━━━━┻━━━━┛` const expectedTableFooter = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ 4┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ x1┃ x2┃ x3┃ x4┃ ┣━━━━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` model = model.WithMaxTotalWidth(lipgloss.Width(expectedTable)) rendered := model.View() assert.Equal(t, expectedTable, rendered) model = model.WithStaticFooter("Footer") rendered = model.View() assert.Equal(t, expectedTableFooter, rendered) } func TestMaxWidthHasNoEffectForSmaller(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }) const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ 4┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ x1┃ x2┃ x3┃ x4┃ ┗━━━━┻━━━━┻━━━━┻━━━━┛` const expectedTableFooter = `┏━━━━┳━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ 4┃ ┣━━━━╋━━━━╋━━━━╋━━━━┫ ┃ x1┃ x2┃ x3┃ x4┃ ┣━━━━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━━━━┛` model = model.WithMaxTotalWidth(lipgloss.Width(expectedTable) + 5) rendered := model.View() assert.Equal(t, expectedTable, rendered) model = model.WithStaticFooter("Footer") rendered = model.View() assert.Equal(t, expectedTableFooter, rendered) } func TestMaxWidthHidesOverflowWithSingleCharExtra(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithStaticFooter("Footer"). // Juuuust barely overflowing... WithMaxTotalWidth(17) const expectedTable = `┏━━━━┳━━━━┳━━━━━┓ ┃ 1┃ 2┃ >┃ ┣━━━━╋━━━━╋━━━━━┫ ┃ x1┃ x2┃ >┃ ┣━━━━┻━━━━┻━━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMaxWidthHidesOverflowWithTwoCharExtra(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). // Just enough to squeeze in a '>' column WithMaxTotalWidth(18) const expectedTable = `┏━━━━┳━━━━┳━━━━┳━┓ ┃ 1┃ 2┃ 3┃>┃ ┣━━━━╋━━━━╋━━━━╋━┫ ┃ x1┃ x2┃ x3┃>┃ ┗━━━━┻━━━━┻━━━━┻━┛` const expectedTableFooter = `┏━━━━┳━━━━┳━━━━┳━┓ ┃ 1┃ 2┃ 3┃>┃ ┣━━━━╋━━━━╋━━━━╋━┫ ┃ x1┃ x2┃ x3┃>┃ ┣━━━━┻━━━━┻━━━━┻━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) model = model.WithStaticFooter("Footer") rendered = model.View() assert.Equal(t, expectedTableFooter, rendered) } func TestScrolledTableSizesFooterCorrectly(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }), }). WithMaxTotalWidth(19). WithStaticFooter("Footer"). ScrollRight() const expectedTable = `┏━┳━━━━┳━━━━┳━━━━┓ ┃<┃ 2┃ 3┃ 4┃ ┣━╋━━━━╋━━━━╋━━━━┫ ┃<┃ x2┃ x3┃ x4┃ ┣━┻━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestHorizontalScrollCaretIsRightAligned(t *testing.T) { leftAlign := lipgloss.NewStyle().Align(lipgloss.Left) model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), NewColumn("4", "4", 4), }). WithRows([]Row{ NewRow(RowData{ "1": "x1", "2": "x2", "3": "x3", "4": "x4", }).WithStyle(leftAlign), }). HeaderStyle(leftAlign). WithStaticFooter("Footer"). WithMaxTotalWidth(17) const expectedTable = `┏━━━━┳━━━━┳━━━━━┓ ┃1 ┃2 ┃ >┃ ┣━━━━╋━━━━╋━━━━━┫ ┃x1 ┃x2 ┃ >┃ ┣━━━━┻━━━━┻━━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func Test3x3WithRoundedBorder(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).BorderRounded() rows := []Row{} for rowIndex := 1; rowIndex <= 3; rowIndex++ { rowData := RowData{} for columnIndex := 1; columnIndex <= 3; columnIndex++ { id := fmt.Sprintf("%d", columnIndex) rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex) } rows = append(rows, NewRow(rowData)) } model = model.WithRows(rows).WithStaticFooter("Footer") const expectedTable = `╭────┬────┬────╮ │ 1│ 2│ 3│ ├────┼────┼────┤ │ 1,1│ 2,1│ 3,1│ │ 1,2│ 2,2│ 3,2│ │ 1,3│ 2,3│ 3,3│ ├────┴────┴────┤ │ Footer│ ╰──────────────╯` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestSingleColumnViewSortedAndFormatted(t *testing.T) { model := New([]Column{ NewColumn("name", "Name", 5), NewColumn("val", "Value", 7). WithFormatString("~%.2f"), }).WithRows([]Row{ NewRow(RowData{"name": "π", "val": 3.14}), NewRow(RowData{"name": "Φ", "val": 1.618}), }).SortByAsc("val") const expectedTable = `┏━━━━━┳━━━━━━━┓ ┃ Name┃ Value┃ ┣━━━━━╋━━━━━━━┫ ┃ Φ┃ ~1.62┃ ┃ π┃ ~3.14┃ ┗━━━━━┻━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleCellView(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithMinimumHeight(5) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ ┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleColumnView(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }).WithMinimumHeight(8) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 1┃ ┃ 2┃ ┃ ┃ ┃ ┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightHeaderNoData(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithMinimumHeight(5) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ ┃ ┃ ┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleRowWithHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }). WithHeaderVisibility(false). WithRows([]Row{ NewRow(RowData{"1": "a", "2": "b", "3": "c"}), }). WithMinimumHeight(4) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ a┃ b┃ c┃ ┃ ┃ ┃ ┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightNoRowsAndHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithHeaderVisibility(false).WithMinimumHeight(3) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ ┃ ┃ ┃ ┗━━━━┻━━━━┻━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleColumnNoDataWithFooter(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithStaticFooter("Foot").WithMinimumHeight(7) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ ┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleColumnWithFooterAndHiddenHeader(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }). WithStaticFooter("Foot"). WithHeaderVisibility(false). WithMinimumHeight(6) const expectedTable = `┏━━━━┓ ┃ ┃ ┃ ┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleRowWithFooter(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4), NewColumn("2", "2", 4), NewColumn("3", "3", 4), }).WithStaticFooter("Footer").WithMinimumHeight(7) const expectedTable = `┏━━━━┳━━━━┳━━━━┓ ┃ 1┃ 2┃ 3┃ ┣━━━━╋━━━━╋━━━━┫ ┃ ┃ ┃ ┃ ┣━━━━┻━━━━┻━━━━┫ ┃ Footer┃ ┗━━━━━━━━━━━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSingleColumnWithFooter(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }).WithStaticFooter("Foot").WithMinimumHeight(9) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 1┃ ┃ 2┃ ┃ ┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightExtraRow(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithStaticFooter("Foot").WithMinimumHeight(6) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ ┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMinimumHeightSmallerThanTable(t *testing.T) { model := New([]Column{ NewColumn("id", "ID", 4), }).WithRows([]Row{ NewRow(RowData{"id": "1"}), NewRow(RowData{"id": "2"}), }).WithStaticFooter("Foot").WithMinimumHeight(7) const expectedTable = `┏━━━━┓ ┃ ID┃ ┣━━━━┫ ┃ 1┃ ┃ 2┃ ┣━━━━┫ ┃Foot┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMultilineEnabled(t *testing.T) { model := New([]Column{ NewColumn("name", "Name", 4), }). WithRows([]Row{ NewRow(RowData{"name": "AAAAAAAAAAAAAAAAAA"}), NewRow(RowData{"name": "BBB"}), }). WithMultiline(true) assert.True(t, model.multiline) const expectedTable = `┏━━━━┓ ┃Name┃ ┣━━━━┫ ┃AAAA┃ ┃AAAA┃ ┃AAAA┃ ┃AAAA┃ ┃AA ┃ ┃BBB ┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMultilineDisabledByDefault(t *testing.T) { model := New([]Column{ NewColumn("name", "Name", 4), }). WithRows([]Row{ NewRow(RowData{"name": "AAAAAAAAAAAAAAAAAA"}), NewRow(RowData{"name": "BBB"}), }) // WithMultiline(false) assert.False(t, model.multiline) const expectedTable = `┏━━━━┓ ┃Name┃ ┣━━━━┫ ┃AAA…┃ ┃ BBB┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) } func TestMultilineDisabledExplicite(t *testing.T) { model := New([]Column{ NewColumn("name", "Name", 4), }). WithRows([]Row{ NewRow(RowData{"name": "AAAAAAAAAAAAAAAAAA"}), NewRow(RowData{"name": "BBB"}), }). WithMultiline(false) assert.False(t, model.multiline) const expectedTable = `┏━━━━┓ ┃Name┃ ┣━━━━┫ ┃AAA…┃ ┃ BBB┃ ┗━━━━┛` rendered := model.View() assert.Equal(t, expectedTable, rendered) }