diff --git a/widgets/donut/donut.go b/widgets/donut/donut.go new file mode 100644 index 0000000..1b27544 --- /dev/null +++ b/widgets/donut/donut.go @@ -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, + } +} diff --git a/widgets/donut/donut_test.go b/widgets/donut/donut_test.go new file mode 100644 index 0000000..10b1c1c --- /dev/null +++ b/widgets/donut/donut_test.go @@ -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) + } + +} diff --git a/widgets/donut/donutdemo/donutdemo b/widgets/donut/donutdemo/donutdemo new file mode 100755 index 0000000..76cb23f Binary files /dev/null and b/widgets/donut/donutdemo/donutdemo differ diff --git a/widgets/donut/donutdemo/donutdemo.go b/widgets/donut/donutdemo/donutdemo.go new file mode 100644 index 0000000..1d5636f --- /dev/null +++ b/widgets/donut/donutdemo/donutdemo.go @@ -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) + } +} diff --git a/widgets/donut/options.go b/widgets/donut/options.go new file mode 100644 index 0000000..20e192e --- /dev/null +++ b/widgets/donut/options.go @@ -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 + }) +}