Repository: Evertras/bubble-table
Pull request #84 (2022-06-02 19:38:16)

Feature: select rows programatically
Add a new `Row.Selected(bool) Row` function that allows to indicate if the row is selected or not in a programatic manner. In addition to this it:

* refactors the way `Model.SelectedRows() []Row` works by computing the selected rows whenever this method is called instead of when the mark is toggled;
* adds test for selecting rows programatically;
* adds benchmark for `Model.SelectedRows()`
* reduce some allocations in `Model.SelectedRows`

As a byproduct of these changes, the previously reported and fixed bug #80 won't reappear again.

Evertras: Just a small wording adjustment, but otherwise all of this seems reasonable.  You'll need to add a newline before the return as well for linting.  Thanks for the contribution!

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/README.md b/README.md
index b880131..4a401f3 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,9 @@ If you want to get started with a simple default table, [check the simplest exam
 
 Displays a table with a header, rows, footer, and borders.
 
-Columns can be fixed-width [or flexible width](./examples/flex).
+Columns can be fixed-width [or flexible width](./examples/flex).  A maximum
+width can be specified which enables [horizontal scrolling](./examples/scrolling),
+and left-most columns can be frozen for easier reference.
 
 Border shape is customizable with a basic thick square default.  The color can
 be modified by applying a base style with `lipgloss.NewStyle().BorderForeground(...)`.
diff --git a/examples/scrolling/README.md b/examples/scrolling/README.md
new file mode 100644
index 0000000..b57964d
--- /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/172004548-7052993e-9e60-44a4-b9b2-b49506c48fb6.png">
diff --git a/examples/scrolling/main.go b/examples/scrolling/main.go
new file mode 100644
index 0000000..c18467f
--- /dev/null
+++ b/examples/scrolling/main.go
@@ -0,0 +1,106 @@
+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))
+	}
+
+	t := table.New(cols).
+		WithRows(rows).
+		WithMaxTotalWidth(30).
+		WithHorizontalFreezeColumnCount(1).
+		WithStaticFooter("A footer").
+		Focused(true)
+
+	return Model{
+		scrollableTable: t,
+	}
+}
+
+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/dimensions.go b/table/dimensions.go
index c187bdc..dbf5997 100644
--- a/table/dimensions.go
+++ b/table/dimensions.go
@@ -14,6 +14,8 @@ func (m *Model) recalculateWidth() {
 	}
 
 	updateColumnWidths(m.columns, m.targetTotalWidth)
+
+	m.recalculateLastHorizontalColumn()
 }
 
 // Updates column width in-place.  This could be optimized but should be called
diff --git a/table/footer.go b/table/footer.go
index 4fd5547..e2abee5 100644
--- a/table/footer.go
+++ b/table/footer.go
@@ -9,14 +9,14 @@ func (m Model) hasFooter() bool {
 	return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered)
 }
 
-func (m Model) renderFooter() string {
+func (m Model) renderFooter(width int) string {
 	if !m.hasFooter() {
 		return ""
 	}
 
 	const borderAdjustment = 2
 
-	styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(m.totalWidth - borderAdjustment)
+	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..bff033c
--- /dev/null
+++ b/table/header.go
@@ -0,0 +1,92 @@
+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, cyclop
+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)
+	}
+
+	for columnIndex, column := range m.columns {
+		var borderStyle lipgloss.Style
+
+		if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
+			if columnIndex == 0 {
+				borderStyle = headerStyles.left.Copy()
+			} else {
+				borderStyle = headerStyles.inner.Copy()
+			}
+
+			rendered := renderHeader(genOverflowColumnLeft(1), borderStyle)
+
+			totalRenderedWidth += lipgloss.Width(rendered)
+
+			headerStrings = append(headerStrings, rendered)
+		}
+
+		if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
+			columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
+			continue
+		}
+
+		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..da42878 100644
--- a/table/model.go
+++ b/table/model.go
@@ -24,7 +24,6 @@ type Model struct {
 	focused        bool
 	keyMap         KeyMap
 	selectableRows bool
-	selectedRows   []Row
 	rowCursorIndex int
 
 	// Styles
@@ -54,8 +53,21 @@ 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
+
+	// How many columns to freeze when scrolling horizontally
+	horizontalScrollFreezeColumnsCount int
+
+	// Calculated maximum column we can scroll to before the last is displayed
+	maxHorizontalColumnIndex int
 }
 
 // New creates a new table ready for further modifications.
diff --git a/table/options.go b/table/options.go
index 31bb491..91e9ec8 100644
--- a/table/options.go
+++ b/table/options.go
@@ -32,7 +32,6 @@ func (m Model) HeaderStyle(style lipgloss.Style) Model {
 // WithRows sets the rows to show as data in the table.
 func (m Model) WithRows(rows []Row) Model {
 	m.rows = rows
-	m.selectedRows = nil
 
 	if m.pageSize != 0 {
 		maxPage := m.MaxPages()
@@ -92,7 +91,15 @@ func (m Model) HighlightedRow() Row {
 
 // SelectedRows returns all rows that have been set as selected by the user.
 func (m Model) SelectedRows() []Row {
-	return m.selectedRows
+	selectedRows := []Row{}
+
+	for _, row := range m.GetVisibleRows() {
+		if row.selected {
+			selectedRows = append(selectedRows, row)
+		}
+	}
+
+	return selectedRows
 }
 
 // HighlightStyle sets a custom style to use when the row is being highlighted
@@ -267,3 +274,40 @@ 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
+
+	m.recalculateWidth()
+
+	return m
+}
+
+// WithHorizontalFreezeColumnCount freezes the given number of columns to the
+// left side.  This is useful for things like ID or Name columns that should
+// always be visible even when scrolling.
+func (m Model) WithHorizontalFreezeColumnCount(columnsToFreeze int) Model {
+	m.horizontalScrollFreezeColumnsCount = columnsToFreeze
+
+	m.recalculateWidth()
+
+	return m
+}
+
+// ScrollRight moves one column to the right.  Use with WithMaxTotalWidth.
+func (m Model) ScrollRight() Model {
+	m.scrollRight()
+
+	return m
+}
+
+// ScrollLeft moves one column to the left.  Use with WithMaxTotalWidth.
+func (m Model) ScrollLeft() Model {
+	m.scrollLeft()
+
+	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..c27833c 100644
--- a/table/row.go
+++ b/table/row.go
@@ -43,13 +43,47 @@ 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 {
+		cellStyle = cellStyle.Align(lipgloss.Right)
+		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, 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{}
 
@@ -62,26 +96,7 @@ 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)
-
-		var str string
-
-		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)
-			}
-		}
-
+		var borderStyle lipgloss.Style
 		var rowStyles borderStyleRow
 
 		if !last {
@@ -90,18 +105,76 @@ func (m Model) renderRow(rowIndex int, last bool) string {
 			rowStyles = stylesLast
 		}
 
-		if columnIndex == 0 {
-			cellStyle = cellStyle.Inherit(rowStyles.left)
+		if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
+			var borderStyle lipgloss.Style
+
+			if columnIndex == 0 {
+				borderStyle = rowStyles.left.Copy()
+			} else {
+				borderStyle = rowStyles.inner.Copy()
+			}
+
+			rendered := m.renderRowColumnData(row, genOverflowColumnLeft(1), rowStyle, borderStyle)
+
+			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 {
-			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)
 	}
 
 	return lipgloss.JoinHorizontal(lipgloss.Bottom, columnStrings...)
 }
+
+// Selected sets whether the row is selected or not.
+func (r Row) Selected(selected bool) Row {
+	r.selected = selected
+
+	return r
+}
diff --git a/table/scrolling.go b/table/scrolling.go
new file mode 100644
index 0000000..c2a0689
--- /dev/null
+++ b/table/scrolling.go
@@ -0,0 +1,56 @@
+package table
+
+func (m *Model) scrollRight() {
+	if m.horizontalScrollOffsetCol < m.maxHorizontalColumnIndex {
+		m.horizontalScrollOffsetCol++
+	}
+}
+
+func (m *Model) scrollLeft() {
+	if m.horizontalScrollOffsetCol > 0 {
+		m.horizontalScrollOffsetCol--
+	}
+}
+
+func (m *Model) recalculateLastHorizontalColumn() {
+	if m.horizontalScrollFreezeColumnsCount >= len(m.columns) {
+		m.maxHorizontalColumnIndex = 0
+
+		return
+	}
+
+	if m.totalWidth <= m.maxTotalWidth {
+		m.maxHorizontalColumnIndex = 0
+
+		return
+	}
+
+	const (
+		leftOverflowWidth = 2
+		borderAdjustment  = 1
+	)
+
+	// Always have left border
+	visibleWidth := borderAdjustment + leftOverflowWidth
+
+	for i := 0; i < m.horizontalScrollFreezeColumnsCount; i++ {
+		visibleWidth += m.columns[i].width + borderAdjustment
+	}
+
+	m.maxHorizontalColumnIndex = len(m.columns) - 1
+
+	// Work backwards from the right
+	for i := len(m.columns) - 1; i >= m.horizontalScrollFreezeColumnsCount && visibleWidth <= m.maxTotalWidth; i-- {
+		visibleWidth += m.columns[i].width + borderAdjustment
+
+		if visibleWidth <= m.maxTotalWidth {
+			m.maxHorizontalColumnIndex = i - m.horizontalScrollFreezeColumnsCount
+		}
+	}
+
+	if m.maxHorizontalColumnIndex <= m.horizontalScrollFreezeColumnsCount {
+		m.maxHorizontalColumnIndex = 0
+
+		return
+	}
+}
diff --git a/table/update.go b/table/update.go
index bd4f807..499527d 100644
--- a/table/update.go
+++ b/table/update.go
@@ -36,14 +36,6 @@ func (m *Model) toggleSelect() {
 	rows[m.rowCursorIndex].selected = !rows[m.rowCursorIndex].selected
 
 	m.rows = rows
-
-	m.selectedRows = []Row{}
-
-	for _, row := range m.GetVisibleRows() {
-		if row.selected {
-			m.selectedRows = append(m.selectedRows, row)
-		}
-	}
 }
 
 func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) {
@@ -60,6 +52,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 +81,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..2c7ee91 100644
--- a/table/view.go
+++ b/table/view.go
@@ -16,37 +16,14 @@ 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++ {
 		rowStrs = append(rowStrs, m.renderRow(i, i == endRowIndex))
 	}
 
-	footer := m.renderFooter()
+	footer := m.renderFooter(lipgloss.Width(rowStrs[0]))
 
 	if footer != "" {
 		rowStrs = append(rowStrs, footer)


Test patch:
diff --git a/table/options_test.go b/table/options_test.go
index a7b336b..100fb52 100644
--- a/table/options_test.go
+++ b/table/options_test.go
@@ -131,9 +131,96 @@ func TestPageOptions(t *testing.T) {
 	assert.Equal(t, 26, model.rowCursorIndex)
 
 	model = model.WithFooterVisibility(false)
-	assert.Equal(t, "", model.renderFooter())
+	assert.Equal(t, "", model.renderFooter(10))
 
 	model = model.WithFooterVisibility(true)
-	assert.Greater(t, len(model.renderFooter()), 10)
-	assert.Contains(t, model.renderFooter(), "6/6")
+	assert.Greater(t, len(model.renderFooter(10)), 10)
+	assert.Contains(t, model.renderFooter(10), "6/6")
 }
+
+func TestSelectRowsProgramatically(t *testing.T) {
+	const col = "id"
+
+	tests := map[string]struct {
+		rows        []Row
+		selectedIds []int
+	}{
+		"no rows selected": {
+			[]Row{
+				NewRow(RowData{col: 1}),
+				NewRow(RowData{col: 2}),
+				NewRow(RowData{col: 3}),
+			},
+			[]int{},
+		},
+
+		"all rows selected": {
+			[]Row{
+				NewRow(RowData{col: 1}).Selected(true),
+				NewRow(RowData{col: 2}).Selected(true),
+				NewRow(RowData{col: 3}).Selected(true),
+			},
+			[]int{1, 2, 3},
+		},
+
+		"first row selected": {
+			[]Row{
+				NewRow(RowData{col: 1}).Selected(true),
+				NewRow(RowData{col: 2}),
+				NewRow(RowData{col: 3}),
+			},
+			[]int{1},
+		},
+
+		"last row selected": {
+			[]Row{
+				NewRow(RowData{col: 1}),
+				NewRow(RowData{col: 2}),
+				NewRow(RowData{col: 3}).Selected(true),
+			},
+			[]int{3},
+		},
+	}
+
+	model := New([]Column{
+		NewColumn(col, col, 1),
+	})
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			sel := model.WithRows(tt.rows).SelectedRows()
+
+			assert.Equal(t, len(tt.selectedIds), len(sel))
+			for i, id := range tt.selectedIds {
+				assert.Equal(t, id, sel[i].Data[col], "expecting row %d to have same %s column value", i)
+			}
+		})
+	}
+}
+
+func BenchmarkSelectedRows(b *testing.B) {
+	const N = 1000
+
+	b.ReportAllocs()
+
+	rows := make([]Row, 0, N)
+	for i := 0; i < N; i++ {
+		rows = append(rows, NewRow(RowData{"row": i}).Selected(i%2 == 0))
+	}
+
+	model := New([]Column{
+		NewColumn("row", "Row", 4),
+	}).WithRows(rows)
+
+	var sel []Row
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		sel = model.SelectedRows()
+	}
+
+	Rows = sel
+}
+
+var Rows []Row
diff --git a/table/scrolling_test.go b/table/scrolling_test.go
new file mode 100644
index 0000000..c6a9c33
--- /dev/null
+++ b/table/scrolling_test.go
@@ -0,0 +1,354 @@
+package table
+
+import (
+	"fmt"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHorizontalScrolling(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 TestHorizontalScrollWithFooter(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() {
+		// Try the programmatic API
+		model = model.ScrollRight()
+	}
+
+	hitScrollLeft := func() {
+		model = model.ScrollLeft()
+	}
+
+	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 TestHorizontalScrollingWithFooterAndFrozenCols(t *testing.T) {
+	model := New([]Column{
+		NewColumn("Name", "Name", 4),
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"Name": "A",
+				"1":    "x1",
+				"2":    "x2",
+				"3":    "x3",
+				"4":    "x4",
+			}),
+		}).
+		WithStaticFooter("Footer").
+		WithMaxTotalWidth(21).
+		WithHorizontalFreezeColumnCount(1).
+		Focused(true)
+
+	const expectedTableOriginal = `┏━━━━┳━━━━┳━━━━┳━━━━┓
+┃Name┃   1┃   2┃   >┃
+┣━━━━╋━━━━╋━━━━╋━━━━┫
+┃   A┃  x1┃  x2┃   >┃
+┣━━━━┻━━━━┻━━━━┻━━━━┫
+┃             Footer┃
+┗━━━━━━━━━━━━━━━━━━━┛`
+
+	const expectedTableAfter = `┏━━━━┳━┳━━━━┳━━━━┳━━┓
+┃Name┃<┃   2┃   3┃ >┃
+┣━━━━╋━╋━━━━╋━━━━╋━━┫
+┃   A┃<┃  x2┃  x3┃ >┃
+┣━━━━┻━┻━━━━┻━━━━┻━━┫
+┃             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())
+}
+
+// This is long due to literal strings
+// nolint: funlen
+func TestHorizontalScrollStopsAtLastColumnBeingVisible(t *testing.T) {
+	model := New([]Column{
+		NewColumn("Name", "Name", 4),
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+		NewColumn("4", "4", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"Name": "A",
+				"1":    "x1",
+				"2":    "x2",
+				"3":    "x3",
+				"4":    "x4",
+			}),
+		}).
+		WithStaticFooter("Footer").
+		WithMaxTotalWidth(21).
+		WithHorizontalFreezeColumnCount(1).
+		Focused(true)
+
+	const expectedTableLeft = `┏━━━━┳━━━━┳━━━━┳━━━━┓
+┃Name┃   1┃   2┃   >┃
+┣━━━━╋━━━━╋━━━━╋━━━━┫
+┃   A┃  x1┃  x2┃   >┃
+┣━━━━┻━━━━┻━━━━┻━━━━┫
+┃             Footer┃
+┗━━━━━━━━━━━━━━━━━━━┛`
+
+	const expectedTableMiddle = `┏━━━━┳━┳━━━━┳━━━━┳━━┓
+┃Name┃<┃   2┃   3┃ >┃
+┣━━━━╋━╋━━━━╋━━━━╋━━┫
+┃   A┃<┃  x2┃  x3┃ >┃
+┣━━━━┻━┻━━━━┻━━━━┻━━┫
+┃             Footer┃
+┗━━━━━━━━━━━━━━━━━━━┛`
+
+	const expectedTableRight = `┏━━━━┳━┳━━━━┳━━━━┓
+┃Name┃<┃   3┃   4┃
+┣━━━━╋━╋━━━━╋━━━━┫
+┃   A┃<┃  x3┃  x4┃
+┣━━━━┻━┻━━━━┻━━━━┫
+┃          Footer┃
+┗━━━━━━━━━━━━━━━━┛`
+
+	hitScrollRight := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+
+	assert.Equal(t, expectedTableLeft, model.View())
+
+	hitScrollRight()
+
+	assert.Equal(t, expectedTableMiddle, model.View())
+
+	hitScrollRight()
+	assert.Equal(t, expectedTableRight, model.View())
+
+	// Should no longer scroll
+	hitScrollRight()
+	assert.Equal(t, expectedTableRight, model.View())
+}
+
+func TestNoScrollingWhenEntireTableIsVisible(t *testing.T) {
+	model := New([]Column{
+		NewColumn("Name", "Name", 4),
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{
+				"Name": "A",
+				"1":    "x1",
+				"2":    "x2",
+				"3":    "x3",
+			}),
+		}).
+		WithStaticFooter("Footer").
+		WithMaxTotalWidth(21).
+		WithHorizontalFreezeColumnCount(1).
+		Focused(true)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┳━━━━┓
+┃Name┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━╋━━━━┫
+┃   A┃  x1┃  x2┃  x3┃
+┣━━━━┻━━━━┻━━━━┻━━━━┫
+┃             Footer┃
+┗━━━━━━━━━━━━━━━━━━━┛`
+
+	hitScrollRight := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+
+	assert.Equal(t, expectedTable, model.View())
+
+	hitScrollRight()
+
+	assert.Equal(t, expectedTable, model.View())
+}
+
+// This is long because of test cases
+// nolint: funlen
+func TestHorizontalScrollingStopEdgeCases(t *testing.T) {
+	tests := []struct {
+		numCols      int
+		nameWidth    int
+		colWidth     int
+		maxWidth     int
+		expectedCols []int
+	}{
+		{
+			numCols:   8,
+			nameWidth: 5,
+			colWidth:  3,
+			maxWidth:  30,
+		},
+		{
+			numCols:      8,
+			nameWidth:    5,
+			colWidth:     3,
+			maxWidth:     20,
+			expectedCols: []int{7, 8},
+		},
+	}
+
+	for i, test := range tests {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			cols := []Column{NewColumn("Name", "Name", test.nameWidth)}
+			for i := 0; i < test.numCols; i++ {
+				s := fmt.Sprintf("%d", i+1)
+				cols = append(cols, NewColumn(s, s, test.colWidth))
+			}
+
+			rowData := RowData{"Name": "A"}
+
+			for i := 0; i < test.numCols; i++ {
+				s := fmt.Sprintf("%d", i+1)
+				rowData[s] = s
+			}
+
+			rows := []Row{NewRow(rowData)}
+
+			model := New(cols).
+				WithRows(rows).
+				WithStaticFooter("Footer").
+				WithMaxTotalWidth(test.maxWidth).
+				WithHorizontalFreezeColumnCount(1).
+				Focused(true)
+
+			hitScrollRight := func() {
+				model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight})
+			}
+
+			// Excessive scrolling attempts to be sure
+			for i := 0; i < test.numCols*2; i++ {
+				hitScrollRight()
+			}
+
+			rendered := model.View()
+
+			assert.NotContains(t, rendered, ">")
+			assert.Contains(t, rendered, fmt.Sprintf("%d", test.numCols))
+
+			for _, expected := range test.expectedCols {
+				assert.Contains(t, rendered, fmt.Sprintf("%d", expected), "Missing expected column")
+			}
+		})
+	}
+}
diff --git a/table/view_test.go b/table/view_test.go
index 9868f21..8169737 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -599,3 +599,249 @@ 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 TestMaxWidthHasNoEffectForSmaller(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) + 5)
+	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)
+}
+
+func TestScrolledTableSizesFooterCorrectly(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(19).
+		WithStaticFooter("Footer").
+		ScrollRight()
+
+	const expectedTable = `┏━┳━━━━┳━━━━┳━━━━┓
+┃<┃   2┃   3┃   4┃
+┣━╋━━━━╋━━━━╋━━━━┫
+┃<┃  x2┃  x3┃  x4┃
+┣━┻━━━━┻━━━━┻━━━━┫
+┃          Footer┃
+┗━━━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestHorizontalScrollCaretIsRightAligned(t *testing.T) {
+	leftAlign := lipgloss.NewStyle().Align(lipgloss.Left)
+	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",
+			}).WithStyle(leftAlign),
+		}).
+		HeaderStyle(leftAlign).
+		WithStaticFooter("Footer").
+		WithMaxTotalWidth(17)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━━┓
+┃1   ┃2   ┃    >┃
+┣━━━━╋━━━━╋━━━━━┫
+┃x1  ┃x2  ┃    >┃
+┣━━━━┻━━━━┻━━━━━┫
+┃         Footer┃
+┗━━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}