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