Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,21 @@ example-sorting:
example-updates:
@go run ./examples/updates/*.go

COVERAGE_THRESHOLD := 98.3

.PHONY: test
test:
@go test -race -cover ./table

.PHONY: check-coverage
check-coverage:
@go test -coverprofile=coverage.out ./table
@awk -v min="$(COVERAGE_THRESHOLD)" \
'NR>1 { stmts += $$2; if ($$3>0) hit += $$2 } \
END { cov = 100*hit/stmts; \
if (cov < min+0) { printf "coverage %.4f%% is below minimum %.1f%%\n", cov, min+0; exit 1 } \
else { printf "coverage %.4f%%\n", cov } }' coverage.out

.PHONY: test-coverage
test-coverage: coverage.out
@go tool cover -html=coverage.out
Expand All @@ -79,6 +90,12 @@ lint: ./bin/golangci-lint$(EXE_EXT)
coverage.out: table/*.go go.*
@go test -coverprofile=coverage.out ./table

.PHONY: install-hooks
install-hooks:
@printf '#!/bin/sh\nset -e\necho "→ linting..."\nmake lint\necho "→ checking coverage..."\nmake check-coverage\necho "→ all checks passed"\n' > .git/hooks/pre-push
@chmod +x .git/hooks/pre-push
@echo "pre-push hook installed"

.PHONY: fmt
fmt: ./bin/gci$(EXE_EXT)
@go fmt ./...
Expand Down
57 changes: 35 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,47 +24,61 @@ for a few helpful tips!
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
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
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
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.
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
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.
Pagination and footer quick reference:

| Goal | Options |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Fixed row count per page | `WithPageSize(n)` — auto footer shows `current/total` |
| Fixed terminal-line height | `WithTargetHeight(n)` — rows per page calculated from actual rendered height; footer shown only when multi-page |
| Truly fixed height (never grows or shrinks) | `WithTargetHeight(n).WithMinimumHeight(n)` — `WithTargetHeight` caps the top, `WithMinimumHeight` pads the bottom when rows don't fill the page |
| Custom footer text | `WithStaticFooter(text)` — replaces the auto page-count footer |
| Hide footer | `WithFooterVisibility(false)` |

`WithPageSize` and `WithTargetHeight` are mutually exclusive. Prefer
`WithTargetHeight` when `WithMultiline` is enabled, since it pages by actual
rendered line count rather than row count.
[See the pagination example](examples/pagination) and
[the targetheight example](examples/targetheight) for demonstrations.

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.
[See the filter example](examples/filter).

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
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
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
Expand All @@ -73,20 +87,20 @@ 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.
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
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
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
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
An example is given below. For more detailed examples, see
[the examples directory](./examples).

```golang
Expand Down Expand Up @@ -147,8 +161,8 @@ rows := []table.Row{
### 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
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.

Expand Down Expand Up @@ -188,9 +202,9 @@ For a more detailed demonstration of this idea in action, please see the

## Demos

Code examples are located in [the examples directory](./examples). Run commands
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
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.

Expand All @@ -206,4 +220,3 @@ make example-dimensions
# Or run any of them directly
go run ./examples/pagination/main.go
```

126 changes: 126 additions & 0 deletions examples/targetheight/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package main

import (
"fmt"
"log"
"strings"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/evertras/bubble-table/table"
)

const (
columnKeyID = "id"
columnKeyName = "name"
columnKeyDescription = "description"

// The height we want the table to occupy, including borders/header/footer.
targetHeight = 12
columnWidth = 30
)

// The first few rows have short Pokédex entries (single-line rows); the remainder
// have longer entries that wrap to multiple lines, demonstrating that WithTargetHeight
// keeps the table height consistent regardless of row content.
var pokeRows = []table.Row{
// Short descriptions — single-line rows
table.NewRow(table.RowData{columnKeyID: 1, columnKeyName: "Bulbasaur", columnKeyDescription: "A strange seed was planted on its back at birth."}),
table.NewRow(table.RowData{columnKeyID: 4, columnKeyName: "Charmander", columnKeyDescription: "The flame on its tail shows its life force."}),
table.NewRow(table.RowData{columnKeyID: 7, columnKeyName: "Squirtle", columnKeyDescription: "Shoots water at prey while in the water."}),
table.NewRow(table.RowData{columnKeyID: 25, columnKeyName: "Pikachu", columnKeyDescription: "Has electric sacs on each cheek."}),
table.NewRow(table.RowData{columnKeyID: 39, columnKeyName: "Jigglypuff", columnKeyDescription: "Uses its round eyes to entrance foes."}),
// Longer Pokédex entries — wrap to multiple lines
table.NewRow(table.RowData{columnKeyID: 143, columnKeyName: "Snorlax", columnKeyDescription: "Very lazy. It just eats and sleeps. As its rotund bulk builds, it becomes steadily more slothful."}),
table.NewRow(table.RowData{columnKeyID: 131, columnKeyName: "Lapras", columnKeyDescription: "A gentle soul that can read the hearts of people. It can ferry people across the sea on its back."}),
table.NewRow(table.RowData{columnKeyID: 147, columnKeyName: "Dratini", columnKeyDescription: "Long considered a mythical Pokémon until a fisherman landed a live specimen after hooking it."}),
table.NewRow(table.RowData{columnKeyID: 137, columnKeyName: "Porygon", columnKeyDescription: "A Pokémon that consists entirely of programming code. Capable of moving freely in cyberspace."}),
table.NewRow(table.RowData{columnKeyID: 133, columnKeyName: "Eevee", columnKeyDescription: "Its genetic code is irregular. It may mutate if it is exposed to radiation from element stones."}),
}

type Model struct {
tableModel table.Model
}

func newTable(rows []table.Row) table.Model {
columns := []table.Column{
table.NewColumn(columnKeyID, "ID", 4),
table.NewColumn(columnKeyName, "Name", 12),
table.NewColumn(columnKeyDescription, "Pokédex Entry", columnWidth),
}

highlight := lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true)

return table.New(columns).
WithRows(rows).
WithMultiline(true).
WithTargetHeight(targetHeight).
WithMinimumHeight(targetHeight).
HighlightStyle(highlight).
Focused(true)
}

func NewModel() Model {
return Model{
tableModel: newTable(pokeRows),
}
}

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.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
cmds = append(cmds, tea.Quit)
}
}

m.tableModel, cmd = m.tableModel.Update(msg)
cmds = append(cmds, cmd)

return m, tea.Batch(cmds...)
}

// ruler draws a vertical ruler showing the target height boundary.
func ruler(height int) string {
lines := make([]string, height)
for i := range lines {
if i == height-1 {
lines[i] = fmt.Sprintf("%2d ◄── target bottom", i+1)
} else {
lines[i] = fmt.Sprintf("%2d │", i+1)
}
}
return strings.Join(lines, "\n")
}

func (m Model) View() tea.View {
body := strings.Builder{}

body.WriteString("Pokédex — WithTargetHeight keeps the table at a fixed height across pages.\n")
fmt.Fprintf(&body, "Short entries render as single lines; long entries wrap — target height: %d lines.\n\n", targetHeight)

rulerStr := ruler(targetHeight)

body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, m.tableModel.View(), " ", rulerStr))
body.WriteString("\n\nPress q to quit")

return tea.NewView(body.String())
}

func main() {
p := tea.NewProgram(NewModel())

if _, err := p.Run(); err != nil {
log.Fatal(err)
}
}
65 changes: 65 additions & 0 deletions table/dimensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,71 @@ func (m *Model) recalculateHeight() {
m.metaHeight = headerHeight + footerHeight
}

// ensurePageMap rebuilds the page-start index cache if it is stale.
// For targetHeight mode it uses a two-pass approach: first calculate without a
// footer to determine whether multiple pages are needed, then if they are,
// recalculate with the footer included in the height budget and rebuild.
func (m *Model) ensurePageMap() {
if m.pageStartIndices == nil {
m.recalculateHeight()
m.buildPageStartIndices()

if m.targetHeight != 0 && len(m.pageStartIndices) > 1 {
// Footer is now active (multi-page); redo with footer in budget.
m.recalculateHeight()
m.buildPageStartIndices()
}
}
}

// buildPageStartIndices computes which row index each page starts on and stores
// the result in m.pageStartIndices. Must be called after recalculateHeight so
// that m.metaHeight is up to date.
func (m *Model) buildPageStartIndices() {
rows := m.GetVisibleRows()

if len(rows) == 0 {
m.pageStartIndices = []int{}

return
}

// metaHeight covers the header (including top border) and footer.
// The bottom border is appended to the last data row by assembleRowOutput,
// so subtract 1 more to account for it.
availableLines := m.targetHeight - m.metaHeight - 1

if availableLines < 1 {
availableLines = 1
}

pageStarts := []int{0}
currentPageLines := 0

for rowIdx, row := range rows {
rowLines := m.rowLineCount(row)

var linesNeeded int
if currentPageLines == 0 {
linesNeeded = rowLines
} else if m.rowSeparator {
linesNeeded = rowLines + 1 // separator between rows
} else {
linesNeeded = rowLines
}

if currentPageLines+linesNeeded > availableLines && currentPageLines > 0 {
// Row doesn't fit on the current page; start a new one.
pageStarts = append(pageStarts, rowIdx)
currentPageLines = m.rowLineCount(row)
} else {
currentPageLines += linesNeeded
}
}

m.pageStartIndices = pageStarts
}

func (m *Model) calculatePadding(numRows int) int {
if m.minimumHeight == 0 {
return 0
Expand Down
Loading
Loading