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}