Repository: Evertras/bubble-table
Pull request #159 (2023-11-23 13:31:39)

Multiline cells

prgres: @Evertras I did the same in the bubbletea.Table and it renders ok https://github.com/charmbracelet/bubbles/pull/433

![image](https://github.com/Evertras/bubble-table/assets/23465248/a3366a96-7648-4db0-8084-84140f66f00a)

It looks ok hence it fails on movement (probably because I do not know how to manipulate the yoffset)
prgres: Okay, I cannot do it that way since RowData is unaware of the column width. 

What I am trying to do is to fill other cells with empty lines to align heigh of multiline
prgres: Some progress: 
<img width="652" alt="image" src="https://github.com/Evertras/bubble-table/assets/23465248/077a7e06-3265-4376-b96e-b534a2533684">

All we have to do is set the `borderStyle.Height` in `renderRowData` to the size of the highest cell
prgres: OMG I think I got this:
<img width="679" alt="image" src="https://github.com/Evertras/bubble-table/assets/23465248/bebea1d7-bba0-46d8-be36-fa0248b64e88">

prgres: 3 tests fails but I hope it will be straightforward to fix that
prgres: To test it on your own use option `WithMultiline(true)` while creating a new table
prgres: @Evertras if you will have some free time to help, I would be very grateful. The current state fails only 3 tests, but I think that not every case - something is noyes with styles for specific cells. I assume it is connected to the fact that I did not cover all logic in my loop
prgres: Do not bother with the look of the code - it is just POC that will need a refactor
prgres: For some reason, this is needed 🤔 

<img width="616" alt="image" src="https://github.com/Evertras/bubble-table/assets/23465248/e4bc0f33-797d-44db-96d8-132f6e9d2850">

prgres: And has to be particular Top - Botttom, Left, Center does not work
prgres: In a _very_ dirty way, I checked that is not (probably) connected to overflow

```
func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string {
	numColumns := len(m.columns)

	columnStrings := []string{}
	totalRenderedWidth := 0
	tmpTotalRenderedWidth := 0
	rawColumnsStrings := make([]string, 3*len(m.columns))

	stylesInner, stylesLast := m.styleRows()

	maxCellHeight := 0
	columnIndex := 0
	for rawColumnIndex := 1; rawColumnIndex/3 < len(m.columns); rawColumnIndex += 3 {
		if rawColumnIndex != 1 {
			columnIndex = (rawColumnIndex - 1) / 3
			// 0 1
			// 1 4
			// 2 7
		}
		column := m.columns[columnIndex]
		emptyStyle := lipgloss.NewStyle()

		if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
			rendered := m.renderRowColumnData(row, genOverflowColumnLeft(1), rowStyle, emptyStyle)

			rawColumnsStrings[rawColumnIndex-1] = rendered
		}

		if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
			columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
			continue
		}

		//--
		cellStr := m.renderRowColumnData(row, column, rowStyle, emptyStyle)
		maxCellHeight = max(maxCellHeight, lipgloss.Height(cellStr))
		rawColumnsStrings[rawColumnIndex] = cellStr
		//--
		if m.maxTotalWidth != 0 {
			renderedWidth := lipgloss.Width(cellStr)

			const (
				borderAdjustment = 1
				overflowColWidth = 2
			)

			targetWidth := m.maxTotalWidth - overflowColWidth

			if columnIndex == len(m.columns)-1 {
				// If this is the last header, we don't need to account for the
				// overflow arrow column
				targetWidth = m.maxTotalWidth
			}

			if tmpTotalRenderedWidth+renderedWidth > targetWidth {
				overflowWidth := m.maxTotalWidth - tmpTotalRenderedWidth - borderAdjustment
				overflowColumn := genOverflowColumnRight(overflowWidth)
				overflowStr := m.renderRowColumnData(row, overflowColumn, rowStyle, emptyStyle)

				rawColumnsStrings[rawColumnIndex+1] = overflowStr

				break
			}

			tmpTotalRenderedWidth += renderedWidth
		}
	}
	// for i, column := range rawColumnsStrings {
	// 	fmt.Println(i, column)
	// }

	// fmt.Println("---")

	for columnIndex, column := range m.columns {
		var borderStyle lipgloss.Style
		var rowStyles borderStyleRow

		if !last {
			rowStyles = stylesInner
		} else {
			rowStyles = stylesLast
		}

		if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
			var borderStyle lipgloss.Style

			if columnIndex == 0 {
				borderStyle = rowStyles.left.Copy()
			} else {
				borderStyle = rowStyles.inner.Copy()
			}

			cellStyle := rowStyle.Copy().Inherit(genOverflowColumnLeft(1).style).
				Inherit(m.baseStyle).Height(maxCellHeight)
			cellStyle = cellStyle.Inherit(borderStyle)
			idx := columnIndex*3 + 1 - 1
			if idx <= 0 {
				idx = 1
			}
			rendered := cellStyle.Render(rawColumnsStrings[idx])
			// rendered := cellStyle.Render(rawColumnsStrings[columnIndex*3-1])

			totalRenderedWidth += lipgloss.Width(rendered)

			columnStrings = append(columnStrings, rendered)
		}

		if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
			columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
			continue
		}

		if len(columnStrings) == 0 {
			borderStyle = rowStyles.left
		} else if columnIndex < numColumns-1 {
			borderStyle = rowStyles.inner
		} else {
			borderStyle = rowStyles.right
		}

		cellStyle := rowStyle.Copy().Inherit(column.style).
			Inherit(m.baseStyle).Height(maxCellHeight)
		cellStyle = cellStyle.Inherit(borderStyle)
		idx := columnIndex*3 + 1
		// if idx <= 0 {
		// 	idx = 1
		// }
		// fmt.Println(idx, rawColumnsStrings[idx])
		cellStr := cellStyle.Render(rawColumnsStrings[idx])
		// cellStr := cellStyle.Render(rawColumnsStrings[columnIndex*3])

		if m.maxTotalWidth != 0 {
			renderedWidth := lipgloss.Width(cellStr)

			const (
				borderAdjustment = 1
				overflowColWidth = 2
			)

			targetWidth := m.maxTotalWidth - overflowColWidth

			if columnIndex == len(m.columns)-1 {
				// If this is the last header, we don't need to account for the
				// overflow arrow column
				targetWidth = m.maxTotalWidth
			}

			if totalRenderedWidth+renderedWidth > targetWidth {
				overflowWidth := m.maxTotalWidth - totalRenderedWidth - borderAdjustment
				cellStyle := rowStyle.Copy().Inherit(genOverflowStyle(rowStyles.right, overflowWidth)).
					Inherit(m.baseStyle).Height(maxCellHeight)
				cellStyle = cellStyle.Inherit(borderStyle)
				rendered := cellStyle.Render(rawColumnsStrings[columnIndex*3+1+1])

				totalRenderedWidth += lipgloss.Width(rendered)

				columnStrings = append(columnStrings, rendered)
				break
			}

			totalRenderedWidth += renderedWidth
		}

		columnStrings = append(columnStrings, cellStr)
	}

	return lipgloss.JoinHorizontal(lipgloss.Bottom, columnStrings...)
}
```
prgres: I managed to achieve multiline feature with clean tests
<img width="1392" alt="image" src="https://github.com/Evertras/bubble-table/assets/23465248/96a130f5-763a-4dcb-8ddc-d821094fdb4d">

The only drawback is that it renders cell content twice if the multiline option is enabled. But I do not have any other idea to handle it without refactoring the current setup.
Evertras: I'm okay with offering an additional feature at a small expense to performance if that feature is entirely opt-in, which it seems to be here.  We should add a small note about potential performance issues in the `WithMultiline` func comment for docs.

Also seems I need to update the linter settings, haven't looked at that in a while...
Evertras: Looking at this a bit more, I feel like the perf shouldn't really be an issue.  So maybe we don't need the comment. 🤔 
Evertras: We'll also definitely want to add some tests around what multiline tables should look like with expected inputs, which should also bump our coverage up to where it needs to be.  Can you add some tests around expected outputs to https://github.com/Evertras/bubble-table/blob/main/table/view_test.go ?

Sorry for missing messages before, was away for a bit.
Evertras: And as a final note, feel free to make the features example permanently show multiline as well as in your screenshot.
prgres: @Evertras I have been waiting to add tests and examples for your approval. As you agreed on that solution, I will add them soon :) 
prgres: @Evertras done

No worries about the missed messages. I treated this PR as a dev log to maintain a history and share hints about the implementation for future contributors

Patch:
diff --git a/Makefile b/Makefile
index 59a8436..7bb5bbc 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,10 @@ example-events:
 example-features:
 	@go run ./examples/features/main.go
 
+.PHONY: example-multiline
+example-multiline:
+	@go run ./examples/multiline/main.go
+
 .PHONY: example-filter
 example-filter:
 	@go run ./examples/filter/*.go
diff --git a/examples/multiline/README.md b/examples/multiline/README.md
new file mode 100644
index 0000000..db0a003
--- /dev/null
+++ b/examples/multiline/README.md
@@ -0,0 +1,5 @@
+# Multiline  Example
+
+This example code showcases the implementation of a multiline feature. The feature enables users to input and display content spanning multiple lines within the row. The provided code allows you to integrate the multiline feature seamlessly into your project. Feel free to experiment and adapt the code based on your specific requirements.
+
+<img width="593" alt="image" src="https://github.com/Evertras/bubble-table/assets/23465248/3092b6f2-1e75-4c11-85f6-fcbea249d509">
diff --git a/examples/multiline/main.go b/examples/multiline/main.go
new file mode 100644
index 0000000..f250dc7
--- /dev/null
+++ b/examples/multiline/main.go
@@ -0,0 +1,166 @@
+package main
+
+import (
+	"log"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/evertras/bubble-table/table"
+)
+
+const (
+	columnKeyName     = "name"
+	columnKeyCountry  = "country"
+	columnKeyCurrency = "crurrency"
+)
+
+type Model struct {
+	tableModel table.Model
+}
+
+func NewModel() Model {
+	columns := []table.Column{
+		table.NewColumn(columnKeyName, "Name", 10).WithStyle(
+			lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#88f")),
+		),
+		table.NewColumn(columnKeyCountry, "Country", 20),
+		table.NewColumn(columnKeyCurrency, "Currency", 10),
+	}
+
+	rows := []table.Row{
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Talon Stokes",
+				columnKeyCountry:  "Mexico",
+				columnKeyCurrency: "$23.17",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Sonia Shepard",
+				columnKeyCountry:  "United States",
+				columnKeyCurrency: "$76.47",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Shad Reed",
+				columnKeyCountry:  "Turkey",
+				columnKeyCurrency: "$62.99",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Kibo Clay",
+				columnKeyCountry:  "Philippines",
+				columnKeyCurrency: "$29.82",
+			}),
+		table.NewRow(
+			table.RowData{
+
+				columnKeyName:     "Leslie Kerr",
+				columnKeyCountry:  "Singapore",
+				columnKeyCurrency: "$70.54",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Micah Hurst",
+				columnKeyCountry:  "Pakistan",
+				columnKeyCurrency: "$80.84",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Dora Miranda",
+				columnKeyCountry:  "Colombia",
+				columnKeyCurrency: "$34.75",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Keefe Walters",
+				columnKeyCountry:  "China",
+				columnKeyCurrency: "$56.82",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Fujimoto Tarokizaemon no shoutokinori",
+				columnKeyCountry:  "Japan",
+				columnKeyCurrency: "$89.31",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Keefe Walters",
+				columnKeyCountry:  "China",
+				columnKeyCurrency: "$56.82",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Vincent Sanchez",
+				columnKeyCountry:  "Peru",
+				columnKeyCurrency: "$71.60",
+			}),
+		table.NewRow(
+			table.RowData{
+				columnKeyName:     "Lani Figueroa",
+				columnKeyCountry:  "United Kingdom",
+				columnKeyCurrency: "$90.67",
+			}),
+	}
+
+	model := Model{
+		tableModel: table.New(columns).
+			WithRows(rows).
+			HeaderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)).
+			Focused(true).
+			WithBaseStyle(
+				lipgloss.NewStyle().
+					BorderForeground(lipgloss.Color("#a38")).
+					Foreground(lipgloss.Color("#a7a")).
+					Align(lipgloss.Left),
+			).
+			WithMultiline(true),
+	}
+
+	return model
+}
+
+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
+	)
+
+	m.tableModel, cmd = m.tableModel.Update(msg)
+	cmds = append(cmds, cmd)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc", "q":
+			cmds = append(cmds, tea.Quit)
+		}
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m Model) View() string {
+	body := strings.Builder{}
+
+	body.WriteString("A table demo with multiline feature enabled!\n")
+	body.WriteString("Press up/down or j/k to move around\n")
+	body.WriteString(m.tableModel.View())
+	body.WriteString("\n")
+
+	return body.String()
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/table/border.go b/table/border.go
index f086760..756d4c8 100644
--- a/table/border.go
+++ b/table/border.go
@@ -354,7 +354,7 @@ func (b *borderStyleRow) inherit(s lipgloss.Style) {
 }
 
 // There's a lot of branches here, but splitting it up further would make it
-// harder to follow.  So just be careful with comments and make sure it's tested!
+// harder to follow. So just be careful with comments and make sure it's tested!
 //
 //nolint:nestif
 func (m Model) styleHeaders() borderStyleRow {
diff --git a/table/model.go b/table/model.go
index 2794ecd..b1934f8 100644
--- a/table/model.go
+++ b/table/model.go
@@ -89,6 +89,9 @@ type Model struct {
 	// Internal cached calculation, the height of the header and footer
 	// including borders. Used to determine how many padding rows to add.
 	metaHeight int
+
+	// If true, the table will be multiline
+	multiline bool
 }
 
 // New creates a new table ready for further modifications.
diff --git a/table/options.go b/table/options.go
index 5aa5258..206bfff 100644
--- a/table/options.go
+++ b/table/options.go
@@ -424,3 +424,10 @@ func (m Model) WithAllRowsDeselected() Model {
 
 	return m
 }
+
+// WithMultiline sets whether or not to wrap text in cells to multiple lines.
+func (m Model) WithMultiline(multiline bool) Model {
+	m.multiline = multiline
+
+	return m
+}
diff --git a/table/row.go b/table/row.go
index a604b30..e59fd1c 100644
--- a/table/row.go
+++ b/table/row.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/charmbracelet/lipgloss"
+	"github.com/muesli/reflow/wordwrap"
 )
 
 // RowData is a map of string column keys to interface{} data.  Data with a key
@@ -43,7 +44,7 @@ func (r Row) WithStyle(style lipgloss.Style) Row {
 	return r
 }
 
-//nolint:nestif // This has many ifs, but they're short
+//nolint:nestif,cyclop // This has many ifs, but they're short
 func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Style, borderStyle lipgloss.Style) string {
 	cellStyle := rowStyle.Copy().Inherit(column.style).Inherit(m.baseStyle)
 
@@ -86,8 +87,15 @@ func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Sty
 		}
 	}
 
+	if m.multiline {
+		str = wordwrap.String(str, column.width)
+		cellStyle = cellStyle.Align(lipgloss.Top)
+	} else {
+		str = limitStr(str, column.width)
+	}
+
 	cellStyle = cellStyle.Inherit(borderStyle)
-	cellStr := cellStyle.Render(limitStr(str, column.width))
+	cellStr := cellStyle.Render(str)
 
 	return cellStr
 }
@@ -121,6 +129,14 @@ func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string
 
 	stylesInner, stylesLast := m.styleRows()
 
+	maxCellHeight := 1
+	if m.multiline {
+		for _, column := range m.columns {
+			cellStr := m.renderRowColumnData(row, column, rowStyle, lipgloss.NewStyle())
+			maxCellHeight = max(maxCellHeight, lipgloss.Height(cellStr))
+		}
+	}
+
 	for columnIndex, column := range m.columns {
 		var borderStyle lipgloss.Style
 		var rowStyles borderStyleRow
@@ -130,6 +146,7 @@ func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string
 		} else {
 			rowStyles = stylesLast
 		}
+		rowStyle = rowStyle.Copy().Height(maxCellHeight)
 
 		if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
 			var borderStyle lipgloss.Style
diff --git a/table/view.go b/table/view.go
index b9fcb83..5ef545c 100644
--- a/table/view.go
+++ b/table/view.go
@@ -6,7 +6,7 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
-// View renders the table.  It does not end in a newline, so that it can be
+// View renders the table. It does not end in a newline, so that it can be
 // composed with other elements more consistently.
 //
 //nolint:cyclop


Test patch:
diff --git a/table/view_test.go b/table/view_test.go
index 787ece3..07014b1 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -1515,3 +1515,76 @@ func TestMinimumHeightSmallerThanTable(t *testing.T) {
 
 	assert.Equal(t, expectedTable, rendered)
 }
+
+func TestMultilineEnabled(t *testing.T) {
+	model := New([]Column{
+		NewColumn("name", "Name", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{"name": "AAAAAAAAAAAAAAAAAA"}),
+			NewRow(RowData{"name": "BBB"}),
+		}).
+		WithMultiline(true)
+
+	assert.True(t, model.multiline)
+
+	const expectedTable = `┏━━━━┓
+┃Name┃
+┣━━━━┫
+┃AAAA┃
+┃AAAA┃
+┃AAAA┃
+┃AAAA┃
+┃AA  ┃
+┃BBB ┃
+┗━━━━┛`
+
+	rendered := model.View()
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMultilineDisabledByDefault(t *testing.T) {
+	model := New([]Column{
+		NewColumn("name", "Name", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{"name": "AAAAAAAAAAAAAAAAAA"}),
+			NewRow(RowData{"name": "BBB"}),
+		})
+		// WithMultiline(false)
+
+	assert.False(t, model.multiline)
+
+	const expectedTable = `┏━━━━┓
+┃Name┃
+┣━━━━┫
+┃AAA…┃
+┃ BBB┃
+┗━━━━┛`
+
+	rendered := model.View()
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMultilineDisabledExplicite(t *testing.T) {
+	model := New([]Column{
+		NewColumn("name", "Name", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{"name": "AAAAAAAAAAAAAAAAAA"}),
+			NewRow(RowData{"name": "BBB"}),
+		}).
+		WithMultiline(false)
+
+	assert.False(t, model.multiline)
+
+	const expectedTable = `┏━━━━┓
+┃Name┃
+┣━━━━┫
+┃AAA…┃
+┃ BBB┃
+┗━━━━┛`
+
+	rendered := model.View()
+	assert.Equal(t, expectedTable, rendered)
+}