Implementing cell, buffer, container options and fake terminal.

Push after a partial commit to prevent data loss.
This isn't complete and doesn't have complete test coverage.
This commit is contained in:
Jakub Sobon 2018-03-28 21:34:20 +03:00
parent 53fe40fcec
commit 59e1bd6472
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
9 changed files with 682 additions and 66 deletions

View File

@ -2,27 +2,73 @@
package canvas
import (
"errors"
"fmt"
"image"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/terminalapi"
)
// Canvas is where a widget draws its output for display on the terminal.
type Canvas struct{}
type Canvas struct {
// area is the area the buffer was created for.
area image.Rectangle
// Size returns the size of the 2-D canvas given to the widget.
// buffer is where the drawing happens.
buffer cell.Buffer
}
// 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)
}
size := image.Point{
area.Dx() + 1,
area.Dy() + 1,
}
b, err := cell.NewBuffer(size)
if err != nil {
return nil, err
}
return &Canvas{
area: area,
buffer: b,
}, nil
}
// Size returns the size of the 2-D canvas.
func (c *Canvas) Size() image.Point {
return image.Point{0, 0}
return c.buffer.Size()
}
// Clear clears all the content on the canvas.
func (c *Canvas) Clear() {}
// FlushDesired provides a hint to the infrastructure that the canvas was
// changed and should be flushed to the terminal.
func (c *Canvas) FlushDesired() {}
func (c *Canvas) Clear() error {
b, err := cell.NewBuffer(c.Size())
if err != nil {
return err
}
c.buffer = b
return nil
}
// SetCell sets the value of the specified cell on the canvas.
// 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) {}
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)
}
cell := c.buffer[p.X][p.Y]
cell.Rune = r
cell.Apply(opts...)
return nil
}
// CopyTo copies the content of the canvas onto the provided 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")
}

70
canvas/canvas_test.go Normal file
View File

@ -0,0 +1,70 @@
package canvas
import (
"image"
"testing"
"github.com/kylelemons/godebug/pretty"
)
func TestNew(t *testing.T) {
tests := []struct {
desc string
area image.Rectangle
wantSize image.Point
wantErr bool
}{
{
desc: "area min has negative X",
area: image.Rect(-1, 0, 0, 0),
wantErr: true,
},
{
desc: "area min has negative Y",
area: image.Rect(0, -1, 0, 0),
wantErr: true,
},
{
desc: "area max has negative X",
area: image.Rect(0, 0, -1, 0),
wantErr: true,
},
{
desc: "area max has negative Y",
area: image.Rect(0, 0, 0, -1),
wantErr: true,
},
{
desc: "smallest valid size",
area: image.Rect(0, 0, 0, 0),
wantSize: image.Point{1, 1},
},
{
desc: "rectangular canvas 3 by 4",
area: image.Rect(0, 0, 2, 3),
wantSize: image.Point{3, 4},
},
{
desc: "non-zero based area",
area: image.Rect(1, 1, 2, 3),
wantSize: image.Point{2, 3},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
c, err := New(tc.area)
if (err != nil) != tc.wantErr {
t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
got := c.Size()
if diff := pretty.Compare(tc.wantSize, got); diff != "" {
t.Errorf("Size => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}

View File

@ -5,6 +5,11 @@ A cell is the smallest point on the terminal.
*/
package cell
import (
"fmt"
"image"
)
// Option is used to provide options for cells on a 2-D terminal.
type Option interface {
// set sets the provided option.
@ -26,6 +31,67 @@ func NewOptions(opts ...Option) *Options {
return o
}
// Cell represents a single cell on the terminal.
type Cell struct {
// Rune is the rune stored in the cell.
Rune rune
// Opts are the cell options.
Opts *Options
}
// New returns a new cell.
func New(r rune, opts ...Option) *Cell {
return &Cell{
Rune: r,
Opts: NewOptions(opts...),
}
}
// Apply applies the provided options to the cell.
func (c *Cell) Apply(opts ...Option) {
for _, opt := range opts {
opt.set(c.Opts)
}
}
// Buffer is a 2-D buffer of cells.
// The axes increase right and down.
type Buffer [][]*Cell
// NewBuffer returns a new Buffer of the provided size.
func NewBuffer(size image.Point) (Buffer, error) {
if size.X <= 0 {
return nil, fmt.Errorf("invalid buffer width (size.X): %d, must be a positive number", size.X)
}
if size.Y <= 0 {
return nil, fmt.Errorf("invalid buffer height (size.Y): %d, must be a positive number", size.Y)
}
b := make([][]*Cell, size.X)
for col := range b {
b[col] = make([]*Cell, size.Y)
for row := range b[col] {
b[col][row] = New(0)
}
}
return b, nil
}
// Size returns the size of the buffer.
func (b Buffer) Size() image.Point {
return image.Point{
len(b),
len(b[0]),
}
}
// 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)

277
cell/cell_test.go Normal file
View File

@ -0,0 +1,277 @@
package cell
import (
"image"
"testing"
"github.com/kylelemons/godebug/pretty"
)
func TestNewOptions(t *testing.T) {
tests := []struct {
desc string
opts []Option
want *Options
}{
{
desc: "no provided options",
want: &Options{},
},
{
desc: "setting foreground color",
opts: []Option{
FgColor(ColorBlack),
},
want: &Options{
FgColor: ColorBlack,
},
},
{
desc: "setting background color",
opts: []Option{
BgColor(ColorRed),
},
want: &Options{
BgColor: ColorRed,
},
},
{
desc: "setting multiple options",
opts: []Option{
FgColor(ColorCyan),
BgColor(ColorMagenta),
},
want: &Options{
FgColor: ColorCyan,
BgColor: ColorMagenta,
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := NewOptions(tc.opts...)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("NewOptions => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestNew(t *testing.T) {
tests := []struct {
desc string
r rune
opts []Option
want Cell
}{
{
desc: "creates empty cell with default options",
want: Cell{
Opts: &Options{},
},
},
{
desc: "cell with the specified rune",
r: 'X',
want: Cell{
Rune: 'X',
Opts: &Options{},
},
},
{
desc: "cell with options",
r: 'X',
opts: []Option{
FgColor(ColorCyan),
BgColor(ColorMagenta),
},
want: Cell{
Rune: 'X',
Opts: &Options{
FgColor: ColorCyan,
BgColor: ColorMagenta,
},
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := New(tc.r, tc.opts...)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("New => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestCellApply(t *testing.T) {
tests := []struct {
desc string
cell *Cell
opts []Option
want *Cell
}{
{
desc: "no options provided",
cell: New(0),
want: New(0),
},
{
desc: "no change in options",
cell: New(0, FgColor(ColorCyan)),
opts: []Option{
FgColor(ColorCyan),
},
want: New(0, FgColor(ColorCyan)),
},
{
desc: "retains previous values",
cell: New(0, FgColor(ColorCyan)),
opts: []Option{
BgColor(ColorBlack),
},
want: New(
0,
FgColor(ColorCyan),
BgColor(ColorBlack),
),
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := tc.cell
got.Apply(tc.opts...)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("Apply => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestNewBuffer(t *testing.T) {
tests := []struct {
desc string
size image.Point
want Buffer
wantErr bool
}{
{
desc: "zero buffer is invalid",
wantErr: true,
},
{
desc: "width cannot be negative",
size: image.Point{-1, 1},
wantErr: true,
},
{
desc: "height cannot be negative",
size: image.Point{1, -1},
wantErr: true,
},
{
desc: "creates single cell buffer",
size: image.Point{1, 1},
want: Buffer{
{
New(0),
},
},
},
{
desc: "creates the buffer",
size: image.Point{2, 3},
want: Buffer{
{
New(0),
New(0),
New(0),
},
{
New(0),
New(0),
New(0),
},
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got, err := NewBuffer(tc.size)
if (err != nil) != tc.wantErr {
t.Errorf("NewBuffer => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("NewBuffer => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestBufferSize(t *testing.T) {
sizes := []image.Point{
{1, 1},
{2, 3},
}
for _, size := range sizes {
t.Run("", func(t *testing.T) {
b, err := NewBuffer(size)
if err != nil {
t.Fatalf("NewBuffer => unexpected error: %v", err)
}
got := b.Size()
if diff := pretty.Compare(size, got); diff != "" {
t.Errorf("Size => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
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,8 +8,10 @@ canvases assigned to the placed widgets.
package container
import (
"errors"
"image"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widget"
)
// Container wraps either sub containers or widgets and positions them on the
@ -22,32 +24,31 @@ type Container struct {
second *Container
// term is the terminal this container is placed on.
// All containers in the tree share the same terminal.
term terminalapi.Terminal
// split identifies how is this container split.
split splitType
// area is the area of the terminal this container has access to.
area image.Rectangle
// widget is the widget in the container.
// A container can have either two sub containers (left and right) or a
// widget. But not both.
widget widget.Widget
// Alignment of the widget if present.
hAlign hAlignType
vAlign vAlignType
// opts are the options provided to the container.
opts *options
}
// New returns a new root container that will use the provided terminal and
// applies the provided options.
func New(t terminalapi.Terminal, opts ...Option) *Container {
c := &Container{
term: t,
o := &options{}
for _, opt := range opts {
opt.set(o)
}
for _, opt := range opts {
opt.set(c)
size := t.Size()
return &Container{
term: t,
// The root container has access to the entire terminal.
area: image.Rect(0, 0, size.X, size.Y),
opts: o,
}
return c
}
// Returns the parent container of this container.
@ -59,7 +60,7 @@ func (c *Container) Parent(opts ...Option) *Container {
p := c.parent
for _, opt := range opts {
opt.set(p)
opt.set(p.opts)
}
return p
}
@ -72,13 +73,13 @@ func (c *Container) Parent(opts ...Option) *Container {
// Returns nil if this container contains a widget, containers with widgets
// cannot have sub containers.
func (c *Container) First(opts ...Option) *Container {
if c == nil || c.widget != nil {
if c == nil || c.opts.widget != nil {
return nil
}
if child := c.first; child != nil {
for _, opt := range opts {
opt.set(child)
opt.set(child.opts)
}
return child
}
@ -96,13 +97,13 @@ func (c *Container) First(opts ...Option) *Container {
// Returns nil if this container contains a widget, containers with widgets
// cannot have sub containers.
func (c *Container) Second(opts ...Option) *Container {
if c == nil || c.widget != nil {
if c == nil || c.opts.widget != nil {
return nil
}
if child := c.second; child != nil {
for _, opt := range opts {
opt.set(child)
opt.set(child.opts)
}
return child
}
@ -111,3 +112,9 @@ func (c *Container) Second(opts ...Option) *Container {
c.second.parent = c
return c.second
}
// Draw requests all widgets in this and all sub containers to draw on their
// respective canvases.
func (c *Container) Draw() error {
return errors.New("unimplemented")
}

View File

@ -7,31 +7,46 @@ import "github.com/mum4k/termdash/widget"
// Option is used to provide options.
type Option interface {
// set sets the provided option.
set(*Container)
set(*options)
}
// options stores the provided options.
type options struct{}
// options stores the options provided to the container.
type options struct {
// split identifies how is this container split.
split splitType
// widget is the widget in the container.
// A container can have either two sub containers (left and right) or a
// widget. But not both.
widget widget.Widget
// Alignment of the widget if present.
hAlign hAlignType
vAlign vAlignType
// border is the border around the container.
border borderType
}
// option implements Option.
type option func(*Container)
type option func(*options)
// set implements Option.set.
func (o option) set(c *Container) {
o(c)
func (o option) set(opts *options) {
o(opts)
}
// PlaceWidget places the provided widget into the container.
func PlaceWidget(w widget.Widget) Option {
return option(func(c *Container) {
c.widget = w
return option(func(opts *options) {
opts.widget = w
})
}
// SplitHorizontal configures the container for a horizontal split.
func SplitHorizontal() Option {
return option(func(c *Container) {
c.split = splitTypeHorizontal
return option(func(opts *options) {
opts.split = splitTypeHorizontal
})
}
@ -39,8 +54,8 @@ func SplitHorizontal() Option {
// This is the default split type if neither if SplitHorizontal() or
// SplitVertical() is specified.
func SplitVertical() Option {
return option(func(c *Container) {
c.split = splitTypeVertical
return option(func(opts *options) {
opts.split = splitTypeVertical
})
}
@ -48,8 +63,8 @@ func SplitVertical() Option {
// container along the horizontal axis. Has no effect if the container contains
// no widget. This is the default horizontal alignment if no other is specified.
func HorizontalAlignLeft() Option {
return option(func(c *Container) {
c.hAlign = hAlignTypeLeft
return option(func(opts *options) {
opts.hAlign = hAlignTypeLeft
})
}
@ -57,8 +72,8 @@ func HorizontalAlignLeft() Option {
// container along the horizontal axis. Has no effect if the container contains
// no widget.
func HorizontalAlignCenter() Option {
return option(func(c *Container) {
c.hAlign = hAlignTypeCenter
return option(func(opts *options) {
opts.hAlign = hAlignTypeCenter
})
}
@ -66,8 +81,8 @@ func HorizontalAlignCenter() Option {
// container along the horizontal axis. Has no effect if the container contains
// no widget.
func HorizontalAlignRight() Option {
return option(func(c *Container) {
c.hAlign = hAlignTypeRight
return option(func(opts *options) {
opts.hAlign = hAlignTypeRight
})
}
@ -75,8 +90,8 @@ func HorizontalAlignRight() Option {
// container along the vertical axis. Has no effect if the container contains
// no widget. This is the default vertical alignment if no other is specified.
func VerticalAlignTop() Option {
return option(func(c *Container) {
c.vAlign = vAlignTypeTop
return option(func(opts *options) {
opts.vAlign = vAlignTypeTop
})
}
@ -84,8 +99,8 @@ func VerticalAlignTop() Option {
// container along the vertical axis. Has no effect if the container contains
// no widget.
func VerticalAlignMiddle() Option {
return option(func(c *Container) {
c.vAlign = vAlignTypeMiddle
return option(func(opts *options) {
opts.vAlign = vAlignTypeMiddle
})
}
@ -93,8 +108,24 @@ func VerticalAlignMiddle() Option {
// container along the vertical axis. Has no effect if the container contains
// no widget.
func VerticalAlignBottom() Option {
return option(func(c *Container) {
c.vAlign = vAlignTypeBottom
return option(func(opts *options) {
opts.vAlign = vAlignTypeBottom
})
}
// BorderNone configures the container to have no border.
// This is the default if none of the Border options is specified.
func BorderNone() Option {
return option(func(opts *options) {
opts.border = borderTypeNone
})
}
// BorderSolid configures the container to have a border made with a solid
// line.
func BorderSolid() Option {
return option(func(opts *options) {
opts.border = borderTypeSolid
})
}
@ -167,3 +198,25 @@ const (
vAlignTypeMiddle
vAlignTypeBottom
)
// borderType represents
type borderType int
// String implements fmt.Stringer()
func (bt borderType) String() string {
if n, ok := borderTypeNames[bt]; ok {
return n
}
return "borderTypeUnknown"
}
// borderTypeNames maps borderType values to human readable names.
var borderTypeNames = map[borderType]string{
borderTypeNone: "borderTypeNone",
borderTypeSolid: "borderTypeSolid",
}
const (
borderTypeNone borderType = iota
borderTypeSolid
)

View File

@ -108,9 +108,6 @@ Containers can be styled with borders and other options.
All widgets indirectly write to the back buffer of the terminal implementation. The changes
to the back buffer only become visible when the infrastructure flushes its content.
Widgets cannot force a flush, but they can indicate that a flush is desired.
The infrastructure throttles the amount of times this happens.
#### Terminal resizing
The terminal resize events are processed by the infrastructure. Each widget

View File

@ -0,0 +1,101 @@
// Package faketerm is a fake implementation of the terminal for the use in tests.
package faketerm
import (
"context"
"errors"
"fmt"
"image"
"log"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/terminalapi"
)
// Option is used to provide options.
type Option interface {
// set sets the provided option.
set(*Terminal)
}
// option implements Option.
type option func(*Terminal)
// set implements Option.set.
func (o option) set(t *Terminal) {
o(t)
}
// Terminal is a fake terminal.
// This implementation is thread-safe.
type Terminal struct {
// buffer holds the terminal cells.
buffer cell.Buffer
}
// New returns a new fake Terminal.
func New(size image.Point, opts ...Option) (*Terminal, error) {
b, err := cell.NewBuffer(size)
if err != nil {
return nil, err
}
t := &Terminal{
buffer: b,
}
for _, opt := range opts {
opt.set(t)
}
return t, nil
}
// Implements terminalapi.Terminal.Size.
func (t *Terminal) Size() image.Point {
return t.buffer.Size()
}
// Implements terminalapi.Terminal.Clear.
func (t *Terminal) Clear(opts ...cell.Option) error {
b, err := cell.NewBuffer(t.buffer.Size())
if err != nil {
return err
}
t.buffer = b
return nil
}
// Implements terminalapi.Terminal.Flush.
func (t *Terminal) Flush() error {
return errors.New("unimplemented")
}
// Implements terminalapi.Terminal.SetCursor.
func (t *Terminal) SetCursor(p image.Point) {
log.Fatal("unimplemented")
}
// Implements terminalapi.Terminal.HideCursor.
func (t *Terminal) HideCursor() {
log.Fatal("unimplemented")
}
// 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)
}
cell := t.buffer[p.X][p.Y]
cell.Rune = r
cell.Apply(opts...)
return nil
}
// Implements terminalapi.Terminal.Event.
func (t *Terminal) Event(ctx context.Context) terminalapi.Event {
log.Fatal("unimplemented")
return nil
}
// Closes the terminal. This is a no-op on the fake terminal.
func (t *Terminal) Close() {}

View File

@ -13,18 +13,17 @@ type Options struct {
}
// Widget is a single widget on the dashboard.
// Implementations must be thread safe.
type Widget interface {
// Draw executes the widget, when called the widget should draw on the
// canvas. The widget can assume that the canvas content wasn't modified
// since the last call, i.e. if the widget doesn't need to change anything in
// the output, this can be a no-op.
// When the infrastructure calls Draw(), the widget must block on the call
// until it finishes drawing onto the provided canvas. When given the
// canvas, the widget must first determine its size by calling
// Canvas.Size(), then limit all its drawing to this area.
//
// The widget must not assume that the size of the canvas or its content
// remains the same between calls.
Draw(canvas *canvas.Canvas) error
// Redraw is called when the widget must redraw all of its content because
// the previous canvas was invalidated. The widget must not assume that
// anything on the canvas remained the same, including its size.
Redraw(canvas *canvas.Canvas) error
// Keyboard is called when the widget is focused on the dashboard and a key
// shortcut the widget registered for was pressed. Only called if the widget
// registered for keyboard events.