grid

package
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Sep 24, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package grid is a product-neutral result-grid transcript block: 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. 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.

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); ←→/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; + emits tui.AddToSidebarMsg for the highlighted row's Ref; / opens bubble-table's built-in filter. While the filter input is focused, CapturesEsc reports true so a surrounding chatshell 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).

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 transcript.

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 DefaultMaxVisibleRows = 12

DefaultMaxVisibleRows is the page size a Model uses when WithMaxVisibleRows is not supplied.

Variables

View Source
var (
	StyleLines = Style{
		Name:        "Lines",
		BorderColor: lipgloss.Color("241"),
		HeaderStyle: lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("255")).Bold(true),
	}
	StyleSoft = Style{
		Name:        "Soft",
		BorderColor: lipgloss.Color("235"),
		HeaderStyle: lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")).Bold(true),
	}
	StyleMinimal = Style{
		Name:        "Minimal",
		BorderColor: lipgloss.Color("232"),
		HeaderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Bold(true),
	}
)

Built-in style presets, ported from DataTug's table_style.go.

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 Column

type Column struct {
	Name    string
	Numeric bool
}

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 transcript.EntityBlock: 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) 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) 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 index of the first horizontally-scrolled-into-view column in the table.

func (*Model) Columns

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

Columns/Rows expose the current (sorted) state for inspection/tests.

func (*Model) Current

func (m *Model) Current() *session.EntityRef

Current implements transcript.EntityBlock: the highlighted row's Ref.

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) CurrentView

func (m *Model) CurrentView() View

CurrentView reports the active view.

func (*Model) ExtraViews

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

ExtraViews returns the currently registered extra views.

func (*Model) Focusable

func (m *Model) Focusable() bool

Focusable implements transcript.Block: a grid is always a focusable stop.

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) 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.

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) Rows

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

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.

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) 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 transcript.Block 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) 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) 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) SetView

func (m *Model) SetView(v View)

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

func (*Model) SetWidth

func (m *Model) SetWidth(width int)

SetWidth resizes the grid and its inner 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) (transcript.Block, tea.Cmd)

Update implements transcript.Block. 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 move the table row WHILE THE TABLE HAS FOCUS (a split layout's primary pane, or the plain table view), 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 tui.AddToSidebarMsg, / opens the built-in filter.

func (*Model) View

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

View implements transcript.Block: 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/recordsetView/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).

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 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 added in v0.0.2

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 WithFooterHook

func WithFooterHook(fn FooterHook) Option

WithFooterHook registers the product footer hook (see FooterHook).

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 transcript. Defaults to DefaultMaxVisibleRows; pass 0 to disable paging.

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 WithoutViewSwitcher added in v0.0.2

func WithoutViewSwitcher() Option

WithoutViewSwitcher 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 Row

type Row struct {
	Key    string
	Values []any
	Ref    *session.EntityRef
}

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, when set, lets the row be pinned to the sidebar or resolved as the "current" entity (transcript.EntityBlock). 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 }

RowActivatedMsg is emitted on Enter over the highlighted row (table view) when no KeyHandler claims "enter" first.

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