Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add minimum height option to table #154

Merged
merged 7 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from 6 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
54 changes: 44 additions & 10 deletions examples/flex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,23 @@ const (
columnKeyElement = "element"
columnKeyDescription = "description"

minWidth = 30
minWidth = 30
minHeight = 8

// Add a fixed margin to account for description & instructions
fixedVerticalMargin = 4
)

type Model struct {
flexTable table.Model
totalMargin int
flexTable table.Model

// Window dimensions
totalWidth int
totalHeight int

// Table dimensions
horizontalMargin int
verticalMargin int
}

func NewModel() Model {
Expand Down Expand Up @@ -69,20 +79,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, tea.Quit)

case "left":
if m.totalWidth-m.totalMargin > minWidth {
m.totalMargin++
if m.calculateWidth() > minWidth {
m.horizontalMargin++
m.recalculateTable()
}

case "right":
if m.totalMargin > 0 {
m.totalMargin--
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()
}
Expand All @@ -91,13 +114,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

func (m *Model) recalculateTable() {
m.flexTable = m.flexTable.WithTargetWidth(m.totalWidth - m.totalMargin)
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 is fixed-width)",
fmt.Sprintf("Target size: %d (left/right to adjust)", m.totalWidth-m.totalMargin),
"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(),
}
Expand Down
2 changes: 1 addition & 1 deletion table/border.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ func (b *borderStyleRow) inherit(s lipgloss.Style) {
//
//nolint:nestif
func (m Model) styleHeaders() borderStyleRow {
hasRows := len(m.GetVisibleRows()) > 0
hasRows := len(m.GetVisibleRows()) > 0 || m.calculatePadding(0) > 0
singleColumn := len(m.columns) == 1
styles := borderStyleRow{}

Expand Down
43 changes: 43 additions & 0 deletions table/dimensions.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package table

import (
"github.com/charmbracelet/lipgloss"
)

func (m *Model) recalculateWidth() {
if m.targetTotalWidth != 0 {
m.totalWidth = m.targetTotalWidth
Expand Down Expand Up @@ -71,3 +75,42 @@ func updateColumnWidths(cols []Column, totalWidth int) {
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
}
84 changes: 84 additions & 0 deletions table/dimensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,87 @@ func TestColumnUpdateWidths(t *testing.T) {
})
}
}

// 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)
})
}
}
7 changes: 7 additions & 0 deletions table/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ type Model struct {

// 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
}

// New creates a new table ready for further modifications.
Expand Down
33 changes: 33 additions & 0 deletions table/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ func (m Model) Filtered(filtered bool) Model {
m.filtered = filtered
m.visibleRowCacheUpdated = false

if m.minimumHeight > 0 {
m.recalculateHeight()
}

return m
}

Expand All @@ -146,6 +150,10 @@ func (m Model) StartFilterTyping() Model {
func (m Model) WithStaticFooter(footer string) Model {
m.staticFooter = footer

if m.minimumHeight > 0 {
m.recalculateHeight()
}

return m
}

Expand All @@ -160,13 +168,21 @@ func (m Model) WithPageSize(pageSize int) Model {
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
}

Expand Down Expand Up @@ -211,6 +227,15 @@ func (m Model) WithTargetWidth(totalWidth int) Model {
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 {
Expand Down Expand Up @@ -310,13 +335,21 @@ func (m Model) WithFilterInputValue(value string) Model {
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
}

Expand Down
25 changes: 17 additions & 8 deletions table/row.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,24 +92,33 @@ func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Sty
return cellStr
}

// This is long and could use some refactoring in the future, but not quite sure
// how to pick it apart yet.
//
//nolint:funlen, cyclop, gocognit
func (m Model) renderRow(rowIndex int, last bool) string {
numColumns := len(m.columns)
row := m.GetVisibleRows()[rowIndex]
highlighted := rowIndex == m.rowCursorIndex
totalRenderedWidth := 0

columnStrings := []string{}

rowStyle := row.Style.Copy()

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, gocognit
func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string {
numColumns := len(m.columns)

columnStrings := []string{}
totalRenderedWidth := 0

stylesInner, stylesLast := m.styleRows()

for columnIndex, column := range m.columns {
Expand Down
Loading
Loading