Skip to content

Commit

Permalink
fix(table): use table height (#358)
Browse files Browse the repository at this point in the history
* feat: only display rows within table height

This will remove excess rows. This does not affect the height that is
set by headers, borders, etc.

* feat: draw ellipsis for overflow

* fix: only display overflow when data exists

* fix: only display overflow when data exists

* test: define height for existing tabel unit tests

* test: add table height unit tests

* fix: handle negative available rows to render

* fix: handle height shrink with offset

* test: add table unit test

* feat: use auto table height by default

* test: remove unnecesary height from table def

* refactor(table): use … instead of ... + remove TODOs

---------

Co-authored-by: bashbunni <bunni@bashbunni.dev>
  • Loading branch information
Broderick-Westrope and bashbunni authored Sep 14, 2024
1 parent 12eac2c commit a5492bc
Show file tree
Hide file tree
Showing 2 changed files with 236 additions and 18 deletions.
90 changes: 72 additions & 18 deletions table/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ type Table struct {
headers []string
data Data

width int
height int
offset int
width int
height int
useManualHeight bool
offset int

// widths tracks the width of each column.
widths []int
Expand Down Expand Up @@ -199,6 +200,7 @@ func (t *Table) Width(w int) *Table {
// Height sets the table height.
func (t *Table) Height(h int) *Table {
t.height = h
t.useManualHeight = true
return t
}

Expand All @@ -217,8 +219,6 @@ func (t *Table) String() string {
return ""
}

var s strings.Builder

// Add empty cells to the headers, until it's the same length as the longest
// row (only if there are at headers in the first place).
if hasHeaders {
Expand Down Expand Up @@ -342,27 +342,46 @@ func (t *Table) String() string {
}
}

var sb strings.Builder

if t.borderTop {
s.WriteString(t.constructTopBorder())
s.WriteString("\n")
sb.WriteString(t.constructTopBorder())
sb.WriteString("\n")
}

if hasHeaders {
s.WriteString(t.constructHeaders())
s.WriteString("\n")
sb.WriteString(t.constructHeaders())
sb.WriteString("\n")
}

for r := t.offset; r < t.data.Rows(); r++ {
s.WriteString(t.constructRow(r))
var bottom string
if t.borderBottom {
bottom = t.constructBottomBorder()
}

if t.borderBottom {
s.WriteString(t.constructBottomBorder())
// If there are no data rows render nothing.
if t.data.Rows() > 0 {
switch {
case t.useManualHeight:
// The height of the top border. Subtract 1 for the newline.
topHeight := lipgloss.Height(sb.String()) - 1
availableLines := t.height - (topHeight + lipgloss.Height(bottom))

sb.WriteString(t.constructRows(availableLines))

default:
for r := t.offset; r < t.data.Rows(); r++ {
sb.WriteString(t.constructRow(r, false))
}
}
}

sb.WriteString(bottom)

return lipgloss.NewStyle().
MaxHeight(t.computeHeight()).
MaxWidth(t.width).Render(s.String())
MaxWidth(t.width).
Render(sb.String())
}

// computeWidth computes the width of the table in it's current configuration.
Expand Down Expand Up @@ -466,13 +485,43 @@ func (t *Table) constructHeaders() string {
return s.String()
}

func (t *Table) constructRows(availableLines int) string {
var sb strings.Builder

// The number of rows to render after removing the offset.
offsetRowCount := t.data.Rows() - t.offset

// The number of rows to render. We always render at least one row.
rowsToRender := min(availableLines, offsetRowCount)
rowsToRender = max(rowsToRender, 1)

// Check if we need to render an overflow row.
needsOverflow := rowsToRender < offsetRowCount

rowIdx := t.offset
for rowsToRender > 0 && rowIdx < t.data.Rows() {
// Whenever the height is too small to render all rows, the bottom row will be an overflow row (ellipsis).
isOverflow := needsOverflow && rowsToRender == 1

sb.WriteString(t.constructRow(rowIdx, isOverflow))

rowIdx++
rowsToRender--
}
return sb.String()
}

// constructRow constructs the row for the table given an index and row data
// based on the current configuration.
func (t *Table) constructRow(index int) string {
// based on the current configuration. If isOverflow is true, the row is
// rendered as an overflow row (using ellipsis).
func (t *Table) constructRow(index int, isOverflow bool) string {
var s strings.Builder

hasHeaders := t.headers != nil && len(t.headers) > 0
height := t.heights[index+btoi(hasHeaders)]
if isOverflow {
height = 1
}

var cells []string
left := strings.Repeat(t.borderStyle.Render(t.border.Left)+"\n", height)
Expand All @@ -481,14 +530,19 @@ func (t *Table) constructRow(index int) string {
}

for c := 0; c < t.data.Columns(); c++ {
cell := t.data.At(index, c)
cellWidth := t.widths[c]

cell := "…"
if !isOverflow {
cell = t.data.At(index, c)
}

cells = append(cells, t.style(index+1, c).
Height(height).
MaxHeight(height).
Width(t.widths[c]).
MaxWidth(t.widths[c]).
Render(ansi.Truncate(cell, t.widths[c]*height, "…")))
Render(ansi.Truncate(cell, cellWidth*height, "…")))

if c < t.data.Columns()-1 && t.borderColumn {
cells = append(cells, left)
Expand Down
164 changes: 164 additions & 0 deletions table/table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,170 @@ func TestTableANSI(t *testing.T) {
}
}

func TestTableHeightExact(t *testing.T) {
table := New().
Height(9).
Border(lipgloss.NormalBorder()).
StyleFunc(TableStyle).
Headers("LANGUAGE", "FORMAL", "INFORMAL").
Row("Chinese", "Nǐn hǎo", "Nǐ hǎo").
Row("French", "Bonjour", "Salut").
Row("Japanese", "こんにけは", "やあ").
Row("Russian", "Zdravstvuyte", "Privet").
Row("Spanish", "Hola", "ΒΏQuΓ© tal?")

expected := strings.TrimSpace(`
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LANGUAGE β”‚ FORMAL β”‚ INFORMAL β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Chinese β”‚ Nǐn hǎo β”‚ Nǐ hǎo β”‚
β”‚ French β”‚ Bonjour β”‚ Salut β”‚
β”‚ Japanese β”‚ こんにけは β”‚ やあ β”‚
β”‚ Russian β”‚ Zdravstvuyte β”‚ Privet β”‚
β”‚ Spanish β”‚ Hola β”‚ ΒΏQuΓ© tal? β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
`)

if table.String() != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String())
}
}

func TestTableHeightExtra(t *testing.T) {
table := New().
Height(100).
Border(lipgloss.NormalBorder()).
StyleFunc(TableStyle).
Headers("LANGUAGE", "FORMAL", "INFORMAL").
Row("Chinese", "Nǐn hǎo", "Nǐ hǎo").
Row("French", "Bonjour", "Salut").
Row("Japanese", "こんにけは", "やあ").
Row("Russian", "Zdravstvuyte", "Privet").
Row("Spanish", "Hola", "ΒΏQuΓ© tal?")

expected := strings.TrimSpace(`
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LANGUAGE β”‚ FORMAL β”‚ INFORMAL β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Chinese β”‚ Nǐn hǎo β”‚ Nǐ hǎo β”‚
β”‚ French β”‚ Bonjour β”‚ Salut β”‚
β”‚ Japanese β”‚ こんにけは β”‚ やあ β”‚
β”‚ Russian β”‚ Zdravstvuyte β”‚ Privet β”‚
β”‚ Spanish β”‚ Hola β”‚ ΒΏQuΓ© tal? β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
`)

if table.String() != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String())
}
}

func TestTableHeightShrink(t *testing.T) {
table := New().
Height(8).
Border(lipgloss.NormalBorder()).
StyleFunc(TableStyle).
Headers("LANGUAGE", "FORMAL", "INFORMAL").
Row("Chinese", "Nǐn hǎo", "Nǐ hǎo").
Row("French", "Bonjour", "Salut").
Row("Japanese", "こんにけは", "やあ").
Row("Russian", "Zdravstvuyte", "Privet").
Row("Spanish", "Hola", "ΒΏQuΓ© tal?")

expected := strings.TrimSpace(`
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LANGUAGE β”‚ FORMAL β”‚ INFORMAL β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Chinese β”‚ Nǐn hǎo β”‚ Nǐ hǎo β”‚
β”‚ French β”‚ Bonjour β”‚ Salut β”‚
β”‚ Japanese β”‚ こんにけは β”‚ やあ β”‚
β”‚ … β”‚ … β”‚ … β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
`)

if table.String() != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String())
}
}

func TestTableHeightMinimum(t *testing.T) {
table := New().
Height(0).
Border(lipgloss.NormalBorder()).
StyleFunc(TableStyle).
Headers("ID", "LANGUAGE", "FORMAL", "INFORMAL").
Row("1", "Chinese", "Nǐn hǎo", "Nǐ hǎo").
Row("2", "French", "Bonjour", "Salut").
Row("3", "Japanese", "こんにけは", "やあ").
Row("4", "Russian", "Zdravstvuyte", "Privet").
Row("5", "Spanish", "Hola", "ΒΏQuΓ© tal?")

expected := strings.TrimSpace(`
β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ID β”‚ LANGUAGE β”‚ FORMAL β”‚ INFORMAL β”‚
β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ … β”‚ … β”‚ … β”‚ … β”‚
β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
`)

if table.String() != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String())
}
}

func TestTableHeightMinimumShowData(t *testing.T) {
table := New().
Height(0).
Border(lipgloss.NormalBorder()).
StyleFunc(TableStyle).
Headers("LANGUAGE", "FORMAL", "INFORMAL").
Row("Chinese", "Nǐn hǎo", "Nǐ hǎo")

expected := strings.TrimSpace(`
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LANGUAGE β”‚ FORMAL β”‚ INFORMAL β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Chinese β”‚ Nǐn hǎo β”‚ Nǐ hǎo β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
`)

if table.String() != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String())
}
}

func TestTableHeightWithOffset(t *testing.T) {
// This test exists to check for a bug/edge case when the table has an
// offset and the height is set.

table := New().
Height(8).
Border(lipgloss.NormalBorder()).
StyleFunc(TableStyle).
Headers("LANGUAGE", "FORMAL", "INFORMAL").
Row("Chinese", "Nǐn hǎo", "Nǐ hǎo").
Row("French", "Bonjour", "Salut").
Row("Japanese", "こんにけは", "やあ").
Row("Russian", "Zdravstvuyte", "Privet").
Row("Spanish", "Hola", "ΒΏQuΓ© tal?").
Offset(1)

expected := strings.TrimSpace(`
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LANGUAGE β”‚ FORMAL β”‚ INFORMAL β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ French β”‚ Bonjour β”‚ Salut β”‚
β”‚ Japanese β”‚ こんにけは β”‚ やあ β”‚
β”‚ Russian β”‚ Zdravstvuyte β”‚ Privet β”‚
β”‚ Spanish β”‚ Hola β”‚ ΒΏQuΓ© tal? β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
`)

if table.String() != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String())
}
}

func debug(s string) string {
return strings.ReplaceAll(s, " ", ".")
}
Expand Down

0 comments on commit a5492bc

Please sign in to comment.