display

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Oct 10, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package display shows the progress of a command on its standard error, from the events of its progress.Bus: the live tree, a few rows at the bottom of a terminal that show the work under way and what of it failed; the counter, one line there that says how far the work has got; plain lines, one for each thing worth a line, for a log as much as a terminal; and the summary a display leaves behind once the command has ended.

The tree and the counter own the bottom rows of the terminal, the region, and nothing else. The command's own output goes to the terminal through the writers of the Terminal, which take the region off before anything else is written, and let it back only once what was written ended a line, so that a question waiting for its answer is never drawn over. The lines a display leaves for good, plain lines and the tree's finished steps, go out through the Terminal too: above the region, never into a line the command has not ended, and ahead of whatever the command writes after the events they tell of. A display leaves the terminal altogether while a question is asked, through progress.Suspend, and never hides the cursor, so a process killed while it draws leaves a terminal that works, at worst with the rows of its last frame on it.

Lines from goroutines that run beside the command, the stack of a panic a pool's worker recovered from or a log line, go through the Terminal's Lines writers, which hold them, whole lines only, while a question is asked or the command has a line open, so that none lands inside the question, and write them above the region once they may.

The tree, the counter and plain lines live the same life. NewTree, NewCounter or NewPlain makes the display on a Terminal of its own, a Terminal carrying one display, and Start begins its drawing, on a goroutine of its own. The program puts it on the Bus as a sink, and the Bus delivers Handle for each event, and Suspend and Resume around a question, through progress.Suspend. Once the command has ended, the program closes the Bus, and then the display, whose Close stops the drawing, takes the region off and writes what the display left. Draw draws a frame on demand, as a test does on a clock it moves, in place of the drawing Start begins. The Summary is a sink alone, with nothing to start or close, whose Line the program writes once the display is closed.

A display draws in no colour unless the program gives it a Theme, one of Themes, drawn in 256 colours, in the terminal's own 16, or in none, its art alone. Whether to draw in colour is the program's to decide: the package reads no environment, NO_COLOR and TERM included.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// Classic draws the marks of the zero Theme in green, red, amber and
	// blue, with a minus for what was skipped, and separators that take one
	// column in any locale: ⋅ and ⋯ where the zero Theme draws · and ….
	// It has no bar or guide, and its running mark, ▸, stands still; it
	// starts the counter's line too, which the zero Theme starts with no
	// mark.
	Classic = Theme{/* contains filtered or unexported fields */}
	// Aurora draws in dots, as night lights: a braille spinner, a braille
	// bar that runs from teal through blue to violet, and a dotted guide,
	// with red for what failed.
	Aurora = Theme{/* contains filtered or unexported fields */}
	// Ember draws a warm instrument panel: a lamp that turns round a
	// square, a bar of segments in orange, dashed panel lines, ok in the
	// terminal's own colour, bold, and what failed in raspberry, which
	// stands apart from the orange of what runs for the readers who tell
	// red from green poorly too.
	Ember = Theme{/* contains filtered or unexported fields */}
	// Neon draws an arcade at night: a sweeping quadrant, chevrons, and a
	// bar of slanted segments in a gradient from electric blue to orchid,
	// with hot pink for what failed.
	Neon = Theme{/* contains filtered or unexported fields */}
	// Tide draws the sea: a turning arc and a bar of bubbles, with ok in
	// blue and failed in orange, which stay apart for the readers who tell
	// red from green poorly.
	Tide = Theme{/* contains filtered or unexported fields */}
)

The themes. Each is drawn in 256 colours; In draws it in others.

Functions

This section is empty.

Types

type Colours added in v0.3.0

type Colours uint8

Colours is how many colours a Theme draws in: as many as the terminal shows, which the program finds out, from TERM or COLORTERM, say.

const (
	// Colours256 draws a theme as it is designed, in colours of the xterm
	// palette of 256, which xterm, VTE, Konsole, iTerm2, Terminal.app,
	// Windows Terminal, tmux and screen show, chosen to read on a light
	// background as on a dark one. It is the zero Colours.
	Colours256 Colours = iota
	// Colours16 draws a theme in the terminal's own palette, which its user
	// chose for its background: green, red, yellow, blue's neighbours cyan
	// and magenta, bold and faint, never bold with a colour and never the
	// bright colours, which some palettes make grey.
	Colours16
	// NoColours draws a theme's art alone, with no escape code: for
	// NO_COLOR. A Colours that is none of these draws as NoColours.
	NoColours
)

func (Colours) String added in v0.3.0

func (c Colours) String() string

String returns "256", "16" or "none", and "Colours(n)" for a value that is none of the Colours.

type Counter

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

Counter is a display of one line, which says for each step whose targets are counted, the roots of a progress.Tally, how far it has got, and how long the command has run:

power on · batch 3/60 · 17/480 · 1 failed · 8 running · 0:41.3

Steps under way side by side each get a segment of their own, split by " | ". The time runs in tenths of a second, so the line changes at every frame. While no counted step is under way the line names the innermost step that is, or the command. Hidden steps are not shown. A step with no name, as a pool given none reports its targets under, goes by the name of the nearest step above it that has one, or of the command.

In a Theme the line says the same in the theme's art and colours. It starts with the theme's spinner, which turns as the time runs, or with its running mark that stands still, in Classic and in ASCII; each counted step has the bar of the targets it has done before their count, those that failed at the end of it in the colour of a failure; and the theme's separators split the parts and the segments. Names are bold, the counts of targets in the colour of how they stand, the time in the theme's colour for the clock, and the separators and what is queued or waits muted. In Neon:

◶ power on ⋄ batch 3/60 ⋄ ▰▱▱▱▱▱▱▱ 17/480 ⋄ 1 failed ⋄ 8 running ⋄ 0:41.3

A line without work that would not fit the terminal with its bars is drawn without any.

The work a span reports, rolled up by a progress.Meter, follows the counts of a counted step, or the name of the step the line names when none is under way: the amount, out of the size when there is one, the share done, the rate and the time left, split by the same separator, as the Tree draws them:

copying the image · 312/480 · 1 failed · 8 running · 160 queued · 629 GiB · 65% · 797 MiB/s · ~7m03s left · 13:28.4
fetching the base image · 1.1/2.0 GiB · 56% · 80.0 MiB/s · ~12s left · 0:14.4

The bar of a counted step whose work has a share fills with it, the cells of the targets that failed at its end. Work in progress.Percent draws its share alone, and work of no known size its amount and rate. The work of the command is not drawn. A line too long for the terminal gives up the time left, then the rate, then the amount, and then the bars, until it fits, as a row of the Tree does; a line without work thus loses its bars first, as before.

A Counter is a progress.Sink and a progress.Suspender. Its methods are safe for concurrent use.

func NewCounter

func NewCounter(term *Terminal, o CounterOptions) *Counter

NewCounter returns a counter that draws on term once Start is called. The time on its line is counted from now. It panics if term has carried a display before.

Example

The Counter draws one line, for a terminal too small for the tree or a user who wants no more.

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// startTargets announces a target for each node, queued, as a pool does
// before it runs the first.
func startTargets(ctx context.Context, nodes ...string) []*progress.Span {
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	return targets
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{}
	term := display.NewTerminal(screen, display.TerminalOptions{})
	counter := display.NewCounter(term, display.CounterOptions{Now: clock.Now})
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{counter}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "uptime")

	ctx, step := progress.Start(ctx, progress.KindStep, "uptime",
		progress.WithFlags(progress.Fold), progress.Total(3), progress.Limit(8))
	targets := startTargets(ctx, "exe001", "exe002", "exe003")
	targets[0].Run()
	targets[0].End(nil)
	targets[1].Run()
	targets[1].End(errors.New("no answer"))
	targets[2].Run()
	clock.Add(41300 * time.Millisecond)
	counter.Draw()
	fmt.Println(screen.String())

	targets[2].End(nil)
	step.End(nil)
	command.End(nil)
	bus.Close()
	counter.Close()
}
Output:
uptime · 2/3 · 1 failed · 1 running · 0:41.3
Example (Theme)

The Counter in a theme starts its line with the theme's spinner, and draws a bar of each counted step's targets before their count, those that failed at its end in the colour of a failure: here Neon's, in 256 colours.

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// startTargets announces a target for each node, queued, as a pool does
// before it runs the first.
func startTargets(ctx context.Context, nodes ...string) []*progress.Span {
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	return targets
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{Styles: true}
	term := display.NewTerminal(screen, display.TerminalOptions{Size: func() (int, int, error) { return 80, 24, nil }})
	counter := display.NewCounter(term, display.CounterOptions{Now: clock.Now, Theme: display.Neon})
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{counter}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "uptime")

	ctx, step := progress.Start(ctx, progress.KindStep, "uptime", progress.WithFlags(progress.Fold), progress.Total(4))
	targets := startTargets(ctx, "exe001", "exe002", "exe003", "exe004")
	for _, target := range targets[:3] {
		target.Run()
	}
	targets[0].End(nil)
	targets[1].End(errors.New("no answer"))
	clock.Add(41300 * time.Millisecond)
	counter.Draw()
	fmt.Print(screen.String())
	fmt.Print(screen.Styled())

	targets[2].End(nil)
	targets[3].Run()
	targets[3].End(nil)
	step.End(nil)
	command.End(nil)
	bus.Close()
	counter.Close()
}
Output:
◶ uptime ⋄ ▰▰▰▰▱▱▱▱ 2/4 ⋄ 1 failed ⋄ 1 running ⋄ 1 queued ⋄ 0:41.3
«38;5;135»◶«» «1»uptime«38;5;103» ⋄ «38;5;33»▰▰«38;5;197»▰▰«38;5;103»▱▱▱▱«» 2/4«38;5;103» ⋄ «38;5;197»1 failed«38;5;103» ⋄ «38;5;135»1 running«38;5;103» ⋄ 1 queued ⋄ «1;38;5;134»0:41.3«»
Example (Work)

The Counter draws, in place of a counted step, the work of the newest step it names.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{}
	term := display.NewTerminal(screen, display.TerminalOptions{})
	counter := display.NewCounter(term, display.CounterOptions{Now: clock.Now})
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{counter}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "fetch")

	ctx, step := progress.Start(ctx, progress.KindStep, "fetching the base image")
	_, call := progress.Start(ctx, progress.KindCall, "download", progress.Work(progress.Bytes, 2<<30))
	for range 144 {
		clock.Add(100 * time.Millisecond)
		call.Advance(8 << 20)
	}
	counter.Draw()
	fmt.Println(screen.String())

	call.End(nil)
	step.End(nil)
	command.End(nil)
	bus.Close()
	counter.Close()
}
Output:
fetching the base image · 1.1/2.0 GiB · 56% · 80.0 MiB/s · ~12s left · 0:14.4

func (*Counter) Close

func (c *Counter) Close()

Close stops the drawing and takes the line off the terminal. Closing a closed Counter does nothing.

func (*Counter) Draw

func (c *Counter) Draw()

Draw draws the line as the work stands now, unless the command has run for less than a second.

func (*Counter) Handle

func (c *Counter) Handle(e progress.Event)

Handle counts e in.

func (*Counter) Resume

func (c *Counter) Resume()

Resume lets the line back.

func (*Counter) Start

func (c *Counter) Start()

Start draws the counter every 100ms, from a second after it was made, until Close. Start does nothing if the Counter was already started or closed.

func (*Counter) Suspend

func (c *Counter) Suspend()

Suspend takes the line off the terminal until Resume.

type CounterOptions

type CounterOptions struct {
	// Now is the clock the time on the line is read from; nil is time.Now.
	Now func() time.Time
	// Theme is how the line looks; the zero Theme draws it as plain text,
	// as a Counter has always drawn it.
	Theme Theme
	// ASCII splits the parts of the line with " - " rather than " · ",
	// for a terminal whose locale is not UTF-8. In a Theme, the line keeps
	// the theme's colours, with a running mark that stands still, ">", and,
	// in a theme that draws bars, bars of [###.....].
	ASCII bool
}

CounterOptions configure a Counter.

type Plain

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

Plain is a display of plain lines, one for each thing worth a line, with the time since the command started in front and no escape codes unless its Theme draws in colour, for a log as much as for a terminal:

[0:00] bmc power › power off: start, 480 targets, 8 at a time
[0:03] bmc power › power off › exe0007 failed (transport): exe0007.mgmt: dial tcp: i/o timeout
[0:10] bmc power › power off: 312/480 done, 1 failed, 8 running, 160 queued
[0:18] bmc power › power off: failed in 18s: 478 ok, 2 failed

A line names the span it tells of by the path to it from the command, its parts joined by "›", or by ">" with PlainOptions.ASCII. It is written as a step or a batch starts and ends, as a pause of a known length starts, and as a wait fails or is interrupted; for each target that fails, once; and, every ten seconds, for each counted step under way, a root of a progress.Tally, and each step or batch with work that no counted span is above, with how far it has got. The line that starts a step or a batch says how many targets it expects, in the word PlainOptions.Noun gives, and as "targets" without one. Hidden spans and calls get no line, and neither do the lines of output the work prints: a command whose product that output is prints it itself. A step with no name, as a pool given none reports its targets under, names no part of a path: its lines name the span above it, it has none of its own under a batch, or another span that counts its targets, and with no span above it that has a name its lines name nothing:

[0:00] start, 2 targets

In a theme, PlainOptions.Theme, a line takes the theme's colours and one mark after the time: that of what runs for the start of a step, a batch or a pause and for how far a step has got, and that of how it ended for a target that failed and for the end of a span. Its words, punctuation and path are those of no theme, which draws no mark, so that the line without its colours and its mark is the line a log of no theme shows. In Classic:

[0:03] ✗ bmc power › power off › exe0007 failed (transport): exe0007.mgmt: dial tcp: i/o timeout

The work a span reports, rolled up by a progress.Meter, follows how the targets of a step stand, or stands alone for a step that counts none, the parts split by commas as the counts are, and the line a step leaves at its end says what the work came to and its mean rate after how it ended:

[0:10] fetch › fetching the base image: 0.8/2.0 GiB, 39%, 80.0 MiB/s, ~16s left
[0:25] fetch › fetching the base image: done in 25s: 2.0 GiB at 80.0 MiB/s
[13:20] deploy › copying the image: 310/480 done, 1 failed, 8 running, 162 queued, 625 GiB, 65%, 797 MiB/s, ~7m08s left
[20:33] deploy › copying the image: failed in 20m33s: 479 ok, 1 failed, 958 GiB at 796 MiB/s

A step with no work and no count says nothing every ten seconds.

The lines go out through the Terminal: ahead of whatever the command writes after the events they tell of, never into a line the command has not ended, and not while a question is asked. A Plain is a progress.Sink and a progress.Suspender; its methods are safe for concurrent use.

func NewPlain

func NewPlain(term *Terminal, o PlainOptions) *Plain

NewPlain returns a display of plain lines on term, which counts the time in front of its lines from now. It panics if term has carried a display before.

Example

Plain writes a line for each thing worth one, for a CI log; the Summary is the line a command prints once it has ended. The noun names the targets, which are nodes here.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// startTargets announces a target for each node, queued, as a pool does
// before it runs the first.
func startTargets(ctx context.Context, nodes ...string) []*progress.Span {
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	return targets
}

func main() {
	clock := newExampleClock()
	term := display.NewTerminal(os.Stdout, display.TerminalOptions{})
	plain := display.NewPlain(term, display.PlainOptions{
		Now: clock.Now,
		Noun: func(n int) string {
			if n == 1 {
				return "1 node"
			}
			return fmt.Sprintf("%d nodes", n)
		},
	})
	summary := &display.Summary{}
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{plain, summary}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "create cluster")

	ctx, step := progress.Start(ctx, progress.KindStep, "starting the nodes",
		progress.WithFlags(progress.Fold), progress.Total(3), progress.Limit(3))
	targets := startTargets(ctx, "worker0", "worker1", "worker2")
	for _, target := range targets {
		target.Run()
	}
	clock.Add(3 * time.Second)
	failed := errors.New("container exited with 1")
	targets[1].End(failed)
	clock.Add(7 * time.Second)
	plain.Draw()
	clock.Add(2 * time.Second)
	targets[0].End(nil)
	targets[2].End(nil)
	step.End(failed)
	command.End(failed)
	bus.Close()
	plain.Close()
	fmt.Println(summary.Line())
}
Output:
[0:00] create cluster › starting the nodes: start, 3 nodes
[0:03] create cluster › starting the nodes › worker1 failed (target): container exited with 1
[0:10] create cluster › starting the nodes: 1/3 done, 1 failed, 2 running
[0:12] create cluster › starting the nodes: failed in 12s: 2 ok, 1 failed
create cluster: failed in 12s: 2 ok, 1 failed
Example (Theme)

Plain lines in a theme take its colours and one mark after the time, here Ember's in the terminal's own 16 colours, and keep their words; so does the Summary in the same theme.

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// startTargets announces a target for each node, queued, as a pool does
// before it runs the first.
func startTargets(ctx context.Context, nodes ...string) []*progress.Span {
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	return targets
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{Styles: true}
	term := display.NewTerminal(screen, display.TerminalOptions{})
	theme := display.Ember.In(display.Colours16)
	plain := display.NewPlain(term, display.PlainOptions{Now: clock.Now, Theme: theme})
	summary := &display.Summary{Theme: theme}
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{plain, summary}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "create cluster")

	ctx, step := progress.Start(ctx, progress.KindStep, "starting the nodes",
		progress.WithFlags(progress.Fold), progress.Total(2))
	targets := startTargets(ctx, "worker0", "worker1")
	for _, target := range targets {
		target.Run()
	}
	clock.Add(3 * time.Second)
	failed := errors.New("container exited with 1")
	targets[1].End(failed)
	targets[0].End(nil)
	step.End(failed)
	command.End(failed)
	bus.Close()
	plain.Close()
	_, _ = fmt.Fprintln(screen, summary.Line())
	fmt.Print(screen.Styled())
}
Output:
«33»[0:00]«» «33»▮«» create cluster«2» › «»starting the nodes: «33»start«», 2 targets
«33»[0:03]«» «31»✘«» create cluster«2» › «»starting the nodes«2» › «»worker1 «31»failed (target)«»: container exited with 1
«33»[0:03]«» «31»✘«» create cluster«2» › «»starting the nodes: «31»failed«» in «2»3.0s«»: «1»1 ok«», «31»1 failed«»
«31»✘«» «1»create cluster«»: «31»failed«» in «2»3.0s«»: «1»1 ok«», «31»1 failed«»
Example (Work)

Plain says how far a step with work has got every ten seconds, and what its work came to when it ends; the Summary says what the command moved.

package main

import (
	"context"
	"fmt"
	"os"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

func main() {
	clock := newExampleClock()
	term := display.NewTerminal(os.Stdout, display.TerminalOptions{})
	plain := display.NewPlain(term, display.PlainOptions{Now: clock.Now})
	summary := &display.Summary{}
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{plain, summary}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "fetch")

	ctx, step := progress.Start(ctx, progress.KindStep, "fetching the base image")
	_, call := progress.Start(ctx, progress.KindCall, "download", progress.Work(progress.Bytes, 2<<30))
	for range 100 {
		clock.Add(100 * time.Millisecond)
		call.Advance(8 << 20)
	}
	plain.Draw()
	for range 150 {
		clock.Add(100 * time.Millisecond)
		call.Advance(8 << 20)
	}
	call.End(nil)
	step.End(nil)
	command.End(nil)
	bus.Close()
	plain.Close()
	fmt.Println(summary.Line())
}
Output:
[0:00] fetch › fetching the base image: start
[0:10] fetch › fetching the base image: 0.8/2.0 GiB, 39%, 80.0 MiB/s, ~16s left
[0:25] fetch › fetching the base image: done in 25s: 2.0 GiB at 80.0 MiB/s
fetch: done in 25s: 2.0 GiB at 80.0 MiB/s

func (*Plain) Close

func (p *Plain) Close()

Close stops the writing Start began, and writes what is left. Closing a closed Plain does nothing.

func (*Plain) Draw

func (p *Plain) Draw()

Draw writes the lines not yet written and the heartbeats due now, where the terminal lets it.

func (*Plain) Handle

func (p *Plain) Handle(e progress.Event)

Handle turns e into its line, if it is worth one.

func (*Plain) Resume

func (p *Plain) Resume()

Resume lets the lines out again.

func (*Plain) Start

func (p *Plain) Start()

Start writes the lines as they come, and the heartbeats as they fall due, until Close. Start does nothing if the Plain was already started or closed.

func (*Plain) Suspend

func (p *Plain) Suspend()

Suspend writes the lines not yet written, and then no more until Resume.

type PlainOptions

type PlainOptions struct {
	// Now is the clock the heartbeats are read from, and the time in front
	// of a line counted from; nil is time.Now. The Bus's clock should be
	// the same.
	Now func() time.Time
	// ASCII writes the path to a span with ">" between its parts, for a
	// locale that is not UTF-8, rather than "›", and the marks of Theme in
	// ASCII, "*" for what runs.
	ASCII bool
	// Theme draws the lines in its colours, each with a mark after the
	// time in front, and with the words, punctuation and paths of no
	// theme. The zero Theme draws them as they have always been drawn,
	// with no mark and no escape code.
	Theme Theme
	// Noun says how many targets a step or a batch expects, n of them,
	// in the line that starts it, in the program's own word for them, such
	// as "480 hosts"; nil is "1 target" and "%d targets".
	Noun func(n int) string
}

PlainOptions configure a Plain.

type Summary

type Summary struct {
	// Theme draws the line in its colours, with the mark of how the
	// command ended in front; the zero Theme draws it as it has always
	// been drawn, with no mark and no escape code. Line reads it: set it
	// before the Summary is put on a Bus.
	Theme Theme
	// ASCII draws the mark of Theme in ASCII, for a locale that is not
	// UTF-8; with the zero Theme it changes nothing. Line reads it: set it
	// before the Summary is put on a Bus.
	ASCII bool
	// contains filtered or unexported fields
}

Summary is the one line a display leaves behind once the command has ended: how the command ended, how long it ran, and how many of the things it worked on ended how.

provision reinstall: failed in 18m03s: 478 ok, 2 failed

How the command ended comes first, since the counts need not say it: a command that fails in a step that counts nothing, or stops before the steps that would have counted the rest, can have counted nothing but ok. Each node, or whatever else a target names, counts once, as the worst of the ways its targets ended below the counted steps: failed, then canceled, then skipped, then ok, so that a node reset after its boot override was set is one node, not two. A batch left out, after one that failed or for an interrupt, counts as its nodes, each once with the rest, when its Node names as many as its Total, and otherwise as its Total. A command that counted no targets says only how it ended. The line says nothing of why a target failed: the command's error says that, once, after it.

When the work the command's spans reported, rolled up by a progress.Meter, is all in one unit, the line ends with the total amount and the average rate, which the targets' counts need not have:

deploy: failed in 20m33s: 479 ok, 1 failed, 958 GiB at 796 MiB/s

Work in two units says nothing, as amounts in different units are never added, and work in progress.Percent has no amount to say.

In a Theme, the line takes the theme's colours and, in front, the mark of how the command ended, and keeps its words and punctuation. In Classic:

✗ provision reinstall: failed in 18m03s: 478 ok, 2 failed

A Summary is a progress.Sink; its methods are safe for concurrent use.

Example (Theme)

The Summary in a theme starts with the mark of how the command ended, in its colour: here Tide's, in none, as under NO_COLOR, which keeps the mark.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// startTargets announces a target for each node, queued, as a pool does
// before it runs the first.
func startTargets(ctx context.Context, nodes ...string) []*progress.Span {
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	return targets
}

func main() {
	clock := newExampleClock()
	summary := &display.Summary{Theme: display.Tide.In(display.NoColours)}
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{summary}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "drain")

	ctx, step := progress.Start(ctx, progress.KindStep, "draining", progress.WithFlags(progress.Fold), progress.Total(2))
	targets := startTargets(ctx, "exe001", "exe002")
	targets[0].Run()
	targets[0].End(nil)
	targets[1].Run()
	clock.Add(2 * time.Second)
	targets[1].End(context.Canceled)
	step.End(context.Canceled)
	command.End(context.Canceled)
	bus.Close()
	fmt.Println(summary.Line())
}
Output:
⊖ drain: canceled in 2.0s: 1 ok, 1 canceled

func (*Summary) Handle

func (s *Summary) Handle(e progress.Event)

Handle counts e in.

func (*Summary) Line

func (s *Summary) Line() string

Line returns the summary once the command has ended, if it is worth a line: when the command ran for a second or longer, as long as a display takes to show itself, so that a command done within its first second leaves standard error as it would without a display, failed targets or not; its error says what failed. Otherwise, and before the command has ended, it is "".

type Terminal

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

Terminal is the terminal a display draws on, shared with the command's own output. It is safe for concurrent use.

A Terminal carries one display, from the NewTree, NewCounter or NewPlain that makes it to its Close. After that it draws no more, and its writers go on passing bytes through. Making a second display on a Terminal, even once the first has been closed, panics: make a Terminal for each.

func NewTerminal

func NewTerminal(w io.Writer, o TerminalOptions) *Terminal

NewTerminal returns the terminal w is, as o configures it. The Terminal keeps what o says, which nothing changes after.

func (*Terminal) Lines

func (t *Terminal) Lines(w io.Writer) io.Writer

Lines returns a writer to w, a stream that shows on the terminal, for lines that come from beside the command, from goroutines that may write at any moment: the stack of a panic a pool's worker recovered from, or a log line. It writes whole lines only: at once where no region is drawn, and otherwise with the next frame, above it. It holds them while a question is asked, between progress.Suspend and its resume, and while the command has a line open, such as a question it asks itself; the next frame, the write that ends the open line, the resume and the display's Close write them, in order, and so does a write of the command's, before its own bytes. A write of the command's that ends a line and then sets the colours (ESC [ … m), the last of them ESC [ 0 m, which sets them back, leaves no line open; one that ends in a colour not set back leaves the line open. A line that does not end waits for its end, and the display's Close ends it. What waits is bounded, 256 KiB: a line past that is left out, and a line written with the others says how many were.

w may be a writer of the Terminal's own, such as its Writer of standard error: the lines then go to the stream under it. What is written is not changed, and Write reports it all written: the lines are a courtesy.

func (*Terminal) Writer

func (t *Terminal) Writer(w io.Writer) io.Writer

Writer returns a writer to w, a stream that shows on the terminal, such as standard error or standard output, that takes the display's region off before it writes. What is written is not changed.

type TerminalOptions

type TerminalOptions struct {
	// Size tells how many columns and rows the terminal has, and is asked
	// at each frame; nil, or a Size that fails or says no columns, is a
	// terminal 80 columns wide, of a height not known.
	Size func() (cols, rows int, err error)
	// Foreground, when it is set, reports whether the process is the job
	// in the terminal's foreground. A job in the background draws nothing:
	// the shell's prompt and what is typed at it are on the rows a frame
	// would erase.
	Foreground func() bool
	// PanicLog receives the stack of a display that panicked while it
	// drew, the front end's diagnostics; nil is the terminal itself. The
	// stack goes through the Terminal's Lines, so that it waits while a
	// question is asked.
	PanicLog io.Writer
	// Program names the program in the line that says a display stopped,
	// and in the one that says how many lines the Lines writers left out,
	// "prog: …", so that neither is read as the command's own; empty
	// leaves the name out.
	Program string
}

TerminalOptions configure a Terminal.

type Theme added in v0.3.0

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

Theme is how a display looks: the colours it draws in, and the marks, spinner, bar, separators and guides it draws with. A Theme is a value: compare it with ==, and pass it in the options of a display and in Summary.Theme.

The zero Theme is none: a display draws as it always has, plain text with no escape code, which is the text a program's tests compare. The five themes, Classic, Aurora, Ember, Neon and Tide, each have a name, which Themes lists and ParseTheme reads, for a flag such as --theme. Assign to none of their variables: they are values to pass, as io.EOF is.

A theme only adds to what a display says. The tree and the counter draw its marks, separators, spinner, bar and guides; plain lines and the summary take its colours and one mark in front, and keep their words, punctuation and paths. Names, errors, requests and output lines stay in the terminal's own colour. Every glyph of a theme takes one column: it is of an East Asian Width that no locale draws wide, and no emoji, which a terminal may draw as a picture two columns wide. The colours and art of a theme may change in a minor release; its name and the words it draws may not (decision 20).

The kit reads no environment. Whether to draw in colour, and in how many colours, is the program's to decide: In draws a theme in the 256 colours it is designed in, in the terminal's own 16, or in none, its art alone, for NO_COLOR. A program that draws for a log, or for a terminal whose TERM is dumb, passes the zero Theme. With the ASCII options a display draws a theme's colours with ASCII marks.

Example

A program takes the theme from a flag, which the text methods of a Theme read, and draws it in as many colours as its terminal shows: here in none, as under NO_COLOR, which keeps the theme's art.

package main

import (
	"flag"
	"fmt"

	"github.com/GSI-HPC/go-clikit/progress/display"
)

func main() {
	flags := flag.NewFlagSet("clusterctl", flag.ContinueOnError)
	var theme display.Theme
	flags.TextVar(&theme, "theme", display.Classic, "how progress looks")
	if err := flags.Parse([]string{"-theme", "aurora"}); err != nil {
		fmt.Println(err)
		return
	}
	noColor := true // the program read NO_COLOR from its environment
	if noColor {
		theme = theme.In(display.NoColours)
	}
	fmt.Println(theme, theme.Colours())
	fmt.Println(display.Themes())
}
Output:
aurora none
[classic aurora ember neon tide]

func ParseTheme added in v0.3.0

func ParseTheme(name string) (Theme, error)

ParseTheme returns the Theme name names, in any case, as String writes it, drawn in 256 colours; "none" and "" are the zero Theme. Any other name is an error that quotes it as given and lists the names.

Example

ParseTheme reads a theme's name in any case, and says which names there are when it reads none.

package main

import (
	"fmt"

	"github.com/GSI-HPC/go-clikit/progress/display"
)

func main() {
	theme, err := display.ParseTheme("Tide")
	fmt.Println(theme, theme.Colours(), err)
	_, err = display.ParseTheme("dawn")
	fmt.Println(err)
}
Output:
tide 256 <nil>
display: unknown theme "dawn": want none, classic, aurora, ember, neon or tide

func Themes added in v0.3.0

func Themes() []Theme

Themes returns every Theme but the zero one, in a new slice: Classic, Aurora, Ember, Neon and Tide, each named by its String.

func (Theme) Colours added in v0.3.0

func (t Theme) Colours() Colours

Colours returns how many colours t draws in: NoColours for the zero Theme.

func (Theme) In added in v0.3.0

func (t Theme) In(c Colours) Theme

In returns t drawn in c colours, with its own marks, spinner, bar and guides. The zero Theme stays the zero Theme, which draws in no colour whatever c is.

func (Theme) MarshalText added in v0.3.0

func (t Theme) MarshalText() ([]byte, error)

MarshalText returns the name of t, so that a Theme can be the value of a flag, with flag.TextVar.

func (Theme) String added in v0.3.0

func (t Theme) String() string

String returns the name of t, "aurora", or "none" for the zero Theme.

func (*Theme) UnmarshalText added in v0.3.0

func (t *Theme) UnmarshalText(text []byte) error

UnmarshalText sets t to the Theme text names, as ParseTheme reads it, in the colours t is drawn in, and leaves t as it was for a name that is no Theme's. The zero Theme keeps no colours, so a theme read over it is drawn in 256: a program that picks the colours sets them with In once its flags are read.

type Tree

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

Tree is the live display of a terminal: a few rows at its bottom, the region, that show the work under way as a tree, redrawn as it goes on, and a line above them for each step once it has finished:

✓ configuring the network boot  2.3s
provision reinstall · 4:12.6
  setting the machines to boot from the network once  312/480 · 1 failed · 8 running · 159 queued
    ✗ exe0007  transport: {}: dial tcp: i/o timeout
    ▸ exe0313  4.8s  PATCH /redfish/v1/Systems/1
    ▸ exe0314  3.1s  GET /redfish/v1/Systems/1
    … 6 more running
    ✓ exe[0001-0006,0008-0312]

The command heads the region, and each step, batch and wait under way has a row, indented under the span it is part of; a step says how many of its targets are done, of how many, and how they stand, as a progress.Tally counts them. The targets of a step are folded: those that ended well into one node set, those that failed into one row for each class and error they share, with a target's own name read as {}, those interrupted and those left out into one row each. A row that names more than 256 targets is folded again at most once a second, so it may leave out those that ended since, which the step's count includes; the line the step leaves names every one. Those still queued are only counted, and those running are listed, the longest running first, with how long they have run, against the bound of the request they wait for when it has one, and what that request is, or the last line of output for a step that shows lines. The command's time and how long each span has run tick in tenths of a second, so a frame differs from the one before while anything runs. A pause counts down in whole seconds.

The work a span reports, rolled up the tree by a progress.Meter, is drawn on its row: a step's or a batch's after how its targets stand, or after its name when it counts none, and a running target's, or that of a call made for the command, after its name, before how long it has run. It reads as the amount, out of the size when there is one, the share done, the rate and the time left, "1.1/2.0 GiB · 56% · 80.0 MiB/s · ~12s left", with how long the work has stalled in place of the rate once it has not grown for five seconds; work in progress.Percent draws its share and its time left alone, and work of no known size its amount and its rate. A row too wide for the terminal gives up its request first, as it is cut off, then the time left, the rate, the amount and the bar, in that order. The line a step leaves says what its work came to after how long it took, and how fast it went on average: "2.0 GiB at 80.0 MiB/s". A span that reports no work, and has none below it, draws as it always has.

A hidden span is drawn only once it has run for a second, and a line is left for it only when it failed or took that long. A span that ends well within a tenth of a second, with nothing drawn below it, is done without a row or a line; one that failed keeps its line however quickly it did, and so does one that had something drawn below it. A step with no name is no row: what is below it is drawn in its place.

The region is at most a third of the terminal's rows, and six rows on a smaller one; rows that do not fit are cut, the running targets first, each list down to a row that says how many it leaves out, and every row is cut to the width, so that none wraps. The tree is drawn at most ten times a second, from a second after it was made: a command done by then never shows one, nor the lines of its steps. On a terminal of fewer than 8 rows or 40 columns, as it says at each frame, the counter's line is drawn instead. Once the command has been interrupted, its row says so, with how many targets will stop and how many will not start.

In a Theme the tree draws the theme's marks and separators, and adds what no theme draws: a spinner in place of the mark of a running target, which turns with the time since the tree was made, so that a frame drawn at the same instant is drawn the same; on the row of a step or a batch whose count it knows, a bar of its share of the work done, or of how many of its targets are done when it has none, between its name and its count, the targets that failed at its end, when the row fits the terminal with it; before the work of a running target, call or step with a share done, a bar of six cells; a guide at the innermost level of each indent; and colours, on the marks, the counts of how the targets stand and ended, the clock, the times, the separators, the amounts and rates of work, its share done, in the colour of the bar, how long it has stalled, in that of a cancellation, and the names of the command and its steps, while the names of targets, errors, requests and lines of output stay in the terminal's own. A theme draws as many rows as no theme, with the same words in the same order, and sets the colours back after each piece it colours, so that none runs on past a row or a line.

A Tree is a progress.Sink, a progress.LineSink and a progress.Suspender. Its methods are safe for concurrent use.

func NewTree

func NewTree(term *Terminal, o TreeOptions) *Tree

NewTree returns a tree that draws on term once Start is called. The time on its first row is counted from now. It panics if term has carried a display before.

Example

The Tree draws the work under way at the bottom of a terminal, redrawn as it goes on; a Screen shows what a terminal would.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{Width: 60}
	term := display.NewTerminal(screen, display.TerminalOptions{Size: func() (int, int, error) { return 60, 24, nil }})
	tree := display.NewTree(term, display.TreeOptions{Now: clock.Now})
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{tree}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "power on")

	ctx, step := progress.Start(ctx, progress.KindStep, "powering on",
		progress.WithFlags(progress.Fold), progress.Total(4), progress.Limit(2))
	nodes := []string{"exe01", "exe02", "exe03", "exe04"}
	ctxs := make([]context.Context, len(nodes))
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		ctxs[i], targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	for _, target := range targets[:2] {
		target.Run()
		target.End(nil)
	}
	targets[2].Run()
	_, call := progress.Start(ctxs[2], progress.KindCall, "redfish",
		progress.HTTP("POST", "/redfish/v1/Systems/1"), progress.Timeout(30*time.Second))
	clock.Add(2 * time.Second)
	tree.Draw()
	fmt.Print(screen.String())
	fmt.Println("---")

	// Once the work is done, the region is gone and the line of the
	// finished step is left.
	call.End(nil)
	targets[2].End(nil)
	targets[3].Run()
	targets[3].End(nil)
	step.End(nil)
	command.End(nil)
	bus.Close()
	tree.Close()
	fmt.Print(screen.String())
}
Output:
power on · 0:02.0
  powering on  2/4 · 1 running · 1 queued
    ▸ exe03  2.0s/30s  POST /redfish/v1/Systems/1
    ✓ exe[01-02]
---
✓ powering on  2.0s  4 ok
Example (Theme)

The Tree in a theme draws its art, here Tide's spinner, bar and guides, in the theme's colours, which a Screen with Styles shows as «p»text«».

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// startTargets announces a target for each node, queued, as a pool does
// before it runs the first.
func startTargets(ctx context.Context, nodes ...string) []*progress.Span {
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	return targets
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{Width: 60, Styles: true}
	term := display.NewTerminal(screen, display.TerminalOptions{Size: func() (int, int, error) { return 60, 24, nil }})
	tree := display.NewTree(term, display.TreeOptions{Now: clock.Now, Theme: display.Tide})
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{tree}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "power on")

	ctx, step := progress.Start(ctx, progress.KindStep, "powering on",
		progress.WithFlags(progress.Fold), progress.Total(4), progress.Limit(2))
	targets := startTargets(ctx, "exe01", "exe02", "exe03", "exe04")
	for _, target := range targets[:2] {
		target.Run()
		target.End(nil)
	}
	targets[2].Run()
	clock.Add(2 * time.Second)
	tree.Draw()
	fmt.Print(screen.String())
	fmt.Print(screen.Styled())

	targets[2].End(errors.New("no answer"))
	targets[3].Run()
	targets[3].End(nil)
	step.End(errors.New("1 of 4 failed"))
	command.End(nil)
	bus.Close()
	tree.Close()
}
Output:
power on ◦ 0:02.0
╎ powering on  ◉◉◉◉◉◌◌◌◌◌ 2/4 ◦ 1 running ◦ 1 queued
  ╎ ◡ exe03  2.0s
  ╎ ✓ exe[01-02]
«1»power on«38;5;66» ◦ «38;5;31»0:02.0«»
«38;5;66»╎ «1»powering on«»  «38;5;32»◉◉◉◉◉«38;5;66»◌◌◌◌◌«» 2/4«38;5;66» ◦ «38;5;29»1 running«38;5;66» ◦ 1 queued«»
  «38;5;66»╎ «38;5;29»◡«» exe03  «38;5;66»2.0s«»
  «38;5;66»╎ «1;38;5;32»✓«» exe[01-02]
Example (Work)

A span reports its work as it goes: Work gives its unit and size, and Advance adds to the amount done. The Tree draws the work on the row of the step the call is made for, which it rolls up into.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/GSI-HPC/go-clikit/progress"
	"github.com/GSI-HPC/go-clikit/progress/display"
	"github.com/GSI-HPC/go-clikit/progress/progresstest"
)

// exampleClock is a clock the example moves on by hand. Its mutex makes it
// safe for concurrent use, as BusOptions.Now must be.
type exampleClock struct {
	mu  sync.Mutex
	now time.Time
}

// newExampleClock returns a clock at noon.
func newExampleClock() *exampleClock {
	return &exampleClock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
}

func (c *exampleClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *exampleClock) Add(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

func main() {
	clock := newExampleClock()
	screen := &progresstest.Screen{Width: 120}
	term := display.NewTerminal(screen, display.TerminalOptions{Size: func() (int, int, error) { return 120, 24, nil }})
	tree := display.NewTree(term, display.TreeOptions{Now: clock.Now})
	bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{tree}, Now: clock.Now})
	ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "fetch")

	ctx, step := progress.Start(ctx, progress.KindStep, "fetching the base image")
	_, call := progress.Start(ctx, progress.KindCall, "download",
		progress.HTTP("GET", "/images/rocky-9.4.qcow2"), progress.Work(progress.Bytes, 2<<30))
	for range 144 {
		clock.Add(100 * time.Millisecond)
		call.Advance(8 << 20)
	}
	tree.Draw()
	fmt.Print(screen.String())
	fmt.Println("---")

	for range 112 {
		clock.Add(100 * time.Millisecond)
		call.Advance(8 << 20)
	}
	call.End(nil)
	step.End(nil)
	command.End(nil)
	bus.Close()
	tree.Close()
	fmt.Print(screen.String())
}
Output:
fetch · 0:14.4
  fetching the base image  1.1/2.0 GiB · 56% · 80.0 MiB/s · ~12s left  14.4s  GET /images/rocky-9.4.qcow2
---
✓ fetching the base image  25s  2.0 GiB at 80.0 MiB/s

func (*Tree) Close

func (t *Tree) Close()

Close stops the drawing, takes the region off the terminal and writes the lines not yet written, if the tree was ever drawn. Closing a closed Tree does nothing.

func (*Tree) Draw

func (t *Tree) Draw()

Draw draws a frame as the work stands now, with the lines of the steps that finished above it, unless the command has run for less than a second. The terminal writes nothing for a frame that reads as the one before.

func (*Tree) Handle

func (t *Tree) Handle(e progress.Event)

Handle keeps what e changes.

func (*Tree) Resume

func (t *Tree) Resume()

Resume lets the region back.

func (*Tree) Start

func (t *Tree) Start()

Start draws the tree every 100ms, from a second after it was made, until Close. Start does nothing if the Tree was already started or closed.

func (*Tree) Suspend

func (t *Tree) Suspend()

Suspend takes the region off the terminal, once the lines not yet written are, until Resume.

func (*Tree) WantsLines

func (t *Tree) WantsLines() bool

WantsLines asks for the lines of output of a step that shows them, the last of which a running target's row shows.

type TreeOptions

type TreeOptions struct {
	// Now is the clock the times of the tree are read from; nil is
	// time.Now. The Bus's clock should be the same.
	Now func() time.Time
	// ASCII draws with ASCII marks alone, for a terminal whose locale is
	// not UTF-8. In a Theme it draws the theme's colours with the ASCII
	// marks, a bar of # and no spinner or guide.
	ASCII bool
	// Theme is how the tree looks: its marks, spinner, bar, guides and
	// colours. The zero Theme draws the tree as it has always been drawn,
	// in plain text.
	Theme Theme
	// Interrupted is closed once the command has been interrupted; nil is
	// a command that never is.
	Interrupted <-chan struct{}
}

TreeOptions configure a Tree.

Jump to

Keyboard shortcuts

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