Repository: Evertras/bubble-table
Pull request #86 (2022-06-04 12:35:08)

Horizontal scrolling
Adds horizontal scrolling as a first pass to tackle #83 

Evertras: Still missing some extra features like column freezing and programmatic access, but will merge this first and add the others as separate PRs as this is already huge.

Patch:
diff --git a/Makefile b/Makefile
index e6be4d7..7800b15 100644
--- a/Makefile
+++ b/Makefile
@@ -34,6 +34,10 @@ example-pagination:
 example-simplest:
 	@go run ./examples/simplest/*.go
 
+.PHONY: example-scrolling
+example-scrolling:
+	@go run ./examples/scrolling/*.go
+
 .PHONY: example-sorting
 example-sorting:
 	@go run ./examples/sorting/*.go
diff --git a/examples/scrolling/README.md b/examples/scrolling/README.md
new file mode 100644
index 0000000..d3a5766
--- /dev/null
+++ b/examples/scrolling/README.md
@@ -0,0 +1,6 @@
+# Scrolling example
+
+This example shows how to use scrolling to navigate particularly large tables
+that may not fit nicely onto the screen.
+
+<img width="423" alt="image" src="https://user-images.githubusercontent.com/5923958/171999237-fea16b62-c378-4996-8143-501452e3ab90.png">
diff --git a/examples/scrolling/main.go b/examples/scrolling/main.go
new file mode 100644
index 0000000..7a30097
--- /dev/null
+++ b/examples/scrolling/main.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/evertras/bubble-table/table"
+)
+
+const (
+	columnKeyID = "id"
+
+	numCols = 100
+	numRows = 10
+)
+
+type Model struct {
+	scrollableTable table.Model
+}
+
+func colKey(colNum int) string {
+	return fmt.Sprintf("%d", colNum)
+}
+
+func genRow(id int) table.Row {
+	data := table.RowData{
+		columnKeyID: fmt.Sprintf("ID %d", id),
+	}
+
+	for i := 0; i < numCols; i++ {
+		data[colKey(i)] = i + 1
+	}
+
+	return table.NewRow(data)
+}
+
+func NewModel() Model {
+	rows := []table.Row{}
+
+	for i := 0; i < numRows; i++ {
+		rows = append(rows, genRow(i))
+	}
+
+	cols := []table.Column{
+		table.NewColumn(columnKeyID, "ID", 5),
+	}
+
+	for i := 0; i < numCols; i++ {
+		cols = append(cols, table.NewColumn(colKey(i), colKey(i+1), 5))
+	}
+
+	return Model{
+		scrollableTable: table.New(cols).WithRows(rows).WithMaxTotalWidth(30).Focused(true),
+	}
+}
+
+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.scrollableTable, cmd = m.scrollableTable.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 scrollable table\nPress shift+left or shift+right to scroll\nPress q or ctrl+c to quit\n\n")
+
+	body.WriteString(m.scrollableTable.View())
+
+	return body.String()
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/go.mod b/go.mod
index 01680e2..50a674b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,9 @@ module github.com/evertras/bubble-table
 go 1.17
 
 require (
-	github.com/charmbracelet/bubbles v0.10.3
-	github.com/charmbracelet/bubbletea v0.19.3
-	github.com/charmbracelet/lipgloss v0.4.0
+	github.com/charmbracelet/bubbles v0.11.0
+	github.com/charmbracelet/bubbletea v0.21.0
+	github.com/charmbracelet/lipgloss v0.5.0
 	github.com/mattn/go-runewidth v0.0.13
 	github.com/muesli/reflow v0.3.0
 	github.com/stretchr/testify v1.7.0
@@ -13,16 +13,16 @@ require (
 
 require (
 	github.com/atotto/clipboard v0.1.4 // indirect
-	github.com/containerd/console v1.0.2 // indirect
+	github.com/containerd/console v1.0.3 // indirect
 	github.com/davecgh/go-spew v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
-	github.com/mattn/go-isatty v0.0.13 // indirect
+	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
-	github.com/muesli/termenv v0.9.0 // indirect
-	github.com/pkg/errors v0.9.1 // indirect
+	github.com/muesli/cancelreader v0.2.0 // indirect
+	github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
-	golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
-	golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect
+	golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
+	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 )
diff --git a/go.sum b/go.sum
index faa8ac7..41c2e5b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,34 +1,35 @@
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
-github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
-github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw=
-github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
-github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
-github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
-github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
+github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
+github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
+github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
+github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
-github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
+github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
-github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
-github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -38,13 +39,14 @@ github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
diff --git a/table/footer.go b/table/footer.go
index 4fd5547..d78eb8f 100644
--- a/table/footer.go
+++ b/table/footer.go
@@ -16,7 +16,13 @@ func (m Model) renderFooter() string {
 
 	const borderAdjustment = 2
 
-	styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(m.totalWidth - borderAdjustment)
+	width := m.totalWidth
+
+	if m.maxTotalWidth != 0 {
+		width = m.maxTotalWidth
+	}
+
+	styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(width - borderAdjustment)
 
 	if m.staticFooter != "" {
 		return styleFooter.Render(m.staticFooter)
diff --git a/table/header.go b/table/header.go
new file mode 100644
index 0000000..56d96d4
--- /dev/null
+++ b/table/header.go
@@ -0,0 +1,87 @@
+package table
+
+import "github.com/charmbracelet/lipgloss"
+
+// This is long and could use some refactoring in the future, but unsure of how
+// to pick it apart right now.
+// nolint: funlen
+func (m Model) renderHeaders() string {
+	headerStrings := []string{}
+
+	totalRenderedWidth := 0
+
+	headerStyles := m.styleHeaders()
+
+	renderHeader := func(column Column, borderStyle lipgloss.Style) string {
+		borderStyle = borderStyle.Inherit(column.style).Inherit(m.baseStyle)
+
+		headerSection := limitStr(column.title, column.width)
+
+		return borderStyle.Render(headerSection)
+	}
+
+	if m.horizontalScrollOffsetCol > 0 {
+		borderStyle := headerStyles.left.Copy()
+
+		rendered := renderHeader(genOverflowColumnLeft(1), borderStyle)
+
+		totalRenderedWidth += lipgloss.Width(rendered)
+
+		headerStrings = append(headerStrings, rendered)
+	}
+
+	for columnIndex, column := range m.columns {
+		if columnIndex < m.horizontalScrollOffsetCol {
+			continue
+		}
+
+		var borderStyle lipgloss.Style
+
+		if len(headerStrings) == 0 {
+			borderStyle = headerStyles.left.Copy()
+		} else if columnIndex < len(m.columns)-1 {
+			borderStyle = headerStyles.inner.Copy()
+		} else {
+			borderStyle = headerStyles.right.Copy()
+		}
+
+		rendered := renderHeader(column, borderStyle)
+
+		if m.maxTotalWidth != 0 {
+			renderedWidth := lipgloss.Width(rendered)
+
+			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
+				overflowStyle := genOverflowStyle(headerStyles.right, overflowWidth)
+				overflowColumn := genOverflowColumnRight(overflowWidth)
+
+				overflowStr := renderHeader(overflowColumn, overflowStyle)
+
+				headerStrings = append(headerStrings, overflowStr)
+
+				break
+			}
+
+			totalRenderedWidth += renderedWidth
+		}
+
+		headerStrings = append(headerStrings, rendered)
+	}
+
+	headerBlock := lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...)
+
+	return headerBlock
+}
diff --git a/table/keys.go b/table/keys.go
index dbf4c1b..bc7d119 100644
--- a/table/keys.go
+++ b/table/keys.go
@@ -22,6 +22,12 @@ type KeyMap struct {
 
 	// FilterClear will clear the filter while it's blurred.
 	FilterClear key.Binding
+
+	// ScrollRight will move one column to the right when overflow occurs.
+	ScrollRight key.Binding
+
+	// ScrollLeft will move one column to the left when overflow occurs.
+	ScrollLeft key.Binding
 }
 
 // DefaultKeyMap returns a set of sensible defaults for controlling a focused table.
@@ -57,5 +63,11 @@ func DefaultKeyMap() KeyMap {
 		FilterClear: key.NewBinding(
 			key.WithKeys("esc"),
 		),
+		ScrollRight: key.NewBinding(
+			key.WithKeys("shift+right"),
+		),
+		ScrollLeft: key.NewBinding(
+			key.WithKeys("shift+left"),
+		),
 	}
 }
diff --git a/table/model.go b/table/model.go
index 5ef9e90..fb49f1a 100644
--- a/table/model.go
+++ b/table/model.go
@@ -54,8 +54,15 @@ type Model struct {
 	// For flex columns
 	targetTotalWidth int
 
-	// Internal cached calculations for reference
+	// The maximum total width for overflow/scrolling
+	maxTotalWidth int
+
+	// Internal cached calculations for reference, may be higher than
+	// maxTotalWidth.  If this is the case, we need to adjust the view
 	totalWidth int
+
+	// How far to scroll to the right, in columns
+	horizontalScrollOffsetCol int
 }
 
 // New creates a new table ready for further modifications.
diff --git a/table/options.go b/table/options.go
index 31bb491..e33b66c 100644
--- a/table/options.go
+++ b/table/options.go
@@ -267,3 +267,13 @@ func (m Model) WithFooterVisibility(visibility bool) Model {
 
 	return m
 }
+
+// WithMaxTotalWidth sets the maximum total width that the table should render.
+// If this width is exceeded by either the target width or by the total width
+// of all the columns (including borders!), anything extra will be treated as
+// overflow and horizontal scrolling will be enabled to see the rest.
+func (m Model) WithMaxTotalWidth(maxTotalWidth int) Model {
+	m.maxTotalWidth = maxTotalWidth
+
+	return m
+}
diff --git a/table/overflow.go b/table/overflow.go
new file mode 100644
index 0000000..1438290
--- /dev/null
+++ b/table/overflow.go
@@ -0,0 +1,22 @@
+package table
+
+import "github.com/charmbracelet/lipgloss"
+
+const columnKeyOverflowRight = "___overflow_r___"
+const columnKeyOverflowLeft = "___overflow_l__"
+
+func genOverflowStyle(base lipgloss.Style, width int) lipgloss.Style {
+	style := lipgloss.NewStyle().Width(width).Align(lipgloss.Right)
+
+	style.Inherit(base)
+
+	return style
+}
+
+func genOverflowColumnRight(width int) Column {
+	return NewColumn(columnKeyOverflowRight, ">", width)
+}
+
+func genOverflowColumnLeft(width int) Column {
+	return NewColumn(columnKeyOverflowLeft, "<", width)
+}
diff --git a/table/row.go b/table/row.go
index 9a0b596..0dcaeac 100644
--- a/table/row.go
+++ b/table/row.go
@@ -43,13 +43,46 @@ func (r Row) WithStyle(style lipgloss.Style) Row {
 	return r
 }
 
-// This is somewhat complicated but at the moment splitting this out feels like
-// it would just make things harder to read.  May revisit later.
-// nolint: cyclop
+// nolint: nestif // 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)
+
+	var str string
+
+	if column.key == columnKeySelect {
+		if row.selected {
+			str = m.selectedText
+		} else {
+			str = m.unselectedText
+		}
+	} else if column.key == columnKeyOverflowRight {
+		str = ">"
+	} else if column.key == columnKeyOverflowLeft {
+		str = "<"
+	} else if entry, exists := row.Data[column.key]; exists {
+		switch entry := entry.(type) {
+		case StyledCell:
+			str = fmt.Sprintf("%v", entry.Data)
+			cellStyle = entry.Style.Copy().Inherit(cellStyle)
+		default:
+			str = fmt.Sprintf("%v", entry)
+		}
+	}
+
+	cellStyle = cellStyle.Inherit(borderStyle)
+	cellStr := cellStyle.Render(limitStr(str, column.width))
+
+	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
 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{}
 
@@ -61,27 +94,29 @@ func (m Model) renderRow(rowIndex int, last bool) string {
 
 	stylesInner, stylesLast := m.styleRows()
 
-	for columnIndex, column := range m.columns {
-		cellStyle := rowStyle.Copy().Inherit(column.style).Inherit(m.baseStyle)
+	if m.horizontalScrollOffsetCol > 0 {
+		var borderStyle lipgloss.Style
+
+		if !last {
+			borderStyle = stylesInner.left.Copy()
+		} else {
+			borderStyle = stylesLast.left.Copy()
+		}
 
-		var str string
+		rendered := m.renderRowColumnData(row, genOverflowColumnLeft(1), rowStyle, borderStyle)
 
-		if column.key == columnKeySelect {
-			if row.selected {
-				str = m.selectedText
-			} else {
-				str = m.unselectedText
-			}
-		} else if entry, exists := row.Data[column.key]; exists {
-			switch entry := entry.(type) {
-			case StyledCell:
-				str = fmt.Sprintf("%v", entry.Data)
-				cellStyle = entry.Style.Copy().Inherit(cellStyle)
-			default:
-				str = fmt.Sprintf("%v", entry)
-			}
+		totalRenderedWidth += lipgloss.Width(rendered)
+
+		columnStrings = append(columnStrings, rendered)
+	}
+
+	for columnIndex, column := range m.columns {
+		if columnIndex < m.horizontalScrollOffsetCol {
+			continue
 		}
 
+		var borderStyle lipgloss.Style
+
 		var rowStyles borderStyleRow
 
 		if !last {
@@ -90,15 +125,45 @@ func (m Model) renderRow(rowIndex int, last bool) string {
 			rowStyles = stylesLast
 		}
 
-		if columnIndex == 0 {
-			cellStyle = cellStyle.Inherit(rowStyles.left)
+		if len(columnStrings) == 0 {
+			borderStyle = rowStyles.left
 		} else if columnIndex < numColumns-1 {
-			cellStyle = cellStyle.Inherit(rowStyles.inner)
+			borderStyle = rowStyles.inner
 		} else {
-			cellStyle = cellStyle.Inherit(rowStyles.right)
+			borderStyle = rowStyles.right
 		}
 
-		cellStr := cellStyle.Render(limitStr(str, column.width))
+		cellStr := m.renderRowColumnData(row, column, rowStyle, borderStyle)
+
+		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
+				overflowStyle := genOverflowStyle(rowStyles.right, overflowWidth)
+				overflowColumn := genOverflowColumnRight(overflowWidth)
+				overflowStr := m.renderRowColumnData(row, overflowColumn, rowStyle, overflowStyle)
+
+				columnStrings = append(columnStrings, overflowStr)
+
+				break
+			}
+
+			totalRenderedWidth += renderedWidth
+		}
 
 		columnStrings = append(columnStrings, cellStr)
 	}
diff --git a/table/scrolling.go b/table/scrolling.go
new file mode 100644
index 0000000..a7b7065
--- /dev/null
+++ b/table/scrolling.go
@@ -0,0 +1,13 @@
+package table
+
+func (m *Model) scrollRight() {
+	if m.horizontalScrollOffsetCol < len(m.columns)-1 {
+		m.horizontalScrollOffsetCol++
+	}
+}
+
+func (m *Model) scrollLeft() {
+	if m.horizontalScrollOffsetCol > 0 {
+		m.horizontalScrollOffsetCol--
+	}
+}
diff --git a/table/update.go b/table/update.go
index bd4f807..7eab5bb 100644
--- a/table/update.go
+++ b/table/update.go
@@ -60,6 +60,7 @@ func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) {
 	return m, cmd
 }
 
+// nolint: cyclop // This is just a series of Matches tests
 func (m *Model) handleKeypress(msg tea.KeyMsg) {
 	switch {
 	case key.Matches(msg, m.keyMap.RowDown):
@@ -88,6 +89,12 @@ func (m *Model) handleKeypress(msg tea.KeyMsg) {
 
 	case key.Matches(msg, m.keyMap.FilterClear):
 		m.filterTextInput.Reset()
+
+	case key.Matches(msg, m.keyMap.ScrollRight):
+		m.scrollRight()
+
+	case key.Matches(msg, m.keyMap.ScrollLeft):
+		m.scrollLeft()
 	}
 }
 
diff --git a/table/view.go b/table/view.go
index 3f64b1c..72242f0 100644
--- a/table/view.go
+++ b/table/view.go
@@ -16,30 +16,7 @@ func (m Model) View() string {
 
 	body := strings.Builder{}
 
-	headerStrings := []string{}
-
-	headerStyles := m.styleHeaders()
-
-	for columnIndex, column := range m.columns {
-		headerSection := limitStr(column.title, column.width)
-		var borderStyle lipgloss.Style
-
-		if columnIndex == 0 {
-			borderStyle = headerStyles.left.Copy()
-		} else if columnIndex < len(m.columns)-1 {
-			borderStyle = headerStyles.inner.Copy()
-		} else {
-			borderStyle = headerStyles.right.Copy()
-		}
-
-		borderStyle = borderStyle.Inherit(column.style).Inherit(m.baseStyle)
-
-		headerStrings = append(headerStrings, borderStyle.Render(headerSection))
-	}
-
-	headerBlock := lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...)
-
-	rowStrs := []string{headerBlock}
+	rowStrs := []string{m.renderHeaders()}
 
 	startRowIndex, endRowIndex := m.VisibleIndices()
 	for i := startRowIndex; i <= endRowIndex; i++ {


Test patch:
diff --git a/table/update_test.go b/table/update_test.go
index b13ada8..4e01b1b 100644
--- a/table/update_test.go
+++ b/table/update_test.go
@@ -324,3 +324,112 @@ func TestFilterWithKeypresses(t *testing.T) {
 
 	assert.Len(t, visible, 2)
 }
+
+func TestScrollRight(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"1": "x1",
+				"2": "x2",
+				"3": "x3",
+				"4": "x4",
+			}),
+		}).
+		WithMaxTotalWidth(18).
+		Focused(true)
+
+	const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━┓
+┃   1┃   2┃   3┃>┃
+┣━━━━╋━━━━╋━━━━╋━┫
+┃  x1┃  x2┃  x3┃>┃
+┗━━━━┻━━━━┻━━━━┻━┛`
+
+	const expectedTableAfter = `┏━┳━━━━┳━━━━┳━━━━┓
+┃<┃   2┃   3┃   4┃
+┣━╋━━━━╋━━━━╋━━━━┫
+┃<┃  x2┃  x3┃  x4┃
+┗━┻━━━━┻━━━━┻━━━━┛`
+
+	hitScrollRight := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+
+	hitScrollLeft := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftLeft})
+	}
+
+	assert.Equal(t, expectedTableOriginal, model.View())
+
+	hitScrollRight()
+
+	assert.Equal(t, expectedTableAfter, model.View())
+
+	hitScrollLeft()
+	assert.Equal(t, expectedTableOriginal, model.View())
+
+	// Try it again, should do nothing
+	hitScrollLeft()
+	assert.Equal(t, expectedTableOriginal, model.View())
+}
+
+func TestScrollRightWithFooter(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"1": "x1",
+				"2": "x2",
+				"3": "x3",
+				"4": "x4",
+			}),
+		}).
+		WithStaticFooter("Footer").
+		WithMaxTotalWidth(18).
+		Focused(true)
+
+	const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━┓
+┃   1┃   2┃   3┃>┃
+┣━━━━╋━━━━╋━━━━╋━┫
+┃  x1┃  x2┃  x3┃>┃
+┣━━━━┻━━━━┻━━━━┻━┫
+┃          Footer┃
+┗━━━━━━━━━━━━━━━━┛`
+
+	const expectedTableAfter = `┏━┳━━━━┳━━━━┳━━━━┓
+┃<┃   2┃   3┃   4┃
+┣━╋━━━━╋━━━━╋━━━━┫
+┃<┃  x2┃  x3┃  x4┃
+┣━┻━━━━┻━━━━┻━━━━┫
+┃          Footer┃
+┗━━━━━━━━━━━━━━━━┛`
+
+	hitScrollRight := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+
+	hitScrollLeft := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftLeft})
+	}
+
+	assert.Equal(t, expectedTableOriginal, model.View())
+
+	hitScrollRight()
+
+	assert.Equal(t, expectedTableAfter, model.View())
+
+	hitScrollLeft()
+	assert.Equal(t, expectedTableOriginal, model.View())
+
+	// Try it again, should do nothing
+	hitScrollLeft()
+	assert.Equal(t, expectedTableOriginal, model.View())
+}
diff --git a/table/view_test.go b/table/view_test.go
index 9868f21..f5425bd 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -599,3 +599,146 @@ func TestViewResizesWhenColumnsChange(t *testing.T) {
 
 	assert.Equal(t, expectedTableUpdated, rendered)
 }
+
+func TestMaxWidthHidesOverflow(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"1": "x1",
+				"2": "x2",
+				"3": "x3",
+				"4": "x4",
+			}),
+		}).
+		WithStaticFooter("Footer").
+		// This includes borders, so should cut off early
+		WithMaxTotalWidth(19)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━┓
+┃   1┃   2┃   3┃ >┃
+┣━━━━╋━━━━╋━━━━╋━━┫
+┃  x1┃  x2┃  x3┃ >┃
+┣━━━━┻━━━━┻━━━━┻━━┫
+┃           Footer┃
+┗━━━━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMaxWidthHasNoEffectForExactFit(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"1": "x1",
+				"2": "x2",
+				"3": "x3",
+				"4": "x4",
+			}),
+		})
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃   4┃
+┣━━━━╋━━━━╋━━━━╋━━━━┫
+┃  x1┃  x2┃  x3┃  x4┃
+┗━━━━┻━━━━┻━━━━┻━━━━┛`
+
+	const expectedTableFooter = `┏━━━━┳━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃   4┃
+┣━━━━╋━━━━╋━━━━╋━━━━┫
+┃  x1┃  x2┃  x3┃  x4┃
+┣━━━━┻━━━━┻━━━━┻━━━━┫
+┃             Footer┃
+┗━━━━━━━━━━━━━━━━━━━┛`
+
+	model = model.WithMaxTotalWidth(lipgloss.Width(expectedTable))
+	rendered := model.View()
+	assert.Equal(t, expectedTable, rendered)
+
+	model = model.WithStaticFooter("Footer")
+	rendered = model.View()
+	assert.Equal(t, expectedTableFooter, rendered)
+}
+
+func TestMaxWidthHidesOverflowWithSingleCharExtra(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"1": "x1",
+				"2": "x2",
+				"3": "x3",
+				"4": "x4",
+			}),
+		}).
+		WithStaticFooter("Footer").
+		// Juuuust barely overflowing...
+		WithMaxTotalWidth(17)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━━┓
+┃   1┃   2┃    >┃
+┣━━━━╋━━━━╋━━━━━┫
+┃  x1┃  x2┃    >┃
+┣━━━━┻━━━━┻━━━━━┫
+┃         Footer┃
+┗━━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMaxWidthHidesOverflowWithTwoCharExtra(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"1": "x1",
+				"2": "x2",
+				"3": "x3",
+				"4": "x4",
+			}),
+		}).
+		// Just enough to squeeze in a '>' column
+		WithMaxTotalWidth(18)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┳━┓
+┃   1┃   2┃   3┃>┃
+┣━━━━╋━━━━╋━━━━╋━┫
+┃  x1┃  x2┃  x3┃>┃
+┗━━━━┻━━━━┻━━━━┻━┛`
+
+	const expectedTableFooter = `┏━━━━┳━━━━┳━━━━┳━┓
+┃   1┃   2┃   3┃>┃
+┣━━━━╋━━━━╋━━━━╋━┫
+┃  x1┃  x2┃  x3┃>┃
+┣━━━━┻━━━━┻━━━━┻━┫
+┃          Footer┃
+┗━━━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+	assert.Equal(t, expectedTable, rendered)
+
+	model = model.WithStaticFooter("Footer")
+	rendered = model.View()
+	assert.Equal(t, expectedTableFooter, rendered)
+}