Remote data¶
When the data lives behind an API and may be too large for one request, tuilib lets scrolling be the pagination. The user scrolls; windows of rows arrive under them. There are no page keys and no "next page" button.
Choose the component by the data's shape¶
Classify two things first.
What an item is:
- a Record: fields you compare across items (a deployment, a book);
- an Event: fields in a timeline read in order (an AWX job event);
- a Line: raw text read in order (a log line).
How an item is reached:
- Seekable: item N can be fetched directly and there is a total
(
?offset=200&limit=100); - Anchored: items are walked forwards or backwards from an anchor, with no
reliable total (Elasticsearch
search_after, logs filtered by time); - Streamed: only the tail, then whatever arrives.
Growing data gains items while the user watches (a running job).
| Seekable | Anchored | Streamed | |
|---|---|---|---|
| Record | table via remote.NewTable + Seekable |
table via remote.NewTable + Anchored |
table + SetKeyedRows from a watch |
| Event | eventlog via remote.NewEventlog + Seekable, inspector on enter |
eventlog via remote.NewEventlog + Anchored, inspector on enter |
logview + inspector |
| Line | eventlog via remote.NewEventlog + Seekable |
eventlog via remote.NewEventlog + Anchored |
logview + pkg/resume |
Rules of thumb:
- If it fits in one request, don't page it. Use
SetRowsor atextview. - Events never go in a table. A timeline has one order; a table exists to
sort and compare. Put an event's fields one
enteraway, in an inspector. - Search jumps, filter narrows. A filter is part of the query and returns fewer items; search moves the view to the next match and keeps the context around it. The eventlog has both, as separate keys.
- Push channels are hints. Websockets that drop messages under load can't be the source of truth; poll, and let a push make the next poll sooner.
The remote data design note classifies specific sources (Prefect, AWX, Elasticsearch, pod logs, generic REST).
Binding a view: pkg/remote¶
Start here. pkg/remote binds a table or eventlog to a source, so the
screen writes only the fetch, as ordinary synchronous Go:
s.tab = remote.NewTable(opts, remote.Seekable[table.KeyedRow]{
Page: s.page,
PageSize: 100,
})
func (s *Screen) Init() tea.Cmd { return s.tab.Init() }
func (s *Screen) Layout() layout.Node { return layout.Sized(s.tab) }
func (s *Screen) IsCapturingKeys() bool { return s.tab.IsCapturingKeys() }
func (s *Screen) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { return s, s.tab.Update(msg) }
func (s *Screen) SetTheme(t theme.Theme) { s.tab.Restyle(s.options(t)) }
// page runs off the UI goroutine. ctx is cancelled when a newer request
// supersedes this one.
func (s *Screen) page(ctx context.Context, w remote.Window) ([]table.KeyedRow, int, error) {
q := url.Values{}
q.Set("offset", strconv.Itoa(w.Offset))
q.Set("limit", strconv.Itoa(w.Limit))
if w.Sort != "" {
q.Set("sort", w.Sort)
}
for _, t := range w.Terms {
if t.Title != "" && t.Regex == nil {
q.Set(strings.ToLower(t.Title), t.Value) // region:eu → ?region=eu
}
}
req, err := http.NewRequestWithContext(ctx, "GET", base+"/apps?"+q.Encode(), nil)
// … do it, decode, return rows and the total
}
The view embeds its component, so Selected, IsActivate, SetDistinct,
focus and help are all there. The binding adds:
| Method | Does |
|---|---|
Init() |
requests the first page |
Update(msg) tea.Cmd |
routes between view and source, runs fetches; forward every message |
Restyle(opts) |
rebuilds on a theme swap, keeping rows, filter, sort, cursor and failure |
Refresh() |
refetches what's on screen under the same query (a retry) |
SetGrowing(b) |
the data is still gaining items: follow the newest and poll |
SetAnchor(a) |
(Anchored) move to source.Newest(), source.Oldest() or source.At(cursor) |
Shapes and request types¶
| Shape | Fetch functions | Request |
|---|---|---|
remote.Seekable[T] |
Page(ctx, Window) (items, total, err), optional Find(ctx, Find) (at, found, err) |
Window{Filter, Offset, Limit} |
remote.Anchored[T] |
Edge(ctx, Edge) (items, more, err), optional Find(ctx, Find) (cursor, found, err) |
Edge{Filter, Newer, Cursor, Inclusive, Limit} |
T is table.KeyedRow for a table and eventlog.Item for an eventlog. Every
request embeds the committed Filter{Raw, Terms, Sort, Desc}, where scoped
filter terms carry their resolved column title. With Find nil, search covers
only what's loaded.
Shared tuning fields on both shapes: Name (labels the query history),
PageSize (default 100), Follow (poll interval for growing data, default
2s), ViewportDelay (default 150ms), Context (parent of every request
context). Seekable also has Prefetch (extra pages beyond the screen);
Anchored has Anchor (where the view starts, default newest).
Worked examples: examples/patterns/remote (a seekable table),
examples/patterns/eventlog (a seekable, growing eventlog over an AWX-shaped
API), examples/patterns/anchored (an Elasticsearch-shaped anchored
eventlog).
What the library does for a slow source¶
With replies taking seconds, the screen still does nothing special:
- Stale, not blank. Committing a new filter or sort leaves the old rows on
screen, dimmed, with the border naming the query they answer and the one
loading. They can still be read and scrolled. The cursor goes to the top
when the new answer lands. The body spinner shows only before the very first
answer.
Stale()lets a verb refuse to act on stale rows. - Debounced sort. A remote sort is staged until sort input has been quiet
for 400ms (
table.Options.SortDebounce), so]]sis one request. - Debounced scroll. Scrolling asks only once the viewport has been still for 150ms, and not at all while the committed query is unanswered.
- Placeholders. Rows not yet received render as
·,Selectedreportsok=falsefor them, and the border reads⠙ loading rows 201–300. - Cancellation. Every request's context is cancelled when a newer request supersedes it, so at most one request per source is live on the server.
- Failures. A failed query stays stale with
✗ failedon the border and the user's filter and sort kept. Committing it again, orRefresh, retries. Nothing retries automatically. - History. Each committed query's outcome (answered with timing, failed, cancelled) is written to the output console, and failures to the statusbar.
Completion for remote filters¶
A table under a remote filter stops scraping completion values from its rows, because one page of a larger set completes to wrong answers. Feed it real values from a facet endpoint:
s.tab.SetDistinct(1, regions) // column 1's candidates for tab completion
The manual loop: pkg/source¶
When pkg/remote doesn't fit, drive the table and a source.Model yourself.
The table reports, the source decides what to ask for, and the screen fetches:
s.src = source.New(source.Options{PageSize: 100})
func (s *Screen) Init() tea.Cmd { return s.src.Init() }
func (s *Screen) Update(msg tea.Msg) (screen.Screen, tea.Cmd) {
var cmds []tea.Cmd
switch m := msg.(type) {
case table.ViewportChangedMsg:
cmds = append(cmds, s.src.Viewport(m.FirstVisible, m.LastVisible))
case table.QueryChangedMsg:
cmds = append(cmds, s.src.SetQuery(m.Raw, m.Terms, m.Sort, m.Desc))
case source.RequestMsg:
return s, s.fetch(m.Query) // your HTTP call, under m.Query.Ctx
case fetchedMsg:
ok, cmd := s.src.Deliver(m.page)
switch {
case !ok: // superseded; drop it
case m.page.Err != nil:
s.table.SetFailed(answered(m.query))
default:
s.table.SetWindow(m.rows, m.page.Offset, m.page.Total, answered(m.query))
}
return s, cmd
}
var cmd tea.Cmd
s.table, cmd = s.table.Update(msg)
cmds = append(cmds, cmd, s.src.Update(msg))
return s, tea.Batch(cmds...)
}
func answered(q source.Query) table.Answer {
return table.Answer{Raw: q.Raw, Sort: q.Sort, Desc: q.Desc}
}
The table must use FilterMode: table.FilterRemote and
SortMode: table.SortRemote. Check what Deliver returns and return its
command: the bool is what stops a slow reply to an old query painting over
the current one, and the command carries the query history and any follow-up
request. Fetch under Query.Ctx so superseded requests are cancelled on the
wire.
source.Options: Name, Mode (ByOffset, or ByCursor for
continuation-token APIs that only walk forward), PageSize, Prefetch,
ViewportDelay, Context, MaxHeld (hold a range rather than one window,
for an eventlog), Follow. source.NewAnchored(source.AnchoredOptions{…}) is
the anchored counterpart. See Packages: source.
Don'ts¶
- Don't paginate by swapping
SetRowsper page. UseSetWindow. - Don't add
n/ppage keys. They collide with reserved keys and add a second navigation model. - Don't give a windowed table content-auto column widths. Columns would
reflow on every window. Use fixed
WidthorFlex. - Don't emit a query per keystroke. The table reports filters on commit.
- Don't call
SetLoadingorSetCursor(0)in a remote loop. The table decides both. - Don't read a placeholder as data. Check
Selected's bool. - Don't let state-restoration setters fetch.
SetSort/SetValueadopt state silently; callQuery()yourself if you set a filter programmatically and want it fetched.