gotermboard

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 14 Imported by: 0

README

gotermboard

A self-refreshing terminal board for Go command-line tools, on Bubble Tea v2.

A program built on it has a tab per view. Each view is one table of folding groups sharing one set of columns, with a side panel that follows the cursor. Each source of data refreshes at its own pace, and the board draws its last good read with that read's age, so a failing backend leaves the table drawn and says the read is old.

type board struct{ jobs *gotermboard.Source[[]job] }

func newBoard() *board {
 return &board{jobs: gotermboard.NewSource("jobs", 5*time.Second, readJobs)}
}

func (b *board) Sources() []gotermboard.Refresher { return []gotermboard.Refresher{b.jobs} }
func (b *board) Groupings() []string              { return nil }

func (b *board) Frame(now time.Time, _ string) gotermboard.Frame {
 group := gotermboard.Group{Key: "jobs", Title: gotermboard.Line{gotermboard.C("JOBS", gotermboard.Bold)}}
 for _, j := range b.jobs.Value() {
  group.Rows = append(group.Rows, gotermboard.Row{Key: j.Name, Cells: []gotermboard.Cell{
   gotermboard.C(j.Name, ""),
   gotermboard.C(j.State, gotermboard.Cyan),
   gotermboard.Ticking(gotermboard.Ago(now.Sub(j.Started)), ""),
  }})
 }
 return gotermboard.Frame{
  Strip:   []gotermboard.Line{gotermboard.ReadStatus([]gotermboard.Status{b.jobs.Status()}, now)},
  Columns: []gotermboard.Column{{Title: ""}, {Title: "STATE"}, {Title: "AGE"}},
  Groups:  []gotermboard.Group{group},
 }
}

func main() {
 pal := gotermstyle.PaletteFor(os.Stdout)
 tabs, err := gotermboard.NewTabs([]gotermboard.Tab{{Name: "jobs", Board: newBoard()}}, pal, "")
 if err != nil {
  log.Fatal(err)
 }
 if _, err := tea.NewProgram(tabs).Run(); err != nil {
  log.Fatal(err)
 }
}

Tabs read only while they show

NewTabs takes the tabs in the order Tab cycles them, and the name of the one to open on. Each tab keeps its own cursor, folds and filter. A hidden tab starts no read and builds no frame. On its return it draws its last good read with that read's age, and every source past its interval reads at once. A source inside its interval keeps its pace, so cycling the tabs quickly starts nothing extra. A program with one tab draws no tab line, and Tab does nothing in it.

Once draws one frame from what a board's sources already hold, and OnceSize picks its size: COLUMNS by LINES, then the terminal, then a fallback you pass.

One line says how the reads stand

ReadStatus is a line for a view's strip. While every read is on schedule it says read 3s ago. A failed read is named in red with its reason, one past twice its interval in yellow, and a note on a read that covered less follows plain. The ? overlay lists every source's age, interval, cost and fault.

A row that changed wears a cue

The board compares each row's and group's key and cells between frames, and marks a changed one for CueFor: a dot in the pointer's column and its first cell in bold. No column moves. A cell built with Ticking follows the clock, as an age or a gauge does, so its text changing is no change to the row, though its color changing is. A first read, a tab's return and a new grouping mark nothing.

Group rows share the columns

A Group with Cells draws them on the columns past the first, which holds its title, and the column titles then take a line of their own at the top. Below holds lines drawn under the header whether it is folded or not, which the cursor moves past as one node. WrapLast on a Row or a Group is the last column as cells of their own colors, continuing on further lines at that column, so a bar wider than the row keeps every cell.

Items makes a line of Below that the draw cuts to the items that fit, ending in +N, because only the draw knows the width and it moves with the side panel. Under is a group's first line, wrapping under Below while f has them on. Paragraphs wrap under Below whether the group is folded or not, for a view that opens a group's text in place.

A Still group has no fold mark and the cursor passes over it. At the top of a frame it stays on screen with the column titles while the table scrolls, which suits a row of totals.

A filter can reach past what shows

FilterOnly keeps a group out of the table until a filter is typed, so a view that shows the first few of something still finds the rest. Text is more the filter matches beyond what a group draws, and Heading says a title only labels what sits under it, so a word in it matches nothing.

The side panel follows the cursor

Panel on a row or a group is what the side panel shows in Detail's place, a line at a time, wrapping at spaces with each cell's own color. A board implementing CursorBoard is told each key the cursor lands on, so a read that only one row needs can follow it. Frame.StartAt names the key the cursor starts on.

A board can add keys

A board implementing Keyed adds Actions, which take precedence over the table's and appear in the footer and the ? overlay. A board implementing TailBoard reads from its newest row, as a log does.

Documentation

Overview

Package gotermboard draws a self-refreshing terminal board on Bubble Tea v2: a tab per view, folding groups on shared columns, a side panel that follows the cursor, and one action table the keys, the footer and the ? overlay all read.

A view is a read model, as in CQRS. Its reader joins sources into a snapshot, and its renderer turns the snapshot into a Frame this package draws. A failed read never gives up a good value: a Source keeps its last good read and says how old it is.

Index

Constants

View Source
const (
	CueGlyph = "•"
	CueCode  = BoldCyan
	CueFor   = 30 * time.Second
)

The cue a row or group wears after it changed. It sits in the pointer's column, so no column moves for it.

View Source
const (
	Red     = gotermstyle.CodeRed
	Yellow  = gotermstyle.CodeYellow
	Green   = gotermstyle.CodeGreen
	Cyan    = gotermstyle.CodeCyan
	Blue    = gotermstyle.CodeBlue
	Magenta = gotermstyle.CodeMagenta
	White   = gotermstyle.CodeWhite
	Bold    = gotermstyle.CodeBold

	BoldRed     = "\033[1;91m"
	BoldYellow  = "\033[1;93m"
	BoldGreen   = "\033[1;92m"
	BoldCyan    = "\033[1;96m"
	BoldBlue    = "\033[1;94m"
	BoldMagenta = "\033[1;95m"
	Reverse     = "\033[7m"
)

The codes a view colors a cell with, all in ANSI 0-15 so they follow the theme.

View Source
const (
	MarkGlyph = "◆"
	MarkCode  = BoldMagenta
)

The mark a Marked row draws.

View Source
const PanelFrom = 160

PanelFrom is the narrowest screen that gets the side panel.

Variables

This section is empty.

Functions

func Ago

func Ago(d time.Duration) string

Ago is a duration as one unit: 45s, 14m, 26h, 3d.

func Once

func Once(board Board, pal gotermstyle.Palette, width, height int) string

Once is one frame at a size, drawn from what the board's sources already hold.

func OnceSize

func OnceSize(fallbackWidth, fallbackHeight int) (width, height int)

OnceSize is the size one printed frame draws at: COLUMNS by LINES, then the terminal's own size, then the fallback.

func RefreshAll

func RefreshAll(ctx context.Context, sources ...Refresher)

RefreshAll reads every source once, all at once, and returns when each ended.

Types

type Action

type Action struct {
	Keys   []string
	Label  string
	Footer string
	Run    func(*Model) tea.Cmd
	// Applies says whether this view can use the key now. Nil means always.
	Applies func(*Model) bool
}

Action is one row of the key table the handler, the footer and the ? overlay all read.

type Board

type Board interface {
	Sources() []Refresher
	Groupings() []string
	Frame(now time.Time, grouping string) Frame
}

Board is one view, as this package draws it: sources to keep fresh, and the frame their last good reads make now.

type Cell

type Cell struct {
	Text  string
	Code  string
	Ticks bool
	// contains filtered or unexported fields
}

Cell is text and its color. Ticks says the text follows the clock, as an age does, so its changing is no change to the row, though its color changing still is.

func C

func C(text, code string) Cell

C is a Cell.

func Ticking

func Ticking(text, code string) Cell

Ticking is a Cell whose text follows the clock.

type Column

type Column struct {
	Title string
	Max   int
	Right bool
}

Column is one column of the table. The last is unpadded, so the screen's edge cuts it first. Max caps its width, where one long value must not take the row.

type CursorBoard added in v0.3.0

type CursorBoard interface {
	CursorOn(key string)
}

CursorBoard is a Board told which key the cursor is on, for a read that follows it.

type Frame

type Frame struct {
	Strip   []Line
	Columns []Column
	Groups  []Group
	// StartAt is the key the cursor starts on, where that key is showing. Without one it
	// starts on the first line holding cells.
	StartAt string
}

Frame is one screen of a view, before it is laid out to a size.

type Group

type Group struct {
	Key   string
	Title Line
	// Count is drawn after the title as " · n", where Counted says to.
	Count   int
	Counted bool
	// Cells draw on the columns past the first, which holds the title, so a group's
	// totals line up with the rows under it.
	Cells []Cell
	// WrapLast is the last column as Row.WrapLast has it.
	WrapLast Line
	// Below are lines drawn under the header whether the group is folded or not. The
	// cursor moves past them as one node.
	Below []Line
	// Under is the group's first line, wrapping on a └─ row under Below while those are on.
	Under Cell
	// Paragraphs wrap under Below whether the group is folded or not, so a view can open
	// a group's text in place. The cursor moves past them too.
	Paragraphs []Cell
	// Summary is drawn past the title while the group is folded.
	Summary Line
	Folded  bool
	// Gap draws a blank line after the group.
	Gap bool
	// Still keeps the header open with no fold mark, and the cursor passes over it. At
	// the top of a frame it stays on screen with the column titles while the table scrolls.
	Still bool
	// Heading says the title only labels what sits under it, so the filter looks past it.
	Heading bool
	// FilterOnly keeps the group out of the table until a filter is typed, so a search
	// reaches past what a view shows.
	FilterOnly bool
	Rows       []Row
	Groups     []Group
	Detail     []Cell
	// Panel is what the side panel shows in Detail's place, as Row.Panel has it.
	Panel []Line
	// Text is more the filter matches beyond what the group draws.
	Text string
	// contains filtered or unexported fields
}

Group is a header carrying its count, with rows and groups under it.

type Keyed

type Keyed interface {
	Actions() []Action
}

Keyed is a Board with keys of its own. They are read ahead of the table's, so a view can add a key or take one over, and the footer and the ? overlay list them.

type Line

type Line []Cell

Line is cells drawn one after another.

func Bar

func Bar(done, underWay, open, cells int) Line

Bar is a progress bar: done in green, the work under way in yellow, and the rest plain.

func Items added in v0.3.0

func Items(code string, items ...string) Line

Items is a line of a Group's Below that the draw cuts to the items that fit, ending in +N for the rest, rather than letting the screen's edge cut one in half. The width it has moves with the side panel, so only the draw knows it. The filter and the change cue read every item, shown or not.

func ReadStatus

func ReadStatus(statuses []Status, now time.Time) Line

ReadStatus is the one line a strip says about its reads. While every read is on schedule it says how old the newest is. A failed read is named in red with its reason, a late one in yellow, and a note follows plain, because a read that covered less still read. The screen's edge cuts them in that order.

func (Line) Text

func (l Line) Text() string

Text is the line with its colors out.

type Model

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

Model is the Bubble Tea program of one view. Tabs holds one per tab.

func NewModel

func NewModel(board Board, pal gotermstyle.Palette) *Model

NewModel is a model reading board, sized until the terminal says otherwise.

func (*Model) Cursor

func (m *Model) Cursor() string

Cursor is the key of the group or row the cursor is on, for a board's own keys.

func (*Model) HalfScreen

func (m *Model) HalfScreen() int

HalfScreen is half the table's height in lines.

func (*Model) Init

func (m *Model) Init() tea.Cmd

Init starts every source's first read and the clock.

func (*Model) Rebuild

func (m *Model) Rebuild()

Rebuild is for a key that changed what the board shows from what it already holds.

func (*Model) Restart

func (m *Model) Restart() tea.Cmd

Restart puts the cursor back at the top and reads every source now, for a board's key that changes what the view shows.

func (*Model) Reveal added in v0.3.0

func (m *Model) Reveal()

Reveal scrolls the cursor's row and what opens under it into view, for a board's key that opens text under a row.

func (*Model) Say

func (m *Model) Say(text string)

Say puts text where the footer says what the last key did.

func (*Model) ScrollBy

func (m *Model) ScrollBy(lines int)

ScrollBy moves the screen over the table by lines and leaves the cursor, for reading through a row's detail taller than the screen. Moving the cursor ends it.

func (*Model) Toggle

func (m *Model) Toggle() tea.Cmd

Toggle folds the group under the cursor or opens its row's detail, for a board's key that takes enter and acts only on some rows.

func (*Model) ToggleEveryRow

func (m *Model) ToggleEveryRow() tea.Cmd

ToggleEveryRow opens every showing row's detail, or closes them all where every one is open already, so a whole group reads as one document.

func (*Model) Update

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

Update is Bubble Tea's.

func (*Model) View

func (m *Model) View() tea.View

View is Bubble Tea's.

type Refresher

type Refresher interface {
	// Claim reports whether a read is due at now, and marks one under way.
	Claim(now time.Time) bool
	Refresh(ctx context.Context)
	// Force makes the next Claim true.
	Force()
	Status() Status
}

Refresher is a Source with its value's type out of the way, for the loop that only starts reads.

type Row

type Row struct {
	Key    string
	Symbol Cell
	Cells  []Cell
	Span   Line
	// WrapLast is the last column as cells of their own colors. It continues on
	// further lines at that column, where a cell in Cells is cut at the screen's edge.
	WrapLast Line
	// Chevron draws ▸ or ▾ in Symbol's place, for a row whose Detail is the point of it.
	Chevron bool
	// Under is the row's first line, drawn on a └─ row while those are on.
	Under Cell
	// Detail is what the side panel shows while the cursor is on the row, and
	// what enter opens under the row. Each cell is a paragraph that wraps.
	Detail []Cell
	// Panel is what the side panel shows in Detail's place, each line wrapping at
	// spaces with its cells' own colors.
	Panel []Line
	// Text is more the filter matches beyond what the row draws.
	Text string
	// Marked draws MarkGlyph in the row's indent and its first cell in MarkCode. No
	// column moves for it.
	Marked bool
	// contains filtered or unexported fields
}

Row is one line of the table: Cells on the columns, or Span across them.

type Source

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

Source is one read at its own interval, holding its last good value.

func NewSource

func NewSource[T any](name string, every time.Duration, read func(context.Context) (T, string, error)) *Source[T]

NewSource declares a read. read answers the value, a note on what it could not cover, and a failure, which leaves the last good value in place.

func (*Source[T]) Claim

func (s *Source[T]) Claim(now time.Time) bool

Claim is Refresher's. A read still under way is never started twice, so a slow source falls behind rather than piling up.

func (*Source[T]) Force

func (s *Source[T]) Force()

Force is Refresher's.

func (*Source[T]) Refresh

func (s *Source[T]) Refresh(ctx context.Context)

Refresh reads once, keeping the last good value where the read fails.

func (*Source[T]) Status

func (s *Source[T]) Status() Status

Status is how the source stands now.

func (*Source[T]) Value

func (s *Source[T]) Value() T

Value is the last good read, and the zero value before the first.

type Status

type Status struct {
	Name    string    `json:"name"`
	Every   string    `json:"every"`
	ReadAt  time.Time `json:"read_at,omitzero"`
	Took    string    `json:"took,omitempty"`
	TriedAt time.Time `json:"tried_at,omitzero"`
	// Fault is why the newest try failed, and empty where it read.
	Fault string `json:"fault,omitempty"`
	// Note is what a read that succeeded could not cover, such as a host asleep.
	Note string `json:"note,omitempty"`
	// Reading says a read is under way.
	Reading bool `json:"reading,omitempty"`
	// contains filtered or unexported fields
}

Status is what a view says about one source, on its strip and in its --json.

type Tab

type Tab struct {
	Name  string
	Board Board
}

Tab is one named view of a program.

type Tabs

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

Tabs is the Bubble Tea program of one Model per tab, the Elm Architecture's composition. Each tab keeps its own cursor, folds and filter. A hidden tab starts no read and builds no frame. On its return it draws its last good read with that read's age, and whatever is due reads at once.

func NewTabs

func NewTabs(tabs []Tab, pal gotermstyle.Palette, open string) (*Tabs, error)

NewTabs is a program of these tabs, opening on the one named open, or the first where open is empty. A name no tab has is an error naming the ones there are.

func (*Tabs) Init

func (t *Tabs) Init() tea.Cmd

Init is Bubble Tea's. One clock runs, and it goes to whichever tab is showing.

func (*Tabs) Update

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

Update is Bubble Tea's. A size goes to every tab, and the rest to the one showing.

func (*Tabs) View

func (t *Tabs) View() tea.View

View is Bubble Tea's.

type TailBoard

type TailBoard interface {
	Tails() bool
}

TailBoard is a Board read from its newest row, as a log is. Its cursor starts on the last row, and stays on the last row as rows land while it is there.

Jump to

Keyboard shortcuts

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