termdash/widgets/donut/donut.go

315 lines
8.8 KiB
Go

// Copyright 2019 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package donut is a widget that displays the progress of an operation as a
// partial or full circle.
package donut
import (
"errors"
"fmt"
"image"
"math"
"sync"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/private/alignfor"
"github.com/mum4k/termdash/private/area"
"github.com/mum4k/termdash/private/canvas"
"github.com/mum4k/termdash/private/canvas/braille"
"github.com/mum4k/termdash/private/draw"
"github.com/mum4k/termdash/private/runewidth"
"github.com/mum4k/termdash/terminal/terminalapi"
"github.com/mum4k/termdash/widgetapi"
)
// 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
)
// Donut displays the progress of an operation by filling a partial circle and
// eventually by completing a full circle. The circle can have a "hole" in the
// middle, which is where the name comes from.
//
// Implements widgetapi.Widget. This object is thread-safe.
type Donut 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 Donut.
mu sync.Mutex
// opts are the provided options.
opts *options
}
// New returns a new Donut.
func New(opts ...Option) (*Donut, error) {
opt := newOptions()
for _, o := range opts {
o.set(opt)
}
if err := opt.validate(); err != nil {
return nil, err
}
return &Donut{
opts: opt,
}, nil
}
// Absolute sets the progress in absolute numbers, e.g. 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 (d *Donut) Absolute(done, total int, opts ...Option) error {
d.mu.Lock()
defer d.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(d.opts)
}
if err := d.opts.validate(); err != nil {
return err
}
d.pt = progressTypeAbsolute
d.current = done
d.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 (d *Donut) Percent(p int, opts ...Option) error {
d.mu.Lock()
defer d.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(d.opts)
}
if err := d.opts.validate(); err != nil {
return err
}
d.pt = progressTypePercent
d.current = p
d.total = 100
return nil
}
// progressText returns the textual representation of the current progress.
func (d *Donut) progressText() string {
switch d.pt {
case progressTypePercent:
return fmt.Sprintf("%d%%", d.current)
case progressTypeAbsolute:
return fmt.Sprintf("%d/%d", d.current, d.total)
default:
return ""
}
}
// holeRadius calculates the radius of the "hole" in the donut.
// Returns zero if no hole should be drawn.
func (d *Donut) holeRadius(donutRadius int) int {
r := int(math.Round(float64(donutRadius) / 100 * float64(d.opts.donutHolePercent)))
if r < 2 { // Smallest possible circle radius.
return 0
}
return r
}
// drawText draws the text label showing the progress.
// The text is only drawn if the radius of the donut "hole" is large enough to
// accommodate it.
// The mid point addresses coordinates in pixels on a braille canvas.
func (d *Donut) drawText(cvs *canvas.Canvas, mid image.Point, holeR int) error {
cells, first := availableCells(mid, holeR)
t := d.progressText()
needCells := runewidth.StringWidth(t)
if cells < needCells {
return nil
}
ar := image.Rect(first.X, first.Y, first.X+cells+2, first.Y+1)
start, err := alignfor.Text(ar, t, align.HorizontalCenter, align.VerticalMiddle)
if err != nil {
return fmt.Errorf("alignfor.Text => %v", err)
}
if err := draw.Text(cvs, t, start, draw.TextMaxX(start.X+needCells), draw.TextCellOpts(d.opts.textCellOpts...)); err != nil {
return fmt.Errorf("draw.Text => %v", err)
}
return nil
}
// drawLabel draws the text label in the area.
func (d *Donut) drawLabel(cvs *canvas.Canvas, labelAr image.Rectangle) error {
start, err := alignfor.Text(labelAr, d.opts.label, d.opts.labelAlign, align.VerticalBottom)
if err != nil {
return err
}
return draw.Text(
cvs, d.opts.label, start,
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextMaxX(labelAr.Max.X),
draw.TextCellOpts(d.opts.labelCellOpts...),
)
}
// Draw draws the Donut widget onto the canvas.
// Implements widgetapi.Widget.Draw.
func (d *Donut) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
d.mu.Lock()
defer d.mu.Unlock()
startA, endA := startEndAngles(d.current, d.total, d.opts.startAngle, d.opts.direction)
if startA == endA {
// No progress recorded, so nothing to do.
return nil
}
var donutAr, labelAr image.Rectangle
if len(d.opts.label) > 0 {
d, l, err := donutAndLabel(cvs.Area())
if err != nil {
return err
}
donutAr = d
labelAr = l
} else {
donutAr = cvs.Area()
}
if donutAr.Dx() < minSize.X || donutAr.Dy() < minSize.Y {
// Reserving area for the label might have resulted in donutAr being
// too small.
return draw.ResizeNeeded(cvs)
}
bc, err := braille.New(donutAr)
if err != nil {
return fmt.Errorf("braille.New => %v", err)
}
mid, r := midAndRadius(bc.Area())
if err := draw.BrailleCircle(bc, mid, r,
draw.BrailleCircleFilled(),
draw.BrailleCircleArcOnly(startA, endA),
draw.BrailleCircleCellOpts(d.opts.cellOpts...),
); err != nil {
return fmt.Errorf("failed to draw the outer circle: %v", err)
}
holeR := d.holeRadius(r)
if holeR != 0 {
if err := draw.BrailleCircle(bc, mid, holeR,
draw.BrailleCircleFilled(),
draw.BrailleCircleClearPixels(),
); err != nil {
return fmt.Errorf("failed to draw the outer circle: %v", err)
}
}
if err := bc.CopyTo(cvs); err != nil {
return err
}
if !d.opts.hideTextProgress {
if err := d.drawText(cvs, mid, holeR); err != nil {
return err
}
}
if !labelAr.Empty() {
if err := d.drawLabel(cvs, labelAr); err != nil {
return err
}
}
return nil
}
// Keyboard input isn't supported on the Donut widget.
func (*Donut) Keyboard(k *terminalapi.Keyboard) error {
return errors.New("the Donut widget doesn't support keyboard events")
}
// Mouse input isn't supported on the Donut widget.
func (*Donut) Mouse(m *terminalapi.Mouse) error {
return errors.New("the Donut widget doesn't support mouse events")
}
// minSize is the smallest area we can draw donut on.
var minSize = image.Point{3, 3}
// Options implements widgetapi.Widget.Options.
func (d *Donut) Options() widgetapi.Options {
return widgetapi.Options{
// We are drawing a circle, ensure equal ratio of rows and columns.
// This is adjusted for the inequality of the braille canvas.
Ratio: image.Point{braille.RowMult, braille.ColMult},
// The smallest circle that "looks" like a circle on the canvas.
MinimumSize: minSize,
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeNone,
}
}
// donutAndLabel splits the canvas area into an area for the donut and an
// area under the donut for the text label.
func donutAndLabel(cvsAr image.Rectangle) (donAr, labelAr image.Rectangle, err error) {
height := cvsAr.Dy()
// Two lines for the text label at the bottom.
// One for the text itself and one for visual space between the donut and
// the label.
donAr, labelAr, err = area.HSplitCells(cvsAr, height-2)
if err != nil {
return image.ZR, image.ZR, err
}
return donAr, labelAr, nil
}