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:
parent
ebc275c4b3
commit
310ea212d3
171
widgets/donut/donut.go
Normal file
171
widgets/donut/donut.go
Normal 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
117
widgets/donut/donut_test.go
Normal 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
BIN
widgets/donut/donutdemo/donutdemo
Executable file
Binary file not shown.
113
widgets/donut/donutdemo/donutdemo.go
Normal file
113
widgets/donut/donutdemo/donutdemo.go
Normal 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
155
widgets/donut/options.go
Normal 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
|
||||
})
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user