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

Implementing canvas functionality.

Done here:
- adding helper library which determines area from size and vice versa.
- fixing an off-by-one bug related to area sizes.
- allowing overwrite of cell options by passing an existing cell.Options
instance.
- implementing canvas and its tests.
This commit is contained in:
Jakub Sobon 2018-03-29 03:28:36 +03:00
parent 59e1bd6472
commit dc1f2c5a29
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
7 changed files with 492 additions and 64 deletions

23
area/area.go Normal file
View File

@ -0,0 +1,23 @@
// Package area provides functions working with image areas.
package area
import (
"fmt"
"image"
)
// Size returns the size of the provided area.
func Size(area image.Rectangle) image.Point {
return image.Point{
area.Dx(),
area.Dy(),
}
}
// FromSize returns the corresponding area for the provided size.
func FromSize(size image.Point) (image.Rectangle, error) {
if size.X < 0 || size.Y < 0 {
return image.Rectangle{}, fmt.Errorf("cannot convert zero or negative size to an area, got: %+v", size)
}
return image.Rect(0, 0, size.X, size.Y), nil
}

104
area/area_test.go Normal file
View File

@ -0,0 +1,104 @@
package area
import (
"image"
"testing"
"github.com/kylelemons/godebug/pretty"
)
func TestSize(t *testing.T) {
tests := []struct {
desc string
area image.Rectangle
want image.Point
}{
{
desc: "zero area",
area: image.Rect(0, 0, 0, 0),
want: image.Point{0, 0},
},
{
desc: "1-D on X axis",
area: image.Rect(0, 0, 1, 0),
want: image.Point{1, 0},
},
{
desc: "1-D on Y axis",
area: image.Rect(0, 0, 0, 1),
want: image.Point{0, 1},
},
{
desc: "area with a single cell",
area: image.Rect(0, 0, 1, 1),
want: image.Point{1, 1},
},
{
desc: "a rectangle",
area: image.Rect(0, 0, 2, 3),
want: image.Point{2, 3},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := Size(tc.area)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("Size => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestFromSize(t *testing.T) {
tests := []struct {
desc string
size image.Point
want image.Rectangle
wantErr bool
}{
{
desc: "negative size on X axis",
size: image.Point{-1, 0},
wantErr: true,
},
{
desc: "negative size on Y axis",
size: image.Point{0, -1},
wantErr: true,
},
{
desc: "zero size",
},
{
desc: "1-D on X axis",
size: image.Point{1, 0},
want: image.Rect(0, 0, 1, 0),
},
{
desc: "1-D on Y axis",
size: image.Point{0, 1},
want: image.Rect(0, 0, 0, 1),
},
{
desc: "a rectangle",
size: image.Point{2, 3},
want: image.Rect(0, 0, 2, 3),
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got, err := FromSize(tc.size)
if (err != nil) != tc.wantErr {
t.Fatalf("FromSize => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("FromSize => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}

View File

@ -2,10 +2,10 @@
package canvas
import (
"errors"
"fmt"
"image"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/terminalapi"
)
@ -13,6 +13,8 @@ import (
// Canvas is where a widget draws its output for display on the terminal.
type Canvas struct {
// area is the area the buffer was created for.
// Contains absolute coordinates on the target terminal, while the buffer
// contains relative zero-based coordinates for this canvas.
area image.Rectangle
// buffer is where the drawing happens.
@ -20,20 +22,17 @@ type Canvas struct {
}
// New returns a new Canvas with a buffer for the provided area.
func New(area image.Rectangle) (*Canvas, error) {
if area.Min.X < 0 || area.Min.Y < 0 || area.Max.X < 0 || area.Max.Y < 0 {
return nil, fmt.Errorf("area cannot start or end on the negative axis, got: %+v", area)
func New(ar image.Rectangle) (*Canvas, error) {
if ar.Min.X < 0 || ar.Min.Y < 0 || ar.Max.X < 0 || ar.Max.Y < 0 {
return nil, fmt.Errorf("area cannot start or end on the negative axis, got: %+v", ar)
}
size := image.Point{
area.Dx() + 1,
area.Dy() + 1,
}
b, err := cell.NewBuffer(size)
b, err := cell.NewBuffer(area.Size(ar))
if err != nil {
return nil, err
}
return &Canvas{
area: area,
area: ar,
buffer: b,
}, nil
}
@ -57,8 +56,12 @@ func (c *Canvas) Clear() error {
// Use the options to specify which attributes to modify, if an attribute
// option isn't specified, the attribute retains its previous value.
func (c *Canvas) SetCell(p image.Point, r rune, opts ...cell.Option) error {
if area := c.buffer.Area(); !p.In(area) {
return fmt.Errorf("cell at point %+v falls out of the canvas area %+v", p, area)
ar, err := area.FromSize(c.buffer.Size())
if err != nil {
return err
}
if !p.In(ar) {
return fmt.Errorf("cell at point %+v falls out of the canvas area %+v", p, ar)
}
cell := c.buffer[p.X][p.Y]
@ -67,8 +70,35 @@ func (c *Canvas) SetCell(p image.Point, r rune, opts ...cell.Option) error {
return nil
}
// CopyTo copies the content of the canvas onto the provided terminal.
// Apply applies the canvas to the corresponding area of the terminal.
// Guarantees to stay within limits of the area the canvas was created with.
func (c *Canvas) Apply(t terminalapi.Terminal) error {
return errors.New("unimplemented")
termArea, err := area.FromSize(t.Size())
if err != nil {
return err
}
bufArea, err := area.FromSize(c.buffer.Size())
if err != nil {
return err
}
if !bufArea.In(termArea) {
return fmt.Errorf("the canvas area %+v doesn't fit onto the terminal %+v", bufArea, termArea)
}
for col := range c.buffer {
for row := range c.buffer[col] {
cell := c.buffer[col][row]
// The image.Point{0, 0} of this canvas isn't always exactly at
// image.Point{0, 0} on the terminal.
// Depends on area assigned by the container.
offset := c.area.Min
p := image.Point{col, row}.Add(offset)
if err := t.SetCell(p, cell.Rune, cell.Opts); err != nil {
return fmt.Errorf("terminal.SetCell(%+v) => error: %v", p, err)
}
}
}
return nil
}

View File

@ -5,6 +5,8 @@ import (
"testing"
"github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/terminal/faketerm"
)
func TestNew(t *testing.T) {
@ -34,20 +36,25 @@ func TestNew(t *testing.T) {
area: image.Rect(0, 0, 0, -1),
wantErr: true,
},
{
desc: "zero area is invalid",
area: image.Rect(0, 0, 0, 0),
wantErr: true,
},
{
desc: "smallest valid size",
area: image.Rect(0, 0, 0, 0),
area: image.Rect(0, 0, 1, 1),
wantSize: image.Point{1, 1},
},
{
desc: "rectangular canvas 3 by 4",
area: image.Rect(0, 0, 2, 3),
area: image.Rect(0, 0, 3, 4),
wantSize: image.Point{3, 4},
},
{
desc: "non-zero based area",
area: image.Rect(1, 1, 2, 3),
wantSize: image.Point{2, 3},
wantSize: image.Point{1, 2},
},
}
@ -68,3 +75,280 @@ func TestNew(t *testing.T) {
})
}
}
func TestSetCellAndApply(t *testing.T) {
tests := []struct {
desc string
termSize image.Point
canvasArea image.Rectangle
point image.Point
r rune
opts []cell.Option
want cell.Buffer // Expected back buffer in the fake terminal.
wantSetCellErr bool
wantApplyErr bool
}{
{
desc: "setting cell outside the designated area",
termSize: image.Point{2, 2},
canvasArea: image.Rect(0, 0, 1, 1),
point: image.Point{0, 2},
wantSetCellErr: true,
},
{
desc: "sets a top-left corner cell",
termSize: image.Point{3, 3},
canvasArea: image.Rect(1, 1, 3, 3),
point: image.Point{0, 0},
r: 'X',
want: cell.Buffer{
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New('X'),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
},
},
{
desc: "sets a top-right corner cell",
termSize: image.Point{3, 3},
canvasArea: image.Rect(1, 1, 3, 3),
point: image.Point{1, 0},
r: 'X',
want: cell.Buffer{
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New('X'),
cell.New(0),
},
},
},
{
desc: "sets a bottom-left corner cell",
termSize: image.Point{3, 3},
canvasArea: image.Rect(1, 1, 3, 3),
point: image.Point{0, 1},
r: 'X',
want: cell.Buffer{
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New('X'),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
},
},
{
desc: "sets a bottom-right corner cell",
termSize: image.Point{3, 3},
canvasArea: image.Rect(1, 1, 3, 3),
point: image.Point{1, 1},
r: 'Z',
want: cell.Buffer{
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New('Z'),
},
},
},
{
desc: "sets cell options",
termSize: image.Point{3, 3},
canvasArea: image.Rect(1, 1, 3, 3),
point: image.Point{1, 1},
r: 'A',
opts: []cell.Option{
cell.BgColor(cell.ColorRed),
},
want: cell.Buffer{
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New('A', cell.BgColor(cell.ColorRed)),
},
},
},
{
desc: "canvas size equals terminal size",
termSize: image.Point{1, 1},
canvasArea: image.Rect(0, 0, 1, 1),
point: image.Point{0, 0},
r: 'A',
want: cell.Buffer{
{
cell.New('A'),
},
},
},
{
desc: "terminal too small for the area",
termSize: image.Point{1, 1},
canvasArea: image.Rect(0, 0, 2, 2),
point: image.Point{0, 0},
r: 'A',
wantApplyErr: true,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
c, err := New(tc.canvasArea)
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
err = c.SetCell(tc.point, tc.r, tc.opts...)
if (err != nil) != tc.wantSetCellErr {
t.Errorf("SetCell => unexpected error: %v, wantSetCellErr: %v", err, tc.wantSetCellErr)
}
if err != nil {
return
}
ft, err := faketerm.New(tc.termSize)
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
err = c.Apply(ft)
if (err != nil) != tc.wantApplyErr {
t.Errorf("Apply => unexpected error: %v, wantApplyErr: %v", err, tc.wantApplyErr)
}
if err != nil {
return
}
got := ft.BackBuffer()
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("faketerm.BackBuffer => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestClear(t *testing.T) {
c, err := New(image.Rect(1, 1, 3, 3))
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
if err := c.SetCell(image.Point{0, 0}, 'X'); err != nil {
t.Fatalf("SetCell => unexpected error: %v", err)
}
ft, err := faketerm.New(image.Point{3, 3})
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
// Set one cell outside of the canvas on the terminal.
if err := ft.SetCell(image.Point{0, 0}, 'A'); err != nil {
t.Fatalf("faketerm.SetCell => unexpected error: %v", err)
}
if err := c.Apply(ft); err != nil {
t.Fatalf("Apply => unexpected error: %v", err)
}
want := cell.Buffer{
{
cell.New('A'),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New('X'),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
}
got := ft.BackBuffer()
if diff := pretty.Compare(want, got); diff != "" {
t.Errorf("faketerm.BackBuffer before Clear => unexpected diff (-want, +got):\n%s", diff)
}
// Call Clear(), Apply() and verify that only the area belonging to the
// canvas was cleared.
if err := c.Clear(); err != nil {
t.Fatalf("Clear => unexpected error: %v", err)
}
if err := c.Apply(ft); err != nil {
t.Fatalf("Apply => unexpected error: %v", err)
}
want = cell.Buffer{
{
cell.New('A'),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
{
cell.New(0),
cell.New(0),
cell.New(0),
},
}
got = ft.BackBuffer()
if diff := pretty.Compare(want, got); diff != "" {
t.Errorf("faketerm.BackBuffer after Clear => unexpected diff (-want, +got):\n%s", diff)
}
}

View File

@ -22,6 +22,11 @@ type Options struct {
BgColor Color
}
// set allows existing options to be passed as an option.
func (o *Options) set(other *Options) {
*other = *o
}
// NewOptions returns a new Options instance after applying the provided options.
func NewOptions(opts ...Option) *Options {
o := &Options{}
@ -86,12 +91,6 @@ func (b Buffer) Size() image.Point {
}
}
// Area returns the area that is covered by this buffer.
func (b Buffer) Area() image.Rectangle {
s := b.Size()
return image.Rect(0, 0, s.X-1, s.Y-1)
}
// option implements Option.
type option func(*Options)

View File

@ -94,6 +94,23 @@ func TestNew(t *testing.T) {
},
},
},
{
desc: "passing full Options overwrites existing",
r: 'X',
opts: []Option{
&Options{
FgColor: ColorBlack,
BgColor: ColorBlue,
},
},
want: Cell{
Rune: 'X',
Opts: &Options{
FgColor: ColorBlack,
BgColor: ColorBlue,
},
},
},
}
for _, tc := range tests {
@ -236,42 +253,3 @@ func TestBufferSize(t *testing.T) {
})
}
}
func TestBufferArea(t *testing.T) {
tests := []struct {
desc string
size image.Point
want image.Rectangle
}{
{
desc: "single cell buffer",
size: image.Point{1, 1},
want: image.Rectangle{
Min: image.Point{0, 0},
Max: image.Point{0, 0},
},
},
{
desc: "rectangular buffer",
size: image.Point{3, 4},
want: image.Rectangle{
Min: image.Point{0, 0},
Max: image.Point{2, 3},
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
b, err := NewBuffer(tc.size)
if err != nil {
t.Fatalf("NewBuffer => unexpected error: %v", err)
}
got := b.Area()
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("Area => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}

View File

@ -8,6 +8,7 @@ import (
"image"
"log"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/terminalapi"
)
@ -49,6 +50,11 @@ func New(size image.Point, opts ...Option) (*Terminal, error) {
return t, nil
}
// BackBuffer returns the back buffer of the fake terminal.
func (t *Terminal) BackBuffer() cell.Buffer {
return t.buffer
}
// Implements terminalapi.Terminal.Size.
func (t *Terminal) Size() image.Point {
return t.buffer.Size()
@ -81,8 +87,12 @@ func (t *Terminal) HideCursor() {
// Implements terminalapi.Terminal.SetCell.
func (t *Terminal) SetCell(p image.Point, r rune, opts ...cell.Option) error {
if area := t.buffer.Area(); !p.In(area) {
return fmt.Errorf("cell at point %+v falls out of the terminal area %+v", p, area)
ar, err := area.FromSize(t.buffer.Size())
if err != nil {
return err
}
if !p.In(ar) {
return fmt.Errorf("cell at point %+v falls out of the terminal area %+v", p, ar)
}
cell := t.buffer[p.X][p.Y]