gotermboard

package module
v0.10.0 Latest Latest
Warning

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

Go to latest
Published: Nov 7, 2026 License: MIT Imports: 16 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 or Beside 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 summary, wrapping under Below while f has them on.

Span draws a long value whole past a group's title, wrapping under itself, so it never sets the first column's width. The group's cells then take the next line, with Beside ahead of them in the first column. Column.Min holds that column wide enough to be useful.

A row can open a document laid out at its width

Document on a Row or a Group is a function of width, because hanging bullets, tables and boxes of code cannot be wrapped after they are drawn. A row's opens under it on enter, or on the full-screen reader where it runs past the screen. A group's draws under Below whether the group is folded or not. The markdown package makes one from markdown, in ANSI 0-15 so it follows the theme:

renderer := markdown.New(nil)
row.Document = func(width int) []gotermboard.Line { return renderer.Lines(description, min(width, 100)) }

A fence naming a language wraps a line wider than its box, marking each continued row with ↪, as bat and delta do. A text or bare fence cuts that line instead, so a diagram or a table of output stays lined up. WrapEveryFence on the renderer wraps those too, for a text whose every word has to reach the reader.

Its argument colors what a caller recognizes in prose, such as a number that names a record.

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 function of the panel's width whose lines wrap at spaces with each cell's own color. A row with no Panel shows its Document. 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 tall document reads on a full screen

Reader is a full screen over one Document. It lays the text out at up to 100 columns, centers it on a wider screen, and keeps the same words at the top through a resize. j and k, ctrl+d and ctrl+u, g and G move through it, [ and ] jump between sections, and / searches with n and N. A query with no capital in it ignores case. y copies CopyText, or the text as drawn, over OSC 52. e opens EditFile in $VISUAL or $EDITOR and lays the text out again after.

Model.Read opens one over the board from a board's own key, and esc returns to the board. Model.Tall says whether a document runs past the screen. NewReader runs as a program of its own, which is all cmd/read-markdown does with a file or a pipe:

go install github.com/datapointchris/gotermboard/cmd/read-markdown@latest
read-markdown README.md

A Cell with Section set marks its line as a section. The markdown package sets it on each heading.

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.

View Source
const ReadingWidth = 100

ReadingWidth is the widest a reader lays text out, because a longer line is hard to follow back to its start. A wider screen keeps the rest as margin. A view that lays the same text out in its table reads this, so the two wrap alike.

View Source
const RuleCode = "\033[0;90m"

RuleCode is the gray a rule draws in, which the terminal palette's theme sets.

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
	// Section says the line it is on opens a section, where a Reader's [ and ] land.
	Section 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
	// Min holds the column at least this wide, for what a group's Beside fits into.
	Min   int
	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 Document added in v0.4.0

type Document func(width int) []Line

Document is text that lays itself out at a width, for what only the draw can place: hanging bullets, tables and boxes of code. Its lines are drawn as they come.

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
	// Span draws whole past the title on the header's line, wrapping under itself, so a
	// long value never sets the first column's width. The cells then take a line of their
	// own beneath it, and Beside sits ahead of them in the first column.
	Span Line
	// Beside is what the first column holds on a Span group's line of cells, under the
	// Span's start. An Items line there is cut to fit.
	Beside Line
	// Under is the group's summary, wrapping on a └─ row under Below while those are on.
	Under Cell
	// Document draws under Below whether the group is folded or not, so a view can open
	// a group's text in place. The cursor moves past it too.
	Document Document
	// 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
	// Rule draws a thin line after the group, in place of Gap's blank one.
	Rule bool
	// Tone is the code the group's overline and underline draw in, where not the rule gray.
	Tone string
	// Overline draws a line across the table above the header.
	Overline RuleWeight
	// Underline draws a line across the table under the header, ahead of Below.
	Underline RuleWeight
	// 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 Document
	// 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) Read added in v0.6.0

func (m *Model) Read(title string, document Document)

Read opens document on a full-screen Reader over the board, until esc closes it.

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) Tall added in v0.6.0

func (m *Model) Tall(document Document) bool

Tall says document runs past the screen, so it reads on a Reader rather than in place.

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

type Reader struct {
	// CopyText is what y copies. Nil copies the text as drawn.
	CopyText func() string
	// EditFile is the file e opens in the editor, after which the text is laid out again.
	// Empty leaves e unbound.
	EditFile string
	// contains filtered or unexported fields
}

Reader is a full screen over one Document. It lays out every row at the width and draws the window that fits, because Bubble Tea keeps only the last lines of a taller view. It runs as a program of its own, or a Model opens one with Read.

func NewReader added in v0.6.0

func NewReader(title string, document Document, pal gotermstyle.Palette) *Reader

NewReader is a Reader over document, sized until the terminal says otherwise.

func (*Reader) Init added in v0.6.0

func (r *Reader) Init() tea.Cmd

Init is Bubble Tea's.

func (*Reader) Once added in v0.6.0

func (r *Reader) Once(width int) string

Once is the whole document at width, for output nobody scrolls.

func (*Reader) Update added in v0.6.0

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

Update is Bubble Tea's.

func (*Reader) View added in v0.6.0

func (r *Reader) 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 summary, 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
	// Document is what enter opens under the row and the side panel shows, in Detail's place.
	Document Document
	// Panel is what the side panel shows in place of both, each line wrapping at spaces
	// with its cells' own colors.
	Panel Document
	// 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 RuleWeight added in v0.8.0

type RuleWeight int

RuleWeight is how heavy a line across the table is drawn.

const (
	RuleNone RuleWeight = iota
	RuleThin
	RuleDouble
)

The weights a rule takes.

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.

Directories

Path Synopsis
cmd
read-markdown command
read-markdown draws a markdown file, or what is piped to it, on a full-screen reader.
read-markdown draws a markdown file, or what is piped to it, on a full-screen reader.
Package markdown lays markdown out as a board's lines at a given width, in colors that follow the terminal's theme.
Package markdown lays markdown out as a board's lines at a given width, in colors that follow the terminal's theme.

Jump to

Keyboard shortcuts

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