Skip to content

Commit 31688b5

Browse files
authored
feat(#188): add WithTargetHeight for fixed terminal-line pagination (#227)
1 parent b4aaed1 commit 31688b5

12 files changed

Lines changed: 1016 additions & 66 deletions

File tree

Makefile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,21 @@ example-sorting:
6060
example-updates:
6161
@go run ./examples/updates/*.go
6262

63+
COVERAGE_THRESHOLD := 98.3
64+
6365
.PHONY: test
6466
test:
6567
@go test -race -cover ./table
6668

69+
.PHONY: check-coverage
70+
check-coverage:
71+
@go test -coverprofile=coverage.out ./table
72+
@awk -v min="$(COVERAGE_THRESHOLD)" \
73+
'NR>1 { stmts += $$2; if ($$3>0) hit += $$2 } \
74+
END { cov = 100*hit/stmts; \
75+
if (cov < min+0) { printf "coverage %.4f%% is below minimum %.1f%%\n", cov, min+0; exit 1 } \
76+
else { printf "coverage %.4f%%\n", cov } }' coverage.out
77+
6778
.PHONY: test-coverage
6879
test-coverage: coverage.out
6980
@go tool cover -html=coverage.out
@@ -79,6 +90,12 @@ lint: ./bin/golangci-lint$(EXE_EXT)
7990
coverage.out: table/*.go go.*
8091
@go test -coverprofile=coverage.out ./table
8192

93+
.PHONY: install-hooks
94+
install-hooks:
95+
@printf '#!/bin/sh\nset -e\necho "→ linting..."\nmake lint\necho "→ checking coverage..."\nmake check-coverage\necho "→ all checks passed"\n' > .git/hooks/pre-push
96+
@chmod +x .git/hooks/pre-push
97+
@echo "pre-push hook installed"
98+
8299
.PHONY: fmt
83100
fmt: ./bin/gci$(EXE_EXT)
84101
@go fmt ./...

README.md

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,47 +24,61 @@ for a few helpful tips!
2424
For a code reference of most available features, please see the [full feature example](./examples/features).
2525
If you want to get started with a simple default table, [check the simplest example](./examples/simplest).
2626

27-
Displays a table with a header, rows, footer, and borders. The header can be
27+
Displays a table with a header, rows, footer, and borders. The header can be
2828
hidden, and the footer can be set to automatically show page information, use
2929
custom text, or be hidden by default.
3030

31-
Columns can be fixed-width [or flexible width](./examples/flex). A maximum
31+
Columns can be fixed-width [or flexible width](./examples/flex). A maximum
3232
width can be specified which enables [horizontal scrolling](./examples/scrolling),
3333
and left-most columns can be frozen for easier reference.
3434

35-
Border shape is customizable with a basic thick square default. The color can
35+
Border shape is customizable with a basic thick square default. The color can
3636
be modified by applying a base style with `lipgloss.NewStyle().BorderForeground(...)`.
3737

3838
Styles can be applied globally and to columns, rows, and individual cells.
3939
The base style is applied first, then column, then row, then cell when
40-
determining overrides. The default base style is a basic right-alignment.
40+
determining overrides. The default base style is a basic right-alignment.
4141
[See the main feature example](./examples/features) to see styles and
4242
how they override each other.
4343

4444
Styles can also be applied via a style function which can be used to apply
4545
zebra striping, data-specific formatting, etc.
4646

47-
Can be focused to highlight a row and navigate with up/down (and j/k). These
47+
Can be focused to highlight a row and navigate with up/down (and j/k). These
4848
keys can be customized with a KeyMap.
4949

5050
Can make rows selectable, and fetch the current selections.
5151

5252
Events can be checked for user interactions.
5353

54-
Pagination can be set with a given page size, which automatically generates a
55-
simple footer to show the current page and total pages.
54+
Pagination and footer quick reference:
55+
56+
| Goal | Options |
57+
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
58+
| Fixed row count per page | `WithPageSize(n)` — auto footer shows `current/total` |
59+
| Fixed terminal-line height | `WithTargetHeight(n)` — rows per page calculated from actual rendered height; footer shown only when multi-page |
60+
| 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 |
61+
| Custom footer text | `WithStaticFooter(text)` — replaces the auto page-count footer |
62+
| Hide footer | `WithFooterVisibility(false)` |
63+
64+
`WithPageSize` and `WithTargetHeight` are mutually exclusive. Prefer
65+
`WithTargetHeight` when `WithMultiline` is enabled, since it pages by actual
66+
rendered line count rather than row count.
67+
[See the pagination example](examples/pagination) and
68+
[the targetheight example](examples/targetheight) for demonstrations.
5669

5770
Built-in filtering can be enabled by setting any columns as filterable, using
5871
a text box in the footer and `/` (customizable by keybind) to start filtering.
72+
[See the filter example](examples/filter).
5973

6074
A missing indicator can be supplied to show missing data in rows.
6175

62-
Columns can be sorted in either ascending or descending order. Multiple columns
63-
can be specified in a row. If multiple columns are specified, first the table
76+
Columns can be sorted in either ascending or descending order. Multiple columns
77+
can be specified in a row. If multiple columns are specified, first the table
6478
is sorted by the first specified column, then each group within that column is
65-
sorted in smaller and smaller groups. [See the sorting example](examples/sorting)
66-
for more information. If a column contains numbers (either ints or floats),
67-
the numbers will be sorted by numeric value. Otherwise rendered string values
79+
sorted in smaller and smaller groups. [See the sorting example](examples/sorting)
80+
for more information. If a column contains numbers (either ints or floats),
81+
the numbers will be sorted by numeric value. Otherwise rendered string values
6882
will be compared.
6983

7084
If a feature is confusing to use or could use a better example, please feel free
@@ -73,20 +87,20 @@ to open an issue.
7387
## Defining table data
7488

7589
A table is defined by a list of `Column` values that define the columns in the
76-
table. Each `Column` is associated with a unique string key.
90+
table. Each `Column` is associated with a unique string key.
7791

78-
A table contains a list of `Row`s. Each `Row` contains a `RowData` object which
92+
A table contains a list of `Row`s. Each `Row` contains a `RowData` object which
7993
is simply a map of string column IDs to arbitrary `any` data values.
80-
When the table is rendered, each `Row` is checked for each `Column` key. If the
94+
When the table is rendered, each `Row` is checked for each `Column` key. If the
8195
key exists in the `Row`'s `RowData`, it is rendered with `fmt.Sprintf("%v")`.
8296
If it does not exist, nothing is rendered.
8397

84-
Extra data in the `RowData` object is ignored. This can be helpful to simply
98+
Extra data in the `RowData` object is ignored. This can be helpful to simply
8599
dump data into `RowData` and create columns that select what is interesting to
86100
view, or to generate different columns based on view options on the fly (see the
87101
[metadata example](./examples/metadata) for an example of using this).
88102

89-
An example is given below. For more detailed examples, see
103+
An example is given below. For more detailed examples, see
90104
[the examples directory](./examples).
91105

92106
```golang
@@ -147,8 +161,8 @@ rows := []table.Row{
147161
### A note on 'metadata'
148162

149163
There may be cases where you wish to reference some kind of data object in the
150-
table. For example, a table of users may display a user name, ID, etc., and you
151-
may wish to retrieve data about the user when the row is selected. This can be
164+
table. For example, a table of users may display a user name, ID, etc., and you
165+
may wish to retrieve data about the user when the row is selected. This can be
152166
accomplished by attaching hidden 'metadata' to the row in the same way as any
153167
other data.
154168

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

189203
## Demos
190204

191-
Code examples are located in [the examples directory](./examples). Run commands
205+
Code examples are located in [the examples directory](./examples). Run commands
192206
are added to the [Makefile](Makefile) for convenience but they should be as
193-
simple as `go run ./examples/features/main.go`, etc. You can also view what
207+
simple as `go run ./examples/features/main.go`, etc. You can also view what
194208
they look like by checking the example's directory in each README here on
195209
Github.
196210

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

examples/targetheight/main.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"strings"
7+
8+
tea "charm.land/bubbletea/v2"
9+
"charm.land/lipgloss/v2"
10+
"github.com/evertras/bubble-table/table"
11+
)
12+
13+
const (
14+
columnKeyID = "id"
15+
columnKeyName = "name"
16+
columnKeyDescription = "description"
17+
18+
// The height we want the table to occupy, including borders/header/footer.
19+
targetHeight = 12
20+
columnWidth = 30
21+
)
22+
23+
// The first few rows have short Pokédex entries (single-line rows); the remainder
24+
// have longer entries that wrap to multiple lines, demonstrating that WithTargetHeight
25+
// keeps the table height consistent regardless of row content.
26+
var pokeRows = []table.Row{
27+
// Short descriptions — single-line rows
28+
table.NewRow(table.RowData{columnKeyID: 1, columnKeyName: "Bulbasaur", columnKeyDescription: "A strange seed was planted on its back at birth."}),
29+
table.NewRow(table.RowData{columnKeyID: 4, columnKeyName: "Charmander", columnKeyDescription: "The flame on its tail shows its life force."}),
30+
table.NewRow(table.RowData{columnKeyID: 7, columnKeyName: "Squirtle", columnKeyDescription: "Shoots water at prey while in the water."}),
31+
table.NewRow(table.RowData{columnKeyID: 25, columnKeyName: "Pikachu", columnKeyDescription: "Has electric sacs on each cheek."}),
32+
table.NewRow(table.RowData{columnKeyID: 39, columnKeyName: "Jigglypuff", columnKeyDescription: "Uses its round eyes to entrance foes."}),
33+
// Longer Pokédex entries — wrap to multiple lines
34+
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."}),
35+
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."}),
36+
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."}),
37+
table.NewRow(table.RowData{columnKeyID: 137, columnKeyName: "Porygon", columnKeyDescription: "A Pokémon that consists entirely of programming code. Capable of moving freely in cyberspace."}),
38+
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."}),
39+
}
40+
41+
type Model struct {
42+
tableModel table.Model
43+
}
44+
45+
func newTable(rows []table.Row) table.Model {
46+
columns := []table.Column{
47+
table.NewColumn(columnKeyID, "ID", 4),
48+
table.NewColumn(columnKeyName, "Name", 12),
49+
table.NewColumn(columnKeyDescription, "Pokédex Entry", columnWidth),
50+
}
51+
52+
highlight := lipgloss.NewStyle().
53+
Foreground(lipgloss.Color("212")).
54+
Bold(true)
55+
56+
return table.New(columns).
57+
WithRows(rows).
58+
WithMultiline(true).
59+
WithTargetHeight(targetHeight).
60+
WithMinimumHeight(targetHeight).
61+
HighlightStyle(highlight).
62+
Focused(true)
63+
}
64+
65+
func NewModel() Model {
66+
return Model{
67+
tableModel: newTable(pokeRows),
68+
}
69+
}
70+
71+
func (m Model) Init() tea.Cmd { return nil }
72+
73+
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74+
var (
75+
cmd tea.Cmd
76+
cmds []tea.Cmd
77+
)
78+
79+
switch msg := msg.(type) {
80+
case tea.KeyPressMsg:
81+
switch msg.String() {
82+
case "ctrl+c", "esc", "q":
83+
cmds = append(cmds, tea.Quit)
84+
}
85+
}
86+
87+
m.tableModel, cmd = m.tableModel.Update(msg)
88+
cmds = append(cmds, cmd)
89+
90+
return m, tea.Batch(cmds...)
91+
}
92+
93+
// ruler draws a vertical ruler showing the target height boundary.
94+
func ruler(height int) string {
95+
lines := make([]string, height)
96+
for i := range lines {
97+
if i == height-1 {
98+
lines[i] = fmt.Sprintf("%2d ◄── target bottom", i+1)
99+
} else {
100+
lines[i] = fmt.Sprintf("%2d │", i+1)
101+
}
102+
}
103+
return strings.Join(lines, "\n")
104+
}
105+
106+
func (m Model) View() tea.View {
107+
body := strings.Builder{}
108+
109+
body.WriteString("Pokédex — WithTargetHeight keeps the table at a fixed height across pages.\n")
110+
fmt.Fprintf(&body, "Short entries render as single lines; long entries wrap — target height: %d lines.\n\n", targetHeight)
111+
112+
rulerStr := ruler(targetHeight)
113+
114+
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, m.tableModel.View(), " ", rulerStr))
115+
body.WriteString("\n\nPress q to quit")
116+
117+
return tea.NewView(body.String())
118+
}
119+
120+
func main() {
121+
p := tea.NewProgram(NewModel())
122+
123+
if _, err := p.Run(); err != nil {
124+
log.Fatal(err)
125+
}
126+
}

table/dimensions.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,71 @@ func (m *Model) recalculateHeight() {
9494
m.metaHeight = headerHeight + footerHeight
9595
}
9696

97+
// ensurePageMap rebuilds the page-start index cache if it is stale.
98+
// For targetHeight mode it uses a two-pass approach: first calculate without a
99+
// footer to determine whether multiple pages are needed, then if they are,
100+
// recalculate with the footer included in the height budget and rebuild.
101+
func (m *Model) ensurePageMap() {
102+
if m.pageStartIndices == nil {
103+
m.recalculateHeight()
104+
m.buildPageStartIndices()
105+
106+
if m.targetHeight != 0 && len(m.pageStartIndices) > 1 {
107+
// Footer is now active (multi-page); redo with footer in budget.
108+
m.recalculateHeight()
109+
m.buildPageStartIndices()
110+
}
111+
}
112+
}
113+
114+
// buildPageStartIndices computes which row index each page starts on and stores
115+
// the result in m.pageStartIndices. Must be called after recalculateHeight so
116+
// that m.metaHeight is up to date.
117+
func (m *Model) buildPageStartIndices() {
118+
rows := m.GetVisibleRows()
119+
120+
if len(rows) == 0 {
121+
m.pageStartIndices = []int{}
122+
123+
return
124+
}
125+
126+
// metaHeight covers the header (including top border) and footer.
127+
// The bottom border is appended to the last data row by assembleRowOutput,
128+
// so subtract 1 more to account for it.
129+
availableLines := m.targetHeight - m.metaHeight - 1
130+
131+
if availableLines < 1 {
132+
availableLines = 1
133+
}
134+
135+
pageStarts := []int{0}
136+
currentPageLines := 0
137+
138+
for rowIdx, row := range rows {
139+
rowLines := m.rowLineCount(row)
140+
141+
var linesNeeded int
142+
if currentPageLines == 0 {
143+
linesNeeded = rowLines
144+
} else if m.rowSeparator {
145+
linesNeeded = rowLines + 1 // separator between rows
146+
} else {
147+
linesNeeded = rowLines
148+
}
149+
150+
if currentPageLines+linesNeeded > availableLines && currentPageLines > 0 {
151+
// Row doesn't fit on the current page; start a new one.
152+
pageStarts = append(pageStarts, rowIdx)
153+
currentPageLines = m.rowLineCount(row)
154+
} else {
155+
currentPageLines += linesNeeded
156+
}
157+
}
158+
159+
m.pageStartIndices = pageStarts
160+
}
161+
97162
func (m *Model) calculatePadding(numRows int) int {
98163
if m.minimumHeight == 0 {
99164
return 0

0 commit comments

Comments
 (0)