From 4391f3846df8675e7d8d0128c104160ba609ee91 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sun, 28 Apr 2019 20:14:26 -0400 Subject: [PATCH 1/3] Adding HSplitCells into the area package. --- internal/area/area.go | 23 +++++++++++ internal/area/area_test.go | 85 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/internal/area/area.go b/internal/area/area.go index 236b9b7..5a36b91 100644 --- a/internal/area/area.go +++ b/internal/area/area.go @@ -101,6 +101,29 @@ func VSplitCells(area image.Rectangle, cells int) (left image.Rectangle, right i return left, right, nil } +// HSplitCells returns two new areas created by splitting the provided area +// after the specified amount of cells of its height. The number of cells must +// be a zero or a positive integer. Providing a zero returns top=image.ZR, +// bottom=area. Providing a number equal or larger to area's height returns +// top=area, bottom=image.ZR. +func HSplitCells(area image.Rectangle, cells int) (top image.Rectangle, bottom image.Rectangle, err error) { + if min := 0; cells < min { + return image.ZR, image.ZR, fmt.Errorf("invalid cells %d, must be a positive integer", cells) + } + if cells == 0 { + return image.ZR, area, nil + } + + height := area.Dy() + if cells >= height { + return area, image.ZR, nil + } + + top = image.Rect(area.Min.X, area.Min.Y, area.Max.X, area.Min.Y+cells) + bottom = image.Rect(area.Min.X, area.Min.Y+cells, area.Max.X, area.Max.Y) + return top, bottom, nil +} + // ExcludeBorder returns a new area created by subtracting a border around the // provided area. Return the zero area if there isn't enough space to exclude // the border. diff --git a/internal/area/area_test.go b/internal/area/area_test.go index 16630cd..4acd496 100644 --- a/internal/area/area_test.go +++ b/internal/area/area_test.go @@ -367,6 +367,91 @@ func TestVSplitCells(t *testing.T) { } } +func TestHSplitCells(t *testing.T) { + tests := []struct { + desc string + area image.Rectangle + cells int + wantTop image.Rectangle + wantBottom image.Rectangle + wantErr bool + }{ + { + desc: "fails on negative cells", + area: image.Rect(1, 1, 2, 2), + cells: -1, + wantErr: true, + }, + { + desc: "returns area as top on cells too large", + area: image.Rect(1, 1, 2, 2), + cells: 2, + wantTop: image.Rect(1, 1, 2, 2), + wantBottom: image.ZR, + }, + { + desc: "returns area as top on cells equal area width", + area: image.Rect(1, 1, 2, 2), + cells: 1, + wantTop: image.Rect(1, 1, 2, 2), + wantBottom: image.ZR, + }, + { + desc: "returns area as bottom on zero cells", + area: image.Rect(1, 1, 2, 2), + cells: 0, + wantBottom: image.Rect(1, 1, 2, 2), + wantTop: image.ZR, + }, + { + desc: "zero area to begin with", + area: image.ZR, + cells: 0, + wantTop: image.ZR, + wantBottom: image.ZR, + }, + { + desc: "splits area with even height", + area: image.Rect(1, 1, 3, 3), + cells: 1, + wantTop: image.Rect(1, 1, 3, 2), + wantBottom: image.Rect(1, 2, 3, 3), + }, + { + desc: "splits area with odd width", + area: image.Rect(1, 1, 4, 4), + cells: 1, + wantTop: image.Rect(1, 1, 4, 2), + wantBottom: image.Rect(1, 2, 4, 4), + }, + { + desc: "splits to unequal areas", + area: image.Rect(0, 0, 4, 4), + cells: 3, + wantTop: image.Rect(0, 0, 4, 3), + wantBottom: image.Rect(0, 3, 4, 4), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotTop, gotBottom, err := HSplitCells(tc.area, tc.cells) + if (err != nil) != tc.wantErr { + t.Errorf("HSplitCells => unexpected error:%v, wantErr:%v", err, tc.wantErr) + } + if err != nil { + return + } + if diff := pretty.Compare(tc.wantTop, gotTop); diff != "" { + t.Errorf("HSplitCells => left value unexpected diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.wantBottom, gotBottom); diff != "" { + t.Errorf("HSplitCells => right value unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + func TestExcludeBorder(t *testing.T) { tests := []struct { desc string From 0c68b3d745b15a93d0434f893c71feac93b7f2ec Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sun, 28 Apr 2019 21:17:07 -0400 Subject: [PATCH 2/3] The donut widget can optionally display a text label. --- widgets/donut/donut.go | 93 +++++++++- widgets/donut/donut_test.go | 246 ++++++++++++++++++++++++++- widgets/donut/donutdemo/donutdemo.go | 5 +- widgets/donut/options.go | 24 +++ 4 files changed, 357 insertions(+), 11 deletions(-) diff --git a/widgets/donut/donut.go b/widgets/donut/donut.go index 031525d..f0819d1 100644 --- a/widgets/donut/donut.go +++ b/widgets/donut/donut.go @@ -25,6 +25,7 @@ import ( "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/internal/alignfor" + "github.com/mum4k/termdash/internal/area" "github.com/mum4k/termdash/internal/canvas" "github.com/mum4k/termdash/internal/canvas/braille" "github.com/mum4k/termdash/internal/draw" @@ -165,7 +166,9 @@ func (d *Donut) holeRadius(donutRadius int) int { // drawText draws the text label showing the progress. // The text is only drawn if the radius of the donut "hole" is large enough to // accommodate it. -func (d *Donut) drawText(cvs *canvas.Canvas, mid image.Point, holeR int) error { +// The mid point addresses coordinates in pixels on a braille canvas. +// The donutAr is the cell area for the donut itself. +func (d *Donut) drawText(cvs *canvas.Canvas, donutAr image.Rectangle, mid image.Point, holeR int) error { cells, first := availableCells(mid, holeR) t := d.progressText() needCells := runewidth.StringWidth(t) @@ -173,6 +176,13 @@ func (d *Donut) drawText(cvs *canvas.Canvas, mid image.Point, holeR int) error { return nil } + if donutAr.Min.Y > 0 { + // donutAr is what the braille canvas is created from, mid is relative + // to it. + // donutAr might have non-zero Y coordinate if we are displaying a text + // label. + first.Y += donutAr.Min.Y + } ar := image.Rect(first.X, first.Y, first.X+cells+2, first.Y+1) start, err := alignfor.Text(ar, t, align.HorizontalCenter, align.VerticalMiddle) if err != nil { @@ -184,23 +194,59 @@ func (d *Donut) drawText(cvs *canvas.Canvas, mid image.Point, holeR int) error { return nil } +// drawLabel draws the text label in the area. +func (d *Donut) drawLabel(cvs *canvas.Canvas, labelAr image.Rectangle) error { + start, err := alignfor.Text(labelAr, d.opts.label, d.opts.labelAlign, align.VerticalMiddle) + if err != nil { + return err + } + if err := draw.Text( + cvs, d.opts.label, start, + draw.TextOverrunMode(draw.OverrunModeThreeDot), + draw.TextMaxX(labelAr.Max.X), + draw.TextCellOpts(d.opts.labelCellOpts...), + ); err != nil { + return err + } + return nil +} + // Draw draws the Donut widget onto the canvas. // Implements widgetapi.Widget.Draw. func (d *Donut) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error { d.mu.Lock() defer d.mu.Unlock() - bc, err := braille.New(cvs.Area()) - if err != nil { - return fmt.Errorf("braille.New => %v", err) - } - startA, endA := startEndAngles(d.current, d.total, d.opts.startAngle, d.opts.direction) if startA == endA { // No progress recorded, so nothing to do. return nil } + var donutAr, labelAr image.Rectangle + if len(d.opts.label) > 0 { + d, l, err := donutAndLabel(cvs.Area()) + if err != nil { + return err + } + donutAr = d + labelAr = l + + } else { + donutAr = cvs.Area() + } + + if donutAr.Dx() < minSize.X || donutAr.Dy() < minSize.Y { + // Reserving area for the label might have resulted in donutAr being + // too small. + return draw.ResizeNeeded(cvs) + } + + bc, err := braille.New(donutAr) + if err != nil { + return fmt.Errorf("braille.New => %v", err) + } + mid, r := midAndRadius(bc.Area()) if err := draw.BrailleCircle(bc, mid, r, draw.BrailleCircleFilled(), @@ -224,7 +270,15 @@ func (d *Donut) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error { } if !d.opts.hideTextProgress { - return d.drawText(cvs, mid, holeR) + if err := d.drawText(cvs, donutAr, mid, holeR); err != nil { + return err + } + } + + if !labelAr.Empty() { + if err := d.drawLabel(cvs, labelAr); err != nil { + return err + } } return nil } @@ -239,6 +293,9 @@ func (*Donut) Mouse(m *terminalapi.Mouse) error { return errors.New("the Donut widget doesn't support mouse events") } +// minSize is the smallest area we can draw donut on. +var minSize = image.Point{3, 3} + // Options implements widgetapi.Widget.Options. func (d *Donut) Options() widgetapi.Options { return widgetapi.Options{ @@ -247,8 +304,28 @@ func (d *Donut) Options() widgetapi.Options { Ratio: image.Point{braille.RowMult, braille.ColMult}, // The smallest circle that "looks" like a circle on the canvas. - MinimumSize: image.Point{3, 3}, + MinimumSize: minSize, WantKeyboard: widgetapi.KeyScopeNone, WantMouse: widgetapi.MouseScopeNone, } } + +// donutAndLabel splits the canvas area into square area for the donut and an +// area under the donut for the text label. +func donutAndLabel(cvsAr image.Rectangle) (donAr, labelAr image.Rectangle, err error) { + height := cvsAr.Dy() + // One line for the text label at the bottom. + top, labelAr, err := area.HSplitCells(cvsAr, height-1) + if err != nil { + return image.ZR, image.ZR, err + } + + // Remove one line from the top too so the donut area remains square. + // When using braille, this effectively removes 4 pixels from both the top + // and the bottom. See braille.RowMult. + donAr, err = area.Shrink(top, 1, 0, 0, 0) + if err != nil { + return image.ZR, image.ZR, err + } + return donAr, labelAr, nil +} diff --git a/widgets/donut/donut_test.go b/widgets/donut/donut_test.go index 42a0ff1..c75c6e5 100644 --- a/widgets/donut/donut_test.go +++ b/widgets/donut/donut_test.go @@ -19,6 +19,7 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/internal/canvas" "github.com/mum4k/termdash/internal/canvas/braille/testbraille" @@ -143,8 +144,14 @@ func TestDonut(t *testing.T) { update: func(d *Donut) error { return d.Percent(100) }, - canvas: image.Rect(0, 0, 1, 1), - wantDrawErr: true, + canvas: image.Rect(0, 0, 1, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustResizeNeeded(cvs) + testcanvas.MustApply(cvs, ft) + return ft + }, }, { desc: "smallest valid donut, 100% progress", @@ -162,6 +169,23 @@ func TestDonut(t *testing.T) { return ft }, }, + { + desc: "adding label to the smallest canvas makes it too small", + opts: []Option{ + Label("hi"), + }, + canvas: image.Rect(0, 0, 3, 3), + update: func(d *Donut) error { + return d.Percent(100) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustResizeNeeded(cvs) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, { desc: "New sets donut options", opts: []Option{ @@ -300,6 +324,33 @@ func TestDonut(t *testing.T) { return ft }, }, + { + desc: "draws hole and label", + opts: []Option{ + Label("hi"), + }, + canvas: image.Rect(0, 0, 6, 6), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(50)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(ft.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 3, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "hi", image.Point{2, 5}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "hole as large as donut", canvas: image.Rect(0, 0, 6, 6), @@ -573,6 +624,197 @@ func TestDonut(t *testing.T) { testdraw.MustText(c, "1/10", image.Point{2, 4}) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "displays text label under the donut", + opts: []Option{ + Label("hi"), + }, + canvas: image.Rect(0, 0, 7, 7), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(80)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(c.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 6, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "100%", image.Point{2, 3}) + + testdraw.MustText(c, "hi", image.Point{2, 6}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "aligns text label center with option", + opts: []Option{ + Label("hi"), + LabelAlign(align.HorizontalCenter), + }, + canvas: image.Rect(0, 0, 7, 7), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(80)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(c.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 6, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "100%", image.Point{2, 3}) + + testdraw.MustText(c, "hi", image.Point{2, 6}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "aligns text label left", + opts: []Option{ + Label("hi"), + LabelAlign(align.HorizontalLeft), + }, + canvas: image.Rect(0, 0, 7, 7), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(80)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(c.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 6, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "100%", image.Point{2, 3}) + + testdraw.MustText(c, "hi", image.Point{0, 6}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "aligns text label right", + opts: []Option{ + Label("hi"), + LabelAlign(align.HorizontalRight), + }, + canvas: image.Rect(0, 0, 7, 7), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(80)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(c.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 6, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "100%", image.Point{2, 3}) + + testdraw.MustText(c, "hi", image.Point{5, 6}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "sets cell options on text label", + opts: []Option{ + Label( + "hi", + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + ), + }, + canvas: image.Rect(0, 0, 7, 7), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(80)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(c.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 6, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "100%", image.Point{2, 3}) + + testdraw.MustText( + c, + "hi", + image.Point{2, 6}, + draw.TextCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + ), + ) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "text label too long, gets trimmed", + opts: []Option{ + Label( + "hello world", + ), + }, + canvas: image.Rect(0, 0, 7, 7), + update: func(d *Donut) error { + return d.Percent(100, HolePercent(80)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + bc := testbraille.MustNew(c.Area()) + + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 6, draw.BrailleCircleFilled()) + testdraw.MustBrailleCircle(bc, image.Point{6, 13}, 5, + draw.BrailleCircleFilled(), + draw.BrailleCircleClearPixels(), + ) + testbraille.MustCopyTo(bc, c) + + testdraw.MustText(c, "100%", image.Point{2, 3}) + + testdraw.MustText(c, "hello …", image.Point{0, 6}) + testcanvas.MustApply(c, ft) return ft }, diff --git a/widgets/donut/donutdemo/donutdemo.go b/widgets/donut/donutdemo/donutdemo.go index f1af14a..acadc1a 100644 --- a/widgets/donut/donutdemo/donutdemo.go +++ b/widgets/donut/donutdemo/donutdemo.go @@ -86,7 +86,10 @@ func main() { defer t.Close() ctx, cancel := context.WithCancel(context.Background()) - green, err := donut.New(donut.CellOpts(cell.FgColor(cell.ColorGreen))) + green, err := donut.New( + donut.CellOpts(cell.FgColor(cell.ColorGreen)), + donut.Label("text label", cell.FgColor(cell.ColorGreen)), + ) if err != nil { panic(err) } diff --git a/widgets/donut/options.go b/widgets/donut/options.go index b09b8ab..060a303 100644 --- a/widgets/donut/options.go +++ b/widgets/donut/options.go @@ -19,6 +19,7 @@ package donut import ( "fmt" + "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/cell" ) @@ -44,6 +45,10 @@ type options struct { textCellOpts []cell.Option cellOpts []cell.Option + labelCellOpts []cell.Option + labelAlign align.Horizontal + label string + // The angle in degrees that represents 0 and 100% of the progress. startAngle int // The direction in which the donut completes as progress increases. @@ -74,6 +79,7 @@ func newOptions() *options { cell.FgColor(cell.ColorDefault), cell.BgColor(cell.ColorDefault), }, + labelAlign: DefaultLabelAlign, } } @@ -157,3 +163,21 @@ func CounterClockwise() Option { opts.direction = 1 }) } + +// Label sets a text label to be displayed under the donut. +func Label(text string, cOpts ...cell.Option) Option { + return option(func(opts *options) { + opts.label = text + opts.labelCellOpts = cOpts + }) +} + +// DefaultLabelAlign is the default value for the LabelAlign option. +const DefaultLabelAlign = align.HorizontalCenter + +// LabelAlign sets the alignment of the label under the donut. +func LabelAlign(la align.Horizontal) Option { + return option(func(opts *options) { + opts.labelAlign = la + }) +} From 553bad2d186fb065919b908c3a599c6ec0cf725d Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sun, 28 Apr 2019 21:18:54 -0400 Subject: [PATCH 3/3] Updating the CHANGELOG. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc25301..d14b150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The `TextInput` widget, an input field allowing interactive text input. +- The `Donut` widget can now display an optional text label under the donut. ### Changed