diff --git a/area/area.go b/area/area.go new file mode 100644 index 0000000..6372115 --- /dev/null +++ b/area/area.go @@ -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 +} diff --git a/area/area_test.go b/area/area_test.go new file mode 100644 index 0000000..391d5d1 --- /dev/null +++ b/area/area_test.go @@ -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) + } + }) + } +} diff --git a/canvas/canvas.go b/canvas/canvas.go index 412aab5..4ef5d1e 100644 --- a/canvas/canvas.go +++ b/canvas/canvas.go @@ -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 } diff --git a/canvas/canvas_test.go b/canvas/canvas_test.go index 0d1f015..721aad0 100644 --- a/canvas/canvas_test.go +++ b/canvas/canvas_test.go @@ -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) + } +} diff --git a/cell/cell.go b/cell/cell.go index 2c26ebc..8e449ac 100644 --- a/cell/cell.go +++ b/cell/cell.go @@ -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) diff --git a/cell/cell_test.go b/cell/cell_test.go index 9359053..17d2f97 100644 --- a/cell/cell_test.go +++ b/cell/cell_test.go @@ -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) - } - }) - } -} diff --git a/terminal/faketerm/faketerm.go b/terminal/faketerm/faketerm.go index 7ce0357..edf5f75 100644 --- a/terminal/faketerm/faketerm.go +++ b/terminal/faketerm/faketerm.go @@ -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]