1// Copyright 2019 Google Inc. 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15// Package donut is a widget that displays the progress of an operation as a 16// partial or full circle. 17package donut 18 19import ( 20 "errors" 21 "fmt" 22 "image" 23 "math" 24 "sync" 25 26 "github.com/mum4k/termdash/align" 27 "github.com/mum4k/termdash/private/alignfor" 28 "github.com/mum4k/termdash/private/area" 29 "github.com/mum4k/termdash/private/canvas" 30 "github.com/mum4k/termdash/private/canvas/braille" 31 "github.com/mum4k/termdash/private/draw" 32 "github.com/mum4k/termdash/private/runewidth" 33 "github.com/mum4k/termdash/terminal/terminalapi" 34 "github.com/mum4k/termdash/widgetapi" 35) 36 37// progressType indicates how was the current progress provided by the caller. 38type progressType int 39 40// String implements fmt.Stringer() 41func (pt progressType) String() string { 42 if n, ok := progressTypeNames[pt]; ok { 43 return n 44 } 45 return "progressTypeUnknown" 46} 47 48// progressTypeNames maps progressType values to human readable names. 49var progressTypeNames = map[progressType]string{ 50 progressTypePercent: "progressTypePercent", 51 progressTypeAbsolute: "progressTypeAbsolute", 52} 53 54const ( 55 progressTypePercent = iota 56 progressTypeAbsolute 57) 58 59// Donut displays the progress of an operation by filling a partial circle and 60// eventually by completing a full circle. The circle can have a "hole" in the 61// middle, which is where the name comes from. 62// 63// Implements widgetapi.Widget. This object is thread-safe. 64type Donut struct { 65 // pt indicates how current and total are interpreted. 66 pt progressType 67 // current is the current progress that will be drawn. 68 current int 69 // total is the value that represents completion. 70 // For progressTypePercent, this is 100, for progressTypeAbsolute this is 71 // the total provided by the caller. 72 total int 73 // mu protects the Donut. 74 mu sync.Mutex 75 76 // opts are the provided options. 77 opts *options 78} 79 80// New returns a new Donut. 81func New(opts ...Option) (*Donut, error) { 82 opt := newOptions() 83 for _, o := range opts { 84 o.set(opt) 85 } 86 if err := opt.validate(); err != nil { 87 return nil, err 88 } 89 return &Donut{ 90 opts: opt, 91 }, nil 92} 93 94// Absolute sets the progress in absolute numbers, e.g. 7 out of 10. 95// The total amount must be a non-zero positive integer. The done amount must 96// be a zero or a positive integer such that done <= total. 97// Provided options override values set when New() was called. 98func (d *Donut) Absolute(done, total int, opts ...Option) error { 99 d.mu.Lock() 100 defer d.mu.Unlock() 101 102 if done < 0 || total < 1 || done > total { 103 return fmt.Errorf("invalid progress, done(%d) must be <= total(%d), done must be zero or positive "+ 104 "and total must be a non-zero positive number", done, total) 105 } 106 107 for _, opt := range opts { 108 opt.set(d.opts) 109 } 110 if err := d.opts.validate(); err != nil { 111 return err 112 } 113 114 d.pt = progressTypeAbsolute 115 d.current = done 116 d.total = total 117 return nil 118} 119 120// Percent sets the current progress in percentage. 121// The provided value must be between 0 and 100. 122// Provided options override values set when New() was called. 123func (d *Donut) Percent(p int, opts ...Option) error { 124 d.mu.Lock() 125 defer d.mu.Unlock() 126 127 if p < 0 || p > 100 { 128 return fmt.Errorf("invalid percentage, p(%d) must be 0 <= p <= 100", p) 129 } 130 131 for _, opt := range opts { 132 opt.set(d.opts) 133 } 134 if err := d.opts.validate(); err != nil { 135 return err 136 } 137 138 d.pt = progressTypePercent 139 d.current = p 140 d.total = 100 141 return nil 142} 143 144// progressText returns the textual representation of the current progress. 145func (d *Donut) progressText() string { 146 switch d.pt { 147 case progressTypePercent: 148 return fmt.Sprintf("%d%%", d.current) 149 case progressTypeAbsolute: 150 return fmt.Sprintf("%d/%d", d.current, d.total) 151 default: 152 return "" 153 } 154} 155 156// holeRadius calculates the radius of the "hole" in the donut. 157// Returns zero if no hole should be drawn. 158func (d *Donut) holeRadius(donutRadius int) int { 159 r := int(math.Round(float64(donutRadius) / 100 * float64(d.opts.donutHolePercent))) 160 if r < 2 { // Smallest possible circle radius. 161 return 0 162 } 163 return r 164} 165 166// drawText draws the text label showing the progress. 167// The text is only drawn if the radius of the donut "hole" is large enough to 168// accommodate it. 169// The mid point addresses coordinates in pixels on a braille canvas. 170func (d *Donut) drawText(cvs *canvas.Canvas, mid image.Point, holeR int) error { 171 cells, first := availableCells(mid, holeR) 172 t := d.progressText() 173 needCells := runewidth.StringWidth(t) 174 if cells < needCells { 175 return nil 176 } 177 178 ar := image.Rect(first.X, first.Y, first.X+cells+2, first.Y+1) 179 start, err := alignfor.Text(ar, t, align.HorizontalCenter, align.VerticalMiddle) 180 if err != nil { 181 return fmt.Errorf("alignfor.Text => %v", err) 182 } 183 if err := draw.Text(cvs, t, start, draw.TextMaxX(start.X+needCells), draw.TextCellOpts(d.opts.textCellOpts...)); err != nil { 184 return fmt.Errorf("draw.Text => %v", err) 185 } 186 return nil 187} 188 189// drawLabel draws the text label in the area. 190func (d *Donut) drawLabel(cvs *canvas.Canvas, labelAr image.Rectangle) error { 191 start, err := alignfor.Text(labelAr, d.opts.label, d.opts.labelAlign, align.VerticalBottom) 192 if err != nil { 193 return err 194 } 195 return draw.Text( 196 cvs, d.opts.label, start, 197 draw.TextOverrunMode(draw.OverrunModeThreeDot), 198 draw.TextMaxX(labelAr.Max.X), 199 draw.TextCellOpts(d.opts.labelCellOpts...), 200 ) 201} 202 203// Draw draws the Donut widget onto the canvas. 204// Implements widgetapi.Widget.Draw. 205func (d *Donut) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error { 206 d.mu.Lock() 207 defer d.mu.Unlock() 208 209 startA, endA := startEndAngles(d.current, d.total, d.opts.startAngle, d.opts.direction) 210 if startA == endA { 211 // No progress recorded, so nothing to do. 212 return nil 213 } 214 215 var donutAr, labelAr image.Rectangle 216 if len(d.opts.label) > 0 { 217 d, l, err := donutAndLabel(cvs.Area()) 218 if err != nil { 219 return err 220 } 221 donutAr = d 222 labelAr = l 223 224 } else { 225 donutAr = cvs.Area() 226 } 227 228 if donutAr.Dx() < minSize.X || donutAr.Dy() < minSize.Y { 229 // Reserving area for the label might have resulted in donutAr being 230 // too small. 231 return draw.ResizeNeeded(cvs) 232 } 233 234 bc, err := braille.New(donutAr) 235 if err != nil { 236 return fmt.Errorf("braille.New => %v", err) 237 } 238 239 mid, r := midAndRadius(bc.Area()) 240 if err := draw.BrailleCircle(bc, mid, r, 241 draw.BrailleCircleFilled(), 242 draw.BrailleCircleArcOnly(startA, endA), 243 draw.BrailleCircleCellOpts(d.opts.cellOpts...), 244 ); err != nil { 245 return fmt.Errorf("failed to draw the outer circle: %v", err) 246 } 247 248 holeR := d.holeRadius(r) 249 if holeR != 0 { 250 if err := draw.BrailleCircle(bc, mid, holeR, 251 draw.BrailleCircleFilled(), 252 draw.BrailleCircleClearPixels(), 253 ); err != nil { 254 return fmt.Errorf("failed to draw the outer circle: %v", err) 255 } 256 } 257 if err := bc.CopyTo(cvs); err != nil { 258 return err 259 } 260 261 if !d.opts.hideTextProgress { 262 if err := d.drawText(cvs, mid, holeR); err != nil { 263 return err 264 } 265 } 266 267 if !labelAr.Empty() { 268 if err := d.drawLabel(cvs, labelAr); err != nil { 269 return err 270 } 271 } 272 return nil 273} 274 275// Keyboard input isn't supported on the Donut widget. 276func (*Donut) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error { 277 return errors.New("the Donut widget doesn't support keyboard events") 278} 279 280// Mouse input isn't supported on the Donut widget. 281func (*Donut) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error { 282 return errors.New("the Donut widget doesn't support mouse events") 283} 284 285// minSize is the smallest area we can draw donut on. 286var minSize = image.Point{3, 3} 287 288// Options implements widgetapi.Widget.Options. 289func (d *Donut) Options() widgetapi.Options { 290 return widgetapi.Options{ 291 // We are drawing a circle, ensure equal ratio of rows and columns. 292 // This is adjusted for the inequality of the braille canvas. 293 Ratio: image.Point{braille.RowMult, braille.ColMult}, 294 295 // The smallest circle that "looks" like a circle on the canvas. 296 MinimumSize: minSize, 297 WantKeyboard: widgetapi.KeyScopeNone, 298 WantMouse: widgetapi.MouseScopeNone, 299 } 300} 301 302// donutAndLabel splits the canvas area into an area for the donut and an 303// area under the donut for the text label. 304func donutAndLabel(cvsAr image.Rectangle) (donAr, labelAr image.Rectangle, err error) { 305 height := cvsAr.Dy() 306 // Two lines for the text label at the bottom. 307 // One for the text itself and one for visual space between the donut and 308 // the label. 309 donAr, labelAr, err = area.HSplitCells(cvsAr, height-2) 310 if err != nil { 311 return image.ZR, image.ZR, err 312 } 313 return donAr, labelAr, nil 314} 315