Repository: Evertras/bubble-table
Pull request #8 (2022-02-13 02:43:13)

Add border customization and refactor existing border code
Addresses #7 

Use a nicer method of generating and applying border styles, along with allowing custom borders to be supplied.


Patch:
diff --git a/Makefile b/Makefile
index 37458fe..4d61c7a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,14 @@
-.PHONY: default
-default:
-	go run examples/features/main.go
+.PHONY: features
+features:
+	@go run ./examples/features/main.go
+
+.PHONY: dimensions
+dimensions:
+	@go run ./examples/dimensions/main.go
 
 .PHONY: test
 test:
-	go test ./table
+	@go test ./table
 
 .PHONY: fmt
 fmt:
diff --git a/README.md b/README.md
index 4ae65a6..c16b025 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,46 @@
 
 A table component for the [Bubble Tea framework](https://github.com/charmbracelet/bubbletea).
 
-This is currently messy as it's in early development and came out of a different
-project, and should be cleaned up over time.
-
 ![Table](sample.png)
 
 [View sample source code](./examples/features/main.go)
 
 ## Features
 
-Displays a table with a header and borders.
+For a code reference, please see the [full feature example](./examples/features/main.go).
+
+Displays a table with a header, rows, and borders.
+
+Border shape is customizable with a basic thick square default.
+
+Rows can be individually styled.
 
 Can be focused to highlight a row and navigate with up/down (and j/k).
 
 Can make rows selectable, and fetch the current selections.
 
+## Demos
+
+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.
+
+To run the examples, clone this repo and run:
+
+```bash
+# Run the full feature demo
+make
+
+# Run the example
+make dimensions
+
+# Or run any of them directly
+go run ./examples/features/main.go
+```
+
 ## Contributing
 
 Contributions welcome, but since this is being actively developed for use in
-[Khan](https://github.com/evertras/khan) please check first by opening an issue!
+[Khan](https://github.com/evertras/khan) please check first by opening an issue
+or commenting on an existing one!
 
diff --git a/examples/dimensions/main.go b/examples/dimensions/main.go
new file mode 100644
index 0000000..edbf432
--- /dev/null
+++ b/examples/dimensions/main.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/evertras/bubble-table/table"
+)
+
+type Model struct {
+	table3x3 table.Model
+	table1x3 table.Model
+	table3x1 table.Model
+	table1x1 table.Model
+	table5x5 table.Model
+}
+
+func genTable(columnCount int, rowCount int) table.Model {
+	headers := []table.Header{}
+
+	for column := 0; column < columnCount; column++ {
+		columnStr := fmt.Sprintf("%d", column+1)
+		headers = append(headers, table.NewHeader(columnStr, columnStr, 4))
+	}
+
+	rows := []table.Row{}
+
+	for row := 1; row < rowCount; row++ {
+		rowData := table.RowData{}
+
+		for column := 0; column < columnCount; column++ {
+			columnStr := fmt.Sprintf("%d", column+1)
+			rowData[columnStr] = fmt.Sprintf("%d,%d", column+1, row+1)
+		}
+
+		rows = append(rows, table.NewRow(rowData))
+	}
+
+	return table.New(headers).WithRows(rows).HeaderStyle(lipgloss.NewStyle().Bold(true))
+}
+
+func NewModel() Model {
+	return Model{
+		table1x1: genTable(1, 1),
+		table3x1: genTable(3, 1),
+		table1x3: genTable(1, 3),
+		table3x3: genTable(3, 3),
+		table5x5: genTable(5, 5),
+	}
+}
+
+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.table1x1, cmd = m.table1x1.Update(msg)
+	cmds = append(cmds, cmd)
+
+	m.table3x1, cmd = m.table3x1.Update(msg)
+	cmds = append(cmds, cmd)
+
+	m.table1x3, cmd = m.table1x3.Update(msg)
+	cmds = append(cmds, cmd)
+
+	m.table3x3, cmd = m.table3x3.Update(msg)
+	cmds = append(cmds, cmd)
+
+	m.table5x5, cmd = m.table5x5.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("Table demo with various sized tables!\nPress space/enter to select a row, q or ctrl+c to quit\n")
+
+	pad := lipgloss.NewStyle().Padding(1)
+
+	tablesSmall := lipgloss.JoinHorizontal(
+		lipgloss.Top,
+		pad.Render(m.table1x1.View()),
+		pad.Render(m.table1x3.View()),
+		pad.Render(m.table3x1.View()),
+		pad.Render(m.table3x3.View()),
+	)
+
+	tableBig := pad.Render(m.table5x5.View())
+
+	body.WriteString(lipgloss.JoinVertical(lipgloss.Center, tablesSmall, tableBig))
+
+	return body.String()
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/examples/features/main.go b/examples/features/main.go
index 6e667f2..4a6997a 100644
--- a/examples/features/main.go
+++ b/examples/features/main.go
@@ -1,3 +1,5 @@
+// This file contains a full demo of most available features, for both testing
+// and for reference
 package main
 
 import (
@@ -17,13 +19,35 @@ const (
 	columnKeyCount       = "count"
 )
 
+var (
+	customBorder = table.Border{
+		Top:    "─",
+		Left:   "│",
+		Right:  "│",
+		Bottom: "─",
+
+		TopRight:    "╮",
+		TopLeft:     "╭",
+		BottomRight: "╯",
+		BottomLeft:  "╰",
+
+		TopJunction:    "╥",
+		LeftJunction:   "├",
+		RightJunction:  "┤",
+		BottomJunction: "╨",
+		InnerJunction:  "╫",
+
+		InnerDivider: "║",
+	}
+)
+
 type Model struct {
 	tableModel table.Model
 }
 
 func NewModel() Model {
 	headers := []table.Header{
-		table.NewHeader(columnKeyID, "ID", 5).WithStyle(lipgloss.NewStyle().Bold(true)),
+		table.NewHeader(columnKeyID, "ID", 5),
 		table.NewHeader(columnKeyName, "Name", 10),
 		table.NewHeader(columnKeyDescription, "Description", 30),
 		table.NewHeader(columnKeyCount, "#", 5),
@@ -53,9 +77,10 @@ func NewModel() Model {
 	return Model{
 		tableModel: table.New(headers).
 			WithRows(rows).
-			HeaderStyle(lipgloss.NewStyle().Bold(true)).
+			HeaderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)).
 			SelectableRows(true).
-			Focused(true),
+			Focused(true).
+			Border(customBorder),
 	}
 }
 
@@ -87,7 +112,7 @@ func (m Model) View() string {
 	body := strings.Builder{}
 
 	highlightedRow := m.tableModel.HighlightedRow()
-	body.WriteString("Table demo with selectable rows!\nPress space/enter to select a row, q or ctrl+c to quit\n")
+	body.WriteString("Table demo with all features enabled!\nPress space/enter to select a row, q or ctrl+c to quit\n")
 
 	body.WriteString(fmt.Sprintf("Currently looking at ID: %s\n", highlightedRow.Data[columnKeyID]))
 
@@ -102,6 +127,8 @@ func (m Model) View() string {
 
 	body.WriteString(m.tableModel.View())
 
+	body.WriteString("\n")
+
 	return body.String()
 }
 
diff --git a/table/border.go b/table/border.go
new file mode 100644
index 0000000..c0a8388
--- /dev/null
+++ b/table/border.go
@@ -0,0 +1,258 @@
+package table
+
+import "github.com/charmbracelet/lipgloss"
+
+// Border defines the borders in and around the table
+type Border struct {
+	Top         string
+	Left        string
+	Right       string
+	Bottom      string
+	TopRight    string
+	TopLeft     string
+	BottomRight string
+	BottomLeft  string
+
+	TopJunction    string
+	LeftJunction   string
+	RightJunction  string
+	BottomJunction string
+
+	InnerJunction string
+
+	InnerDivider string
+
+	// Styles for 2x2 tables and larger
+	styleMultiTopLeft     lipgloss.Style
+	styleMultiTop         lipgloss.Style
+	styleMultiTopRight    lipgloss.Style
+	styleMultiRight       lipgloss.Style
+	styleMultiBottomRight lipgloss.Style
+	styleMultiBottom      lipgloss.Style
+	styleMultiBottomLeft  lipgloss.Style
+	styleMultiLeft        lipgloss.Style
+	styleMultiInner       lipgloss.Style
+
+	// Styles for a single column table
+	styleSingleColumnTop    lipgloss.Style
+	styleSingleColumnInner  lipgloss.Style
+	styleSingleColumnBottom lipgloss.Style
+
+	// Styles for a single row table
+	styleSingleRowLeft  lipgloss.Style
+	styleSingleRowInner lipgloss.Style
+	styleSingleRowRight lipgloss.Style
+
+	// Style for a table with only one cell
+	styleSingleCell lipgloss.Style
+}
+
+var (
+	// https://www.w3.org/TR/xml-entity-names/025.html
+
+	borderDefault = Border{
+		Top:    "━",
+		Left:   "┃",
+		Right:  "┃",
+		Bottom: "━",
+
+		TopRight:    "┓",
+		TopLeft:     "┏",
+		BottomRight: "┛",
+		BottomLeft:  "┗",
+
+		TopJunction:    "┳",
+		LeftJunction:   "┣",
+		RightJunction:  "┫",
+		BottomJunction: "┻",
+		InnerJunction:  "╋",
+
+		InnerDivider: "┃",
+	}
+)
+
+func init() {
+	borderDefault.generateStyles()
+}
+
+func (b *Border) generateStyles() {
+	b.styleMultiTopLeft = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			TopLeft:     b.TopLeft,
+			Top:         b.Top,
+			TopRight:    b.TopJunction,
+			Right:       b.InnerDivider,
+			BottomRight: b.InnerJunction,
+			Bottom:      b.Bottom,
+			BottomLeft:  b.LeftJunction,
+			Left:        b.Left,
+		},
+	)
+
+	b.styleMultiTop = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Right:  b.InnerDivider,
+			Bottom: b.Bottom,
+
+			TopRight:    b.TopJunction,
+			BottomRight: b.InnerJunction,
+		},
+	).BorderTop(true).BorderBottom(true).BorderRight(true)
+
+	b.styleMultiTopRight = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Right:  b.Right,
+			Bottom: b.Bottom,
+
+			TopRight:    b.TopRight,
+			BottomRight: b.RightJunction,
+		},
+	).BorderTop(true).BorderBottom(true).BorderRight(true)
+
+	b.styleMultiLeft = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Left:  b.Left,
+			Right: b.InnerDivider,
+		},
+	).BorderRight(true).BorderLeft(true)
+
+	b.styleMultiRight = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Right: b.Right,
+		},
+	).BorderRight(true)
+
+	b.styleMultiInner = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Right: b.InnerDivider,
+		},
+	).BorderRight(true)
+
+	b.styleMultiBottomLeft = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Left:   b.Left,
+			Right:  b.InnerDivider,
+			Bottom: b.Bottom,
+
+			BottomLeft:  b.BottomLeft,
+			BottomRight: b.BottomJunction,
+		},
+	).BorderLeft(true).BorderBottom(true).BorderRight(true)
+
+	b.styleMultiBottom = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Right:  b.InnerDivider,
+			Bottom: b.Bottom,
+
+			BottomRight: b.BottomJunction,
+		},
+	).BorderBottom(true).BorderRight(true)
+
+	b.styleMultiBottomRight = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Right:  b.Right,
+			Bottom: b.Bottom,
+
+			BottomRight: b.BottomRight,
+		},
+	).BorderBottom(true).BorderRight(true)
+
+	b.styleSingleColumnTop = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Left:   b.Left,
+			Right:  b.Right,
+			Bottom: b.Bottom,
+
+			TopLeft:     b.TopLeft,
+			TopRight:    b.TopRight,
+			BottomLeft:  b.LeftJunction,
+			BottomRight: b.RightJunction,
+		},
+	)
+
+	b.styleSingleColumnInner = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Left:  b.Left,
+			Right: b.Right,
+		},
+	).BorderRight(true).BorderLeft(true)
+
+	b.styleSingleColumnBottom = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Left:   b.Left,
+			Right:  b.Right,
+			Bottom: b.Bottom,
+
+			BottomLeft:  b.BottomLeft,
+			BottomRight: b.BottomRight,
+		},
+	).BorderRight(true).BorderLeft(true).BorderBottom(true)
+
+	b.styleSingleRowLeft = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Left:   b.Left,
+			Right:  b.InnerDivider,
+			Bottom: b.Bottom,
+
+			BottomLeft:  b.BottomLeft,
+			BottomRight: b.BottomJunction,
+			TopRight:    b.TopJunction,
+			TopLeft:     b.TopLeft,
+		},
+	)
+
+	b.styleSingleRowInner = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Right:  b.InnerDivider,
+			Bottom: b.Bottom,
+
+			BottomRight: b.BottomJunction,
+			TopRight:    b.TopJunction,
+		},
+	).BorderTop(true).BorderBottom(true).BorderRight(true)
+
+	b.styleSingleRowRight = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Right:  b.Right,
+			Bottom: b.Bottom,
+
+			BottomRight: b.BottomRight,
+			TopRight:    b.TopRight,
+		},
+	).BorderTop(true).BorderBottom(true).BorderRight(true)
+
+	b.styleSingleCell = lipgloss.NewStyle().BorderStyle(
+		lipgloss.Border{
+			Top:    b.Top,
+			Left:   b.Left,
+			Right:  b.Right,
+			Bottom: b.Bottom,
+
+			BottomLeft:  b.BottomLeft,
+			BottomRight: b.BottomRight,
+			TopRight:    b.TopRight,
+			TopLeft:     b.TopLeft,
+		},
+	)
+}
+
+func (m Model) BorderDefault() Model {
+	// Already generated styles
+	m.border = borderDefault
+
+	return m
+}
+
+func (m Model) Border(border Border) Model {
+	border.generateStyles()
+
+	m.border = border
+
+	return m
+}
diff --git a/table/header.go b/table/header.go
index 63ed0a2..286e64c 100644
--- a/table/header.go
+++ b/table/header.go
@@ -2,15 +2,12 @@ package table
 
 import (
 	"fmt"
-
-	"github.com/charmbracelet/lipgloss"
 )
 
 type Header struct {
 	Title string
 	Key   string
 	Width int
-	Style lipgloss.Style
 
 	fmtString string
 }
@@ -23,65 +20,3 @@ func NewHeader(key, title string, width int) Header {
 		fmtString: fmt.Sprintf("%%%ds", width),
 	}
 }
-
-func (h Header) WithStyle(style lipgloss.Style) Header {
-	h.Style = style.Copy()
-	return h
-}
-
-// https://www.w3.org/TR/xml-entity-names/025.html
-
-var borderHeaderFirst = lipgloss.Border{
-	Top:         "━",
-	Bottom:      "━",
-	Left:        "┃",
-	Right:       "┃",
-	TopRight:    "┳",
-	TopLeft:     "┏",
-	BottomRight: "╋",
-	BottomLeft:  "┣",
-}
-
-var borderHeaderTriangleFirst = lipgloss.Border{
-	Top:         "━",
-	Bottom:      "━",
-	Left:        "┃",
-	Right:       "┃",
-	TopRight:    "┳",
-	TopLeft:     "◤",
-	BottomRight: "╋",
-	BottomLeft:  "◣",
-}
-
-var borderHeaderMiddle = lipgloss.Border{
-	Top:         "━",
-	Bottom:      "━",
-	Left:        "",
-	Right:       "┃",
-	TopRight:    "┳",
-	TopLeft:     "",
-	BottomRight: "╋",
-	BottomLeft:  "",
-}
-
-var borderHeaderLast = lipgloss.Border{
-	Top:         "━",
-	Bottom:      "━",
-	Left:        "",
-	Right:       "┃",
-	TopRight:    "┓",
-	TopLeft:     "",
-	BottomRight: "┫",
-	BottomLeft:  "",
-}
-
-var borderHeaderTriangleLast = lipgloss.Border{
-	Top:         "━",
-	Bottom:      "━",
-	Left:        "",
-	Right:       "┃",
-	TopRight:    "◥",
-	TopLeft:     "",
-	BottomRight: "◢",
-	BottomLeft:  "",
-}
diff --git a/table/row.go b/table/row.go
index 4714874..0e10558 100644
--- a/table/row.go
+++ b/table/row.go
@@ -34,37 +34,42 @@ func (r Row) WithStyle(style lipgloss.Style) Row {
 	return r
 }
 
-var borderRowLeft = lipgloss.Border{
-	Left:        "┃",
-	Right:       "┃",
-	Bottom:      "━",
-	BottomLeft:  "┗",
-	BottomRight: "┻",
-}
-
-var borderRowMiddle = lipgloss.Border{
-	Right:       "┃",
-	Bottom:      "━",
-	BottomRight: "┻",
-}
-
-var borderRowLast = lipgloss.Border{
-	Right:       "┃",
-	Bottom:      "━",
-	BottomRight: "┛",
-}
-
 func (m Model) renderRow(i int) string {
+	numHeaders := len(m.headers)
 	row := m.rows[i]
 	last := i == len(m.rows)-1
 	highlighted := i == m.rowCursorIndex
 
 	columnStrings := []string{}
 
-	baseStyle := lipgloss.NewStyle()
+	baseStyle := row.Style.Copy()
 
 	if m.focused && highlighted {
-		baseStyle = m.highlightStyle
+		baseStyle = baseStyle.Inherit(m.highlightStyle)
+	}
+
+	var (
+		rowStyleLeft  lipgloss.Style
+		rowStyleInner lipgloss.Style
+		rowStyleRight lipgloss.Style
+
+		rowLastStyleLeft  lipgloss.Style
+		rowLastStyleInner lipgloss.Style
+		rowLastStyleRight lipgloss.Style
+	)
+
+	if numHeaders == 1 {
+		rowStyleLeft = m.border.styleSingleColumnInner
+
+		rowLastStyleLeft = m.border.styleSingleColumnBottom
+	} else {
+		rowStyleLeft = m.border.styleMultiLeft
+		rowStyleInner = m.border.styleMultiInner
+		rowStyleRight = m.border.styleMultiRight
+
+		rowLastStyleLeft = m.border.styleMultiBottomLeft
+		rowLastStyleInner = m.border.styleMultiBottom
+		rowLastStyleRight = m.border.styleMultiBottomRight
 	}
 
 	for i, header := range m.headers {
@@ -82,21 +87,27 @@ func (m Model) renderRow(i int) string {
 
 		cellStyle := baseStyle.Copy()
 
-		if i == 0 {
-			cellStyle = cellStyle.BorderStyle(borderRowLeft).BorderRight(true).BorderLeft(true)
-		} else if i < len(m.headers)-1 {
-			cellStyle = cellStyle.BorderStyle(borderRowMiddle).BorderRight(true)
+		if !last {
+			if i == 0 {
+				cellStyle = cellStyle.Inherit(rowStyleLeft)
+			} else if i < numHeaders-1 {
+				cellStyle = cellStyle.Inherit(rowStyleInner)
+			} else {
+				cellStyle = cellStyle.Inherit(rowStyleRight)
+			}
 		} else {
-			cellStyle = cellStyle.BorderStyle(borderRowLast).BorderRight(true)
-		}
-
-		if last {
-			cellStyle = cellStyle.BorderBottom(true)
+			if i == 0 {
+				cellStyle = cellStyle.Inherit(rowLastStyleLeft)
+			} else if i < numHeaders-1 {
+				cellStyle = cellStyle.Inherit(rowLastStyleInner)
+			} else {
+				cellStyle = cellStyle.Inherit(rowLastStyleRight)
+			}
 		}
 
-		dataStr := row.Style.Render(fmt.Sprintf(header.fmtString, limitStr(str, header.Width)))
+		cellStr := cellStyle.Render(fmt.Sprintf(header.fmtString, limitStr(str, header.Width)))
 
-		columnStrings = append(columnStrings, cellStyle.Render(dataStr))
+		columnStrings = append(columnStrings, cellStr)
 	}
 
 	return lipgloss.JoinHorizontal(lipgloss.Bottom, columnStrings...)
diff --git a/table/table.go b/table/table.go
index 8ec2075..6de10be 100644
--- a/table/table.go
+++ b/table/table.go
@@ -33,19 +33,19 @@ type Model struct {
 	highlightStyle lipgloss.Style
 
 	selectedRows []Row
+
+	border Border
 }
 
 func New(headers []Header) Model {
 	m := Model{
 		headers:        make([]Header, len(headers)),
 		highlightStyle: defaultHighlightStyle.Copy(),
+		border:         borderDefault,
 	}
 
 	// Do a full deep copy to avoid unexpected edits
 	copy(m.headers, headers)
-	for i, header := range m.headers {
-		m.headers[i].Style = header.Style.Copy()
-	}
 
 	return m
 }
@@ -83,7 +83,7 @@ func (m Model) HighlightedRow() Row {
 		return m.rows[m.rowCursorIndex]
 	}
 
-	// This shouldn't really happen... better indication?
+	// TODO: Better way to do this without pointers/nil?  Or should it be nil?
 	return Row{}
 }
 
@@ -154,33 +154,71 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
 }
 
 func (m Model) View() string {
+	numHeaders := len(m.headers)
+	hasRows := len(m.rows) > 0
+
+	// Safety valve for empty tables
+	if numHeaders == 0 {
+		return ""
+	}
+
 	body := strings.Builder{}
 
 	headerStrings := []string{}
 
+	var (
+		headerStyleLeft  lipgloss.Style
+		headerStyleInner lipgloss.Style
+		headerStyleRight lipgloss.Style
+	)
+
+	if numHeaders == 1 {
+		if hasRows {
+			headerStyleLeft = m.border.styleSingleColumnTop
+		} else {
+			headerStyleLeft = m.border.styleSingleCell
+		}
+
+		headerStyleLeft = headerStyleLeft.Copy().Inherit(m.headerStyle)
+	} else {
+		if hasRows {
+			headerStyleLeft = m.border.styleMultiTopLeft
+			headerStyleInner = m.border.styleMultiTop
+			headerStyleRight = m.border.styleMultiTopRight
+		} else {
+			headerStyleLeft = m.border.styleSingleRowLeft
+			headerStyleInner = m.border.styleSingleRowInner
+			headerStyleRight = m.border.styleSingleRowRight
+		}
+
+		headerStyleLeft = headerStyleLeft.Copy().Inherit(m.headerStyle)
+		headerStyleInner = headerStyleInner.Copy().Inherit(m.headerStyle)
+		headerStyleRight = headerStyleRight.Copy().Inherit(m.headerStyle)
+	}
+
 	for i, header := range m.headers {
 		headerSection := fmt.Sprintf(header.fmtString, header.Title)
-		borderStyle := m.headerStyle.Copy()
+		var borderStyle lipgloss.Style
 
 		if i == 0 {
-			borderStyle = borderStyle.BorderStyle(borderHeaderFirst)
+			borderStyle = headerStyleLeft
 		} else if i < len(m.headers)-1 {
-			borderStyle = borderStyle.BorderStyle(borderHeaderMiddle).BorderTop(true).BorderBottom(true).BorderRight(true)
+			borderStyle = headerStyleInner
 		} else {
-			borderStyle = borderStyle.BorderStyle(borderHeaderLast).BorderTop(true).BorderBottom(true).BorderRight(true)
+			borderStyle = headerStyleRight
 		}
 
-		headerStrings = append(headerStrings, borderStyle.Render(header.Style.Render(headerSection)))
+		headerStrings = append(headerStrings, borderStyle.Render(headerSection))
 	}
 
-	body.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...))
-
-	body.WriteString("\n")
+	headerBlock := lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...)
 
+	rowStrs := []string{headerBlock}
 	for i := range m.rows {
-		body.WriteString(m.renderRow(i))
-		body.WriteString("\n")
+		rowStrs = append(rowStrs, m.renderRow(i))
 	}
 
+	body.WriteString(lipgloss.JoinVertical(lipgloss.Left, rowStrs...))
+
 	return body.String()
 }


Test patch:
diff --git a/table/table_test.go b/table/table_test.go
index 5d08e76..c82ec07 100644
--- a/table/table_test.go
+++ b/table/table_test.go
@@ -1,6 +1,7 @@
 package table
 
 import (
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -28,4 +29,12 @@ func TestBasicTableShowsAllHeaders(t *testing.T) {
 
 	assert.Contains(t, rendered, firstTitle)
 	assert.Contains(t, rendered, secondTitle)
+
+	assert.False(t, strings.HasSuffix(rendered, "\n"), "Should not end in newline")
+}
+
+func TestNilHeadersSafelyReturnsEmptyView(t *testing.T) {
+	table := New(nil)
+
+	assert.Equal(t, "", table.View())
 }