teatable

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 6, 2026 License: MIT Imports: 11 Imported by: 0

README

teatable

A generic, feature-rich table component for Bubble Tea v2.

Table of contents


Quick start

package main

import (
    "context"
    "fmt"
    "os"

    tea "charm.land/bubbletea/v2"
    tt "github.com/anadale/teaservice/teatable"
)

type Employee struct{ ID, Name, Dept string }

type ds struct {
    tt.InMemorySource[Employee]
}

func (d *ds) RowId(e Employee) string { return e.ID }
func (d *ds) CellValue(e Employee, col string) string {
    switch col {
    case "name":       return e.Name
    case "department": return e.Dept
    }
    return ""
}

type model struct{ tbl *tt.Model[Employee] }

func (m *model) Init() tea.Cmd {
    return m.tbl.Refresh()
}

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if ws, ok := msg.(tea.WindowSizeMsg); ok {
        m.tbl.SetWidth(ws.Width)
        m.tbl.SetHeight(ws.Height)
    }
    if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "q" {
        return m, tea.Quit
    }
    return m, m.tbl.Update(msg)
}

func (m *model) View() string { return m.tbl.View() }

func main() {
    cols := []tt.Column{
        tt.NewColumn("name",       "Name",       tt.WithFlexRatio(1)),
        tt.NewColumn("department", "Department", tt.WithFlexRatio(1)),
    }
    source := &ds{}
    source.SetData([]Employee{
        {"1", "Alice Chen", "Engineering"},
        {"2", "Bob Martin", "Design"},
    })
    tbl := tt.New(source, context.Background(), cols, tt.WithRoundedBorder())
    if _, err := tea.NewProgram(&model{tbl: tbl}).Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Datasource

Every datasource implements Datasource[T]:

type Datasource[T any] interface {
    RowId(item T) string                        // unique, stable row identifier
    CellValue(item T, column string) string     // cell text for the given column name
}

Declare the loading mode by additionally implementing exactly one of:

Interface Mode Signature
Loader[T] in-memory Load(ctx) ([]T, error)
Pager[T] paginated Load(ctx, page, size int) ([]T, totalCount int, err error)
Streamer[T] cursor-based Load(ctx, cursor string) ([]T, nextCursor string, hasMore bool, err error)
In-memory

Embed InMemorySource[T] in your datasource and implement RowId/CellValue. Call SetData to update the slice, then Refresh() to reload — cursor, selection, and horizontal scroll are preserved.

type myDS struct {
    tt.InMemorySource[MyType]
}

func (d *myDS) RowId(item MyType) string              { return item.ID }
func (d *myDS) CellValue(item MyType, col string) string { ... }

source := &myDS{}
source.SetData(initialItems)
tbl := tt.New(source, ctx, cols)

// Init:
return tbl.Refresh()

// After data changes:
source.SetData(newItems)
return tbl.Refresh()  // cursor and scroll are preserved

Use Reload() instead of Refresh() when you want a full reset (cursor goes to the first row, scroll resets to the left, selection is cleared):

return tbl.Reload()
Paginated

Implement Pager[T]. Return totalCount = -1 when the total is unknown. Call Refresh() for the first load. NextPage() / PrevPage() navigate between pages.

func (d *myDS) Load(ctx context.Context, page, size int) ([]MyType, int, error) {
    items, total, err := db.QueryPage(ctx, page, size)
    return items, total, err
}
Cursor-based streaming

Implement Streamer[T]. Pass an empty cursor to start from the beginning. When the user scrolls to the last row the model automatically requests the next batch.

func (d *myDS) Load(ctx context.Context, cursor string) ([]MyType, string, bool, error) {
    items, next, more, err := api.Fetch(ctx, cursor)
    return items, next, more, err
}
Optional datasource capabilities
Interface Purpose
LocalFilterable[T] client-side row filtering via Matches(item, filter) bool
LocalSortable[T] client-side row sorting via Compare(a, col, b) int
ServerFilterable[T] server-side filtering setter: Filter(filter string)
ServerSortable[T] server-side sorting setter: Sort(column string, dir SortDirection)

Client-side and server-side variants are mutually exclusive per capability: if the datasource implements the server variant it takes precedence.


Columns

tt.NewColumn(name, title string, opts ...tt.ColumnOption) tt.Column

name must be non-empty and match the column argument in CellValue. title is displayed in the header.

Width modes

Three mutually exclusive modes control column width:

Option Mode Behaviour
(none) auto width = widest cell value (including header)
WithFixedWidth(w int) fixed content area is exactly w characters
WithFlexRatio(ratio int) flex proportional share of remaining free space

For flex columns, WithMinWidth(w int) sets a floor so the column never shrinks below w characters on overflow:

tt.NewColumn("description", "Description",
    tt.WithFlexRatio(2),
    tt.WithMinWidth(20),
)
tt.NewColumn("status", "Status",
    tt.WithFlexRatio(1),
    tt.WithMinWidth(8),
)

Free space is distributed in proportion to the ratios. The remainder (if any) goes to the column with the largest ratio.

Cell and header styles

WithColumnStyles accepts a ColumnStyles struct with two lipgloss.Style fields:

tt.NewColumn("amount", "Amount",
    tt.WithFixedWidth(12),
    tt.WithColumnStyles(tt.ColumnStyles{
        Header: lipgloss.NewStyle().Align(lipgloss.Right).Bold(true),
        Cell:   lipgloss.NewStyle().Align(lipgloss.Right),
    }),
)

Column cell styles are the baseline: the active row style is blended on top.

Ellipsis

Append a suffix to truncated cells with WithEllipsis:

tt.NewColumn("notes", "Notes", tt.WithFixedWidth(30), tt.WithEllipsis("…"))
Sortable columns

Mark a column with WithSortable(). The datasource must also implement LocalSortable[T] or ServerSortable[T]. Pressing the sort key on the column cycles: ascending → descending → off.

tt.NewColumn("name", "Name", tt.WithSortable())

// Datasource:
func (d myDS) Compare(a MyType, col string, b MyType) int {
    return tt.StringCompare(d, a, col, b) // convenience helper
}

// Or manually:
func (d myDS) Compare(a MyType, col string, b MyType) int {
    switch col {
    case "name": return strings.Compare(a.Name, b.Name)
    case "age":  return cmp.Compare(a.Age, b.Age)
    }
    return 0
}

Sorting can also be controlled programmatically:

cmd, err := m.tbl.SetSort("name", tt.SortAsc)
cmd, err  = m.tbl.ToggleSort("name") // Asc → Desc → clear
cmd       = m.tbl.ClearSort()
Column groups

Adjacent columns can be spanned by a group header row. Create a group with NewColumnGroup and assign columns to it with WithGroup:

contact := tt.NewColumnGroup("Contact Details")

cols := []tt.Column{
    tt.NewColumn("id",    "ID",    tt.WithFixedWidth(4)),
    tt.NewColumn("email", "Email", tt.WithFlexRatio(1), tt.WithGroup(contact)),
    tt.NewColumn("phone", "Phone", tt.WithFixedWidth(16), tt.WithGroup(contact)),
}

Groups can be nested: pass WithGroup as a ColumnGroupOption:

personal := tt.NewColumnGroup("Personal")
work     := tt.NewColumnGroup("Work", tt.WithGroup(personal))
Column templates

A ColumnTemplate captures a reusable set of options:

// Base template for numeric columns
numTmpl := tt.NewColumnTemplate(
    tt.WithFixedWidth(12),
    tt.WithEllipsis("…"),
    tt.WithColumnStyles(tt.ColumnStyles{
        Header: lipgloss.NewStyle().Align(lipgloss.Right),
        Cell:   lipgloss.NewStyle().Align(lipgloss.Right),
    }),
)

// Narrower variant derived from the base
shortNumTmpl := tt.NewColumnTemplateFromTemplate(numTmpl, tt.WithFixedWidth(6))

// Create columns from the templates
tt.NewColumnFromTemplate("price",    "Price",    numTmpl)
tt.NewColumnFromTemplate("quantity", "Quantity", shortNumTmpl)

NewColumnGroupFromTemplate extracts the Header style from a template and applies it to a group header, keeping column and group styling in sync:

header := tt.NewColumnGroupFromTemplate("Financials", numTmpl)

Frozen columns

Pin columns to the left or right edge so they stay visible during horizontal scroll:

tt.New(ds, ctx, cols,
    tt.WithFrozenColumns(2),      // first 2 columns are always visible on the left
    tt.WithRightFrozenColumns(1), // last column is always visible on the right
)

To change after creation use SetFrozenColumns / SetRightFrozenColumns. A distinct vertical divider separates frozen zones from the scrollable area — see Dividers.


Dividers

Dividers are passed to New via WithDividers and can be updated later with SetDividers. Five ready-made presets cover most use cases:

Preset What is enabled
SimpleDividers() header divider only
FullGridDividers() all dividers (header, rows, column separators, frozen separator)
StyledFullGridDividers(style) full grid with a uniform lipgloss.Style
ASCIIDividers() full grid using ASCII characters only

The outer border is configured separately with WithOuterBorder / WithRoundedBorder / WithNormalBorder. A title can be embedded in the top border with WithTitle.

tt.New(ds, ctx, cols,
    tt.WithRoundedBorder(),
    tt.WithTitle("Employees"),
    tt.WithDividers(tt.SimpleDividers()),
)

For fine-grained control, each divider type has individual constructors:

Constructor Position
SimpleHeaderDivider() between header and data rows
SimpleRowsDivider() between every pair of data rows
SimpleColumnsDivider() between regular (non-frozen) columns
SimpleFrozenColumnsDivider() between left-frozen zone and scrollable area
SimpleFrozenDivider() between individual left-frozen columns
SimpleRightFrozenColumnsDivider() between scrollable area and right-frozen zone

Each has a SimpleStyled… variant that accepts a lipgloss.Style:

divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))

tt.New(ds, ctx, cols,
    tt.WithDividers(tt.TableDividers{
        Header:        tt.SimpleStyledHeaderDivider(divStyle),
        Columns:       tt.SimpleStyledColumnsDivider(divStyle),
        FrozenColumns: tt.SimpleStyledFrozenColumnsDivider(divStyle),
    }),
)

Row styles

Row appearance is controlled by RowStyles, set via WithRowStyles or SetRowStyles:

m.tbl.SetRowStyles(tt.RowStyles{
    Normal:         lipgloss.NewStyle(),
    Alternate:      lipgloss.NewStyle().Faint(true),
    Cursor:         lipgloss.NewStyle().Reverse(true),
    Selected:       lipgloss.NewStyle().Bold(true),
    CursorSelected: lipgloss.NewStyle().Reverse(true).Bold(true),
})

Enable alternating rows with WithAlternateRows() or SetAlternateRowsEnabled(true). Use BlackAndWhiteRowStyles() for a high-contrast black-and-white preset.

State priority

When a row matches multiple states the following priority applies (highest first):

Priority State Condition
1 CursorSelected row is under cursor and selected
2 Cursor row is under cursor (not selected)
3 Selected row is selected (cursor elsewhere)
4 Alternate odd index and AlternateRowsEnabled == true
5 Normal all other rows
ANSI stripping

Cell values may contain embedded ANSI color sequences (e.g. from a syntax highlighter). These can interfere with the row style's own colors. Strip them before the row style is applied:

styles := tt.DefaultRowStyles()
styles.CursorStripANSI         = true  // strip when cursor
styles.SelectedStripANSI       = true  // strip when selected
styles.CursorSelectedStripANSI = true  // strip when both
m.tbl.SetRowStyles(styles)

// Or enable all three at once:
m.tbl.SetStripANSIForActiveStates(true)
Per-row and per-cell styler callbacks

For dynamic styling (e.g. highlighting rows based on data), install a styler:

m.tbl.SetRowStyler(func(row MyType, base lipgloss.Style, state tt.RowState) lipgloss.Style {
    if row.IsAlert {
        return base.Foreground(lipgloss.Color("9"))
    }
    return base
})

For per-column overrides within a row use SetCellStyler:

m.tbl.SetCellStyler(func(row MyType, col string, base lipgloss.Style, state tt.RowState) lipgloss.Style {
    if col == "status" && row.Status == "error" {
        return base.Bold(true).Foreground(lipgloss.Color("9"))
    }
    return base
})

RowState exposes IsCursor, IsSelected, and IsAlternate for the callback to inspect.


Scroll indicator

A vertical scrollbar rendered alongside the table:

tt.New(ds, ctx, cols,
    tt.WithScrollIndicator(tt.DataOnlyScrollIndicator()),
)

Three placement modes:

Mode Description
ScrollIndicatorHidden not rendered (default)
ScrollIndicatorDataOnly spans data rows only (excludes header and dividers)
ScrollIndicatorFullHeight spans full table height (includes header and dividers)

Two ready-made constructors are available:

tt.DataOnlyScrollIndicator()    // data area only, "█" thumb, "░" track
tt.FullHeightScrollIndicator()  // full height,    "█" thumb, "░" track

Custom configuration:

tt.WithScrollIndicator(tt.ScrollIndicatorConfig{
    Mode:      tt.ScrollIndicatorDataOnly,
    ThumbChar: "┃",
    TrackChar: "╎",
    Style:     lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
})

To change after creation:

m.tbl.SetScrollIndicator(tt.DataOnlyScrollIndicator())

Selection

Enable multi-row selection:

tt.New(ds, ctx, cols, tt.WithSelectionEnabled())
// or after creation:
m.tbl.SetSelectionEnabled(true)
Method Description
SelectRow(filteredIndex) select a specific row
UnselectRow(filteredIndex) deselect a specific row
ToggleRowSelection(filteredIndex) toggle one row
SelectAllRows() select all filtered rows
ClearSelection() deselect all
IsRowSelected(filteredIndex) check if a row is selected
SelectedRows() return selected items in filtered order
SelectedRowsCount() number of selected rows

All mutating methods return a tea.Cmd that emits SelectionChangedMsg.

Default key bindings for selection: Space (toggle), Insert/J (toggle and move down), Ctrl+A (select all), Ctrl+\ (deselect all).


Filtering

Implement LocalFilterable[T] for client-side filtering:

func (d myDS) Matches(item MyType, filter string) bool {
    return strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter))
}

Then apply the filter from your input widget:

cmd := m.tbl.SetFilter(inputValue)

For server-side filtering implement ServerFilterable[T]:

func (d *myDS) Filter(filter string) {
    d.activeFilter = filter // stored; used in the next Load call
}

Calling SetFilter on a server-side filterable datasource resets pagination and triggers a new load automatically.


Sorting

See Sortable columns for datasource setup.

Programmatic sort control:

cmd, err := m.tbl.SetSort("name", tt.SortAsc)   // set explicitly
cmd, err  = m.tbl.ToggleSort("name")             // Asc → Desc → off
cmd       = m.tbl.ClearSort()                    // remove sort

col := m.tbl.SortColumn()    // active sort column name
dir := m.tbl.SortDirection() // tt.SortAsc or tt.SortDesc

SetSort returns ErrColumnNotSortable if the column was not created with WithSortable().


Navigation & key bindings

Default key bindings:

Key Action
/ k cursor up
/ j cursor down
PgUp / b page up
PgDn / f page down
Home / g first row
End / G last row
/ h scroll left
/ l scroll right
Space toggle selection
Insert / J toggle selection and move down
Ctrl+A select all
Ctrl+\ deselect all

Override with WithKeyMap / SetKeyMap:

km := tt.DefaultKeyMap()
km.LineUp   = key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "up"))
km.LineDown = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "down"))

tt.New(ds, ctx, cols, tt.WithKeyMap(km))

KeyMap implements help.KeyMap (ShortHelp / FullHelp) for use with the bubbles help component.

Cursor position can also be controlled programmatically:

m.tbl.MoveCursorUp()
m.tbl.MoveCursorDown()
m.tbl.MoveCursorPageUp()
m.tbl.MoveCursorPageDown()
m.tbl.MoveCursorToFirstRow()
m.tbl.MoveCursorToLastRow()
m.tbl.MoveCursorToRow(index)

row, ok  := m.tbl.CursorRow()
idx, ok  := m.tbl.CursorIndex()

Messages

The model emits the following messages as tea.Cmd return values:

Message Emitted when
CursorMovedMsg{Index, RowId} cursor position changes
SelectionChangedMsg{Count, Ids} selected set changes
FilteredRowsChangedMsg{Count, Total} filtered row count changes
DataLoadedMsg[T]{Items} Loader.Load completes
PageLoadedMsg[T]{Items, Page, TotalCount} Pager.Load completes
MoreLoadedMsg[T]{Items, NextCursor, HasMore, Append} Streamer.Load completes
LoadErrorMsg{Err} any datasource load fails

Handle them in your model's Update:

case tt.CursorMovedMsg:
    m.selectedEmployee, _ = m.tbl.CursorRow()
case tt.SelectionChangedMsg:
    m.selectionCount = msg.Count
case tt.FilteredRowsChangedMsg:
    m.statusLine = fmt.Sprintf("%d / %d rows", msg.Count, msg.Total)

Bottom border content

Embed dynamic text into the bottom border line with SetBottomBorderRenderer. Requires an outer border to be enabled.

m.tbl.SetBottomBorderRenderer(
    tt.PagerStatus[MyType](),   // built-in: "← 1 2 [3] 4 5 →"
    tt.AlignCenter,
    0,
)

Two built-in renderers:

Renderer Output
PagerStatus[T]() page navigation: ← 1 … [5] … 99 →
StreamerStatus[T]() ↓ more when more data is available

Custom renderer:

m.tbl.SetBottomBorderRenderer(
    func(m *tt.Model[MyType]) string {
        count := m.FilteredRowsCount()
        return fmt.Sprintf("%d rows", count)
    },
    tt.AlignRight,
    2, // offset from the right corner
)

Alignment constants: AlignLeft, AlignCenter, AlignRight. Pass nil to remove.


Size

Always call SetWidth and SetHeight from your model's Update in response to tea.WindowSizeMsg. Both dimensions include the outer border if one is enabled.

case tea.WindowSizeMsg:
    m.tbl.SetWidth(msg.Width)
    m.tbl.SetHeight(msg.Height)

SetHeight automatically recalculates pageSize for paginated datasources.


Examples

Documentation

Overview

Package teatable provides a generic, feature-rich terminal table widget for the Bubble Tea framework.

Quick start

Implement Datasource for your item type and pass it to New:

type Employee struct { ID, Name, Department string }

type EmployeeDS struct {
    teatable.InMemorySource[Employee]
}

func (d *EmployeeDS) RowId(e Employee) string            { return e.ID }
func (d *EmployeeDS) CellValue(e Employee, col string) string {
    switch col {
    case "name":       return e.Name
    case "department": return e.Department
    }
    return ""
}

source := &EmployeeDS{}
source.SetData(employees)

cols := []teatable.Column{
    teatable.NewColumn("name",       "Name",       teatable.WithFixedWidth(20)),
    teatable.NewColumn("department", "Department", teatable.WithFlexRatio(1)),
}

// In Init(): return tbl.Refresh()
tbl := teatable.New(source, ctx, cols)

Columns

Each column is created with NewColumn (or NewColumnFromTemplate) and accepts a variadic list of ColumnOption values.

## Width modes

Three mutually exclusive width modes are available:

  • Auto (default) — the column width is derived from the widest cell value. No extra options needed.

  • Fixed — the content area is exactly w characters wide regardless of cell content:

    teatable.WithFixedWidth(w)

  • Flex — remaining horizontal space (after fixed and auto columns are measured) is distributed among flex columns in proportion to their ratio. The column never shrinks below its minimum width, which defaults to zero and can be raised with WithMinWidth:

    teatable.WithFlexRatio(2) // flex weight 2 teatable.WithFlexRatio(1), teatable.WithMinWidth(10) // weight 1, min 10 chars

## Styles

Column-level styles are set with WithColumnStyles. ColumnStyles has two fields:

  • Header — applied to the column header cell.
  • Cell — applied to every data cell in this column.

Column cell styles are a baseline: the row style (see Row styles below) is blended on top during render.

teatable.WithColumnStyles(teatable.ColumnStyles{
    Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")),
    Cell:   lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
})

## Ellipsis

When a cell value is truncated to fit the column width, an optional suffix is appended with WithEllipsis:

teatable.WithEllipsis("…")

## Sortable columns

Mark a column as sortable with WithSortable. The datasource must also implement LocalSortable (client-side) or ServerSortable (server-side) for sorting to take effect. Pressing the sort key cycles through ascending → descending → off.

teatable.WithSortable()

## Column groups

Multiple adjacent columns can be spanned by a group header row. Create a group with NewColumnGroup and assign columns to it with WithGroup:

contacts := teatable.NewColumnGroup("Contact Details")
teatable.NewColumn("email", "Email", teatable.WithGroup(contacts))
teatable.NewColumn("phone", "Phone", teatable.WithGroup(contacts))

Groups can be nested: pass WithGroup as a ColumnGroupOption to place one group inside another.

Column templates

A ColumnTemplate captures a reusable set of options that can be applied to many columns without repetition:

numericTmpl := teatable.NewColumnTemplate(
    teatable.WithFixedWidth(12),
    teatable.WithEllipsis("…"),
    teatable.WithColumnStyles(teatable.ColumnStyles{
        Header: lipgloss.NewStyle().Align(lipgloss.Right),
        Cell:   lipgloss.NewStyle().Align(lipgloss.Right),
    }),
)

// Derive a narrower variant:
shortNumericTmpl := teatable.NewColumnTemplateFromTemplate(numericTmpl,
    teatable.WithFixedWidth(6),
)

// Create columns from the template:
teatable.NewColumnFromTemplate("price",  "Price",  numericTmpl)
teatable.NewColumnFromTemplate("qty",    "Qty",    shortNumericTmpl)

NewColumnGroupFromTemplate extracts the Header style from a template and applies it to a group header, keeping column and group styling in sync.

Frozen (pinned) columns

Columns at the left or right edge can be frozen so that they remain visible while the user scrolls horizontally. Pass the count to New as options:

teatable.New(ds, ctx, cols,
    teatable.WithFrozenColumns(2),       // pin first 2 columns on the left
    teatable.WithRightFrozenColumns(1),  // pin last column on the right
)

After creation the counts can be changed with Model.SetColumns (rebuild the column slice) or by calling the relevant model setters. A distinct vertical divider separates frozen columns from scrollable ones; see SimpleFrozenColumnsDivider.

Dividers

Dividers are configured through TableDividers and passed via WithDividers:

teatable.New(ds, ctx, cols, teatable.WithDividers(teatable.SimpleDividers()))

Ready-made presets:

Individual divider constructors allow mixing styles:

Row styles

Row appearance is driven by RowStyles, configured via WithRowStyles or Model.SetRowStyles. Six fields control the five row states:

styles := teatable.RowStyles{
    Normal:         lipgloss.NewStyle(),
    Alternate:      lipgloss.NewStyle().Faint(true),
    Cursor:         lipgloss.NewStyle().Reverse(true),
    Selected:       lipgloss.NewStyle().Bold(true),
    CursorSelected: lipgloss.NewStyle().Reverse(true).Bold(true),
}

## State priority

When a row matches multiple states the following priority applies (highest first):

  1. CursorSelected — row is both under the cursor and selected.
  2. Cursor — row is under the cursor (not selected).
  3. Selected — row is selected (cursor is elsewhere).
  4. Alternate — row has an odd index and WithAlternateRows is enabled.
  5. Normal — default style for all other rows.

## ANSI stripping

Cell values may already contain ANSI color sequences (e.g. from a syntax highlighter). When the row style applies its own background or foreground color, the embedded sequences can interfere. Set the strip flags to remove them before the row style is applied:

styles.CursorStripANSI         = true  // strip in cursor state
styles.SelectedStripANSI       = true  // strip in selected state
styles.CursorSelectedStripANSI = true  // strip in cursor+selected state

As a shortcut, Model.SetStripANSIForActiveStates sets all three flags at once.

## Per-row and per-cell style overrides

For dynamic styling (e.g. coloring critical rows red), install a styler callback:

model.SetRowStyler(func(row MyType, base lipgloss.Style, state teatable.RowState) lipgloss.Style {
    if row.IsAlert {
        return base.Foreground(lipgloss.Color("9"))
    }
    return base
})

model.SetCellStyler(func(row MyType, col string, base lipgloss.Style, state teatable.RowState) lipgloss.Style {
    if col == "status" && row.Status == "error" {
        return base.Bold(true).Foreground(lipgloss.Color("9"))
    }
    return base
})

Scroll indicator

A vertical scroll indicator (scrollbar) can be rendered alongside the table. Configure it with ScrollIndicatorConfig and pass it via WithScrollIndicator:

teatable.New(ds, ctx, cols,
    teatable.WithScrollIndicator(teatable.DataOnlyScrollIndicator()),
)

## Modes

Three ScrollIndicatorMode values control placement:

## Appearance

ScrollIndicatorConfig exposes three appearance fields:

  • ThumbChar — character drawn for the thumb (current viewport position), e.g. "█".
  • TrackChar — character drawn for the track background, e.g. "░".
  • Style — a lipgloss.Style applied to both thumb and track.

Ready-made constructors:

Custom example:

teatable.New(ds, ctx, cols,
    teatable.WithScrollIndicator(teatable.ScrollIndicatorConfig{
        Mode:      teatable.ScrollIndicatorDataOnly,
        ThumbChar: "┃",
        TrackChar: "╎",
        Style:     lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
    }),
)

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrColumnNotSortable = errors.New("column is not sortable")

ErrColumnNotSortable is returned by sort methods when the column is not marked as sortable.

View Source
var ErrIndexOutOfRange = errors.New("index out of range")

ErrIndexOutOfRange is returned when the given index is out of the filtered row list bounds.

View Source
var ErrSelectionDisabled = errors.New("selection disabled")

ErrSelectionDisabled is returned by selection methods when row selection is disabled.

Functions

func StringCompare

func StringCompare[T any](ds Datasource[T], item T, column string, other T) int

StringCompare compares two items by column using strings.Compare on their CellValues. Intended for trivial LocalSortable implementations:

func (ds MyDatasource) Compare(item MyType, col string, other MyType) int {
    return teatable.StringCompare(ds, item, col, other)
}
Example

ExampleStringCompare shows how to implement [tt.LocalSortable.Compare] by delegating to StringCompare for lexicographic column comparison.

package main

import (
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	a := product{Name: "Alpha"}
	b := product{Name: "Beta"}

	result := tt.StringCompare(src, a, "Name", b)
	fmt.Println("Alpha < Beta:", result < 0)
}
Output:
Alpha < Beta: true

func WithAlternateRows

func WithAlternateRows() func(*Options)

WithAlternateRows enables alternating row styles.

func WithDividers

func WithDividers(d TableDividers) func(*Options)

WithDividers sets the table dividers.

func WithFrozenColumns

func WithFrozenColumns(n int) func(*Options)

WithFrozenColumns sets the number of frozen columns on the left.

func WithFrozenColumnsDivider

func WithFrozenColumnsDivider(d VerticalDivider) func(*Options)

WithFrozenColumnsDivider sets the vertical divider after the last frozen column.

func WithFrozenDivider

func WithFrozenDivider(d VerticalDivider) func(*Options)

WithFrozenDivider sets the vertical divider between frozen columns.

func WithGroup

func WithGroup(g ColumnGroup) groupOption

WithGroup returns an option that assigns the column to the given group, or nests a group inside the given parent group.

func WithHeaderDivider

func WithHeaderDivider(d HorizontalDivider) func(*Options)

WithHeaderDivider sets the horizontal divider under the header.

func WithNormalBorder

func WithNormalBorder() func(*Options)

WithNormalBorder sets a standard rectangular border.

func WithOuterBorder

func WithOuterBorder(b OuterBorder) func(*Options)

WithOuterBorder sets the table outer border.

func WithRightFrozenColumns

func WithRightFrozenColumns(n int) func(*Options)

WithRightFrozenColumns sets the number of frozen columns on the right.

func WithRoundedBorder

func WithRoundedBorder() func(*Options)

WithRoundedBorder sets a rounded border.

func WithRowStyles

func WithRowStyles(styles RowStyles) func(*Options)

WithRowStyles sets the row styles for the table.

func WithScrollIndicator

func WithScrollIndicator(c ScrollIndicatorConfig) func(*Options)

WithScrollIndicator sets the scroll indicator configuration.

func WithSelectionEnabled

func WithSelectionEnabled() func(*Options)

WithSelectionEnabled enables row selection.

func WithTitle

func WithTitle(title string) func(*Options)

WithTitle sets the table title displayed in the center of the top border.

Types

type Align

type Align int

Align controls horizontal placement of content embedded in the bottom border.

const (
	// AlignLeft places content at the left edge, offset chars from the left corner.
	AlignLeft Align = iota
	// AlignCenter places content in the horizontal center of the border line.
	AlignCenter
	// AlignRight places content at the right edge, offset chars from the right corner.
	AlignRight
)

type BottomBorderRenderer

type BottomBorderRenderer[T any] func(m *Model[T]) string

BottomBorderRenderer is a function that produces a string to embed in the bottom border. It receives the current model and returns the content (may include ANSI styling). The visual width of the returned string must not exceed the table's inner width.

func PagerStatus

func PagerStatus[T any]() BottomBorderRenderer[T]

PagerStatus returns a BottomBorderRenderer that shows current page navigation: "← 1 2 … [5] … 99 →", with the current page surrounded by brackets. Returns an empty string when totalCount or pageSize is not set (≤ 0).

func StreamerStatus

func StreamerStatus[T any]() BottomBorderRenderer[T]

StreamerStatus returns a BottomBorderRenderer that shows "↓ more" when more streamed data is available, or an empty string when all data has been loaded.

type Column

type Column struct {
	// Name is the unique column name, used in Datasource.CellValue(item, columnName) and LocalSortable.Compare(item, columnName, other).
	Name string

	// Title is the displayed column header.
	Title string
	// contains filtered or unexported fields
}

Column describes a single table column: its name, title, width mode, styles, and sort flag.

func NewColumn

func NewColumn(name, title string, opts ...ColumnOption) Column

NewColumn creates a column with the given name, title, and options. Panics if the name is empty.

Example

ExampleNewColumn shows column creation with various sizing options.

package main

import (
	"fmt"

	tt "github.com/anadale/teaservice/teatable"
)

func main() {
	// Fixed-width column — exact character width.
	id := tt.NewColumn("id", "ID", tt.WithFixedWidth(6))

	// Proportional columns — distribute remaining space by ratio.
	name := tt.NewColumn("name", "Name", tt.WithFlexRatio(2))
	dept := tt.NewColumn("dept", "Department", tt.WithFlexRatio(1))

	// Sortable column — enables SetSort and ToggleSort for this column.
	score := tt.NewColumn("score", "Score", tt.WithFlexRatio(1), tt.WithSortable())

	_ = []tt.Column{id, name, dept, score}
	fmt.Println("columns created")
}
Output:
columns created

func NewColumnFromTemplate

func NewColumnFromTemplate(name, title string, tmpl ColumnTemplate, opts ...ColumnOption) Column

NewColumnFromTemplate creates a Column by applying tmpl options first, then opts.

type ColumnGroup

type ColumnGroup struct {
	Title string
	// contains filtered or unexported fields
}

ColumnGroup represents a named group spanning one or more adjacent columns.

func NewColumnGroup

func NewColumnGroup(title string, opts ...ColumnGroupOption) ColumnGroup

NewColumnGroup creates a ColumnGroup with the given title and options.

func NewColumnGroupFromTemplate

func NewColumnGroupFromTemplate(title string, tmpl ColumnTemplate, opts ...ColumnGroupOption) ColumnGroup

NewColumnGroupFromTemplate creates a ColumnGroup by extracting styles from the template (via a temporary Column) and passing them to NewColumnGroup.

type ColumnGroupOption

type ColumnGroupOption interface {
	// contains filtered or unexported methods
}

ColumnGroupOption is an option for configuring a ColumnGroup.

func WithGroupStyles

func WithGroupStyles(styles ColumnGroupStyles) ColumnGroupOption

WithGroupStyles sets the header style for the column group.

type ColumnGroupStyles

type ColumnGroupStyles struct {
	Header lipgloss.Style
}

ColumnGroupStyles defines styles for the group header row.

func DefaultColumnGroupStyles

func DefaultColumnGroupStyles() ColumnGroupStyles

DefaultColumnGroupStyles returns default group styles (empty styles).

type ColumnOption

type ColumnOption interface {
	// contains filtered or unexported methods
}

ColumnOption is an option for configuring a Column.

func WithColumnStyles

func WithColumnStyles(styles ColumnStyles) ColumnOption

WithColumnStyles sets the column header and cell styles.

func WithEllipsis

func WithEllipsis(s string) ColumnOption

WithEllipsis sets the string appended to truncated cell and header content (e.g. "...", "…").

func WithFixedWidth

func WithFixedWidth(w int) ColumnOption

WithFixedWidth sets a fixed content width for the column in characters.

func WithFlexRatio

func WithFlexRatio(ratio int) ColumnOption

WithFlexRatio sets proportional width mode with the given ratio. Free space is distributed among flex columns proportionally to their ratio. The remainder goes to the column with the largest flexRatio.

func WithMinWidth

func WithMinWidth(w int) ColumnOption

WithMinWidth sets the minimum content width for a column with WithFlexRatio. On horizontal overflow, flex columns are not shrunk below this value.

func WithSortable

func WithSortable() ColumnOption

WithSortable marks the column as sortable. When sorting is active, the model calls row.Compare(col.Name, other).

type ColumnStyles

type ColumnStyles struct {
	// Header is the style for the column header cell.
	Header lipgloss.Style

	// Cell is the style for the column data cells.
	Cell lipgloss.Style
}

ColumnStyles defines styles for the column header and cells.

func DefaultColumnStyles

func DefaultColumnStyles() ColumnStyles

DefaultColumnStyles returns default column styles (empty styles).

type ColumnTemplate

type ColumnTemplate struct {
	// contains filtered or unexported fields
}

ColumnTemplate holds a reusable set of ColumnOptions.

func NewColumnTemplate

func NewColumnTemplate(opts ...ColumnOption) ColumnTemplate

NewColumnTemplate creates a ColumnTemplate from the given options.

func NewColumnTemplateFromTemplate

func NewColumnTemplateFromTemplate(base ColumnTemplate, opts ...ColumnOption) ColumnTemplate

NewColumnTemplateFromTemplate creates a new template that applies base options first, then the provided opts.

type CursorMovedMsg

type CursorMovedMsg struct {
	Index int    // index in filteredRows; -1 if no rows
	RowId string // RowId at cursor; "" if no rows
}

CursorMovedMsg emitted when cursor position changes.

type DataLoadedMsg

type DataLoadedMsg[T any] struct {
	Items []T
}

DataLoadedMsg is emitted after a successful Loader.Load call (inMemory mode).

type Datasource

type Datasource[T any] interface {
	// RowId returns a unique, stable identifier for the item.
	// Used for cursor tracking, selection, and Refresh merge-update.
	RowId(item T) string

	// CellValue returns the string representation of the field named column
	// for the given item. Return an empty string for unknown column names.
	CellValue(item T, column string) string
}

Datasource is the base interface for all table data sources. T is the domain item type displayed in each row. Every datasource must implement RowId and CellValue; loading mode is declared by implementing exactly one of Loader, Pager, or Streamer.

type FilteredRowsChangedMsg

type FilteredRowsChangedMsg struct {
	Count int // filtered rows count
	Total int // total rows count (before filter)
}

FilteredRowsChangedMsg emitted when filtered rows count changes.

type HorizontalDivider

type HorizontalDivider struct {
	// Enabled turns on drawing the divider.
	Enabled bool

	// Char is the main character of the divider line.
	Char string

	// LeftJoin is the character joining the left border (e.g. ├).
	LeftJoin string

	// RightJoin is the character joining the right border (e.g. ┤).
	RightJoin string

	// Crossing is the character at vertical divider crossings (e.g. ┼).
	Crossing string

	// CrossingDown is the character where the horizontal divider meets a vertical line
	// that goes only downward (no line above). E.g. ┬. Falls back to Crossing if empty.
	CrossingDown string

	// Style sets the divider style.
	Style lipgloss.Style
}

HorizontalDivider defines a horizontal divider (between rows or under the header).

func SimpleHeaderDivider

func SimpleHeaderDivider() HorizontalDivider

SimpleHeaderDivider returns a horizontal divider under the header.

func SimpleRowsDivider

func SimpleRowsDivider() HorizontalDivider

SimpleRowsDivider returns a horizontal divider between data rows.

func SimpleStyledHeaderDivider

func SimpleStyledHeaderDivider(style lipgloss.Style) HorizontalDivider

SimpleStyledHeaderDivider returns a horizontal divider under the header with the specified style.

func SimpleStyledRowsDivider

func SimpleStyledRowsDivider(style lipgloss.Style) HorizontalDivider

SimpleStyledRowsDivider returns a horizontal divider between data rows with the specified style.

type InMemorySource

type InMemorySource[T any] struct {
	// contains filtered or unexported fields
}

InMemorySource is an embeddable struct that implements Loader[T]. Embed it in a custom datasource type and implement [Datasource.RowId] and [Datasource.CellValue] to complete the datasource contract.

type employeeDS struct {
    teatable.InMemorySource[Employee]
}

func (d *employeeDS) RowId(e Employee) string              { return e.ID }
func (d *employeeDS) CellValue(e Employee, col string) string { ... }

Call InMemorySource.SetData to update the contents, then call Model.Refresh on the table to reload and merge-update with preserved cursor and scroll state.

func (*InMemorySource[T]) Data

func (s *InMemorySource[T]) Data() []T

Data returns the current items slice.

func (*InMemorySource[T]) Load

func (s *InMemorySource[T]) Load(_ context.Context) ([]T, error)

Load implements Loader[T] and returns a copy of the current items. A copy is returned so that once the loading goroutine has captured the slice, a subsequent InMemorySource.SetData call cannot mutate the elements it is working with. Safe when InMemorySource.SetData is called before Model.Refresh within the same BubbleTea update cycle; calling SetData from a separate goroutine without external synchronisation is a data race.

func (*InMemorySource[T]) SetData

func (s *InMemorySource[T]) SetData(items []T)

SetData replaces the items slice.

type KeyMap

type KeyMap struct {
	// LineUp moves the cursor one row up.
	LineUp key.Binding

	// LineDown moves the cursor one row down.
	LineDown key.Binding

	// PageUp moves the cursor one page up.
	PageUp key.Binding

	// PageDown moves the cursor one page down.
	PageDown key.Binding

	// GotoTop moves the cursor to the first row and resets the horizontal scroll.
	GotoTop key.Binding

	// GotoBottom moves the cursor to the last row and resets the horizontal scroll.
	GotoBottom key.Binding

	// ScrollLeft scrolls the table horizontally left.
	ScrollLeft key.Binding

	// ScrollRight scrolls the table horizontally right.
	ScrollRight key.Binding

	// SelectAll selects all filtered rows.
	SelectAll key.Binding

	// DeselectAll deselects all selected rows.
	DeselectAll key.Binding

	// ToggleSelection toggles the selection of the row under the cursor.
	ToggleSelection key.Binding

	// ToggleSelectionAndLineDown toggles the selection of the current row and moves the cursor one row down.
	ToggleSelectionAndLineDown key.Binding
}

KeyMap defines key bindings for table control.

func DefaultKeyMap

func DefaultKeyMap() KeyMap

DefaultKeyMap returns standard key bindings: up/k, down/j, pgup/b, pgdn/f, home/g, end/G, left/h, right/l, ctrl+a, space.

func (KeyMap) FullHelp

func (k KeyMap) FullHelp() [][]key.Binding

FullHelp implements help.KeyMap — returns all keys grouped by purpose.

func (KeyMap) ShortHelp

func (k KeyMap) ShortHelp() []key.Binding

ShortHelp implements help.KeyMap — returns main navigation keys.

type LoadErrorMsg

type LoadErrorMsg struct {
	Err error
}

LoadErrorMsg is emitted when a datasource load fails.

type Loader

type Loader[T any] interface {
	Load(ctx context.Context) ([]T, error)
}

Loader loads all items at once into memory.

type LocalFilterable

type LocalFilterable[T any] interface {
	Matches(item T, filter string) bool
}

LocalFilterable enables client-side row filtering.

type LocalSortable

type LocalSortable[T any] interface {
	Compare(item T, column string, other T) int
}

LocalSortable enables client-side row sorting. Compare returns a negative number, zero, or positive — the same as strings.Compare.

type Model

type Model[T any] struct {
	// contains filtered or unexported fields
}

Model is the main TeaTable model. T is the domain item type displayed in each row.

func New

func New[T any](ds Datasource[T], ctx context.Context, columns []Column, opts ...func(*Options)) *Model[T]

New creates a new table model with the given datasource, context, columns and options. Defaults: showHeader = true, cursor = -1 when there are no rows.

Example

ExampleNew demonstrates creating an in-memory table with [tt.InMemorySource]. In a real program, call [tt.Model.SetWidth] and [tt.Model.SetHeight] from your model's Update in response to tea.WindowSizeMsg, then call [tt.Model.Refresh] from Init to trigger the first data load.

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	cols := []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(2)),
		tt.NewColumn("Category", "Category", tt.WithFlexRatio(1)),
		tt.NewColumn("Price", "Price", tt.WithFixedWidth(10)),
	}

	tbl := tt.New(src, context.Background(), cols,
		tt.WithRoundedBorder(),
		tt.WithTitle("Products"),
		tt.WithAlternateRows(),
	)
	tbl.SetWidth(60)
	tbl.SetHeight(10)
	loadTable(tbl)

	fmt.Println("rows:", tbl.RowsCount())
}
Output:
rows: 4
Example (WithFilter)

ExampleNew_withFilter demonstrates a table with client-side row filtering. The datasource must implement [tt.LocalFilterable].

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	cols := []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1)),
		tt.NewColumn("Category", "Category", tt.WithFlexRatio(1)),
	}

	tbl := tt.New(src, context.Background(), cols)
	tbl.SetWidth(60)
	tbl.SetHeight(10)
	loadTable(tbl)

	_ = tbl.SetFilter("Gadget")
	fmt.Printf("filter=%q filtered=%d total=%d\n",
		tbl.Filter(), tbl.FilteredRowsCount(), tbl.RowsCount())
}
Output:
filter="Gadget" filtered=2 total=4
Example (WithSelection)

ExampleNew_withSelection demonstrates a table with row selection enabled.

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	tbl := tt.New(src, context.Background(), []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1)),
	}, tt.WithSelectionEnabled())
	tbl.SetWidth(60)
	tbl.SetHeight(10)
	loadTable(tbl)

	_, _ = tbl.SelectRow(0)
	_, _ = tbl.SelectRow(2)
	fmt.Println("selected:", tbl.SelectedRowsCount())
}
Output:
selected: 2
Example (WithSorting)

ExampleNew_withSorting demonstrates a table with client-side sorting. The datasource must implement [tt.LocalSortable]; columns must be marked with [tt.WithSortable]. Use [tt.StringCompare] for lexicographic comparison.

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	cols := []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1), tt.WithSortable()),
		tt.NewColumn("Category", "Category", tt.WithFlexRatio(1), tt.WithSortable()),
	}

	tbl := tt.New(src, context.Background(), cols, tt.WithRoundedBorder())
	tbl.SetWidth(60)
	tbl.SetHeight(10)
	loadTable(tbl)

	_, err := tbl.SetSort("Name", tt.SortAsc)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("active=%v column=%s\n", tbl.SortActive(), tbl.SortColumn())
}
Output:
active=true column=Name

func (*Model[T]) AlternateRowsEnabled

func (m *Model[T]) AlternateRowsEnabled() bool

AlternateRowsEnabled returns whether alternating rows is enabled.

func (*Model[T]) ClearSelection

func (m *Model[T]) ClearSelection() tea.Cmd

ClearSelection deselects all rows.

func (*Model[T]) ClearSort

func (m *Model[T]) ClearSort() tea.Cmd

ClearSort clears sorting.

func (*Model[T]) Columns

func (m *Model[T]) Columns() []Column

Columns return the table columns.

func (*Model[T]) CurrentPage

func (m *Model[T]) CurrentPage() int

CurrentPage returns the current page index (0-based) in paged mode.

func (*Model[T]) CursorIndex

func (m *Model[T]) CursorIndex() (int, bool)

CursorIndex returns the cursor index in filteredRows. false if there are no rows.

func (*Model[T]) CursorRow

func (m *Model[T]) CursorRow() (T, bool)

CursorRow returns the row under the cursor. false if there are no rows.

func (*Model[T]) Dividers

func (m *Model[T]) Dividers() TableDividers

Dividers return the current dividers.

func (*Model[T]) Filter

func (m *Model[T]) Filter() string

Filter returns the current filter.

func (*Model[T]) FilteredRow

func (m *Model[T]) FilteredRow(index int) (T, bool)

FilteredRow returns the row at index in the filtered set.

func (*Model[T]) FilteredRowIdIndex

func (m *Model[T]) FilteredRowIdIndex(id string) (int, bool)

FilteredRowIdIndex returns the row index by RowId in the filtered set.

func (*Model[T]) FilteredRowIndex

func (m *Model[T]) FilteredRowIndex(row T) (int, bool)

FilteredRowIndex returns the row index in the filtered set.

func (*Model[T]) FilteredRows

func (m *Model[T]) FilteredRows() []T

FilteredRows returns the filtered rows.

func (*Model[T]) FilteredRowsCount

func (m *Model[T]) FilteredRowsCount() int

FilteredRowsCount returns the number of filtered rows.

func (*Model[T]) FrozenColumns

func (m *Model[T]) FrozenColumns() int

FrozenColumns returns the number of frozen columns.

func (*Model[T]) HScrollOffset

func (m *Model[T]) HScrollOffset() int

HScrollOffset returns the current horizontal scroll offset.

func (*Model[T]) HasMore

func (m *Model[T]) HasMore() bool

HasMore reports whether more items are available to stream.

func (*Model[T]) IsRowSelected

func (m *Model[T]) IsRowSelected(filteredIndex int) bool

IsRowSelected returns true if the row at index in filteredRows is selected.

func (*Model[T]) KeyMap

func (m *Model[T]) KeyMap() KeyMap

KeyMap returns the current key bindings.

func (*Model[T]) Loading

func (m *Model[T]) Loading() bool

Loading reports whether a data fetch is in progress. Use this to display a loading indicator in your UI.

Example

ExampleModel_Loading shows that Loading reports false when no fetch is in progress.

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	tbl := tt.New(src, context.Background(), []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1)),
	})

	fmt.Println("before load:", tbl.Loading())
	loadTable(tbl)
	fmt.Println("after load:", tbl.Loading())
}
Output:
before load: false
after load: false

func (*Model[T]) MoveCursorDown

func (m *Model[T]) MoveCursorDown() tea.Cmd

MoveCursorDown moves the cursor one row down. In stream mode, automatically triggers loading more items when the last row is reached. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) MoveCursorPageDown

func (m *Model[T]) MoveCursorPageDown() tea.Cmd

MoveCursorPageDown moves the cursor one page down. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) MoveCursorPageUp

func (m *Model[T]) MoveCursorPageUp() tea.Cmd

MoveCursorPageUp moves the cursor one page up. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) MoveCursorToFirstRow

func (m *Model[T]) MoveCursorToFirstRow() tea.Cmd

MoveCursorToFirstRow moves the cursor to the first row and resets the horizontal scroll. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) MoveCursorToLastRow

func (m *Model[T]) MoveCursorToLastRow() tea.Cmd

MoveCursorToLastRow moves the cursor to the last row and resets the horizontal scroll. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) MoveCursorToRow

func (m *Model[T]) MoveCursorToRow(index int) tea.Cmd

MoveCursorToRow moves the cursor to the row at the given index in filteredRows. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) MoveCursorUp

func (m *Model[T]) MoveCursorUp() tea.Cmd

MoveCursorUp moves the cursor one row up. Returns CursorMovedMsg if position changed, nil otherwise.

func (*Model[T]) NextPage

func (m *Model[T]) NextPage() tea.Cmd

NextPage loads the next page in paged mode. No-op if not in paged mode or on the last page.

func (*Model[T]) OuterBorder

func (m *Model[T]) OuterBorder() OuterBorder

OuterBorder returns the current outer border.

func (*Model[T]) PrevPage

func (m *Model[T]) PrevPage() tea.Cmd

PrevPage loads the previous page in paged mode. No-op if not in paged mode or on the first page.

func (*Model[T]) Refresh

func (m *Model[T]) Refresh() tea.Cmd

Refresh reloads data from the datasource. In in-memory mode cursor, selection, and hScrollOffset are preserved (merge semantics). In paged and stream modes a full reset is performed (page 0, cursor reset).

Example

ExampleModel_Refresh demonstrates updating table data with cursor preserved.

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	tbl := tt.New(src, context.Background(), []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1)),
	})
	tbl.SetWidth(60)
	tbl.SetHeight(10)
	loadTable(tbl)
	fmt.Println("rows after load:", tbl.RowsCount())

	// Add a new item and refresh — cursor stays in place.
	src.SetData(append(src.Data(), product{"5", "New Item", "Misc", "$1.00"}))
	loadTable(tbl)
	fmt.Println("rows after refresh:", tbl.RowsCount())
}
Output:
rows after load: 4
rows after refresh: 5

func (*Model[T]) Reload

func (m *Model[T]) Reload() tea.Cmd

Reload reloads data from the datasource with a full reset: cursor, selection, and hScrollOffset are cleared. The filter is also cleared unless the datasource implements ServerFilterable, in which case it is preserved to stay in sync with the datasource's server-side filter state. This is equivalent to the former Refresh behaviour.

In paged mode Reload resets to page 0. In stream mode it resets to the beginning of the stream. In both cases cursor and scroll are always reset.

func (*Model[T]) RightFrozenColumns

func (m *Model[T]) RightFrozenColumns() int

RightFrozenColumns returns the number of right frozen columns.

func (*Model[T]) Row

func (m *Model[T]) Row(index int) (T, bool)

Row returns the row at index in the full set.

func (*Model[T]) RowIdIndex

func (m *Model[T]) RowIdIndex(id string) (int, bool)

RowIdIndex returns the row index by RowId in the full set.

func (*Model[T]) RowIndex

func (m *Model[T]) RowIndex(row T) (int, bool)

RowIndex returns the row index in the full set.

func (*Model[T]) RowStyles

func (m *Model[T]) RowStyles() RowStyles

RowStyles returns the current row styles.

func (*Model[T]) Rows

func (m *Model[T]) Rows() []T

Rows returns all rows (before filtering).

func (*Model[T]) RowsCount

func (m *Model[T]) RowsCount() int

RowsCount returns the total number of rows.

func (*Model[T]) ScrollIndicator

func (m *Model[T]) ScrollIndicator() ScrollIndicatorConfig

ScrollIndicator returns the current scroll indicator configuration.

func (*Model[T]) ScrollLeft

func (m *Model[T]) ScrollLeft()

ScrollLeft scrolls the table horizontally left.

func (*Model[T]) ScrollRight

func (m *Model[T]) ScrollRight()

ScrollRight scrolls the table horizontally right.

func (*Model[T]) SelectAllRows

func (m *Model[T]) SelectAllRows() tea.Cmd

SelectAllRows selects all filtered rows.

func (*Model[T]) SelectRow

func (m *Model[T]) SelectRow(filteredIndex int) (tea.Cmd, error)

SelectRow selects the row at index in filteredRows.

func (*Model[T]) SelectedRow

func (m *Model[T]) SelectedRow(index int) (T, bool)

SelectedRow returns the selected row at index in filteredRows.

func (*Model[T]) SelectedRows

func (m *Model[T]) SelectedRows() []T

SelectedRows returns selected rows in the order they appear in filteredRows.

func (*Model[T]) SelectedRowsCount

func (m *Model[T]) SelectedRowsCount() int

SelectedRowsCount returns the number of selected rows visible in filteredRows. Consistent with SelectedRows() and SelectionChangedMsg.Count.

func (*Model[T]) SelectionEnabled

func (m *Model[T]) SelectionEnabled() bool

SelectionEnabled returns whether selection is enabled.

func (*Model[T]) SetAlternateRowsEnabled

func (m *Model[T]) SetAlternateRowsEnabled(enabled bool)

SetAlternateRowsEnabled enables or disables alternating rows.

func (*Model[T]) SetBottomBorderRenderer

func (m *Model[T]) SetBottomBorderRenderer(fn BottomBorderRenderer[T], align Align, offset int)

SetBottomBorderRenderer sets the renderer for content embedded in the bottom border line, along with its horizontal alignment and offset from the edge (used by AlignLeft/AlignRight). Pass nil as fn to remove an existing renderer.

func (*Model[T]) SetCellStyler

func (m *Model[T]) SetCellStyler(s func(T, string, lipgloss.Style, RowState) lipgloss.Style)

SetCellStyler sets an optional callback to override the resolved cell style on each render. The callback receives the row, column name, the base cell style (column style inherited from row style), and the current RowState. Pass nil to remove the styler.

func (*Model[T]) SetColumns

func (m *Model[T]) SetColumns(columns []Column)

SetColumns sets the table columns.

func (*Model[T]) SetDividers

func (m *Model[T]) SetDividers(d TableDividers)

SetDividers sets the table dividers.

func (*Model[T]) SetFilter

func (m *Model[T]) SetFilter(filter string) tea.Cmd

SetFilter sets the filter and recalculates filteredRows. If ds implements ServerFilterable[T], calls ds.Filter(filter) and triggers a load. If ds implements LocalFilterable[T], applies client-side filtering. Returns a tea.Batch with CursorMovedMsg and FilteredRowsChangedMsg. Also emits SelectionChangedMsg if a non-empty filter clears an active selection.

func (*Model[T]) SetFrozenColumns

func (m *Model[T]) SetFrozenColumns(n int)

SetFrozenColumns sets the number of frozen columns.

func (*Model[T]) SetHeight

func (m *Model[T]) SetHeight(height int)

SetHeight sets the table outer height including the border. Also recalculates pageSize for paged datasources.

func (*Model[T]) SetKeyMap

func (m *Model[T]) SetKeyMap(keyMap KeyMap)

SetKeyMap sets key bindings.

func (*Model[T]) SetOuterBorder

func (m *Model[T]) SetOuterBorder(b OuterBorder)

SetOuterBorder sets the outer border.

func (*Model[T]) SetRightFrozenColumns

func (m *Model[T]) SetRightFrozenColumns(n int)

SetRightFrozenColumns sets the number of frozen columns on the right.

func (*Model[T]) SetRowStyler

func (m *Model[T]) SetRowStyler(s func(T, lipgloss.Style, RowState) lipgloss.Style)

SetRowStyler sets an optional callback to override the resolved row style on each render. The callback receives the row, the base style computed from RowStyles, and the current RowState. Pass nil to remove the styler.

Example

ExampleModel_SetRowStyler demonstrates applying a custom row style based on row content.

src := &productDS{}
src.SetData(sampleProducts())

tbl := tt.New(src, context.Background(), []tt.Column{
	tt.NewColumn("Name", "Name", tt.WithFlexRatio(2)),
	tt.NewColumn("Category", "Category", tt.WithFlexRatio(1)),
})

// Highlight "Gadgets" rows with a distinct foreground color.
tbl.SetRowStyler(func(p product, base lipgloss.Style, _ tt.RowState) lipgloss.Style {
	if p.Category == "Gadgets" {
		return base.Foreground(lipgloss.Color("214"))
	}
	return base
})

fmt.Println("row styler configured")
Output:
row styler configured

func (*Model[T]) SetRowStyles

func (m *Model[T]) SetRowStyles(styles RowStyles)

SetRowStyles sets row styles.

func (*Model[T]) SetScrollIndicator

func (m *Model[T]) SetScrollIndicator(c ScrollIndicatorConfig)

SetScrollIndicator sets the scroll indicator configuration.

func (*Model[T]) SetSelectionEnabled

func (m *Model[T]) SetSelectionEnabled(enabled bool)

SetSelectionEnabled enables or disables row selection.

func (*Model[T]) SetShowHeader

func (m *Model[T]) SetShowHeader(show bool)

SetShowHeader sets header row visibility.

func (*Model[T]) SetSort

func (m *Model[T]) SetSort(columnName string, direction SortDirection) (tea.Cmd, error)

SetSort sets sorting by the column named columnName. If ds implements ServerSortable[T], delegates to ds.Sort and triggers a load. If ds implements LocalSortable[T], applies client-side sorting. Returns ErrColumnNotSortable if the column is not marked with WithSortable().

func (*Model[T]) SetStripANSIForActiveStates

func (m *Model[T]) SetStripANSIForActiveStates(strip bool)

SetStripANSIForActiveStates sets CursorStripANSI, SelectedStripANSI, and CursorSelectedStripANSI to strip on all active row states at once.

func (*Model[T]) SetTitle

func (m *Model[T]) SetTitle(title string)

SetTitle sets the table title. Empty string removes the title.

func (*Model[T]) SetWidth

func (m *Model[T]) SetWidth(width int)

SetWidth sets the table outer width including the border.

func (*Model[T]) ShowHeader

func (m *Model[T]) ShowHeader() bool

ShowHeader returns whether the header is visible.

func (*Model[T]) SortActive

func (m *Model[T]) SortActive() bool

SortActive reports whether sorting is currently applied. Use this to decide whether to show a sort indicator in your UI.

func (*Model[T]) SortColumn

func (m *Model[T]) SortColumn() string

SortColumn returns the active sort column name.

func (*Model[T]) SortDirection

func (m *Model[T]) SortDirection() SortDirection

SortDirection returns the current sort direction.

func (*Model[T]) Title

func (m *Model[T]) Title() string

Title returns the current table title.

func (*Model[T]) ToggleRowSelection

func (m *Model[T]) ToggleRowSelection(filteredIndex int) (tea.Cmd, error)

ToggleRowSelection toggles the selection of the row at index in filteredRows.

func (*Model[T]) ToggleSort

func (m *Model[T]) ToggleSort(columnName string) (tea.Cmd, error)

ToggleSort cycles sort direction: Asc → Desc → clear.

Example

ExampleModel_ToggleSort shows the Asc → Desc → clear cycle for a sortable column.

package main

import (
	"context"
	"fmt"
	"strings"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

// loadTable runs the initial data load synchronously (for use in examples).
func loadTable[T any](tbl *tt.Model[T]) {
	cmd := tbl.Refresh()
	if cmd != nil {
		tbl.Update(cmd())
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	tbl := tt.New(src, context.Background(), []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1), tt.WithSortable()),
	})
	tbl.SetWidth(60)
	tbl.SetHeight(10)
	loadTable(tbl)

	_, _ = tbl.ToggleSort("Name") // first toggle → Asc
	fmt.Println("asc:", tbl.SortActive(), tbl.SortDirection() == tt.SortAsc)

	_, _ = tbl.ToggleSort("Name") // second toggle → Desc
	fmt.Println("desc:", tbl.SortActive(), tbl.SortDirection() == tt.SortDesc)

	_, _ = tbl.ToggleSort("Name") // third toggle → cleared
	fmt.Println("cleared:", tbl.SortActive())
}
Output:
asc: true true
desc: true true
cleared: false

func (*Model[T]) TotalCount

func (m *Model[T]) TotalCount() int

TotalCount returns the total item count reported by the datasource (-1 if unknown).

func (*Model[T]) UnselectRow

func (m *Model[T]) UnselectRow(filteredIndex int) (tea.Cmd, error)

UnselectRow deselects the row at index in filteredRows.

func (*Model[T]) Update

func (m *Model[T]) Update(msg tea.Msg) tea.Cmd

Update handles keyboard events and datasource messages.

Example

ExampleModel_Update shows forwarding messages from a Bubble Tea Update function.

package main

import (
	"context"
	"fmt"
	"strings"

	tea "charm.land/bubbletea/v2"

	tt "github.com/anadale/teaservice/teatable"
)

// product is the domain type used in teatable examples.
type product struct {
	ID       string
	Name     string
	Category string
	Price    string
}

// productDS implements [tt.Datasource][product], [tt.LocalSortable][product],
// and [tt.LocalFilterable][product] using the embedded [tt.InMemorySource].
type productDS struct {
	tt.InMemorySource[product]
}

func (d *productDS) RowId(p product) string { return p.ID }

func (d *productDS) CellValue(p product, col string) string {
	switch col {
	case "Name":
		return p.Name
	case "Category":
		return p.Category
	case "Price":
		return p.Price
	}
	return ""
}

func (d *productDS) Compare(item product, col string, other product) int {
	return tt.StringCompare(d, item, col, other)
}

func (d *productDS) Matches(item product, filter string) bool {
	return strings.Contains(item.Name, filter) || strings.Contains(item.Category, filter)
}

func sampleProducts() []product {
	return []product{
		{"1", "Widget A", "Widgets", "$9.99"},
		{"2", "Gadget Pro", "Gadgets", "$49.99"},
		{"3", "Widget B", "Widgets", "$12.99"},
		{"4", "Gadget Lite", "Gadgets", "$19.99"},
	}
}

func main() {
	src := &productDS{}
	src.SetData(sampleProducts())

	tbl := tt.New(src, context.Background(), []tt.Column{
		tt.NewColumn("Name", "Name", tt.WithFlexRatio(1)),
	})
	tbl.SetWidth(60)
	tbl.SetHeight(10)

	// Forward any unhandled message to the table.
	cmd := tbl.Update(tea.KeyPressMsg{Code: tea.KeyDown})
	_ = cmd
	fmt.Println("update handled")
}
Output:
update handled

func (*Model[T]) View

func (m *Model[T]) View() string

View renders the table to a string. Returns an empty string if width or height is zero.

type MoreLoadedMsg

type MoreLoadedMsg[T any] struct {
	Items      []T
	NextCursor string
	HasMore    bool
	Append     bool
}

MoreLoadedMsg is emitted after a Streamer.Load call (stream mode). Append=true means items should be appended; false means replace (initial load).

type Options

type Options struct {
	KeyMap               KeyMap
	RowStyles            RowStyles
	AlternateRowsEnabled bool
	ShowHeader           bool
	SelectionEnabled     bool
	FrozenColumns        int
	RightFrozenColumns   int
	Title                string
	OuterBorder          OuterBorder
	Dividers             TableDividers
	ScrollIndicator      ScrollIndicatorConfig
}

Options holds model creation parameters via functional options.

type OuterBorder

type OuterBorder struct {
	// Enabled turns on drawing the outer border.
	Enabled bool

	// Border defines the border characters.
	Border lipgloss.Border

	// Style sets the border style (color, attributes).
	Style lipgloss.Style
}

OuterBorder defines the table outer border.

func DoubleBorder

func DoubleBorder() OuterBorder

DoubleBorder returns an OuterBorder with double lines.

func NormalBorder

func NormalBorder() OuterBorder

NormalBorder returns an OuterBorder with standard square corners.

func RoundedBorder

func RoundedBorder() OuterBorder

RoundedBorder returns an OuterBorder with rounded corners.

func ThickBorder

func ThickBorder() OuterBorder

ThickBorder returns an OuterBorder with thick lines.

type PageLoadedMsg

type PageLoadedMsg[T any] struct {
	Items      []T
	Page       int
	TotalCount int // -1 if total is unknown
}

PageLoadedMsg is emitted after a successful Pager.Load call (paged mode).

type Pager

type Pager[T any] interface {
	Load(ctx context.Context, page int, size int) (items []T, totalCount int, err error)
}

Pager loads a single page of items. totalCount is -1 when the total is unknown.

type RowState

type RowState struct {
	IsCursor    bool
	IsSelected  bool
	IsAlternate bool
}

RowState describes the current render state of a row.

type RowStyles

type RowStyles struct {
	// Normal is the base style for a regular row.
	Normal lipgloss.Style

	// Alternate is the style for rows with odd index (0-based: 1, 3, 5...).
	// Used only when AlternateRowsEnabled == true.
	Alternate lipgloss.Style

	// Cursor is the style for the row under the cursor.
	// Overrides Normal and Alternate.
	Cursor lipgloss.Style

	// Selected is the style for a selected row.
	// Overrides Normal and Alternate, but is overridden by Cursor.
	Selected lipgloss.Style

	// CursorSelected is the style for a row that is both under the cursor and selected.
	// Overrides Cursor and Selected.
	CursorSelected lipgloss.Style

	// CursorStripANSI removes ANSI sequences from cell values when rendering rows in
	// the cursor state, preventing embedded color codes from overriding the row style.
	CursorStripANSI bool

	// SelectedStripANSI removes ANSI sequences from cell values when rendering rows in
	// the selected state, preventing embedded color codes from overriding the row style.
	SelectedStripANSI bool

	// CursorSelectedStripANSI removes ANSI sequences from cell values when rendering rows
	// in the cursor+selected state, preventing embedded color codes from overriding the row style.
	CursorSelectedStripANSI bool
}

RowStyles defines styles for different table row states.

func BlackAndWhiteRowStyles

func BlackAndWhiteRowStyles() RowStyles

BlackAndWhiteRowStyles returns high-contrast row styles using only black and white: normal and alternate rows are white-on-black, while the cursor and cursor-selected states are inverted to black-on-white.

func DefaultRowStyles

func DefaultRowStyles() RowStyles

DefaultRowStyles returns default row styles: - Cursor: inverted colors; - Selected: distinct from Normal (bold); - Alternate: empty style (visually the same as Normal until configured); - Normal: empty style.

type ScrollIndicatorConfig

type ScrollIndicatorConfig struct {
	// Mode defines how the indicator is displayed.
	Mode ScrollIndicatorMode

	// ThumbChar is the character for the thumb showing current position in the data.
	ThumbChar string

	// TrackChar is the character for the track the thumb moves along.
	TrackChar string

	// Style is the indicator rendering style.
	Style lipgloss.Style
}

ScrollIndicatorConfig holds the vertical scroll indicator configuration.

func DataOnlyScrollIndicator

func DataOnlyScrollIndicator() ScrollIndicatorConfig

DataOnlyScrollIndicator returns scroll indicator config spanning only the data area (ScrollIndicatorDataOnly). Uses "█" for thumb and "░" for track.

func DefaultScrollIndicator

func DefaultScrollIndicator() ScrollIndicatorConfig

DefaultScrollIndicator returns the default scroll indicator config: indicator hidden (ScrollIndicatorHidden).

func FullHeightScrollIndicator

func FullHeightScrollIndicator() ScrollIndicatorConfig

FullHeightScrollIndicator returns scroll indicator config spanning full table height (ScrollIndicatorFullHeight). Uses "█" for thumb and "░" for track.

type ScrollIndicatorMode

type ScrollIndicatorMode int

ScrollIndicatorMode defines how the vertical scroll indicator is displayed.

const (
	// ScrollIndicatorHidden — indicator is hidden (default).
	ScrollIndicatorHidden ScrollIndicatorMode = iota

	// ScrollIndicatorFullHeight — indicator spans full table height
	// (including header row and dividers).
	ScrollIndicatorFullHeight

	// ScrollIndicatorDataOnly — indicator spans only the data area
	// (without a header row and dividers).
	ScrollIndicatorDataOnly
)

type SelectionChangedMsg

type SelectionChangedMsg struct {
	Count int      // number of selected rows
	Ids   []string // RowIds in filteredRows order
}

SelectionChangedMsg emitted when set of selected rows changes.

type ServerFilterable

type ServerFilterable[T any] interface {
	Filter(filter string)
}

ServerFilterable enables server-side filtering. Filter is a setter called before triggerLoad; it does not load data itself.

type ServerSortable

type ServerSortable[T any] interface {
	Sort(column string, dir SortDirection)
}

ServerSortable enables server-side sorting. Sort is a setter called before triggerLoad; it does not load data itself.

type SortDirection

type SortDirection int

SortDirection defines sort order.

const (
	// SortAsc — ascending order (A → Z).
	SortAsc SortDirection = iota
	// SortDesc — descending order (Z → A).
	SortDesc
)

type Streamer

type Streamer[T any] interface {
	Load(ctx context.Context, cursor string) (items []T, nextCursor string, hasMore bool, err error)
}

Streamer loads the next batch of items using cursor-based pagination. Pass an empty cursor to start from the beginning.

type TableDividers

type TableDividers struct {
	// Header is the horizontal divider between header and data.
	Header HorizontalDivider

	// Rows is the horizontal divider between data rows.
	Rows HorizontalDivider

	// FrozenColumns is the vertical divider after the last frozen column.
	FrozenColumns VerticalDivider

	// Frozen is the vertical divider between frozen columns.
	Frozen VerticalDivider

	// Columns is the vertical divider between regular (non-frozen) columns.
	Columns VerticalDivider

	// RightFrozenColumns is the vertical divider before the first right frozen column.
	RightFrozenColumns VerticalDivider

	// RightFrozen is the vertical divider between right frozen columns.
	RightFrozen VerticalDivider
}

TableDividers groups all table dividers.

func ASCIIDividers

func ASCIIDividers() TableDividers

ASCIIDividers returns a set with all five dividers (ASCII characters).

func FullGridDividers

func FullGridDividers() TableDividers

FullGridDividers returns a set with all five dividers (Unicode).

func SimpleDividers

func SimpleDividers() TableDividers

SimpleDividers returns a set with only the header divider enabled.

func StyledFullGridDividers

func StyledFullGridDividers(style lipgloss.Style) TableDividers

StyledFullGridDividers returns a set with all five dividers (Unicode).

type VerticalDivider

type VerticalDivider struct {
	// Enabled turns on drawing the divider.
	Enabled bool

	// Char is the main character of the vertical line (e.g. │).
	Char string

	// TopJoin is the character joining the top border (e.g. ┬).
	TopJoin string

	// BottomJoin is the character joining the bottom border (e.g. ┴).
	BottomJoin string

	// HCrossing is used when a horizontal divider fully crosses this vertical divider (e.g. ╫ for ║+─).
	// Falls back to the horizontal divider's Crossing if empty.
	HCrossing string

	// HCrossingRight is used when only the right side has horizontal content at this crossing (e.g. ╟).
	// Falls back to HCrossing, then the horizontal divider's LeftJoin.
	HCrossingRight string

	// HCrossingLeft is used when only the left side has horizontal content at this crossing (e.g. ╢).
	// Falls back to HCrossing, then the horizontal divider's RightJoin.
	HCrossingLeft string

	// HCrossingDown is used when only the downward direction exists at this crossing (e.g. ╥).
	// Falls back to HCrossing, then the horizontal divider's CrossingDown.
	HCrossingDown string

	// Style sets the divider style.
	Style lipgloss.Style
}

VerticalDivider defines a vertical divider (between columns).

func SimpleColumnsDivider

func SimpleColumnsDivider() VerticalDivider

SimpleColumnsDivider returns a vertical divider between regular columns.

func SimpleFrozenColumnsDivider

func SimpleFrozenColumnsDivider() VerticalDivider

SimpleFrozenColumnsDivider returns a vertical divider after the last frozen column.

func SimpleFrozenDivider

func SimpleFrozenDivider() VerticalDivider

SimpleFrozenDivider returns a vertical divider between frozen columns.

func SimpleRightFrozenColumnsDivider

func SimpleRightFrozenColumnsDivider() VerticalDivider

SimpleRightFrozenColumnsDivider returns a vertical divider before the first right frozen column.

func SimpleStyledColumnsDivider

func SimpleStyledColumnsDivider(style lipgloss.Style) VerticalDivider

SimpleStyledColumnsDivider returns a vertical divider between regular columns.

func SimpleStyledFrozenColumnsDivider

func SimpleStyledFrozenColumnsDivider(style lipgloss.Style) VerticalDivider

SimpleStyledFrozenColumnsDivider returns a vertical divider after the last frozen column.

func SimpleStyledFrozenDivider

func SimpleStyledFrozenDivider(style lipgloss.Style) VerticalDivider

SimpleStyledFrozenDivider returns a vertical divider between frozen columns.

func SimpleStyledRightFrozenColumnsDivider

func SimpleStyledRightFrozenColumnsDivider(style lipgloss.Style) VerticalDivider

SimpleStyledRightFrozenColumnsDivider returns a vertical divider before the first right frozen column.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL