grid

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package grid is a product-neutral result grid: a bubble-table-backed table with sort, per-cell column selection, a scrollbar, style presets, and slots for a product's own secondary views (charts, a current-row card, raw responses, ...) and split-pane layout. It is a mutable component (a *Model, not a value) that reports what happened with messages, never callbacks: RowActivatedMsg (Enter), SelectionChangedMsg (the highlighted row or selected column changed), PinRowMsg ("+"). Its established API shape is View(width, focused), Update(msg) (*Model, tea.Cmd), SetFocused; it implements widgets.Boundary (AtEdge) and widgets.Editor (Editing) so the navigation shell can hand focus over at its edges.

Ported and generalised from DataTug chat's GridModel/gridState (datatug-cli/pkg/chat/grid.go, ui.go, recordset_ui.go, recordset_views.go, table_style.go). DataTug and Sneat Chat use this one grid for tabular/contact data — not two competing ones; DataTug's gridState is a thin wrapper embedding a *grid.Model. The grid moved here from strongo/aichat tui/grid so that any tuigoff product can use it (dependency direction: aichat imports tuigoff, never the reverse); aichat keeps a thin transcript adapter that turns Row.Ref into its own entity reference and PinRowMsg into its sidebar message.

Keybindings

↑↓/k/j move the row (a product's own WithKeyHandler is checked first and can still claim "j"/"down" for its own use, e.g. DataTug's join-candidate navigation, by handling it before the grid's default runs); home/end jump to the first/last row and pgup/pgdown page; ←→/h/l select a column, auto-scrolling it into view (SelectedColumn); digit keys switch views ("1" is always the table, "2".. select a registered ExtraView in order); Tab toggles focus between the table and a split secondary view (ToggleSecondaryFocusIfSplit); s sorts (toggling ascending/descending) by the selected column; Enter emits RowActivatedMsg (with the selected column); + emits PinRowMsg for the highlighted row's Ref; / opens bubble-table's built-in filter. While the filter input is focused, CapturesEsc and Editing report true so a surrounding shell lets Esc clear/blur the filter before doing anything else.

A product's WithKeyHandler hook is checked first, for every key the filter isn't consuming, and can claim any of the above (e.g. DataTug's Enter opens a cell-detail dialog instead of emitting RowActivatedMsg, and c/r/a/d/b/B/e/q/space are entirely DataTug's own workspace actions with no generic-grid meaning at all).

Master/detail

Update emits SelectionChangedMsg{Row, Index, Column, ID} once whenever the highlighted row or the selected column changed because of the message it handled, and nothing while the selection stays put. A screen uses it to show the foreign-key target or the referrers of the current cell. Enter still emits RowActivatedMsg. WithID names the grid so a screen with several grids can tell their messages apart. Selection changes made with the SelectRow/SelectColumn setters are silent.

Large and lazy data

WithRowSource(src, columns) backs the grid with a RowSource (Len, Row(i)) instead of a []Row: only the page holding the highlighted row is ever materialised and formatted, so a 1,000,000-row source renders and scrolls in constant time (see RowSource for what sort and filter do then).

Embedding and sizing

By default the grid draws its own bordered card (title, view switcher, a scrollbar, the footer in the bottom border). WithoutFrame renders just the table and its footer line, for a screen that already frames it. SetSize (width, height) fits the page of rows to a height (the chrome differs between the two modes); WithMaxVisibleRows is then an upper bound.

Columns and cells

WithFixedColumns(n) freezes the first n columns while the rest scroll horizontally. WithCellStyle(fn) styles individual cells (type colours, underlined foreign-key values, error cells) — a data hook, not behaviour. Column.MaxWidth caps one column's width. WithRowSelection turns the grid into a row list with no column selection.

Replacing the data

SetData(columns, rows) and SetRowSource(src, columns) replace the content after construction, for results that arrive asynchronously ("Loading..." first, the rows later) or lists a screen re-filters itself. WithFilterOnType lets printable keys feed the row filter without pressing "/"; the footer shows the active filter text.

Rows and values

Row.Values is positional (aligned with the Columns slice the Row was built against), not a map keyed by column name, so two columns sharing a name (e.g. `SELECT a.id, b.id`) each keep their own value. Use grid.Absent for a column with no value at all for a row (a sparse selection), which renders differently from an explicit nil ("NULL"). A Row.Values entry may be a raw Go value (formatted by FormatValue) or a product's own pre-formatted display string (e.g. DataTug's date-only formatting) — Sort and the table cells use whichever was supplied. Row.Key, when set to a stable identifier (e.g. the row's original/source index), survives Sort; IndexForKey finds it again after a sort or a data refresh.

Views

There is no fixed Card/Inspector view: the built-in table is always view 0; everything else is a product-registered ExtraView (WithExtraViews / SetExtraViews), in whatever order the product wants. CardView and InspectorView are ready-made ExtraView constructors — a formatted vertical field list and a raw-Go-value dump of the highlighted row, respectively — for a product that wants one (DataTug registers CardView as its "Current row" view, third in its own Table/Charts/Current row/Raw/Headers order).

A product registers its own secondary views (DataTug's Charts, Raw response, Headers) with WithExtraViews, and its split-pane policy — table and the active secondary view side by side when there's room, generalising DataTug's chooseRecordsetLayout — with WithSplitLayout (Model.NaturalWidth gives the LayoutFunc the table's unclipped content width to compare against the pane's total width). Large results are capped to DefaultMaxVisibleRows (or WithMaxVisibleRows's value) per page so a 1000-row result never renders fully into a scrolling pane.

Style

WithStyle/SetStyle pick a Style preset (StyleLines, StyleSoft, StyleMinimal are built in); ParseStyle recovers one by name, e.g. from a persisted session. WithFooterHook lets a product append its own text (e.g. a save-status badge) to the grid's built-in stats footer (row/column range, sort indicator).

Adoption

DataTug adopts it by mapping a secureread.Result to Columns/Rows:

cols := make([]grid.Column, len(result.Columns))
for i, name := range result.Columns {
	cols[i] = grid.Column{Name: name, Numeric: columnIsNumeric(result.Rows, name)}
}
rows := make([]grid.Row, len(result.Rows))
for i, row := range result.Rows {
	values := make([]any, len(result.Columns))
	for c, name := range result.Columns {
		if v, ok := row.Data[name]; ok {
			values[c] = v
		} else {
			values[c] = grid.Absent
		}
	}
	rows[i] = grid.Row{Key: strconv.Itoa(i), Values: values}
}
m := grid.New(cols, rows, grid.WithTitle(title),
	grid.WithExtraViews(chartsView, grid.CardView("Current row"), rawView, headersView),
	grid.WithSplitLayout(chooseRecordsetLayout),
	grid.WithKeyHandler(dataTugGridActions))

Index

Constants

View Source
const DefaultMaxColumnWidth = 28

DefaultMaxColumnWidth is the widest a column grows when its Column.MaxWidth is not set.

View Source
const DefaultMaxVisibleRows = theme.MaxInlineGridRows

DefaultMaxVisibleRows is the page size a Model uses when WithMaxVisibleRows is not supplied — theme.MaxInlineGridRows, the shared inline-grid default every aichat product gets automatically (founder 2026-09-25); a product overrides it per grid via WithMaxVisibleRows(n), never by shadowing this constant.

Variables

View Source
var (
	StyleLines   = styleLines()
	StyleSoft    = styleSoft()
	StyleMinimal = styleMinimal()
)

Built-in style presets, ported from DataTug's table_style.go. Every colour is resolved fresh from tui/theme on each access (via a function, not a package-level var baked in at import time — theme.Dark can change at runtime, e.g. a product calling theme.SetDark, and a memoised colour would silently keep rendering the OLD variant forever after — see styleLines/styleSoft/styleMinimal below), so a grid always renders in whichever Dark variant is current, light or dark, never a hard-coded background that only looks right in one of them.

View Source
var Absent any = absentType{}

Absent is the sentinel Row.Values entry meaning "no value was supplied for this column" — distinct from an explicit nil ("NULL"). Product adapters use it for sparse selections (e.g. DataTug's cell-range picks) where only some columns have a value for a given row.

Styles lists the built-in presets in cycling order.

Functions

func FormatValue

func FormatValue(value any) string

FormatValue applies basic terminal-safe value formatting, shared with the card/inspector views.

Types

type CellStyleFunc

type CellStyleFunc func(row Row, column int, value any) lipgloss.Style

CellStyleFunc styles one cell: row is the row being drawn, column the column index and value the row's raw value for it (Absent when the row has none). The returned style is layered over the grid's own column style (alignment, selected-column emphasis): properties the function sets win. It is a data hook (type colours, underlined foreign-key values, error cells), never behaviour, and must be cheap and pure: it runs for every visible cell whenever the table is rebuilt. Colours must come from pkg/theme.

type Column

type Column struct {
	Name    string
	Numeric bool
	// MaxWidth caps the column's rendered width in cells (longer values are
	// truncated); 0 means the default cap, DefaultMaxColumnWidth.
	MaxWidth int
}

Column is the UI-ready description of a result column.

type ExtraView

type ExtraView struct {
	// Label is shown in the view switcher header, e.g. "Charts".
	Label string
	// ShortLabel is shown instead of Label once the header is too narrow
	// for the full text (see viewLabels) — main's own fixed forms
	// ("Charts" → "C", "Current row" → "Row") rather than a generic
	// N-character truncation of Label, which can make two labels
	// indistinguishable once both are cut to the same length (e.g.
	// "Charts"/"Current row" both truncating to "Ch"/"Cu" reads fine, but
	// a runt truncation of arbitrary text has no such guarantee). Falls
	// back to Label's own generic truncation when empty.
	ShortLabel string
	// Render draws the view's body at the given content width/height.
	Render func(m *Model, width, height int) string
	// Update optionally handles a key press while this view is active and
	// focused (e.g. arrow keys moving between chart candidates). It returns
	// the command to run (if any) and whether it handled the message; when
	// it returns false the grid's own key handling still runs.
	Update func(m *Model, msg tea.KeyPressMsg) (tea.Cmd, bool)
}

ExtraView is a product-registered secondary view — DataTug's Charts, Raw response, Headers and current-row views are ExtraViews — shown alongside the table and selected the same way (number keys, cycling through the header). A grid stays the one generic component; products supply their own panes (or the CardView/InspectorView helpers) instead of building a competing grid.

func CardView

func CardView(label string) ExtraView

CardView returns an ExtraView rendering the highlighted row as a formatted vertical field list (Column name / FormatValue'd value). label defaults to "Current row" when empty; its ShortLabel is main's own "Row". Ported from DataTug's recordset_views.go currentRowContent (raw=false).

func InspectorView

func InspectorView(label string) ExtraView

InspectorView is CardView's raw-value counterpart: it renders each field's Go value (%#v) instead of FormatValue's terminal-safe text. label defaults to "Inspector" when empty, ShortLabel to "Insp".

type FooterHook

type FooterHook func(m *Model, builtin string) string

FooterHook lets a product append extra stats to the grid's own footer (row/column range, sort indicator), e.g. a version badge or a save-status note. It receives the built-in footer text and returns the final text.

type KeyHandler

type KeyHandler func(m *Model, msg tea.KeyPressMsg) (tea.Cmd, bool)

KeyHandler lets a product own specific key presses (e.g. DataTug's c/r/a/d/b/s/B/e actions) instead of the grid's own defaults. It is checked first, for every key press the filter input isn't consuming; returning handled=false falls through to the grid's built-in handling (column/row navigation, view switching, sort, Enter, +, /).

type LayoutFunc

type LayoutFunc func(totalWidth, naturalWidth int, view View) SplitLayout

LayoutFunc chooses, for the active non-table view, whether to split the pane between the table and that view. totalWidth is the grid's full width; naturalWidth is the table's natural (unclipped) content width, from Model.NaturalWidth(). Generalises DataTug's chooseRecordsetLayout so a product's split-pane policy is a plugged-in function, not a second grid.

type Model

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

Model is a result grid with a sortable/filterable table, per-cell column selection, a scrollbar, style presets, and slots for a product's own secondary views (ExtraView) and split-pane layout. Ported and generalised from DataTug's GridModel/gridState, recordset_ui.go, recordset_views.go and table_style.go.

func New

func New(columns []Column, rows []Row, opts ...Option) *Model

New builds a grid from columns and rows. Row order is preserved until the user sorts.

func (*Model) ActiveView

func (m *Model) ActiveView() View

ActiveView reports the active view.

func (*Model) ActiveViewContent

func (m *Model) ActiveViewContent(width, height int) string

ActiveViewContent renders just the active view's own body (table or the active ExtraView) at the given size, without any card chrome and without the OTHER pane a split layout would show alongside it. A product's test wants this instead of the full View() output whenever a split layout could put the table's own header/cells within reach of a substring match aimed only at the secondary view (e.g. asserting a CardView's scroll position by checking which fields are currently rendered).

func (*Model) AtEdge

func (m *Model) AtEdge(dir widgets.Direction) bool

AtEdge implements widgets.Boundary so the shell may turn an arrow key into a focus move: Up is true on the first row, Down on the last row (both also when there are no rows), Left when the first column is selected and Right when the last one is (both always in a WithRowSelection grid). While a split or extra view holds secondary focus the grid keeps Up and Down for that view (false) and Left/Right are true.

func (*Model) CapturesEsc

func (m *Model) CapturesEsc() bool

CapturesEsc reports whether the grid's own filter input is currently focused, in which case Esc should clear/blur that filter rather than be handled by a surrounding chatshell (e.g. to close the block or the pane).

func (*Model) Cell

func (m *Model) Cell(rowIndex, columnIndex int) string

Cell returns the formatted display text for a row/column (the same text shown in the table), or "" out of bounds.

func (*Model) ColumnOffset

func (m *Model) ColumnOffset() int

ColumnOffset is the number of columns scrolled out of view to the left of the first scrolled-in column (beyond the frozen ones, see WithFixedColumns); with no fixed columns it is the index of the first visible column.

func (*Model) Columns

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

Columns returns the grid's columns.

func (*Model) Current

func (m *Model) Current() any

Current returns the highlighted row's Ref (the product's opaque reference), or nil when there is no highlighted row or the row has no Ref. A typed nil pointer (a (*T)(nil) stored in Ref) counts as no Ref and is returned as a plain nil, so callers can test the result against nil.

func (*Model) CurrentIndex

func (m *Model) CurrentIndex() int

CurrentIndex returns the display index of the highlighted row, or -1 when there are no rows. It is filter-aware: bubble-table's cursor indexes GetVisibleRows() (the post-filter subset), so the row's hidden sourceKey metadata — not the raw cursor index — is what recovers the position in Model.rows.

func (*Model) CurrentRow

func (m *Model) CurrentRow() (Row, bool)

CurrentRow returns the highlighted Row and whether one exists.

func (*Model) Editing

func (m *Model) Editing() bool

Editing implements widgets.Editor: true while the grid's filter input is focused and owns a text cursor.

func (*Model) ExtraViews

func (m *Model) ExtraViews() []ExtraView

ExtraViews returns the currently registered extra views.

func (*Model) Focused

func (m *Model) Focused() bool

Focused reports the grid's current focus state.

func (*Model) Footer

func (m *Model) Footer() string

Footer returns the grid's current footer text (row/column range, sort indicator, plus any WithFooterHook/SetFooterHook text) — the same text shown in the card's bottom border.

func (*Model) HeaderLine

func (m *Model) HeaderLine(width int) string

HeaderLine returns the card's title bar content at a given width — the same text embedded in the top border by View — without rendering the whole card.

func (*Model) Height

func (m *Model) Height() int

Height is the last height passed to SetSize (0 when none).

func (*Model) IndexForKey

func (m *Model) IndexForKey(key string) int

IndexForKey returns the display index of the row whose Key equals key, or -1. Row.Key is preserved across Sort, so a product can save a row's Key (e.g. a stable source-record index) and restore the selection after a sort or a data refresh. A RowSource grid always returns -1 (finding a key would mean scanning every row): the product maps its own keys to indices and uses SelectRow.

func (*Model) NaturalWidth

func (m *Model) NaturalWidth() int

NaturalWidth is the table's unclipped content width (sum of column widths plus borders), for a LayoutFunc to compare against the pane's total width — the same quantity DataTug's chooseRecordsetLayout compares against.

func (*Model) Refresh

func (m *Model) Refresh()

Refresh reloads the current page from a RowSource after the source's data changed (the cursor is clamped to the new Len()). It does not emit SelectionChangedMsg and is a no-op for a slice-backed grid.

func (*Model) Rows

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

Rows returns the current (sorted) rows for inspection/tests. For a RowSource grid it returns only the materialised page (see RowSource), never all rows.

func (*Model) SecondaryFocus

func (m *Model) SecondaryFocus() bool

SecondaryFocus reports whether keyboard focus is on the active secondary (non-table) view rather than the table, when the pane is split. See SetSecondaryFocus.

func (*Model) SelectColumn

func (m *Model) SelectColumn(index int)

SelectColumn selects a column directly (as h/l do interactively), clamping to bounds and scrolling it into view. Like SelectRow it does not emit SelectionChangedMsg.

func (*Model) SelectRow

func (m *Model) SelectRow(index int)

SelectRow highlights the row at the given display index (a position in Model.rows/Rows(), the same index space as IndexForKey — NOT bubble- table's own cursor position, which indexes the filtered/visible subset). It does not change SelectedColumn. A no-op when that row is currently filtered out of view.

func (*Model) SelectedColumn

func (m *Model) SelectedColumn() int

SelectedColumn is the column h/l (or SelectColumn) currently has selected.

func (*Model) SetData

func (m *Model) SetData(columns []Column, rows []Row)

SetData replaces the grid's columns and rows (async loading: a "Loading..." grid that receives its result later, a refreshed query, a re-filtered list). The grid becomes slice-backed, the highlight returns to the first row and the sort state, filter and horizontal scroll are cleared; the selected column is kept when it still exists. It emits no message.

func (*Model) SetExtraViews

func (m *Model) SetExtraViews(views ...ExtraView)

SetExtraViews replaces the registered extra views (e.g. once an HTTP response becomes available and a product wants to add Raw/Headers views that weren't known at construction time). The active view is reset to ViewTable if it no longer resolves.

func (*Model) SetFocused

func (m *Model) SetFocused(focused bool)

SetFocused sets the grid's focus state directly, for a caller that renders its own width/focus rather than going through the View signature (e.g. a modal dialog's own grid).

func (*Model) SetFooterHook

func (m *Model) SetFooterHook(fn FooterHook)

SetFooterHook registers (or replaces) the product footer hook after construction. See WithFooterHook.

func (*Model) SetKeyHandler

func (m *Model) SetKeyHandler(fn KeyHandler)

SetKeyHandler registers (or replaces) the product key-handler hook after construction — useful when the hook's closure needs context only available once the Model itself exists (e.g. a dialog capturing its own *Model to react to Space/Enter).

func (*Model) SetRowSource

func (m *Model) SetRowSource(src RowSource, columns []Column)

SetRowSource is SetData for a lazy RowSource (see WithRowSource): it loads the first page of the new source and clears the same state as SetData.

func (*Model) SetSecondaryFocus

func (m *Model) SetSecondaryFocus(focused bool)

SetSecondaryFocus moves keyboard focus to/from the active secondary view. It is a no-op (always false) while the table view is active. Ported from DataTug's gridState.setSecondaryFocus.

func (*Model) SetSize

func (m *Model) SetSize(width, height int)

SetSize resizes the grid to width columns and height lines. The page of rows is auto-fitted to the height: the data rows that fit between the grid's own chrome (a framed grid draws a top border, the column header and a bottom border; a WithoutFrame grid the column header, a footer line and, when extra views exist, the switcher line). An explicit WithMaxVisibleRows acts as an upper bound. A height of 0 or less clears the fit and restores the WithMaxVisibleRows page size. View still takes its own width; the height set here is what View honours. The number of lines View returns is at most height, fewer when there are fewer rows than fit: the parent pads (widgets.Fit).

func (*Model) SetStyle

func (m *Model) SetStyle(s Style)

SetStyle changes the grid's border/header color preset.

func (*Model) SetTitle

func (m *Model) SetTitle(title string)

SetTitle changes the grid's header title after construction (e.g. a version badge DataTug prefixes onto it once an HTTP refresh is compared against its parent).

func (*Model) SetWidth

func (m *Model) SetWidth(width int)

SetWidth resizes the grid and its inner table.

func (*Model) ShowView

func (m *Model) ShowView(v View)

ShowView switches the active view (ViewTable or a registered ExtraView index), clamped to a valid value. Mirrors DataTug's gridState.selectRecordsetPane, auto-focusing the new secondary view when it won't be split with the table.

func (*Model) Sort

func (m *Model) Sort(column int)

Sort toggles ascending/descending order on column, stably. Ported from DataTug's GridModel.Sort (pkg/chat/grid.go).

func (*Model) SortState

func (m *Model) SortState() (column int, desc bool)

SortState reports the column currently sorted (-1 if none) and direction.

func (*Model) Style

func (m *Model) Style() Style

Style is the grid's current border/header color preset.

func (*Model) TableView

func (m *Model) TableView() string

TableView renders just the inner table (no card border/scrollbar/footer), at the Model's last-set width, for a caller that wants to embed it in its own chrome rather than grid.Model's own View.

func (*Model) Title

func (m *Model) Title() string

Title returns the grid's current header title.

func (*Model) ToggleSecondaryFocusIfSplit

func (m *Model) ToggleSecondaryFocusIfSplit() bool

ToggleSecondaryFocusIfSplit toggles SecondaryFocus when the active non-table view is currently sharing the pane with the table (per the registered LayoutFunc), and reports whether it did. A product's own Tab handling (e.g. falling back to focusing its composer) uses the return value to know whether the grid consumed the key.

func (*Model) Update

func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd)

Update handles a message and returns the receiver (the grid is a mutable component, not a value) plus a command. Key handling order: the filter input (while focused) always wins; then, ONLY while secondary focus holds the active non-table view (see SecondaryFocus/SetSecondaryFocus — always true for a non-split view, since the table isn't reachable there), that ExtraView's own Update; then the product KeyHandler (see WithKeyHandler), which can claim any key including Enter, digits or Tab; then the grid's own defaults: h/l select a column (scrolling it into view), up/k and down/j move the table row WHILE THE TABLE HAS FOCUS (a split layout's primary pane, or the plain table view), home/end jump to the first/last row, digit keys switch views (1 is always the table), Tab toggles focus between the table and a split secondary view, s sorts by the selected column, Enter emits RowActivatedMsg, + emits PinRowMsg for the highlighted row's Ref, / opens the built-in filter.

Whenever the highlighted row or the selected column changed because of the message, Update also emits one SelectionChangedMsg (a change made with the SelectRow/SelectColumn setters is not reported). It emits nothing when the selection is unchanged, so holding a key at the last row does not spam.

func (*Model) View

func (m *Model) View(width int, focused bool) string

View renders the grid at the given width: a bordered card (title + view switcher, content, scrollbar down the right edge, a stats footer in the bottom border) around the active view's body — the table by default, or a registered ExtraView, optionally split side by side with the table per WithSplitLayout. Ported from DataTug's gridState.viewWithTitle/recordsetPane/recordsetHeader.

func (*Model) VisibleColumnRange

func (m *Model) VisibleColumnRange() (int, int)

VisibleColumnRange returns the 1-based (first, last) column numbers currently rendered in the table (0, 0 when the pane is too narrow to show any full data column, only bubble-table's overflow marker). With fixed columns first is 1: the frozen columns are always rendered, and columns between them and last may be scrolled out of view.

func (*Model) VisibleIndices

func (m *Model) VisibleIndices() (int, int)

VisibleIndices returns the display-index range (inclusive) of the table's current page, or (0, -1) with no rows.

func (*Model) Width

func (m *Model) Width() int

Width is the last width passed to SetWidth or View.

type Option

type Option func(*Model)

Option configures a Model at construction time.

func WithCellStyle

func WithCellStyle(fn CellStyleFunc) Option

WithCellStyle registers the per-cell style hook (see CellStyleFunc).

func WithExtraViews

func WithExtraViews(views ...ExtraView) Option

WithExtraViews registers product-specific secondary views (e.g. DataTug's Charts/Current-row/Raw/Headers) after the built-in table view, in the given order. They are selected the same way: number keys and the header switcher. See also Model.SetExtraViews for registering them after construction (e.g. once an HTTP response becomes available).

func WithFilterDisabled

func WithFilterDisabled() Option

WithFilterDisabled turns off bubble-table's built-in "/" row filter entirely — no filter typing, no CapturesEsc-while-filtering state — for a grid where that isn't a meaningful operation (e.g. DataTug's bookmark, dock and parameter-lookup grids, which already show a narrow, purpose- built row set) or where the product wants "/" for something else.

func WithFilterOnType

func WithFilterOnType() Option

WithFilterOnType lets printable keys start and feed the row filter directly, without pressing "/" first (a searchable list: type to narrow, Enter blurs the input, Esc clears it). Those keys are then no longer the grid's own h/j/k/l/s/+/digit shortcuts (arrows, home/end and Enter still are), and a WithKeyHandler still sees every key first. It has no effect when the filter is disabled or the grid is backed by a RowSource.

func WithFixedColumns

func WithFixedColumns(n int) Option

WithFixedColumns freezes the first n columns: they stay visible at the left while the remaining columns scroll horizontally under the selected-column navigation (h/l), e.g. an ID or name column. n is clamped to the number of columns. The frozen columns count against the width, so a grid too narrow for them shows an empty scroll region.

func WithFooterHook

func WithFooterHook(fn FooterHook) Option

WithFooterHook registers the product footer hook (see FooterHook).

func WithID

func WithID(id string) Option

WithID names the grid: the id is copied into every message the grid emits (RowActivatedMsg, PinRowMsg, SelectionChangedMsg), so a screen that holds several grids can tell them apart.

func WithInitialSort

func WithInitialSort(column int, desc bool) Option

WithInitialSort records that rows are already ordered by column/desc (e.g. a product re-fetched pre-sorted data, such as DataTug's view-backed docks, rather than calling Sort itself), without re-sorting them. It only sets the grid's own sort-state bookkeeping — the footer's sort indicator and, importantly, the toggle direction the NEXT Sort(column) call picks — to match rows the caller has already arranged. Ported from DataTug's rebuildDockGrids initializing GridModel.sortColumn/sortDesc from the backing View's persisted OrderBy/Descending.

func WithKeyHandler

func WithKeyHandler(fn KeyHandler) Option

WithKeyHandler registers the product key-handler hook (see KeyHandler).

func WithMaxVisibleRows

func WithMaxVisibleRows(n int) Option

WithMaxVisibleRows caps how many rows the table view renders per page so a large result (e.g. 1000 rows) never renders fully into a scrolling pane. Defaults to DefaultMaxVisibleRows; pass 0 to disable paging (not allowed for a RowSource grid, which always pages: 0 then means the default). When the grid is given a height with SetSize, the page is auto-fitted to the height and this value acts as an upper bound.

func WithRowSelection

func WithRowSelection() Option

WithRowSelection makes the grid a row list: whole rows are selected and no column is (h/l and the arrow keys sideways do nothing, the selected column stays 0 and is not emphasised, SelectionChangedMsg.Column and RowActivatedMsg.Column are always 0). For simple name lists such as a table chooser. Horizontal scrolling is then not reachable.

func WithRowSource

func WithRowSource(src RowSource, columns []Column) Option

WithRowSource backs the grid with a lazy RowSource and its columns instead of a []Row (pass nil rows to New). See RowSource for what sort, filter and the row accessors do in this mode.

func WithSplitLayout

func WithSplitLayout(fn LayoutFunc) Option

WithSplitLayout registers the policy used to decide whether a non-table view shares the pane with the table (side by side) or takes the full width. Without it, a non-table view always takes the full pane, matching prior behaviour.

func WithStyle

func WithStyle(s Style) Option

WithStyle sets the grid's initial border/header color preset (see Style). Defaults to StyleLines.

func WithTitle

func WithTitle(title string) Option

WithTitle sets the grid's header title (defaults to "Result").

func WithoutFrame

func WithoutFrame() Option

WithoutFrame makes the grid render just the table and its footer line, with no border, title, focus bullet or scrollbar, for a screen that already frames it (widgets.Frame, the navigation shell). The view switcher line is kept, above the table, only when extra views exist (and WithoutSwitcher was not given). Split layouts still draw their secondary view in a small card of its own.

func WithoutSwitcher

func WithoutSwitcher() Option

WithoutSwitcher hides the "1 Table [· 2 Charts ...]" view-switcher text from the header entirely — main's own title-only header for a grid with no other views worth advertising (DataTug's bookmark, dock and parameter-lookup grids). Digit keys still switch views if any are registered; this only affects what the header displays.

type PinRowMsg

type PinRowMsg struct {
	Ref any
	ID  string
}

PinRowMsg is emitted by the "+" key for the highlighted row when that row has a Ref: the product decides what pinning means (a sidebar entry, a bookmark). Ref is the row's opaque Row.Ref; ID is the grid's WithID value.

type Row

type Row struct {
	Key    string
	Values []any
	Ref    any
}

Row is one grid row. Values is positional, aligned with the Columns slice the Row was built against — not a map keyed by column name — so two columns sharing a name (e.g. `SELECT a.id, b.id`) each keep their own value. A product may pass either raw Go values (formatted by FormatValue) or its own pre-formatted display strings (e.g. DataTug's date-only formatting) — both are valid Row.Values entries. Ref is an opaque reference the product attaches to a row (an entity reference, a record key, ...): the grid never inspects it, it only hands it back through Current, PinRowMsg and CurrentRow. Key, when set to a stable identifier (e.g. the row's original/source index), survives Sort — IndexForKey resolves it back to a display index.

type RowActivatedMsg

type RowActivatedMsg struct {
	Row    Row
	Column int
	ID     string
}

RowActivatedMsg is emitted on Enter over the highlighted row (table view) when no KeyHandler claims "enter" first. Column is the selected column at that moment (the cell the user activated); ID is the grid's WithID value.

type RowSource

type RowSource interface {
	// Len is the total number of rows. It is called once per key press and
	// once per page load, so it must be cheap.
	Len() int
	// Row returns row i (0 <= i < Len()) in display order.
	Row(i int) Row
}

RowSource supplies rows lazily, for a result too large to hold as a []Row (a table with a million records, a paged database cursor). Pass one with WithRowSource instead of a row slice.

The grid asks the source for the rows of ONE page only (at most the page size, see WithMaxVisibleRows and SetSize), when the page is loaded — on construction, when the highlighted row moves onto another page, and on SetSize/Refresh — never on View and never for all Len() rows. Rendering and scrolling therefore cost O(page size), independent of Len().

Sort and filter need all the data, so for a RowSource grid:

  • the "/" filter is disabled (a product that wants search filters its own source and calls Refresh);
  • sort (the "s" key, Model.Sort) is a no-op unless the source also implements Sorter, in which case the grid delegates to it;
  • IndexForKey returns -1 and Rows returns only the loaded page.

Column widths cannot be measured over all rows: they are sized from the header and every page loaded so far and only ever grow, so scrolling never makes a column shrink.

type SelectionChangedMsg

type SelectionChangedMsg struct {
	Row    Row
	Index  int
	Column int
	ID     string
}

SelectionChangedMsg is emitted by Update whenever the highlighted row or the selected column changed because of the message just handled (cursor keys, filter typing, sort, a KeyHandler calling SelectRow, ...), so a screen can show the details of the current cell: a foreign-key target, the referrers of the current row. It is emitted once per change, never while the selection stays put, and never for the SelectRow/SelectColumn/Refresh calls a product makes outside Update. Row is the highlighted row (the zero Row and Index -1 when the grid has no rows), Index its display index (the same index space as SelectRow), Column the selected column, ID the WithID value. A sort that puts a different row (a different Row.Key) under the same highlighted position is also a change.

type Sorter

type Sorter interface {
	SortBy(column int, desc bool)
}

Sorter is an optional interface of a RowSource: it can reorder itself. SortBy is called by Model.Sort (the "s" key) with the column index and direction; the grid then reloads its page. Ordering is the source's business (typically an ORDER BY re-query).

type SplitLayout

type SplitLayout struct {
	Split          bool
	PrimaryWidth   int
	SecondaryWidth int
}

SplitLayout is the result of a LayoutFunc: whether the secondary (non-table) view should share the pane with the table, and at what widths.

type Style

type Style struct {
	Name        string
	BorderColor color.Color
	HeaderStyle lipgloss.Style
}

Style is a grid's border/header color preset. It is presentation only: changing it never rewrites rows or columns. Ported from DataTug's table_style.go (tableStyle), generalised so any product can define and cycle through its own presets.

func ParseStyle

func ParseStyle(name string) Style

ParseStyle finds a built-in preset by Name (e.g. as persisted in a saved session), defaulting to StyleLines for an unknown or empty name.

type View

type View int

View selects what the grid's secondary area shows: the built-in table (ViewTable, always index 0), or a product-registered ExtraView (index 1..len(extraViews), in registration order). Unlike an earlier revision, there is no fixed Card/Inspector view: use the CardView/InspectorView constructors below to register one (or both, in whatever order) as an ExtraView, alongside a product's own (Charts, Raw response, ...).

const ViewTable View = 0

ViewTable is the sortable/filterable table (the default, always index 0).

Jump to

Keyboard shortcuts

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