1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Skeleton for the donut widget.

This commit is contained in:
Jakub Sobon 2019-01-19 21:48:29 -05:00
parent ebc275c4b3
commit 310ea212d3
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
5 changed files with 556 additions and 0 deletions

171
widgets/donut/donut.go Normal file
View File

@ -0,0 +1,171 @@
// 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"
"sync"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/canvas/braille"
"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
)
// 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
}
// Draw draws the Donut widget onto the canvas.
// Implements widgetapi.Widget.Draw.
func (d *Donut) Draw(cvs *canvas.Canvas) error {
d.mu.Lock()
defer d.mu.Unlock()
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")
}
// 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 needs
// to have a radius of two. We need at least three columns and two rows
// of cells to display it.
MinimumSize: image.Point{3, 2},
WantKeyboard: false,
WantMouse: false,
}
}

117
widgets/donut/donut_test.go Normal file
View File

@ -0,0 +1,117 @@
// 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
import (
"image"
"testing"
"github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/terminal/faketerm"
"github.com/mum4k/termdash/widgetapi"
)
func TestDonut(t *testing.T) {
tests := []struct {
desc string
opts []Option
update func(*Donut) error // update gets called before drawing of the widget.
canvas image.Rectangle
want func(size image.Point) *faketerm.Terminal
wantUpdateErr bool // whether to expect an error on a call to the update function
wantDrawErr bool
}{
{
desc: "draws empty for no data points",
canvas: image.Rect(0, 0, 1, 1),
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
d, err := New(tc.opts...)
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
c, err := canvas.New(tc.canvas)
if err != nil {
t.Fatalf("canvas.New => unexpected error: %v", err)
}
if tc.update != nil {
err = tc.update(d)
if (err != nil) != tc.wantUpdateErr {
t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr)
}
if err != nil {
return
}
}
err = d.Draw(c)
if (err != nil) != tc.wantDrawErr {
t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
}
if err != nil {
return
}
got, err := faketerm.New(c.Size())
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
if err := c.Apply(got); err != nil {
t.Fatalf("Apply => unexpected error: %v", err)
}
var want *faketerm.Terminal
if tc.want != nil {
want = tc.want(c.Size())
} else {
want = faketerm.MustNew(c.Size())
}
if diff := faketerm.Diff(want, got); diff != "" {
t.Errorf("Draw => %v", diff)
}
})
}
}
func TestOptions(t *testing.T) {
d, err := New()
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
got := d.Options()
want := widgetapi.Options{
Ratio: image.Point{4, 2},
MinimumSize: image.Point{3, 2},
WantKeyboard: false,
WantMouse: false,
}
if diff := pretty.Compare(want, got); diff != "" {
t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)
}
}

BIN
widgets/donut/donutdemo/donutdemo Executable file

Binary file not shown.

View File

@ -0,0 +1,113 @@
// 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.
// Binary gaugedemo displays a couple of Gauge widgets.
// Exist when 'q' is pressed.
package main
import (
"context"
"time"
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminal/termbox"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgets/donut"
)
// playType indicates how to play a donut.
type playType int
const (
playTypePercent playType = iota
playTypeAbsolute
)
// playDonut continuously changes the displayed percent value on the donut by the
// step once every delay. Exits when the context expires.
func playDonut(ctx context.Context, d *donut.Donut, step int, delay time.Duration, pt playType) {
progress := 0
mult := 1
ticker := time.NewTicker(delay)
defer ticker.Stop()
for {
select {
case <-ticker.C:
switch pt {
case playTypePercent:
if err := d.Percent(progress); err != nil {
panic(err)
}
case playTypeAbsolute:
if err := d.Absolute(progress, 100); err != nil {
panic(err)
}
}
progress += step * mult
if progress > 100 || 100-progress < step {
progress = 100
} else if progress < 0 || progress < step {
progress = 0
}
if progress == 100 {
mult = -1
} else if progress == 0 {
mult = 1
}
case <-ctx.Done():
return
}
}
}
func main() {
t, err := termbox.New()
if err != nil {
panic(err)
}
defer t.Close()
ctx, cancel := context.WithCancel(context.Background())
d, err := donut.New()
if err != nil {
panic(err)
}
go playDonut(ctx, d, 10, 500*time.Millisecond, playTypePercent)
c, err := container.New(
t,
container.Border(draw.LineStyleLight),
container.BorderTitle("PRESS Q TO QUIT"),
container.PlaceWidget(d),
)
if err != nil {
panic(err)
}
quitter := func(k *terminalapi.Keyboard) {
if k.Key == 'q' || k.Key == 'Q' {
cancel()
}
}
if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter)); err != nil {
panic(err)
}
}

155
widgets/donut/options.go Normal file
View File

@ -0,0 +1,155 @@
// 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
// options.go contains configurable options for Donut.
import (
"fmt"
"github.com/mum4k/termdash/cell"
)
// Option is used to provide options.
type Option interface {
// set sets the provided option.
set(*options)
}
// option implements Option.
type option func(*options)
// set implements Option.set.
func (o option) set(opts *options) {
o(opts)
}
// options holds the provided options.
type options struct {
donutHolePercent int
hideTextProgress bool
textCellOpts []cell.Option
donutCellOpts []cell.Option
// The angle in degrees that represents 0 and 100% of the progress.
startAngle int
// The direction in which the donut completes as progress increases.
// Positive for clockwise, negative for counter-clockwise.
direction int
}
// validate validates the provided options.
func (o *options) validate() error {
if min, max := 0, 100; o.donutHolePercent < min || o.donutHolePercent > max {
return fmt.Errorf("invalid donut hole percent %d, must be in range %d <= p <= %d", o.donutHolePercent, min, max)
}
if min, max := 0, 360; o.startAngle < min || o.startAngle > max {
return fmt.Errorf("invalid start angle %d, must be in range %d <= angle <= %d", o.startAngle, min, max)
}
return nil
}
// newOptions returns options with the default values set.
func newOptions() *options {
return &options{
donutHolePercent: DefaultDonutHolePercent,
startAngle: DefaultStartAngle,
direction: 1,
}
}
// DefaultDonutHolePercent is the default value for the DonutHolePercent
// option.
const DefaultDonutHolePercent = 20
// DonutHolePercent sets the size of the "hole" inside the donut as a
// percentage of the donut's radius.
// Setting this to zero disables the hole so that the donut will become just a
// circle. Valid range is 0 <= p <= 100.
func DonutHolePercent(p int) Option {
return option(func(opts *options) {
opts.donutHolePercent = p
})
}
// ShowTextProgress configures the Gauge so that it also displays a text
// enumerating the progress. This is the default behavior.
// If the progress is set by a call to Percent(), the displayed text will show
// the percentage, e.g. "50%". If the progress is set by a call to Absolute(),
// the displayed text will those the absolute numbers, e.g. "5/10".
//
// The progress is only displayed if there is enough space for it in the middle
// of the drawn donut.
//
// Providing this option also sets DonutHolePercent to its default value.
func ShowTextProgress() Option {
return option(func(opts *options) {
opts.hideTextProgress = false
})
}
// HideTextProgress disables the display of a text enumerating the progress.
func HideTextProgress() Option {
return option(func(opts *options) {
opts.hideTextProgress = true
})
}
// TextCellOpts sets cell options on cells that contain the displayed text
// progress.
func TextCellOpts(cOpts ...cell.Option) Option {
return option(func(opts *options) {
opts.textCellOpts = cOpts
})
}
// DonutCellOpts sets cell options on cells that contain the donut.
func DonutCellOpts(cOpts ...cell.Option) Option {
return option(func(opts *options) {
opts.donutCellOpts = cOpts
})
}
// DefaultStartAngle is the default value for the StartAngle option.
const DefaultStartAngle = 90
// StartAngle sets the starting angle in degrees, i.e. the point that will
// represent both 0% and 100% of progress.
// Valid values are in range 0 <= angle <= 360.
// Angles start at the X axis and grow counter-clockwise.
func StartAngle(angle int) Option {
return option(func(opts *options) {
opts.startAngle = angle
})
}
// Clockwise sets the donut widget for a progression in the clockwise
// direction. This is the default option.
func Clockwise() Option {
return option(func(opts *options) {
opts.direction = 1
})
}
// CounterClockwise sets the donut widget for a progression in the counter-clockwise
// direction.
func CounterClockwise() Option {
return option(func(opts *options) {
opts.direction = -1
})
}