1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-27 13:48:49 +08:00
Jakub Sobon 8968704de2
Fixing racy behavior between Options and Draw.
This applies to widgets whose Options depend on user data.
Documenting this in the docs and on API and protecting against this
condition in the affected widgets.
2019-01-26 23:58:38 -05:00

332 lines
8.4 KiB
Go

// Copyright 2018 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 gauge implements a widget that displays the progress of an operation.
package gauge
import (
"bytes"
"errors"
"fmt"
"image"
"sync"
runewidth "github.com/mattn/go-runewidth"
"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"
)
// 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 b bytes.Buffer
b.WriteString(g.progressText())
if g.opts.textLabel != "" {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString(fmt.Sprintf("(%s)", g.opts.textLabel))
}
return b.String()
}
// drawText draws the text enumerating the progress and the text label.
func (g *Gauge) drawText(cvs *canvas.Canvas, progress image.Rectangle) error {
text := g.gaugeText()
if text == "" {
return nil
}
ar := g.usable(cvs)
trimmed, err := draw.TrimText(text, ar.Dx(), draw.OverrunModeThreeDot)
if err != nil {
return err
}
cur, err := align.Text(ar, trimmed, g.opts.hTextAlign, g.opts.vTextAlign)
if err != nil {
return err
}
for _, r := range trimmed {
if !cur.In(ar) {
break
}
next := image.Point{cur.X + 1, cur.Y}
rw := runewidth.RuneWidth(r)
// If the current rune is full-width and only one of its cells falls
// within the filled area of the gauge, extend the gauge by one cell to
// fully cover the full-width rune.
if rw == 2 && next.In(ar) && cur.In(progress) && !next.In(progress) {
fixup := image.Rect(
next.X,
ar.Min.Y,
next.X+1,
ar.Max.Y,
)
if err := draw.Rectangle(cvs, fixup,
draw.RectChar(g.opts.gaugeChar),
draw.RectCellOpts(cell.BgColor(g.opts.color)),
); err != nil {
return err
}
}
var cellOpts []cell.Option
if cur.In(progress) {
cellOpts = append(cellOpts, cell.FgColor(g.opts.filledTextColor))
} else {
cellOpts = append(cellOpts, cell.FgColor(g.opts.emptyTextColor))
}
cells, err := cvs.SetCell(cur, r, cellOpts...)
if err != nil {
return err
}
cur = image.Point{cur.X + cells, cur.Y}
}
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()
needAr, err := area.FromSize(g.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
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, progress)
}
// 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,
}
}