table.go

  1// Package table provides a simple table component for Bubble Tea applications.
  2package table
  3
  4import (
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/help"
  8	"github.com/charmbracelet/bubbles/v2/key"
  9	"github.com/charmbracelet/bubbles/v2/viewport"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/lipgloss/v2"
 12	"github.com/mattn/go-runewidth"
 13)
 14
 15// Model defines a state for the table widget.
 16type Model struct {
 17	KeyMap KeyMap
 18	Help   help.Model
 19
 20	cols   []Column
 21	rows   []Row
 22	cursor int
 23	focus  bool
 24	styles Styles
 25
 26	viewport viewport.Model
 27	start    int
 28	end      int
 29}
 30
 31// Row represents one line in the table.
 32type Row []string
 33
 34// Column defines the table structure.
 35type Column struct {
 36	Title string
 37	Width int
 38}
 39
 40// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
 41// is used to render the help menu.
 42type KeyMap struct {
 43	LineUp       key.Binding
 44	LineDown     key.Binding
 45	PageUp       key.Binding
 46	PageDown     key.Binding
 47	HalfPageUp   key.Binding
 48	HalfPageDown key.Binding
 49	GotoTop      key.Binding
 50	GotoBottom   key.Binding
 51}
 52
 53// ShortHelp implements the KeyMap interface.
 54func (km KeyMap) ShortHelp() []key.Binding {
 55	return []key.Binding{km.LineUp, km.LineDown}
 56}
 57
 58// FullHelp implements the KeyMap interface.
 59func (km KeyMap) FullHelp() [][]key.Binding {
 60	return [][]key.Binding{
 61		{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
 62		{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
 63	}
 64}
 65
 66// DefaultKeyMap returns a default set of keybindings.
 67func DefaultKeyMap() KeyMap {
 68	return KeyMap{
 69		LineUp: key.NewBinding(
 70			key.WithKeys("up", "k"),
 71			key.WithHelp("↑/k", "up"),
 72		),
 73		LineDown: key.NewBinding(
 74			key.WithKeys("down", "j"),
 75			key.WithHelp("↓/j", "down"),
 76		),
 77		PageUp: key.NewBinding(
 78			key.WithKeys("b", "pgup"),
 79			key.WithHelp("b/pgup", "page up"),
 80		),
 81		PageDown: key.NewBinding(
 82			key.WithKeys("f", "pgdown", "space"),
 83			key.WithHelp("f/pgdn", "page down"),
 84		),
 85		HalfPageUp: key.NewBinding(
 86			key.WithKeys("u", "ctrl+u"),
 87			key.WithHelp("u", "½ page up"),
 88		),
 89		HalfPageDown: key.NewBinding(
 90			key.WithKeys("d", "ctrl+d"),
 91			key.WithHelp("d", "½ page down"),
 92		),
 93		GotoTop: key.NewBinding(
 94			key.WithKeys("home", "g"),
 95			key.WithHelp("g/home", "go to start"),
 96		),
 97		GotoBottom: key.NewBinding(
 98			key.WithKeys("end", "G"),
 99			key.WithHelp("G/end", "go to end"),
100		),
101	}
102}
103
104// Styles contains style definitions for this list component. By default, these
105// values are generated by DefaultStyles.
106type Styles struct {
107	Header   lipgloss.Style
108	Cell     lipgloss.Style
109	Selected lipgloss.Style
110}
111
112// DefaultStyles returns a set of default style definitions for this table.
113func DefaultStyles() Styles {
114	return Styles{
115		Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
116		Header:   lipgloss.NewStyle().Bold(true).Padding(0, 1),
117		Cell:     lipgloss.NewStyle().Padding(0, 1),
118	}
119}
120
121// SetStyles sets the table styles.
122func (m *Model) SetStyles(s Styles) {
123	m.styles = s
124	m.UpdateViewport()
125}
126
127// Option is used to set options in New. For example:
128//
129//	table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
130type Option func(*Model)
131
132// New creates a new model for the table widget.
133func New(opts ...Option) Model {
134	m := Model{
135		cursor:   0,
136		viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd
137
138		KeyMap: DefaultKeyMap(),
139		Help:   help.New(),
140		styles: DefaultStyles(),
141	}
142
143	for _, opt := range opts {
144		opt(&m)
145	}
146
147	m.UpdateViewport()
148
149	return m
150}
151
152// WithColumns sets the table columns (headers).
153func WithColumns(cols []Column) Option {
154	return func(m *Model) {
155		m.cols = cols
156	}
157}
158
159// WithRows sets the table rows (data).
160func WithRows(rows []Row) Option {
161	return func(m *Model) {
162		m.rows = rows
163	}
164}
165
166// WithHeight sets the height of the table.
167func WithHeight(h int) Option {
168	return func(m *Model) {
169		m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
170	}
171}
172
173// WithWidth sets the width of the table.
174func WithWidth(w int) Option {
175	return func(m *Model) {
176		m.viewport.SetWidth(w)
177	}
178}
179
180// WithFocused sets the focus state of the table.
181func WithFocused(f bool) Option {
182	return func(m *Model) {
183		m.focus = f
184	}
185}
186
187// WithStyles sets the table styles.
188func WithStyles(s Styles) Option {
189	return func(m *Model) {
190		m.styles = s
191	}
192}
193
194// WithKeyMap sets the key map.
195func WithKeyMap(km KeyMap) Option {
196	return func(m *Model) {
197		m.KeyMap = km
198	}
199}
200
201// Update is the Bubble Tea update loop.
202func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
203	if !m.focus {
204		return m, nil
205	}
206
207	switch msg := msg.(type) {
208	case tea.KeyPressMsg:
209		switch {
210		case key.Matches(msg, m.KeyMap.LineUp):
211			m.MoveUp(1)
212		case key.Matches(msg, m.KeyMap.LineDown):
213			m.MoveDown(1)
214		case key.Matches(msg, m.KeyMap.PageUp):
215			m.MoveUp(m.viewport.Height())
216		case key.Matches(msg, m.KeyMap.PageDown):
217			m.MoveDown(m.viewport.Height())
218		case key.Matches(msg, m.KeyMap.HalfPageUp):
219			m.MoveUp(m.viewport.Height() / 2) //nolint:mnd
220		case key.Matches(msg, m.KeyMap.HalfPageDown):
221			m.MoveDown(m.viewport.Height() / 2) //nolint:mnd
222		case key.Matches(msg, m.KeyMap.GotoTop):
223			m.GotoTop()
224		case key.Matches(msg, m.KeyMap.GotoBottom):
225			m.GotoBottom()
226		}
227	}
228
229	return m, nil
230}
231
232// Focused returns the focus state of the table.
233func (m Model) Focused() bool {
234	return m.focus
235}
236
237// Focus focuses the table, allowing the user to move around the rows and
238// interact.
239func (m *Model) Focus() {
240	m.focus = true
241	m.UpdateViewport()
242}
243
244// Blur blurs the table, preventing selection or movement.
245func (m *Model) Blur() {
246	m.focus = false
247	m.UpdateViewport()
248}
249
250// View renders the component.
251func (m Model) View() string {
252	return m.headersView() + "\n" + m.viewport.View()
253}
254
255// HelpView is a helper method for rendering the help menu from the keymap.
256// Note that this view is not rendered by default and you must call it
257// manually in your application, where applicable.
258func (m Model) HelpView() string {
259	return m.Help.View(m.KeyMap)
260}
261
262// UpdateViewport updates the list content based on the previously defined
263// columns and rows.
264func (m *Model) UpdateViewport() {
265	renderedRows := make([]string, 0, len(m.rows))
266
267	// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
268	// Constant runtime, independent of number of rows in a table.
269	// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
270	if m.cursor >= 0 {
271		m.start = clamp(m.cursor-m.viewport.Height(), 0, m.cursor)
272	} else {
273		m.start = 0
274	}
275	m.end = clamp(m.cursor+m.viewport.Height(), m.cursor, len(m.rows))
276	for i := m.start; i < m.end; i++ {
277		renderedRows = append(renderedRows, m.renderRow(i))
278	}
279
280	m.viewport.SetContent(
281		lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
282	)
283}
284
285// SelectedRow returns the selected row.
286// You can cast it to your own implementation.
287func (m Model) SelectedRow() Row {
288	if m.cursor < 0 || m.cursor >= len(m.rows) {
289		return nil
290	}
291
292	return m.rows[m.cursor]
293}
294
295// Rows returns the current rows.
296func (m Model) Rows() []Row {
297	return m.rows
298}
299
300// Columns returns the current columns.
301func (m Model) Columns() []Column {
302	return m.cols
303}
304
305// SetRows sets a new rows state.
306func (m *Model) SetRows(r []Row) {
307	m.rows = r
308
309	if m.cursor > len(m.rows)-1 {
310		m.cursor = len(m.rows) - 1
311	}
312
313	m.UpdateViewport()
314}
315
316// SetColumns sets a new columns state.
317func (m *Model) SetColumns(c []Column) {
318	m.cols = c
319	m.UpdateViewport()
320}
321
322// SetWidth sets the width of the viewport of the table.
323func (m *Model) SetWidth(w int) {
324	m.viewport.SetWidth(w)
325	m.UpdateViewport()
326}
327
328// SetHeight sets the height of the viewport of the table.
329func (m *Model) SetHeight(h int) {
330	m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
331	m.UpdateViewport()
332}
333
334// Height returns the viewport height of the table.
335func (m Model) Height() int {
336	return m.viewport.Height()
337}
338
339// Width returns the viewport width of the table.
340func (m Model) Width() int {
341	return m.viewport.Width()
342}
343
344// Cursor returns the index of the selected row.
345func (m Model) Cursor() int {
346	return m.cursor
347}
348
349// SetCursor sets the cursor position in the table.
350func (m *Model) SetCursor(n int) {
351	m.cursor = clamp(n, 0, len(m.rows)-1)
352	m.UpdateViewport()
353}
354
355// MoveUp moves the selection up by any number of rows.
356// It can not go above the first row.
357func (m *Model) MoveUp(n int) {
358	m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
359
360	offset := m.viewport.YOffset()
361	switch {
362	case m.start == 0:
363		offset = clamp(offset, 0, m.cursor)
364	case m.start < m.viewport.Height():
365		offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height())
366	case offset >= 1:
367		offset = clamp(offset+n, 1, m.viewport.Height())
368	}
369	m.viewport.SetYOffset(offset)
370	m.UpdateViewport()
371}
372
373// MoveDown moves the selection down by any number of rows.
374// It can not go below the last row.
375func (m *Model) MoveDown(n int) {
376	m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
377	m.UpdateViewport()
378
379	offset := m.viewport.YOffset()
380	switch {
381	case m.end == len(m.rows) && offset > 0:
382		offset = clamp(offset-n, 1, m.viewport.Height())
383	case m.cursor > (m.end-m.start)/2 && offset > 0:
384		offset = clamp(offset-n, 1, m.cursor)
385	case offset > 1:
386	case m.cursor > offset+m.viewport.Height()-1:
387		offset = clamp(offset+1, 0, 1)
388	}
389	m.viewport.SetYOffset(offset)
390}
391
392// GotoTop moves the selection to the first row.
393func (m *Model) GotoTop() {
394	m.MoveUp(m.cursor)
395}
396
397// GotoBottom moves the selection to the last row.
398func (m *Model) GotoBottom() {
399	m.MoveDown(len(m.rows))
400}
401
402// FromValues create the table rows from a simple string. It uses `\n` by
403// default for getting all the rows and the given separator for the fields on
404// each row.
405func (m *Model) FromValues(value, separator string) {
406	rows := []Row{}
407	for _, line := range strings.Split(value, "\n") {
408		r := Row{}
409		for _, field := range strings.Split(line, separator) {
410			r = append(r, field)
411		}
412		rows = append(rows, r)
413	}
414
415	m.SetRows(rows)
416}
417
418func (m Model) headersView() string {
419	s := make([]string, 0, len(m.cols))
420	for _, col := range m.cols {
421		if col.Width <= 0 {
422			continue
423		}
424		style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
425		renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
426		s = append(s, m.styles.Header.Render(renderedCell))
427	}
428	return lipgloss.JoinHorizontal(lipgloss.Top, s...)
429}
430
431func (m *Model) renderRow(r int) string {
432	s := make([]string, 0, len(m.cols))
433	for i, value := range m.rows[r] {
434		if m.cols[i].Width <= 0 {
435			continue
436		}
437		style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
438		renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…")))
439		s = append(s, renderedCell)
440	}
441
442	row := lipgloss.JoinHorizontal(lipgloss.Top, s...)
443
444	if r == m.cursor {
445		return m.styles.Selected.Render(row)
446	}
447
448	return row
449}
450
451func clamp(v, low, high int) int {
452	return min(max(v, low), high)
453}