From 66b6d1e6507e1f0f35c7a00692020e2ea2efdd79 Mon Sep 17 00:00:00 2001 From: wheelibin Date: Wed, 3 Jun 2026 23:16:58 +0100 Subject: [PATCH] feat(#188): add WithTargetHeight for fixed terminal-line pagination --- Makefile | 17 ++ README.md | 57 +++-- examples/targetheight/main.go | 126 +++++++++ table/dimensions.go | 65 +++++ table/footer.go | 40 +-- table/model.go | 10 + table/options.go | 85 ++++++- table/pagination.go | 133 ++++++++-- table/row.go | 17 ++ table/target_height_test.go | 466 ++++++++++++++++++++++++++++++++++ table/view.go | 29 ++- table/view_test.go | 37 +++ 12 files changed, 1016 insertions(+), 66 deletions(-) create mode 100644 examples/targetheight/main.go create mode 100644 table/target_height_test.go diff --git a/Makefile b/Makefile index e9c71df..4defd01 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 ./... diff --git a/README.md b/README.md index 9d3f733..70dbe2c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -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. @@ -206,4 +220,3 @@ make example-dimensions # Or run any of them directly go run ./examples/pagination/main.go ``` - diff --git a/examples/targetheight/main.go b/examples/targetheight/main.go new file mode 100644 index 0000000..c245b6c --- /dev/null +++ b/examples/targetheight/main.go @@ -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) + } +} diff --git a/table/dimensions.go b/table/dimensions.go index fe12be4..d5e0623 100644 --- a/table/dimensions.go +++ b/table/dimensions.go @@ -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 diff --git a/table/footer.go b/table/footer.go index 2d767d5..a452630 100644 --- a/table/footer.go +++ b/table/footer.go @@ -6,7 +6,9 @@ import ( ) func (m Model) hasFooter() bool { - return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered) + multiPageTargetHeight := m.targetHeight != 0 && m.pageStartIndices != nil && len(m.pageStartIndices) > 1 + + return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || multiPageTargetHeight || m.filtered) } func (m Model) footerFilterSection() string { @@ -27,6 +29,25 @@ func (m Model) footerFilterSection() string { return "" } +func (m Model) footerPageSection() string { + isPaged := m.pageSize != 0 || (m.targetHeight != 0 && m.pageStartIndices != nil && len(m.pageStartIndices) > 1) + if !isPaged { + return "" + } + + 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) + } + + return str +} + func (m Model) renderFooter(width int, includeTop bool) string { if !m.hasFooter() { return "" @@ -52,20 +73,9 @@ func (m Model) renderFooter(width int, includeTop bool) string { sections = append(sections, section) } - // 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) + if section := m.footerPageSection(); section != "" { + sections = append(sections, section) } - footerText := strings.Join(sections, " ") - - return styleFooter.Render(footerText) + return styleFooter.Render(strings.Join(sections, " ")) } diff --git a/table/model.go b/table/model.go index ef8c84d..efdd901 100644 --- a/table/model.go +++ b/table/model.go @@ -107,6 +107,16 @@ type Model struct { // Minimum total height of the table minimumHeight int + // Target total height of the table in terminal lines, including borders, + // header, and footer. When set, the table fits as many rows as possible + // per page. Mutually exclusive with pageSize. + targetHeight int + + // Cached page boundary indices when targetHeight is set. + // pageStartIndices[i] is the index of the first visible row on page i. + // Nil means the cache needs rebuilding. + pageStartIndices []int + // Internal cached calculation, the height of the header and footer // including borders. Used to determine how many padding rows to add. metaHeight int diff --git a/table/options.go b/table/options.go index 11813f1..18564ff 100644 --- a/table/options.go +++ b/table/options.go @@ -74,7 +74,16 @@ func (m Model) WithRows(rows []Row) Model { m.rowCursorIndex = 0 } - if m.pageSize != 0 { + if m.targetHeight != 0 { + m.pageStartIndices = nil + m.ensurePageMap() + + maxPage := m.MaxPages() + + if maxPage <= m.currentPage { + m.pageLast() + } + } else if m.pageSize != 0 { maxPage := m.MaxPages() // MaxPages is 1-index, currentPage is 0 index @@ -166,10 +175,14 @@ func (m Model) Filtered(filtered bool) Model { m.filtered = filtered m.visibleRowCacheUpdated = false - if m.minimumHeight > 0 { + if m.minimumHeight > 0 || m.targetHeight != 0 { m.recalculateHeight() } + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -184,10 +197,14 @@ func (m Model) StartFilterTyping() Model { func (m Model) WithStaticFooter(footer string) Model { m.staticFooter = footer - if m.minimumHeight > 0 { + if m.minimumHeight > 0 || m.targetHeight != 0 { m.recalculateHeight() } + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -274,6 +291,10 @@ func (m Model) WithOuterBorder(show bool) Model { func (m Model) WithRowBorder(show bool) Model { m.rowSeparator = show + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -285,6 +306,10 @@ func (m Model) WithTargetWidth(totalWidth int) Model { m.recalculateWidth() + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -297,6 +322,20 @@ func (m Model) WithMinimumHeight(minimumHeight int) Model { return m } +// WithTargetHeight sets the total target height of the table in terminal lines, +// including borders, header, and footer. The table automatically fits as many +// rows as possible within that height per page, with proper pagination for the +// remainder. This is the correct way to constrain table height when +// WithMultiline is enabled. Mutually exclusive with WithPageSize. +func (m Model) WithTargetHeight(height int) Model { + m.targetHeight = height + m.pageSize = 0 + m.pageStartIndices = nil + m.ensurePageMap() + + 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 { @@ -331,6 +370,26 @@ func (m Model) PageFirst() Model { // 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.targetHeight != 0 { + m.ensurePageMap() + + maxPages := m.MaxPages() + + if currentPage < 1 { + currentPage = 1 + } else if currentPage > maxPages { + currentPage = maxPages + } + + m.currentPage = currentPage - 1 + + if m.currentPage < len(m.pageStartIndices) { + m.rowCursorIndex = m.pageStartIndices[m.currentPage] + } + + return m + } + if m.pageSize == 0 || currentPage == m.CurrentPage() { return m } @@ -363,6 +422,10 @@ func (m Model) WithColumns(columns []Column) Model { m = m.SelectableRows(true) } + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -416,10 +479,14 @@ func (m Model) WithFuzzyFilter() Model { func (m Model) WithFooterVisibility(visibility bool) Model { m.footerVisible = visibility - if m.minimumHeight > 0 { + if m.minimumHeight > 0 || m.targetHeight != 0 { m.recalculateHeight() } + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -427,10 +494,14 @@ func (m Model) WithFooterVisibility(visibility bool) Model { func (m Model) WithHeaderVisibility(visibility bool) Model { m.headerVisible = visibility - if m.minimumHeight > 0 { + if m.minimumHeight > 0 || m.targetHeight != 0 { m.recalculateHeight() } + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } @@ -510,6 +581,10 @@ func (m Model) WithAllRowsDeselected() Model { func (m Model) WithMultiline(multiline bool) Model { m.multiline = multiline + if m.targetHeight != 0 { + m.pageStartIndices = nil + } + return m } diff --git a/table/pagination.go b/table/pagination.go index 6fce9b5..d2ce0f5 100644 --- a/table/pagination.go +++ b/table/pagination.go @@ -14,6 +14,16 @@ func (m *Model) CurrentPage() int { // MaxPages returns the maximum number of pages that are visible. func (m *Model) MaxPages() int { + if m.targetHeight != 0 { + m.ensurePageMap() + + if len(m.pageStartIndices) == 0 { + return 1 + } + + return len(m.pageStartIndices) + } + totalRows := len(m.GetVisibleRows()) if m.pageSize == 0 || totalRows == 0 { @@ -34,6 +44,24 @@ func (m *Model) TotalRows() int { func (m *Model) VisibleIndices() (start, end int) { totalRows := len(m.GetVisibleRows()) + if m.targetHeight != 0 { + m.ensurePageMap() + + if totalRows == 0 || len(m.pageStartIndices) == 0 { + return 0, -1 + } + + start = m.pageStartIndices[m.currentPage] + + if m.currentPage+1 < len(m.pageStartIndices) { + end = m.pageStartIndices[m.currentPage+1] - 1 + } else { + end = totalRows - 1 + } + + return start, end + } + if m.pageSize == 0 { start = 0 end = totalRows - 1 @@ -51,43 +79,75 @@ func (m *Model) VisibleIndices() (start, end int) { return start, end } -func (m *Model) pageDown() { - if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize { +func (m *Model) wrappedOrClamped(wrappedValue, clampedValue int) int { + if m.paginationWrapping { + return wrappedValue + } + + return clampedValue +} + +func (m *Model) clampCurrentPage(maxPageIndex int) { + if m.currentPage > maxPageIndex { + m.currentPage = m.wrappedOrClamped(0, maxPageIndex) + } else if m.currentPage < 0 { + m.currentPage = m.wrappedOrClamped(maxPageIndex, 0) + } +} + +func (m *Model) pageDownTargetHeight() { + m.ensurePageMap() + + if len(m.pageStartIndices) <= 1 { return } m.currentPage++ + m.clampCurrentPage(len(m.pageStartIndices) - 1) + m.rowCursorIndex = m.pageStartIndices[m.currentPage] +} - maxPageIndex := m.MaxPages() - 1 +func (m *Model) pageUpTargetHeight() { + m.ensurePageMap() - if m.currentPage > maxPageIndex { - if m.paginationWrapping { - m.currentPage = 0 - } else { - m.currentPage = maxPageIndex - } + if len(m.pageStartIndices) <= 1 { + return } - m.rowCursorIndex = m.currentPage * m.pageSize + m.currentPage-- + m.clampCurrentPage(len(m.pageStartIndices) - 1) + m.rowCursorIndex = m.pageStartIndices[m.currentPage] } -func (m *Model) pageUp() { +func (m *Model) pageDown() { + if m.targetHeight != 0 { + m.pageDownTargetHeight() + + return + } + if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize { return } - m.currentPage-- + m.currentPage++ + m.clampCurrentPage(m.MaxPages() - 1) + m.rowCursorIndex = m.currentPage * m.pageSize +} - maxPageIndex := m.MaxPages() - 1 +func (m *Model) pageUp() { + if m.targetHeight != 0 { + m.pageUpTargetHeight() - if m.currentPage < 0 { - if m.paginationWrapping { - m.currentPage = maxPageIndex - } else { - m.currentPage = 0 - } + return + } + + if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize { + return } + m.currentPage-- + m.clampCurrentPage(m.MaxPages() - 1) m.rowCursorIndex = m.currentPage * m.pageSize } @@ -97,16 +157,45 @@ func (m *Model) pageFirst() { } func (m *Model) pageLast() { + if m.targetHeight != 0 { + m.ensurePageMap() + + if len(m.pageStartIndices) == 0 { + return + } + + m.currentPage = len(m.pageStartIndices) - 1 + m.rowCursorIndex = m.pageStartIndices[m.currentPage] + + return + } + m.currentPage = m.MaxPages() - 1 m.rowCursorIndex = m.currentPage * m.pageSize } func (m *Model) expectedPageForRowIndex(rowIndex int) int { + if m.targetHeight != 0 { + m.ensurePageMap() + + // Binary search: find the last page whose start index <= rowIndex. + low, high := 0, len(m.pageStartIndices)-1 + + for low < high { + mid := (low + high + 1) / 2 //nolint:mnd // standard ceiling-midpoint formula + if m.pageStartIndices[mid] <= rowIndex { + low = mid + } else { + high = mid - 1 + } + } + + return low + } + if m.pageSize == 0 { return 0 } - expectedPage := rowIndex / m.pageSize - - return expectedPage + return rowIndex / m.pageSize } diff --git a/table/row.go b/table/row.go index 3114835..0fa4129 100644 --- a/table/row.go +++ b/table/row.go @@ -53,6 +53,23 @@ func (r Row) WithStyle(style lipgloss.Style) Row { return r } +// rowLineCount returns the number of terminal lines the row occupies when rendered. +// For non-multiline tables this is always 1. +func (m *Model) rowLineCount(row Row) int { + if !m.multiline { + return 1 + } + + maxLines := 1 + + for _, column := range m.columns { + cellStr := m.renderRowColumnData(row, column, row.Style, lipgloss.NewStyle()) + maxLines = max(maxLines, lipgloss.Height(cellStr)) + } + + return maxLines +} + //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.Inherit(column.style).Inherit(m.baseStyle) diff --git a/table/target_height_test.go b/table/target_height_test.go new file mode 100644 index 0000000..d5b2bc1 --- /dev/null +++ b/table/target_height_test.go @@ -0,0 +1,466 @@ +package table + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// genTargetHeightTable builds a table with a fixed-width content column and +// WithTargetHeight set. Rows with long content will wrap when multiline is on. +func genTargetHeightTable(targetHeight int, multiline bool, rows []Row) Model { + return New([]Column{ + NewColumn("id", "ID", 3), + NewColumn("content", "Content", 20), + }). + WithRows(rows). + WithMultiline(multiline). + WithTargetHeight(targetHeight) +} + +func shortRow(id int) Row { + return NewRow(RowData{"id": id, "content": "short"}) +} + +func longRow(id int) Row { + // This content exceeds the 20-char column width and will wrap to 2 lines. + return NewRow(RowData{"id": id, "content": fmt.Sprintf("row%d long content that wraps", id)}) +} + +// TestTargetHeightUniformShortRows checks that uniform single-line rows produce +// the expected number of pages and visible row counts. +// metaHeight=5 (header=3 + footer=2), so availableLines = 10-5-1 = 4 rows per page. +func TestTargetHeightUniformShortRows(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + + assert.Equal(t, 3, model.MaxPages(), "12 rows at 4 per page = 3 pages") + + start, end := model.VisibleIndices() + assert.Equal(t, 0, start) + assert.Equal(t, 3, end, "page 1 should show rows 0-3") +} + +// TestTargetHeightPageNavigationShortRows checks cursor and page after pageDown. +func TestTargetHeightPageNavigationShortRows(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + model.pageDown() + + assert.Equal(t, 2, model.CurrentPage()) + + start, end := model.VisibleIndices() + assert.Equal(t, 4, start) + assert.Equal(t, 7, end, "page 2 should show rows 4-7") + assert.Equal(t, 4, model.rowCursorIndex, "cursor should move to first row of page 2") +} + +// TestTargetHeightMultilineVariableRows checks that the page map respects actual +// rendered row heights so that wrapping rows push subsequent rows onto the next page. +// targetHeight=8: pass 1 (no footer) availableLines=4 → 2 pages, triggers pass 2. +// Pass 2 (footer) availableLines=2: +// - short(1)+short(2) = 2 lines → page 1 full +// - long(3) = 2 lines → page 2 (alone, no room for short(4)) +// - short(4)+short(5) = 2 lines → page 3 +func TestTargetHeightMultilineVariableRows(t *testing.T) { + rows := []Row{ + shortRow(1), + shortRow(2), + longRow(3), + shortRow(4), + shortRow(5), + } + + model := genTargetHeightTable(8, true, rows) + + require.Equal(t, 3, model.MaxPages(), "two-pass with footer budget: 3 pages") + + start, end := model.VisibleIndices() + assert.Equal(t, 0, start) + assert.Equal(t, 1, end, "page 1 should contain rows 0-1") + + model.pageDown() + + start, end = model.VisibleIndices() + assert.Equal(t, 2, start) + assert.Equal(t, 2, end, "page 2 should contain only the long row") + + model.pageDown() + + start, end = model.VisibleIndices() + assert.Equal(t, 3, start) + assert.Equal(t, 4, end, "page 3 should contain rows 3-4") +} + +// TestTargetHeightCursorStaysInBoundsAcrossPages checks that cursor navigation +// stays within the visible range after crossing a page boundary. +func TestTargetHeightCursorStaysInBoundsAcrossPages(t *testing.T) { + rows := make([]Row, 10) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(8, false, rows) + + // Move down past the end of page 1 (4 rows) and into page 2. + for i := 0; i < 5; i++ { + model.moveHighlightDown() + } + + start, end := model.VisibleIndices() + assert.GreaterOrEqual(t, model.rowCursorIndex, start) + assert.LessOrEqual(t, model.rowCursorIndex, end) +} + +// TestTargetHeightExpectedPageForRowIndex checks the binary search page lookup. +// Pages start at [0, 4, 8] for 12 single-line rows with availableLines=4. +func TestTargetHeightExpectedPageForRowIndex(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + + assert.Equal(t, 0, model.expectedPageForRowIndex(0)) + assert.Equal(t, 0, model.expectedPageForRowIndex(3)) + assert.Equal(t, 1, model.expectedPageForRowIndex(4)) + assert.Equal(t, 1, model.expectedPageForRowIndex(7)) + assert.Equal(t, 2, model.expectedPageForRowIndex(8)) + assert.Equal(t, 2, model.expectedPageForRowIndex(11)) +} + +// TestTargetHeightPageMapRebuildOnWithRows checks that changing rows invalidates +// and rebuilds the page map. +// Uses 7 rows: pass 1 (avail=6) gives 2 pages, triggering pass 2 (avail=4, 2 pages). +func TestTargetHeightPageMapRebuildOnWithRows(t *testing.T) { + rows := make([]Row, 7) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + assert.Equal(t, 2, model.MaxPages()) + + newRows := make([]Row, 3) + for i := range newRows { + newRows[i] = shortRow(i + 1) + } + + model = model.WithRows(newRows) + assert.Equal(t, 1, model.MaxPages()) +} + +// TestTargetHeightSingleRowTallerThanBudget checks that a row taller than the +// entire available height still appears alone on its page rather than being dropped. +func TestTargetHeightSingleRowTallerThanBudget(t *testing.T) { + // targetHeight=4 → availableLines = 4-3-1 = 0, clamped to 1. + // The long row wraps to 2 lines — exceeds the budget. + // It must still appear alone on page 1; short(2) goes to page 2. + rows := []Row{ + longRow(1), + shortRow(2), + } + + model := genTargetHeightTable(4, true, rows) + + assert.Equal(t, 2, model.MaxPages()) + + start, end := model.VisibleIndices() + assert.Equal(t, 0, start) + assert.Equal(t, 0, end, "oversized row should occupy page 1 alone") +} + +// TestTargetHeightPageLast checks pageLast navigates to the correct page and cursor. +func TestTargetHeightPageLast(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + model.pageLast() + + assert.Equal(t, 3, model.CurrentPage()) + + start, end := model.VisibleIndices() + assert.Equal(t, 8, start) + assert.Equal(t, 11, end) + assert.Equal(t, 8, model.rowCursorIndex) +} + +// TestTargetHeightWithCurrentPage checks the public WithCurrentPage API. +func TestTargetHeightWithCurrentPage(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + model = model.WithCurrentPage(3) + + assert.Equal(t, 3, model.CurrentPage()) + assert.Equal(t, 8, model.rowCursorIndex) +} + +// TestTargetHeightRowSeparatorReducesBudget checks that row separators consume +// budget lines, resulting in fewer rows visible per page. +func TestTargetHeightRowSeparatorReducesBudget(t *testing.T) { + rows := make([]Row, 10) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + modelPlain := genTargetHeightTable(8, false, rows) + modelSep := genTargetHeightTable(8, false, rows).WithRowBorder(true) + + _, endPlain := modelPlain.VisibleIndices() + _, endSep := modelSep.VisibleIndices() + + assert.Greater(t, endPlain, endSep, "separators should reduce rows per page") +} + +// TestTargetHeightViewHeightRespected renders the table and confirms the output +// does not exceed targetHeight lines. +func TestTargetHeightViewHeightRespected(t *testing.T) { + rows := []Row{ + longRow(1), + longRow(2), + longRow(3), + longRow(4), + longRow(5), + } + + const target = 10 + + model := genTargetHeightTable(target, true, rows) + rendered := model.View() + + lines := strings.Split(rendered, "\n") + assert.LessOrEqual(t, len(lines), target, + "rendered height (%d lines) exceeded target (%d)", len(lines), target) +} + +// TestTargetHeightPageUp checks that pageUp navigates from page 2 back to page 1. +func TestTargetHeightPageUp(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + model.pageDown() + assert.Equal(t, 2, model.CurrentPage()) + + model.pageUp() + + assert.Equal(t, 1, model.CurrentPage()) + + start, end := model.VisibleIndices() + assert.Equal(t, 0, start) + assert.Equal(t, 3, end, "page 1 should show rows 0-3 after paging up") + assert.Equal(t, 0, model.rowCursorIndex, "cursor should return to first row of page 1") +} + +// TestTargetHeightPageDownSinglePage checks that pageDown is a no-op when all +// rows fit on a single page. +func TestTargetHeightPageDownSinglePage(t *testing.T) { + rows := []Row{shortRow(1), shortRow(2)} + + model := genTargetHeightTable(8, false, rows) + assert.Equal(t, 1, model.MaxPages()) + + model.pageDown() + + assert.Equal(t, 1, model.CurrentPage(), "pageDown on a single page should not change page") +} + +// TestTargetHeightPageUpSinglePage checks that pageUp is a no-op when all +// rows fit on a single page. +func TestTargetHeightPageUpSinglePage(t *testing.T) { + rows := []Row{shortRow(1), shortRow(2)} + + model := genTargetHeightTable(8, false, rows) + assert.Equal(t, 1, model.MaxPages()) + + model.pageUp() + + assert.Equal(t, 1, model.CurrentPage(), "pageUp on a single page should not change page") +} + +// TestTargetHeightWithMultilineInvalidatesPageMap checks that calling WithMultiline +// on a model that already has targetHeight set invalidates and rebuilds the page map. +// With availableLines=4 (targetHeight=10, metaHeight=5, bottom border=1) and 4 long rows: +// - multiline off: each row is 1 line → all 4 fit on page 1 → 1 page +// - multiline on: each row wraps to 2 lines → only 2 fit per page → 2 pages +func TestTargetHeightWithMultilineInvalidatesPageMap(t *testing.T) { + rows := []Row{longRow(1), longRow(2), longRow(3), longRow(4)} + + model := genTargetHeightTable(10, false, rows) + assert.Equal(t, 1, model.MaxPages(), "multiline off: all 4 rows fit on one page") + + model = model.WithMultiline(true) + assert.Equal(t, 2, model.MaxPages(), "multiline on: wrapping rows should split across 2 pages") +} + +// TestTargetHeightEmptyRows checks that a table with targetHeight and no rows +// returns sane defaults from MaxPages, VisibleIndices, and pageLast without panicking. +func TestTargetHeightEmptyRows(t *testing.T) { + model := genTargetHeightTable(8, false, []Row{}) + + assert.Equal(t, 1, model.MaxPages(), "empty table should report 1 page") + + start, end := model.VisibleIndices() + assert.Equal(t, 0, start) + assert.Equal(t, -1, end, "empty table should return -1 end index") + + // pageLast on an empty table should be a no-op (not panic) + model.pageLast() + assert.Equal(t, 1, model.CurrentPage()) +} + +// TestTargetHeightWithRowsShrinksClampsPage checks that calling WithRows with +// fewer rows than the current page requires correctly clamps to the last page. +func TestTargetHeightWithRowsShrinksClampsPage(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + model.pageDown() + assert.Equal(t, 2, model.CurrentPage(), "should be on page 2 before shrink") + + // Reduce to 3 rows (1 page) while on page 2 — WithRows should clamp to page 1. + newRows := []Row{shortRow(1), shortRow(2), shortRow(3)} + model = model.WithRows(newRows) + + assert.Equal(t, 1, model.CurrentPage(), "page should be clamped to last after row shrink") +} + +// TestTargetHeightWithCurrentPageBelowOne checks that WithCurrentPage clamps +// values less than 1 to page 1 in targetHeight mode. +func TestTargetHeightWithCurrentPageBelowOne(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(8, false, rows) + model = model.WithCurrentPage(0) + + assert.Equal(t, 1, model.CurrentPage(), "page below 1 should be clamped to 1") + assert.Equal(t, 0, model.rowCursorIndex) +} + +// TestTargetHeightVisibleDataLinesWithSeparator calls View() on a table with +// both multiline and row separators enabled to exercise the separator branch in +// visibleDataLines. +func TestTargetHeightVisibleDataLinesWithSeparator(t *testing.T) { + rows := []Row{ + shortRow(1), + shortRow(2), + shortRow(3), + } + + const target = 12 + + model := New([]Column{ + NewColumn("id", "ID", 3), + NewColumn("content", "Content", 20), + }). + WithRows(rows). + WithMultiline(true). + WithRowBorder(true). + WithTargetHeight(target). + WithMinimumHeight(target) + + rendered := model.View() + lines := strings.Split(rendered, "\n") + + assert.LessOrEqual(t, len(lines), target, + "rendered height (%d lines) exceeded target (%d)", len(lines), target) + assert.Greater(t, len(lines), 0, "table should not be empty") +} + +// TestTargetHeightNoFooterOnSinglePage checks that no page-count footer is +// rendered when all rows fit on a single page. +func TestTargetHeightNoFooterOnSinglePage(t *testing.T) { + rows := []Row{shortRow(1), shortRow(2)} + + model := genTargetHeightTable(10, false, rows) + + assert.Equal(t, 1, model.MaxPages(), "all rows should fit on one page") + + rendered := model.View() + assert.NotContains(t, rendered, "1/1", "page-count footer must not appear on a single-page table") +} + +// TestTargetHeightPageMapInvalidatedByOptions checks that options which affect +// layout correctly invalidate the page map so MaxPages stays accurate. +func TestTargetHeightPageMapInvalidatedByOptions(t *testing.T) { + makeRows := func(n int) []Row { + rows := make([]Row, n) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + return rows + } + + base := genTargetHeightTable(10, false, makeRows(12)) + basePages := base.MaxPages() + + require.Greater(t, basePages, 1, "test setup requires a multi-page table") + + // Each of these options should invalidate the page map; MaxPages must still + // return a sensible (non-zero) value after the rebuild. + cases := []struct { + name string + apply func(Model) Model + }{ + {"Filtered", func(m Model) Model { return m.Filtered(true) }}, + {"WithStaticFooter", func(m Model) Model { return m.WithStaticFooter("hint") }}, + {"WithTargetWidth", func(m Model) Model { return m.WithTargetWidth(50) }}, + {"WithColumns", func(m Model) Model { + return m.WithColumns([]Column{NewColumn("id", "ID", 3), NewColumn("content", "Content", 20)}) + }}, + {"WithFooterVisibility", func(m Model) Model { return m.WithFooterVisibility(false) }}, + {"WithHeaderVisibility", func(m Model) Model { return m.WithHeaderVisibility(false) }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + model := tc.apply(base) + assert.Greater(t, model.MaxPages(), 0, "MaxPages should be positive after %s", tc.name) + }) + } +} + +// TestTargetHeightWithCurrentPageAboveMax checks that WithCurrentPage clamps +// values above MaxPages to the last page in targetHeight mode. +func TestTargetHeightWithCurrentPageAboveMax(t *testing.T) { + rows := make([]Row, 12) + for i := range rows { + rows[i] = shortRow(i + 1) + } + + model := genTargetHeightTable(10, false, rows) + + require.Greater(t, model.MaxPages(), 1, "test setup requires a multi-page table") + + model = model.WithCurrentPage(999) + + assert.Equal(t, model.MaxPages(), model.CurrentPage(), "page above max should be clamped to last page") +} diff --git a/table/view.go b/table/view.go index 9ce823e..6f87a21 100644 --- a/table/view.go +++ b/table/view.go @@ -6,6 +6,30 @@ import ( "charm.land/lipgloss/v2" ) +// visibleDataLines returns the total terminal lines occupied by visible rows in +// the range [startRowIndex, endRowIndex]. When multiline is disabled every row +// is one line, so the result equals endRowIndex - startRowIndex + 1. +func (m *Model) visibleDataLines(startRowIndex, endRowIndex int) int { + numRows := endRowIndex - startRowIndex + 1 + + if !m.multiline || numRows <= 0 { + return numRows + } + + total := 0 + visibleRows := m.GetVisibleRows() + + for rowIdx := startRowIndex; rowIdx <= endRowIndex; rowIdx++ { + total += m.rowLineCount(visibleRows[rowIdx]) + + if m.rowSeparator && rowIdx > startRowIndex { + total++ + } + } + + return total +} + // View renders the table. It does not end in a newline, so that it can be // composed with other elements more consistently. // @@ -23,9 +47,10 @@ func (m Model) View() string { headers := m.renderHeaders() startRowIndex, endRowIndex := m.VisibleIndices() - numRows := endRowIndex - startRowIndex + 1 - padding := m.calculatePadding(numRows) + padding := m.calculatePadding(m.visibleDataLines(startRowIndex, endRowIndex)) + + numRows := endRowIndex - startRowIndex + 1 if m.headerVisible { rowStrs = append(rowStrs, headers) diff --git a/table/view_test.go b/table/view_test.go index b59b0fd..eaf85b4 100644 --- a/table/view_test.go +++ b/table/view_test.go @@ -1024,6 +1024,43 @@ func Test3x3WithFilterFooter(t *testing.T) { assert.Equal(t, expectedFilteredDoneTable, model.View()) } +func Test3x3WithFilterFooterAndPageSize(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 <= 6; 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(3).Filtered(true).Focused(true) + + hitKey := func(key rune) { + model, _ = model.Update(tea.KeyPressMsg{Code: key, Text: string(key)}) + } + + // Start typing a filter — this focuses the filter input and should + // render the page count using the inline style alongside the filter text. + hitKey('/') + hitKey('1') + + rendered := model.View() + + // The page count must appear in the footer while the filter is active. + assert.Contains(t, rendered, "1/") +} + func TestSingleCellFlexView(t *testing.T) { model := New([]Column{ NewFlexColumn("id", "ID", 1),