mirror of https://github.com/mum4k/termdash.git
315 lines
8.8 KiB
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
|
|
}
|