1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

313 lines
8.1 KiB
Go
Raw Normal View History

// Package gauge implements a widget that displays the progress of an operation.
package gauge
import (
"errors"
"fmt"
"image"
"strings"
"sync"
"unicode/utf8"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgetapi"
"golang.org/x/exp/utf8string"
)
// progressType indicates how was the current progress provided by the caller.
type progressType int
// String implements fmt.Stringer()
func (pt progressType) String() string {
if n, ok := progressTypeNames[pt]; ok {
return n
}
return "progressTypeUnknown"
}
// progressTypeNames maps progressType values to human readable names.
var progressTypeNames = map[progressType]string{
progressTypePercent: "progressTypePercent",
progressTypeAbsolute: "progressTypeAbsolute",
}
const (
progressTypePercent = iota
progressTypeAbsolute
)
// Gauge displays the progress of an operation.
//
// Draws a rectangle, a progress bar with optional display of percentage and /
// or text label.
//
// Implements widgetapi.Widget. This object is thread-safe.
type Gauge struct {
// pt indicates how current and total are interpreted.
pt progressType
// current is the current progress that will be drawn.
current int
// total is the value that represents completion.
// For progressTypePercent, this is 100, for progressTypeAbsolute this is
// the total provided by the caller.
total int
// mu protects the Gauge.
mu sync.Mutex
// opts are the provided options.
opts *options
}
// New returns a new Gauge.
func New(opts ...Option) *Gauge {
opt := newOptions()
for _, o := range opts {
o.set(opt)
}
return &Gauge{
opts: opt,
}
}
// Absolute sets the progress in absolute numbers, i.e. 7 out of 10.
// The total amount must be a non-zero positive integer. The done amount must
// be a zero or a positive integer such that done <= total.
// Provided options override values set when New() was called.
func (g *Gauge) Absolute(done, total int, opts ...Option) error {
g.mu.Lock()
defer g.mu.Unlock()
if done < 0 || total < 1 || done > total {
return fmt.Errorf("invalid progress, done(%d) must be <= total(%d), done must be zero or positive "+
"and total must be a non-zero positive number", done, total)
}
for _, opt := range opts {
opt.set(g.opts)
}
g.pt = progressTypeAbsolute
g.current = done
g.total = total
return nil
}
// Percent sets the current progress in percentage.
// The provided value must be between 0 and 100.
// Provided options override values set when New() was called.
func (g *Gauge) Percent(p int, opts ...Option) error {
g.mu.Lock()
defer g.mu.Unlock()
if p < 0 || p > 100 {
return fmt.Errorf("invalid percentage, p(%d) must be 0 <= p <= 100", p)
}
for _, opt := range opts {
opt.set(g.opts)
}
g.pt = progressTypePercent
g.current = p
g.total = 100
return nil
}
// width determines the required width of the gauge drawn on the provided area
// in order to represent the current progress.
func (g *Gauge) width(ar image.Rectangle) int {
mult := float32(g.current) / float32(g.total)
width := float32(ar.Dx()) * mult
return int(width)
}
// hasBorder determines of the gauge has a border.
func (g *Gauge) hasBorder() bool {
return g.opts.border != draw.LineStyleNone
}
// usable determines the usable area for the gauge itself.
func (g *Gauge) usable(cvs *canvas.Canvas) image.Rectangle {
if g.hasBorder() {
return area.ExcludeBorder(cvs.Area())
}
return cvs.Area()
}
// progressText returns the textual representation of the current progress.
func (g *Gauge) progressText() string {
if g.opts.hideTextProgress {
return ""
}
switch g.pt {
case progressTypePercent:
return fmt.Sprintf("%d%%", g.current)
case progressTypeAbsolute:
return fmt.Sprintf("%d/%d", g.current, g.total)
default:
return ""
}
}
// gaugeText returns full text to be displayed within the gauge, i.e. the
// progress text and the optional label.
func (g *Gauge) gaugeText() string {
var sb strings.Builder
sb.WriteString(g.progressText())
if g.opts.textLabel != "" {
if sb.Len() > 0 {
sb.WriteString(" ")
}
sb.WriteString(fmt.Sprintf("(%s)", g.opts.textLabel))
}
return sb.String()
}
// drawText draws the text enumerating the progress and the text label.
func (g *Gauge) drawText(cvs *canvas.Canvas) error {
text := g.gaugeText()
if text == "" {
return nil
}
ar := g.usable(cvs)
textStart, err := align.Text(ar, text, g.opts.hTextAlign, g.opts.vTextAlign)
if err != nil {
return err
}
textEndX := textStart.X + utf8.RuneCountInString(text)
if textEndX >= ar.Max.X { // The text will be trimmed.
textEndX = ar.Max.X - 1
}
gaugeEndX := g.width(ar)
switch {
case gaugeEndX < textStart.X:
// The text entirely falls outside of the drawn gauge.
return draw.Text(cvs, text, textStart,
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextCellOpts(cell.FgColor(g.opts.emptyTextColor)),
draw.TextMaxX(ar.Max.X),
)
case gaugeEndX >= textEndX:
// The text entirely falls inside of the drawn gauge.
return draw.Text(cvs, text, textStart,
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextCellOpts(cell.FgColor(g.opts.filledTextColor)),
draw.TextMaxX(ar.Max.X),
)
default:
// Part of the text falls inside of the drawn gauge and part outside.
utfText := utf8string.NewString(text)
insideCount := ar.Min.X + gaugeEndX - textStart.X
insideText := utfText.Slice(0, insideCount)
outsideText := utfText.Slice(insideCount, utfText.RuneCount())
if err := draw.Text(cvs, insideText, textStart,
draw.TextOverrunMode(draw.OverrunModeTrim),
draw.TextCellOpts(cell.FgColor(g.opts.filledTextColor)),
); err != nil {
return err
}
outsideStart := image.Point{textStart.X + insideCount, textStart.Y}
if outsideStart.In(ar) {
if err := draw.Text(cvs, outsideText, outsideStart,
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextCellOpts(cell.FgColor(g.opts.emptyTextColor)),
draw.TextMaxX(ar.Max.X),
); err != nil {
return err
}
}
}
return nil
}
// Draw draws the Gauge widget onto the canvas.
// Implements widgetapi.Widget.Draw.
func (g *Gauge) Draw(cvs *canvas.Canvas) error {
g.mu.Lock()
defer g.mu.Unlock()
if g.hasBorder() {
if err := draw.Border(cvs, cvs.Area(),
draw.BorderLineStyle(g.opts.border),
draw.BorderTitle(g.opts.borderTitle, draw.OverrunModeThreeDot, g.opts.borderCellOpts...),
draw.BorderTitleAlign(g.opts.borderTitleHAlign),
draw.BorderCellOpts(g.opts.borderCellOpts...),
); err != nil {
return err
}
}
usable := g.usable(cvs)
progress := image.Rect(
usable.Min.X,
usable.Min.Y,
usable.Min.X+g.width(usable),
usable.Max.Y,
)
if progress.Dx() > 0 {
if err := draw.Rectangle(cvs, progress,
draw.RectChar(g.opts.gaugeChar),
draw.RectCellOpts(cell.BgColor(g.opts.color)),
); err != nil {
return err
}
}
return g.drawText(cvs)
}
// Keyboard input isn't supported on the Gauge widget.
func (g *Gauge) Keyboard(k *terminalapi.Keyboard) error {
return errors.New("the Gauge widget doesn't support keyboard events")
}
// Mouse input isn't supported on the Gauge widget.
func (g *Gauge) Mouse(m *terminalapi.Mouse) error {
return errors.New("the Gauge widget doesn't support mouse events")
}
// maxSize determines the maximum size of the canvas.
func (g *Gauge) maxSize() image.Point {
maxHeight := g.opts.height
if g.hasBorder() {
// Add the required space for the border.
maxHeight += 2
}
return image.Point{0, maxHeight}
}
// minSize determines the minimum required size of the canvas.
func (g *Gauge) minSize() image.Point {
minWidth := 1 // Shorter gauge than this cannot display anything.
minHeight := 1 // At least one line for the gauge itself.
if g.hasBorder() {
// Add the required space for the border.
minWidth += 2
minHeight += 2
}
return image.Point{minWidth, minHeight}
}
// Options implements widgetapi.Widget.Options.
func (g *Gauge) Options() widgetapi.Options {
g.mu.Lock()
defer g.mu.Unlock()
return widgetapi.Options{
MaximumSize: g.maxSize(),
MinimumSize: g.minSize(),
WantKeyboard: false,
WantMouse: false,
}
}