gotermboard

package module
v0.44.1 Latest Latest
Warning

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

Go to latest
Published: Nov 8, 2026 License: MIT Imports: 21 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, builds no frame and moves no strip. 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.

A row jumps to the tab that shows what it names, and esc comes back

A jump is a back stack, as a browser's history or Vim's jumplist is. A row or a group lists what it is about in Entities, each an EntityRef of a kind and an id every view agrees on. The package gives no kind a meaning. A board implementing EntityBoard names the kinds it shows, so t on a row naming one goes to that tab, pushing the tab it left. Where what a row names goes to more than one place, t lists them by number and goes to the one typed.

  • ShowEntity hears the entity first, and says whether the board now shows it whole. A board that follows one thing changes what it reads and answers true. Otherwise the cursor goes to the first group or row naming the entity, opening the groups that hold it. Where none names it once the reads land, the footer says so, and the jump still lands. A read that failed says the tab may hold it instead. Either way the tab starts at its top and reads every source at once, as Restart does.
  • esc at the tab a jump opened goes back, on the row it left. It sits ahead of the board's own esc, so that tab is the top of where the jump went. Clearing a filter, closing a question and closing a reader come first. With nothing to go back along, esc is the board's.
  • Tab and shift+tab empty the stack, because the reader left the trail the jumps made.
  • OpensOutside adds a program that shows a kind whole, such as a pull request tool opened on one. A jump there hands it the terminal and pushes nothing, because quitting it comes back. It ends as HandOver does: the board receives the HandedBack, and a failure says the last line of its stderr. Command returns a tea.ExecCommand, so the caller's own runner starts it.
  • Tabs.Keys is the keys a board's own would hide: tab and shift+tab where there are tabs to cycle, and t where a row could go somewhere. TableKeys is the table's alone. Model.CursorEntities is what the cursor's line names.

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. A screen that is no board picks its printed frame's size the same way with printedframe.Size, which imports nothing that draws.

A theme file colors a board by role, and SIGUSR2 recolors a running board

NewTabs, NewModel, NewReader and Once read the TUI theme file that gotermstyle's tuitheme.DefaultPath names. Without one, a board draws in the terminal's own 16 colors. A file that will not load at start leaves those 16 too, and the footer says why.

  • The cursor's row takes the file's ui.selection background across the table's width: its indent, its cells and the gaps between columns. Each cell keeps its own foreground, so a state's color still reads on it. Without a file the row is bold, with ▌ in the pointer's column. No column moves either way.
  • The tab line marks the current tab by color and weight rather than a box, as a tmux status line marks its window. The current tab is bold in ui.warning and the rest are in ui.muted, so the tab line reads like a tmux bar drawn from the same theme. Each name is padded by a space, two spaces apart.
  • A reader's search matches draw in ui.match, bold, over the ui.selection background, or reversed without a file, so a match inside a code span still stands out.
  • A cell draws in the role its Role names, with its Code laid over that as attributes, wherever a board, its tabs or a reader draws it. A marked row's first cell draws in MarkCode instead. The markdown package names one for every cell, and its code takes the nvim-treesitter capture closest to each token, under syntax, so a theme file made from an editor's colors reaches it. A name the file lacks falls back to the wider one, so syntax.type.builtin draws as syntax.type. A heading stays bold whatever its role says. The board looks a role up as it draws, so a reload recolors what is already laid out. Line.Paint draws a role in the built-in theme, for a line printed outside a board.
  • SIGUSR2 reads the file again, so a theme change recolors a running board with no restart. A file that will not load keeps the colors on screen. The program's Init starts the wait, so a model wrapping Tabs calls its Init and hands it the messages it does not handle itself.
  • A test that draws a board sets TUI_THEME_FILE to a path that does not exist, so it draws the built-in theme whatever file the machine running it has.

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. TallyBar is such a bar, one cell per unit, because a fixed width rounds one blocked unit in sixty away. Done is filled and every other kind is hollow, so a frame printed without color still shows which units are done. Under way, blocked and the rest differ by color. Line.Paint turns a line into text in its colors, so a command that prints rather than runs a board draws the same bar.

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 summary on a └─ line while f has them on: a row's under the row, and a group's wrapping under Below. They start off, and a board implementing TwoLineBoard starts them on, for rows whose identity reads dim in that summary. UnderWhereCut on a row draws its Under only where the last column is too narrow for the row's cell there, for a summary that repeats that cell. f leaves the footer while no group or row has a summary to draw.

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 board can draw its rows as blocks ruled apart, spread across a margined screen

Every piece of this is off in its zero value, so a board that sets none draws the default look.

  • Second on a Row is a line it always draws under its first. Its first cell sits in the first column under the row's first cell, and the rest draw one after another from the second column, cut at the table's edge. It suits a state over a second state, with the row's identity beside them. The filter reads it, and the cursor keeps both lines in view. It is not Under: a row with both draws Second, then its Under summary.
  • BetweenRows on a Group draws a blank line and a rule between each two of its rows, so rows that set Second read as blocks. RuleDotted is the lightest weight, ┈.
  • A board implementing LookBoard returns a Look for the screen's width. Sides and Ends are a margin of blank columns and lines around the whole screen. Spread lays the columns out at their own widths and deals what they leave out between them from the left, so the table ends on its edge. A column with no title continues the one before it and takes none of the spread, so a group of untitled counts stays together. A table whose rows wrap their last column with WrapLast keeps the slack in it instead. Rules puts the column titles on a line of their own with a rule under it, and a rule over the footer. Both rules span the table's width. BoldCursor draws the cursor's row bold over the theme's selection, across every line the row draws. NoRowChevrons leaves each row's chevron cell blank.

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 space, however tall it is. enter opens it the same way, or on the full-screen reader where it runs past the screen or the side panel draws it, whether or not it shows in place. StartsOpen on a row shows its text from the first frame, so a view can draw its newest entry whole and still fold it to its first line. A group's draws under Below whether the group is folded or not. The markdown package makes one from markdown, in the theme file's roles:

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. It is handed each run's text and attributes. A cell it returns in those attributes takes the run's role, and a cell it colors keeps the color it was given. A caller tinting a document's uncolored words afterward tests Cell.Plain, because a cell colored by its role carries no Code.

What holds the text stays at the top while it scrolls

Reading down an open row's text keeps the row and the groups above it on the band at the top of the table, as VS Code's sticky scroll and nvim-treesitter-context do. The band shows the first line of each node holding the first line it leaves showing, so the cursor's row carries its pointer there. A node joins once its own line has scrolled off, and a sibling arriving under the band takes its predecessor's place.

  • The band covers lines rather than pushing them down, so nothing under it moves as it grows.
  • It holds a few lines, never a large share of the table, and drops the outermost first.
  • A band line is no cursor stop. A cursor moved under the band brings its row out below it.
  • Only a row or group with lines under it and words to name them by joins, so a folded group, a closed row and a group with no title never take a line of the band. A Still group at the top of the frame stays above the table already, so it never joins either.

Every board gets the band with no field to set, because gotermboard draws the table.

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 chevron marks only what opens

The board decides the mark from what a node holds. A row draws ▸ or ▾ where it holds Detail or a Document, and a group where it holds a row or a group the table draws. Anywhere else enter and space change nothing, so no mark promises otherwise and the footer and ? leave both keys out on that line. A filter holds every group open, so a group draws no mark while one is typed.

A row's chevron sits in the last cell of its indent, one cell in from its parent's, with Symbol beside it. MarkGlyph sits in the cell before the chevron. No column moves for either. A board whose every row opens can blank the row chevrons with Look.NoRowChevrons, because there a chevron tells no row apart. The cell stays, so no column moves for that either.

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

From PanelFrom columns inside a Look's margin, or of the screen where a board has none, the panel grows with the screen, up to ReadingWidth. It draws where any row in the frame holds Panel, Document or Detail, or any group but a Still one holds Panel or Detail, counting lines folded away or held back for a filter. A frame with none of them draws no panel, and the table takes the width. The frame decides rather than the cursor's line, so the table never jumps as the cursor moves.

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, under its ReaderTitle in bold where it has one. Such a row then reads its Document beside the table rather than under itself: StartsOpen leaves it folded while the panel shows, and enter opens it on the full-screen reader. 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. Without one it starts on the first line holding cells, or the last on a TailBoard, past any group with no panel text. With no such line it starts on the first line with text to read.

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, or shows the last line the editor wrote to stderr where it failed. EditorCommand(path) is that editor on any file, for a board's own key to hand over.

Its top line names where the text sits: the groups the table's band would name over the row that opened it, its title, then each heading still open above the top row. A heading joins once its own row scrolls off, as the table's band does. Where the line runs long, the longest name is cut first, and the outermost leaves once every name is down to a few words.

That cut is breadcrumb.Fit, over the names' widths alone. A screen that is no board fits its own path line the same way: the breadcrumb package imports nothing that draws, so Bubble Tea stays out of that screen's build.

Model.Read opens one over the board from a board's own key, and esc or space returns to the board. Model.Follow opens one at the document's last line and keeps it there as the document grows, as tail -f and less +F do. Scrolling up holds it where it is, and G follows again. The footer says following or G follows, and the function it takes runs as the reader closes, so the board can stop what feeds it. An open reader lays its document out again after each message a ReceivingBoard receives, so lines a board's command streams in show as each lands. Model.Tall says whether a document runs past the screen. A program that draws its own screen rather than a board hands each key, paste and size to a NewReader while it shows one, and ClosesWith names what esc and space return to. 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 starts the words of a heading, and its value is the heading's depth, 1 outermost. A heading closes every open one at its depth or deeper. The markdown package sets it on each heading's first word, past the bar that marks it.

A board's strip can move between its frames

A board implementing MovingBoard gets its strip from StripAt(now), redrawn every MoveEvery(), for a diagram of work in motion between its reads. Only the strip redraws at that pace, so the table, its change cue and the side panel wait for the next frame. Only the tab showing runs the clock, and a reader over the board holds it until the key that closes the reader. A MoveEvery of zero holds the strip still until the board's next tick asks again, so a board with nothing moving spends nothing.

func (b *board) MoveEvery() time.Duration { return 100 * time.Millisecond }

func (b *board) StripAt(now time.Time) []gotermboard.Line {
 return []gotermboard.Line{b.lane(b.runs.Value(), now)}
}

StripAt replaces Frame.Strip on every draw, so it returns the lines that hold still as well. A strip changing its height between beats redraws the table, because the table's height moves with it.

A strip that turns a spinner, such as one beside a read under way, draws SpinnerAt(now) and returns SpinEvery from MoveEvery while it turns. Those are the footer's drawings at the footer's pace, so the two spinners on one screen match.

A beat builds no frame and draws no table, so a moving strip costs a fraction of rebuilding the whole frame at the same pace. What a beat does cost is mostly Bubble Tea's renderer, which compares the whole screen on every frame that changed.

Charts take the cells the draw gives them

The chart package draws plain series as lines of cells:

  • TimeSeries draws braille lines over a time axis and a value axis. A Dotted series reads as a forecast, a Columns series draws each reading as an eighth-block column until the next, as a count per day reads, a Band shades the cells between two edges, and Legend names each beside its mark. A column three or more cells wide ends a cell short, so neighbors read apart, and readings sharing a cell draw their mean. Readings that all share one time draw an hour either side of it. An axis under six hours names round minutes, every five on an axis of half an hour.
  • Heatmap shades a grid such as hour against weekday, from ░ to █ in one color. A shade orders by density in any theme, where a ramp of the sixteen colors orders differently in each. Given fewer lines than it has rows, it drops its column names first, then ends on a +N line for the rest.
  • Sparkline draws eighth-block columns, averaging more values than columns so the whole span shows, and with Fill widening fewer so they span it. Any value above zero draws at least ▁, so a quiet stretch reads apart from an empty one.
  • Bars draws an upright bar per value in its own color, over an axis labeled at the top, the middle and zero. A bar with a Stack draws each part over the one before it.
  • Meter draws one bar across a line to an eighth of a cell, each part after the one before it.
  • Timeline draws spans on one time axis, a row each, as a Gantt chart or a trace waterfall lays them out. A bar starts on the cell its span starts in and ends to an eighth of a cell, so a span lasting any time draws at least ▏, and a moment draws a milestone's ◆. A span with no End is under way and runs to the axis's end, so a chart of work under way sets To to now. A wait between two spans shows as the gap between their bars. Given fewer lines than a row per span and the axis, it drops its axis first, then ends on a +N line for the rest.
  • StackedArea stacks layers over time from zero, as a cumulative flow diagram reads, over the axes TimeSeries draws. Each layer's edge falls to an eighth of a row. Where two layers meet inside a cell, the lower one's block sits on the upper one's color, painted as the cell's background.

Each fills exactly the width and height it is given, so chart.Beside lays them side by side. A chart colors its cells with the codes the caller passes, and the board's palette paints them. It draws through ntcharts and reads each cell back, so ntcharts never writes an escape of its own.

A board knows the screen's size only at the draw, so Frame.Charts is a function of it, drawn between the strip and the table:

frame.Charts = func(width, height int) []gotermboard.Line {
 week := chart.TimeSeries{Series: []chart.Series{{Name: "used", Points: used, Code: gotermboard.Cyan}}, Max: 100}
 return append([]gotermboard.Line{week.Legend()}, week.Lines(width, min(14, height/5))...)
}

A row's Panel takes a chart the same way, at the panel's width: chart.Document(week, 12). The charts are laid out once a frame, so a moving strip's beat leaves them as they stand.

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. One implementing LastRowBoard hears whether the cursor sits on the last row the filter and the folds leave showing, so it can hold back rows that land while the reader is scrolled back and let them in once the reader is at the end again, as less +F does.

enter and space are separate keys in the table, so a board that takes enter leaves space folding in place. Model.Toggle is what the table's enter does, for a board's enter that acts only on some rows. Bind it to enter alone. A row open in place that turns tall, as on a pane shrink, reads on Toggle and closes only on the table's space. A board that binds space to a row verb, such as a mark, calls Model.CloseInPlace first, which closes the cursor's row where it is open and says whether it was, so space still closes it.

Model.MoveCursorTo puts the cursor on a key, for a key that acts on the cursor's row and moves on, as a merge moves to the next row in its group. It says whether the key shows, so a board can try the next candidate where the filter or a fold hides one. A key that changed what the board holds calls Rebuild first, so the key is looked for in the new frame.

A program that draws its own screen rather than a board builds its key table from the same rows. KeyAction[H] is a row for a screen of type H, and Action is a Model's. UniversalActions is the rows for moving, opening a row, the ? overlay and quitting, each bound where its Applies holds. esc stays the host's, because what it backs out of is the host's. The Host answers how to move, scroll, jump, open a row, show the keys and quit. ActionsNow(host, own, UniversalActions[H]()) is every row that applies now, with each of the host's keys hiding the universal one while it applies. RunKey runs a pressed key from those rows, and FooterHints is their footer's words in order. BySection puts them under their Section, each where its first row stands, for an overlay that heads them. A Model's overlay lists its rows in one run.

Model.Ask puts a Question on the footer's lines. With Typed set it takes one line typed under the question, and enter hands Answer what was typed, trimmed. Without it enter alone answers, for an act that takes no text. esc or ctrl+c takes the question away and runs Cancel where it is set, so a board can say that nothing was done. Until one of them every key goes to the question: q and ? type themselves, tab does nothing, and a paste lands as one line.

{Keys: []string{"m"}, Label: "merge the row into a key you type", Footer: "m merge", Run: func(m *gotermboard.Model) tea.Cmd {
 key := m.Cursor()
 m.Ask(gotermboard.Question{Text: "merge " + key + " into:", Typed: true, Answer: func(into string) tea.Cmd {
  m.Say(merge(key, into))
  return nil
 }})
 return nil
}}

The question draws in bold as written, so its own punctuation ends it. It wraps rather than being cut, and so does what Say puts on the footer. Each stops at a few lines, the last ending in … where more was left. The typed line is cut from the left, so its end and the cursor stay on the screen, as the filter and the reader's search are. A footer that grows keeps the cursor's row in view. The answer runs after the question closes, so it can Say what it did, ask another question, or return a command, such as one that opens an editor.

A question about several things names each one in its Listing, a Line each, drawn above the question and cut at the screen's edge. The listing takes the screen above the footer and leaves the table one line, which the cursor's row holds. A listing longer than that ends on how many more it held.

A question with Choices is a menu. Each Choice draws under the question as its key and a label, and pressing that key answers at once, handing Answer the key. Every other key does nothing, enter included, so a reflex press picks nothing, and esc cancels as it does any question. The choices are never cut, so the listing gives way to them.

m.Ask(gotermboard.Question{
 Listing: []gotermboard.Line{{gotermboard.C("#13 conflicts with main in 2 files", "")}},
 Text:    "fix #13:",
 Choices: []gotermboard.Choice{{Key: "n", Label: "resolve in nvim"}, {Key: "c", Label: "hand to claude"}},
 Answer:  func(key string) tea.Cmd { return fixes[key](m) },
})

A key's command reports back to its board

A key's Run returns a command, and a command that runs in the background ends in a message. A board implementing ReceivingBoard is handed every message the model does not handle itself, with the model, so it can Say what came of the command, Rebuild, or return the next command. Tabs hands such a message to every tab, because the tab that started the command may be hidden by the time it ends, so a board switches on its own types.

A board implementing BusyBoard names its work under way from UnderWay(). The footer draws it after a spinner, above what the last key did, and a key does not clear it. The spinner turns only while the tab shows and nothing is open over the board.

func (b *board) merge(m *gotermboard.Model) tea.Cmd {
 b.underWay = "merging #12"
 return func() tea.Msg { return mergedMsg{err: exec.Command("gh", "pr", "merge", "12", "--merge").Run()} }
}

func (b *board) UnderWay() string { return b.underWay }

func (b *board) Receive(m *gotermboard.Model, msg tea.Msg) tea.Cmd {
 if merged, ok := msg.(mergedMsg); ok {
  b.underWay = ""
  m.Say(mergeResult(merged.err))
 }
 return nil
}

A key leaves the board for another program and comes back

Model.HandOver(cmd, then) gives the terminal to cmd until it exits, and then gets how it ended and the end of what it wrote to stderr. LastLine(stderr, err) is the sentence a failure says: the last line it wrote, or its error where it wrote nothing.

To keep stderr, the program's fd 2 is a pipe, drained to the terminal as bytes arrive. So a program that asks whether stderr is a terminal hears no, as git does before drawing progress. A caller handing over such a program sets cmd.Stderr, such as to os.Stderr, and keeps nothing. The pipe drains for as long as anything holds it open, so a child the program leaves running keeps writing to the terminal, and the board does not wait for it to exit.

Before then runs, the board receives a HandedBack through ReceivingBoard, to read again what the program may have changed. Only the tab whose key handed the terminal over receives it, hidden or not. EditorCommand(path) is the user's editor on a file, ready to hand over.

func (b *board) edit(m *gotermboard.Model) tea.Cmd {
 return m.HandOver(gotermboard.EditorCommand(b.path), func(m *gotermboard.Model, err error, stderr []byte) tea.Cmd {
  if err != nil {
   m.Say("the editor failed: " + gotermboard.LastLine(stderr, err))
  }
  return nil
 })
}

func (b *board) Receive(m *gotermboard.Model, msg tea.Msg) tea.Cmd {
 if _, ok := msg.(gotermboard.HandedBack); ok {
  b.source.Force()
 }
 return nil
}

OpenWindow(name, dir, argv, needs...) opens argv in a new tmux window, which tmux makes current, while the board keeps running in its own. Several arguments run with no shell, so none needs quoting. tmux opens a window whether or not its program starts, and in another directory where dir is gone. So OpenWindow first looks for dir, and for tmux, argv[0] and each of needs on PATH. A lone argument holding a space is a shell command, which tmux runs through its default shell, so needs names the programs it runs. Outside tmux it opens nothing and says why. OpenWindowBehind takes the same arguments and leaves the board's window current, for a command that runs on its own while the board stays in front.

OpenClaudeSession(ClaudeSession{On, Dir, Fallback, Brief, Env}) is a key that opens a Claude session on a record. Inside tmux it opens in a window named claude <On>, and outside tmux it holds the terminal until it ends. Brief says what the record is and asks nothing. The brief goes on to ask the session to say what it would do and wait. With a Fallback, a bare clause such as 12 names no one repo, it then says where the session runs and why. Env reaches the session inside tmux too, through env, because a window takes the tmux server's environment. The command ends in a ClaudeSessionOutcome either way, whose Said says what came of it. Inside tmux that is only that the window opened, because tmux answers before claude starts. Outside it, a failed session's Said carries its last stderr line. A Model shows Said on the footer of the tab showing, then hands the outcome to its ReceivingBoard. HeldTerminal says the session held the terminal and ended, so what it changed is there to read again. A program that draws its own screen shows Said itself.

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 (
	SectionMoving     = "Moving"
	SectionOpening    = "Opening"
	SectionEverywhere = "Everywhere"
)

Sections UniversalActions files its rows under.

View Source
const JumpKey = "t"

JumpKey goes to what the cursor's row names, where another tab or a program outside shows it.

View Source
const PanelFrom = 160

PanelFrom is the narrowest width inside a board's Look margin that gets the side panel. On a board with no Look, that is the screen's width.

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.

View Source
const SpinEvery = 100 * time.Millisecond

SpinEvery is how long a spinner holds each drawing: the footer's beside work under way, and one a board turns on its own clock.

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

func EditorCommand(path string) *exec.Cmd

EditorCommand is the user's editor on path: $VISUAL, then $EDITOR, then vi, split on spaces, so EDITOR="code --wait" works. A board's key hands it the terminal with Model.HandOver, as the Reader's e does with tea.ExecProcess.

func FooterHints added in v0.43.0

func FooterHints[H any](actions []KeyAction[H]) []string

FooterHints is the footer's words for actions in their order, leaving out each row that leaves its key to the ? overlay.

func LastLine added in v0.30.0

func LastLine(out []byte, err error) string

LastLine is the last line a failed command wrote, or how it ended where it wrote nothing.

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. It is printedframe.Size.

func OpenClaudeSession added in v0.43.0

func OpenClaudeSession(session ClaudeSession) tea.Cmd

OpenClaudeSession is C: a Claude session on a record, in session.Dir. Inside tmux it opens in a window of its own, so the board stays usable while the session works. tmux answers once the window exists, before claude starts, so the outcome says only that the window opened. Outside tmux it holds the terminal until it ends, and a failure says the last line it wrote to stderr. The command ends in a ClaudeSessionOutcome either way, so a Model and a host that draws its own screen open it alike.

func OpenWindow added in v0.30.0

func OpenWindow(name, dir string, argv []string, needs ...string) error

OpenWindow runs argv in a new tmux window named name, in dir where dir is not empty. tmux makes the window current, and the board keeps running in its own. tmux runs several arguments with no shell, so none needs quoting, and runs a lone one through its default-shell.

tmux opens the window whether or not its program starts, and in another directory where dir is gone, so tmux, argv[0] and every program in needs are looked for on PATH first, and dir is looked for too. A lone argument holding a space is a shell command rather than a program, so needs names what it runs. Outside tmux it opens nothing, because tmux would open the window in whichever session it last used.

func OpenWindowBehind added in v0.42.0

func OpenWindowBehind(name, dir string, argv []string, needs ...string) error

OpenWindowBehind is OpenWindow with the board's window left current: tmux opens the new one behind it, for a command that runs on its own while the board stays in front.

func RefreshAll

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

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

func RunKey added in v0.43.0

func RunKey[H any](host H, actions []KeyAction[H], key string) (tea.Cmd, bool)

RunKey runs the first of actions bound to key on host, and says whether one was.

func SpinnerAt added in v0.23.0

func SpinnerAt(now time.Time) string

SpinnerAt is the spinner's drawing at now, one cell wide, for a board that turns one on its own clock, such as a MovingBoard's strip while a read runs. It turns through the footer's drawings at the footer's pace.

func TableKeys added in v0.19.0

func TableKeys() []string

TableKeys is every key the built-in table binds. A board's key hides the table's, so a board taking keys from its own config asks this which ones would take a key from the table, and Tabs.Keys where it is one of several tabs.

Types

type Action

type Action = KeyAction[*Model]

Action is one row of a Model's key table. A board's key hides the table's action for it even while Applies is false.

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

type BusyBoard interface {
	UnderWay() string
}

BusyBoard is a Board with work under way, such as a command its key started. The footer names it after a spinner, above what the last key did, until UnderWay is empty again. A key never clears it.

type Cell

type Cell struct {
	Text string
	Code string
	// Role is the TUI theme role the cell draws in, such as syntax.keyword, with Code's
	// attributes over it. A board, its tabs and a Reader resolve it in their live theme wherever
	// they draw the cell, so a reload recolors it. Line.Paint resolves it in the built-in theme.
	Role  string
	Ticks bool
	// Section says this cell starts the words of a heading, where a Reader's [ and ] land, and
	// how deep the heading sits: 1 is outermost, and 0 is no heading. A heading closes every
	// open one at its depth or deeper, which is the path a Reader's top line draws.
	Section int
	// 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.

func (Cell) Plain added in v0.33.0

func (c Cell) Plain() bool

Plain says the cell names neither a Code nor a Role, so it draws in the terminal's own text color. A caller tinting a Document's uncolored words tests this rather than Code alone.

type Choice added in v0.42.0

type Choice struct {
	// Key is the key that picks it, as Bubble Tea names a press, such as "n" or "N".
	Key string
	// Label says what picking it does, drawn after the key and cut at the screen's edge.
	Label string
}

Choice is one answer of a Question's menu.

type ClaudeSession added in v0.43.0

type ClaudeSession struct {
	// On names the record as its handle on the command line, such as an issue's number. The tmux
	// window is named "claude <On>", and every sentence about the session names it.
	On string
	// Dir is where the session runs: the record's checkout, or else the board's own directory.
	Dir string
	// Fallback is why Dir is not the record's checkout, as a bare clause completing "because",
	// such as "12 names no one repo". It is empty where Dir is the record's checkout.
	Fallback string
	// Brief says what the record is and the command that prints it whole, and asks nothing.
	// OpenClaudeSession adds the ask after it, and with a Fallback where the session runs and
	// why, because that directory may hold another repo's code.
	Brief string
	// Env is KEY=value pairs the session starts with on top of the program's environment. Inside
	// tmux they go ahead of claude through env, because a window takes its environment from the
	// tmux server.
	Env []string
}

ClaudeSession is a Claude session a board's C opens on one record.

type ClaudeSessionOutcome added in v0.43.0

type ClaudeSessionOutcome struct {
	Session ClaudeSession
	// Said is the sentence saying what came of it.
	Said string
	// Err is why no session opened, or how one that held the terminal failed. It is nil where a
	// window opened, or the session ended 0.
	Err error
	// HeldTerminal says the session held the terminal and has ended, so what it changed is there
	// to read again.
	HeldTerminal bool
	// Stderr is the end of what a session that held the terminal wrote to stderr.
	Stderr []byte
}

ClaudeSessionOutcome is what came of OpenClaudeSession. A Model says Said on its footer while it shows, then hands the outcome to its ReceivingBoard. The tab showing is the tab whose key opened the session, because a session holding the terminal leaves no key to change tabs with, and tmux makes the window it opens current. A host draws Said where it says what a key did.

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
	// contains filtered or unexported fields
}

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

type EntityBoard interface {
	// EntityKinds is the kinds a jump lands on here.
	EntityKinds() []string
	// ShowEntity is told the entity a jump came for, before its tab shows, so the board can
	// change what it reads. It says whether the board now shows the entity whole, as a page
	// given to one thing does. Where it does not, the cursor goes to the first group or row
	// naming the entity once the reads land. Either way the tab starts at its top and reads
	// every source at once, as Model.Restart does.
	ShowEntity(ref EntityRef) (whole bool)
}

EntityBoard is a Board that shows some kinds of entity, so a row in another tab naming one can jump here.

type EntityRef added in v0.40.0

type EntityRef struct {
	Kind string `json:"kind"`
	ID   string `json:"id"`
}

EntityRef names one thing a row or a group is about, by a kind and an id every view agrees on, as a Backstage entity reference or a Kubernetes object reference does. The package gives no kind a meaning, so a caller names its own.

type Frame

type Frame struct {
	Strip []Line
	// Charts is drawn under the strip at the screen's width, for what has to be laid out at the
	// width it gets, as a chart is. It is handed the screen's height too, so a board can keep it to
	// a share of a short screen. A moving strip leaves it as the frame drew it.
	Charts  func(width, height int) []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, or the last on a TailBoard, past any group with
	// no panel text. With no such line it starts on the first line with text to read.
	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
	// BlankAboveRule draws a blank line between the group's last line and its Rule.
	BlankAboveRule bool
	// BetweenRows draws a blank line and then a rule of this weight between each two of the
	// group's rows, so rows of more than one line read as blocks.
	BetweenRows RuleWeight
	// 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
	// Entities is what the group is about, as Row.Entities has it.
	Entities []EntityRef
	// contains filtered or unexported fields
}

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

type HandedBack added in v0.30.0

type HandedBack struct {
	// Err is how the program ended, nil where it exited 0.
	Err error
	// Stderr is the end of what the program wrote to stderr, empty where the caller set
	// cmd.Stderr.
	Stderr []byte
	// contains filtered or unexported fields
}

HandedBack is what a program Model.HandOver gave the terminal to sends once it exits. Only the model that handed it over takes it, so among Tabs the tab whose key started it runs its then, hidden or not, and no other tab does. That model's board receives it first through ReceivingBoard, to read again what the program may have changed.

type Host added in v0.43.0

type Host interface {
	// MoveBy moves the cursor by rows, stopping on the first and the last. g and G move by 1<<30
	// rows, so the cursor plus rows stays inside an int on a 32-bit build.
	MoveBy(rows int) tea.Cmd
	// HalfScreen is half the list's height in lines.
	HalfScreen() int
	// ScrollBy moves the text open under the cursor's row by lines, and leaves the cursor on it.
	ScrollBy(lines int)
	// OnOpenText says the cursor's row shows text open under it, which the half-screen keys and
	// J and K then read through.
	OnOpenText() bool
	// JumpGroup goes to the next group at the cursor's depth or shallower, or the previous where
	// direction is negative, as Vim's ]] and [[ move by section.
	JumpGroup(direction int)
	// HasGroups says the screen has groups for [ and ] to jump between.
	HasGroups() bool
	// Toggles says enter and space would change something on the cursor's line.
	Toggles() bool
	// Open is enter: it opens the row's text full screen where it runs past the screen, or a
	// side panel already shows it, and otherwise does what ToggleInPlace does.
	Open() tea.Cmd
	// ToggleInPlace is space: it folds or opens the cursor's group, or opens or closes its row's
	// text in place however tall.
	ToggleInPlace() tea.Cmd
	// ShowKeys opens the ? overlay.
	ShowKeys()
	// Quit ends the program.
	Quit() tea.Cmd
}

Host is a screen of rows that UniversalActions act on: a host that draws its own screen, or a Model. It gains no method, because each host's build would break on the release that added one. A universal key needing more of a host asks for it through an interface of its own, and binds only on a host that implements it.

type KeyAction added in v0.43.0

type KeyAction[H any] struct {
	Keys []string
	// Section heads the row's group in a ? overlay that groups its rows. A Model's overlay lists
	// its rows in one run.
	Section string
	Label   string
	// Footer is the footer's words for the key, or empty to leave it to the ? overlay.
	Footer string
	Run    func(H) tea.Cmd
	// Applies says whether the screen can use the key now. Nil means always.
	Applies func(H) bool
}

KeyAction is one row of a key table, for a screen of type H. The key handler, the footer and the ? overlay all read the same rows, so a key bound in one shows in the other two. A Model's rows are Action, and a Reader's are KeyAction[*Reader]. A host that draws its own screen builds its table from UniversalActions and rows of its own, reads what applies now through ActionsNow, and reads those rows through RunKey, FooterHints and BySection.

func ActionsNow added in v0.43.0

func ActionsNow[H any](host H, layers ...[]KeyAction[H]) []KeyAction[H]

ActionsNow is every key host can use now from layers of rows: each layer's rows that apply, less the keys a layer before it took. A host's key handler, footer and ? overlay all read it, so a key the screen cannot use is in none of them. A host's own rows go in a layer ahead of UniversalActions, so its key wins where it applies, and the universal row acts everywhere else. The rows are in the footer's order, which splits a section where layers share it, so an overlay that heads each section reads BySection(ActionsNow(...)).

func UniversalActions added in v0.43.0

func UniversalActions[H Host]() []KeyAction[H]

UniversalActions is the keys every board binds, and a host's table starts from, acting on a Host: moving by rows, to either end and by half a screen, scrolling the open text, jumping between groups, opening a row, the ? overlay and quitting. Each binds where its Applies holds. esc stays the host's, because what it backs out of is the host's. They are in the order a footer names them, with the way to the other keys and the way out ahead of what a narrow footer cuts.

type KeySection added in v0.43.0

type KeySection[H any] struct {
	Section string
	Actions []KeyAction[H]
}

KeySection is one section of a ? overlay: its heading and its rows.

func BySection added in v0.43.0

func BySection[H any](actions []KeyAction[H]) []KeySection[H]

BySection is actions under their sections, each section where its first row stands and its rows in their order.

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

type LastRowBoard interface {
	CursorOnLastRow(on bool)
}

LastRowBoard is a Board told whether the cursor sits on the last row the table shows, once each time that changes. The filter and the folds decide which row that is, so a board holding back rows that land while the reader is scrolled back, as less +F stops following once the reader scrolls, lets them in when the reader is back at the end of what shows. A table with no row to sit on counts as on its last, because nothing in it is scrolled back from.

type Line

type Line []Cell

Line is cells drawn one after another.

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

func TallyBar(done, underWay, blocked, rest int) Line

TallyBar is one cell per unit counted: done ▰ in green, under way ▱ in yellow, blocked ▱ in red and the rest ▱ plain. Its length shows how many there are, and one blocked unit among sixty still shows, where a fixed width would round it away. Without color only done is filled, so no unfinished unit reads as done. In a WrapLast it keeps every cell however narrow the row.

func (Line) Paint added in v0.16.0

func (l Line) Paint(pal gotermstyle.Palette) string

Paint is the line as text in its cells' colors, so a TallyBar can print outside a board. A cell's Role draws in the built-in theme.

func (Line) Text

func (l Line) Text() string

Text is the line with its colors out.

type Look added in v0.35.0

type Look struct {
	// Sides is the blank columns at each side of the screen, and Ends the blank lines above
	// and below it. A screen too small for them keeps a column and a line to draw in.
	Sides, Ends int
	// Spread fills the table's width with its columns at their own widths, dealing what they
	// leave out between them from the left, rather than the last column taking it. A column
	// with no title continues the one before it and takes none of the spread. A table whose
	// rows or groups set WrapLast keeps the slack in its last column, where the text wraps.
	Spread bool
	// Rules puts the column titles on a line of their own with a rule under it, and a rule
	// above the footer.
	Rules bool
	// BoldCursor draws the cursor's row bold over the theme's selection, as it is where there
	// is none.
	BoldCursor bool
	// NoRowChevrons leaves a row's chevron cell blank, for a board whose every row opens, where
	// a chevron tells no row apart. A group's chevron still draws, and no column moves.
	NoRowChevrons bool
}

Look is how a board's screen is laid out where it does not take the default.

type LookBoard added in v0.35.0

type LookBoard interface {
	// Look is how the board draws on a screen this wide, read again as the screen changes size.
	Look(width int) Look
}

LookBoard is a Board drawn in a Look of its own rather than the default, whose zero Look draws nothing differently.

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. It draws in the TUI theme file gotermstyle's tuitheme finds, and reads it again on SIGUSR2 while it runs as the program. A file that will not load at start leaves ANSI 0-15, and on a reload keeps the colors on screen. The footer says why.

func (*Model) Ask added in v0.17.0

func (m *Model) Ask(q Question)

Ask puts q on the footer's lines. Until enter or esc every key and paste goes to it, so q and ? type themselves and tab changes no tab.

func (*Model) CloseInPlace added in v0.28.0

func (m *Model) CloseInPlace() bool

CloseInPlace closes the text open under the cursor's row, as the table's space does, and says whether any was open. A board that binds space to a row verb, such as a mark, calls it first, so space still closes a row open in place.

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) CursorEntities added in v0.40.0

func (m *Model) CursorEntities() []EntityRef

CursorEntities is what the group or row under the cursor names.

func (*Model) Follow added in v0.37.0

func (m *Model) Follow(title string, document Document, closed func())

Follow opens document on a full-screen Reader over the board at its last line, and keeps it there as the document grows, as tail -f and less +F do, until esc or space closes it. Scrolling up holds the reader where it is, and G follows again. Lines a board's command streams into the document show as each message the board receives lands. closed runs as esc or space closes the reader, so the board can stop what feeds it.

func (*Model) HalfScreen

func (m *Model) HalfScreen() int

HalfScreen is half the table's height in lines.

func (*Model) HandOver added in v0.30.0

func (m *Model) HandOver(cmd *exec.Cmd, then func(m *Model, err error, stderr []byte) tea.Cmd) tea.Cmd

HandOver gives the terminal to cmd until it exits, then runs then with how it ended and the end of what it wrote to stderr. LastLine(stderr, err) is the sentence a failure says.

To keep stderr, the program's fd 2 is a pipe whose bytes reach the terminal as they come, so a program that asks whether stderr is a terminal hears no. A caller handing over such a program sets cmd.Stderr, such as to os.Stderr, and keeps nothing.

func (*Model) Init

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

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

func (*Model) MoveCursorTo added in v0.28.0

func (m *Model) MoveCursorTo(key string) bool

MoveCursorTo puts the cursor on the group or row keyed key, for a board's key that acts on the cursor's row and moves on, and says whether key shows. A row in a folded group, or one the filter hides, does not. A key that changed what the board holds calls Rebuild first.

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 or space closes it. The reader's top line names the groups holding the cursor's line, then title.

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 enter reads it on a Reader.

func (*Model) Toggle

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

Toggle is what enter does, for a board's key that takes enter and acts only on some rows: a row's Document that runs past the screen opens on a Reader, open in place or not, and anything else folds or opens in place. Bind it to enter alone and leave space to the table, which folds in place however tall the text. A row open in place that turns tall closes only on that space.

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

type MovingBoard interface {
	// MoveEvery is how long the strip holds each drawing. Zero or less holds it still, and the
	// next tick asks again, so a board with nothing moving spends nothing.
	MoveEvery() time.Duration
	// StripAt is the whole strip at now. It stands in for Frame.Strip on every draw, so a line
	// holding still is returned as it is. A strip that changes its height redraws the table.
	StripAt(now time.Time) []Line
}

MovingBoard is a Board whose strip moves between its frames, as a diagram of work in motion does. Only the strip redraws at this pace, so the table and its change cue wait for their frame. Only the tab showing runs it, and a reader over the board holds it.

type Outside added in v0.40.0

type Outside struct {
	Kind string
	// Name names it among the places a row goes, as a tab's name does.
	Name string
	// Command is the program opened on one entity's id, which the caller's own runner starts,
	// or nil where it cannot open that id. It ends as Model.HandOver's program does: the end
	// of its stderr is kept, the board whose row it left receives the HandedBack, and a
	// failure says its last line.
	Command func(id string) tea.ExecCommand
}

Outside is a program that shows one kind of entity whole, such as a pull request tool opened on one pull request. A jump to it hands it the terminal and pushes nothing, because quitting it comes back to the row the jump left.

type Question added in v0.17.0

type Question struct {
	// Listing draws above Text, a line each, for a question about several things that names
	// each one. Each line is cut at the screen's edge. While the question is open the table
	// keeps one line, and a listing longer than the rest of the screen ends on how many more
	// it held.
	Listing []Line
	// Text draws in bold as written, so its own punctuation ends it.
	Text string
	// Typed takes one line typed under the question as the answer. Without it enter alone
	// answers, for an act that takes no text, and Answer is handed an empty string.
	Typed bool
	// Choices are the answers a single key picks, drawn under Text a line each, as a menu. A key
	// naming one closes the question and hands Answer that Key, and every other key but esc and
	// ctrl+c does nothing, enter included. Typed is ignored where Choices are set.
	Choices []Choice
	// Answer runs on enter with what was typed, trimmed, or on a choice's key with that key. The
	// question has closed by then, so it can Say what it did, ask another question, or return a
	// command such as an editor.
	Answer func(typed string) tea.Cmd
	// Cancel, where set, runs on esc or ctrl+c, so a board can say that nothing was done.
	Cancel func() tea.Cmd
}

Question is what a key asks on the footer's lines, until enter or a choice's key answers it or esc takes it away.

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. It draws in the TUI theme file gotermstyle's tuitheme finds, and reads it again on SIGUSR2 while it runs as the program.

func (*Reader) ClosesWith added in v0.31.0

func (r *Reader) ClosesWith(back func())

ClosesWith is for a program drawing the reader inside a screen of its own, as Model.Read does over a board: esc and space call back once nothing is left to clear, where on a reader running as the program they do nothing.

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

type ReceivingBoard interface {
	Receive(m *Model, msg tea.Msg) tea.Cmd
}

ReceivingBoard is a Board handed every message the model does not handle itself, such as the one a command its key started sends when it ends. Receive runs with the model, so it can Say what came of the command, Rebuild, or return the next command. Tabs hands such a message to every tab, because the tab whose key started the command may be hidden by the time it ends, so a board ignores a message of a type it does not own. A message whose type names where it goes is the exception: a HandedBack reaches only the board whose model handed the terminal over, and a ClaudeSessionOutcome only the board of the tab showing.

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
	// Under is the row's summary, drawn on a └─ row while those are on.
	Under Cell
	// Second is a line the row draws under its first, always. Its first cell sits in the first
	// column under the row's first cell, and the rest draw one after another from the second
	// column, cut at the table's edge. The cursor's row wears its look across both lines. A
	// row's Under draws after it.
	Second Line
	// UnderWhereCut draws Under only where the last column is too narrow for the row's cell
	// there, for a summary that repeats that cell. Only the draw knows the column's width.
	UnderWhereCut bool
	// Detail is what the side panel shows while the cursor is on the row, and what opens
	// under the row. Each cell is a paragraph that wraps. A row holding Detail or a
	// Document draws a chevron in its indent.
	Detail []Cell
	// Document is what opens under the row and the side panel shows, in Detail's place.
	Document Document
	// ReaderTitle heads the Reader enter opens on a tall Document, and the side panel where
	// it draws the Document. Empty, the row's own words head the Reader.
	ReaderTitle string
	// StartsOpen shows the row's Detail or Document under it until a key closes it, so a
	// view can draw its newest entry whole and still fold it to its first line. A row whose
	// Document the side panel draws starts folded while the panel shows, since the panel
	// draws it whole beside the table.
	StartsOpen bool
	// Panel is what the side panel shows in place of both, each line wrapping at spaces
	// with its cells' own colors. Without one, the panel draws the Document.
	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
	// Entities is what the row is about, which JumpKey lists and a jump's cursor looks for.
	Entities []EntityRef
	// 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
	RuleDotted
)

The weights a rule takes. RuleDotted is the lightest, for between rows.

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, builds no frame and moves no strip. 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. Every tab draws in the one TUI theme file gotermstyle's tuitheme finds, read again on SIGUSR2.

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) Keys added in v0.40.0

func (t *Tabs) Keys() []string

Keys is every key Tabs binds beside the table's, which a board's own key would hide: tab and shift+tab where there are tabs to cycle, and JumpKey where a row could go somewhere. A board taking keys from its own config asks this and TableKeys which ones it would take.

func (*Tabs) OpensOutside added in v0.40.0

func (t *Tabs) OpensOutside(programs ...Outside)

OpensOutside adds programs that a row's entity can jump to, after the tabs that show its kind.

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 so does any message this package does not own, which is a ReceivingBoard's. A key, a paste and the clocks go to the tab showing.

func (*Tabs) View

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

View is Bubble Tea's. The tab line sits inside the showing tab's margin.

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.

type TwoLineBoard added in v0.26.0

type TwoLineBoard interface {
	TwoLines() bool
}

TwoLineBoard is a Board whose rows' Under summaries start shown. f still hides them.

Directories

Path Synopsis
Package breadcrumb fits a path of names, such as "uv › add › Options", on one line.
Package breadcrumb fits a path of names, such as "uv › add › Options", on one line.
Package chart draws plain series as lines of cells for a board.
Package chart draws plain series as lines of cells for a board.
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, naming a TUI theme role for each cell, such as markdown.heading.2 or syntax.keyword.
Package markdown lays markdown out as a board's lines at a given width, naming a TUI theme role for each cell, such as markdown.heading.2 or syntax.keyword.
Package printedframe picks the size one printed frame draws at, as a TUI's --once prints it.
Package printedframe picks the size one printed frame draws at, as a TUI's --once prints it.

Jump to

Keyboard shortcuts

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