From 59e1bd64724b1b4b64acfafe3f39b71a381cdff7 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Wed, 28 Mar 2018 21:34:20 +0300 Subject: [PATCH] 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. --- canvas/canvas.go | 64 ++++++-- canvas/canvas_test.go | 70 +++++++++ cell/cell.go | 66 ++++++++ cell/cell_test.go | 277 ++++++++++++++++++++++++++++++++++ container/container.go | 49 +++--- container/options.go | 101 ++++++++++--- doc/design.md | 3 - terminal/faketerm/faketerm.go | 101 +++++++++++++ widget/widget.go | 17 +-- 9 files changed, 682 insertions(+), 66 deletions(-) create mode 100644 canvas/canvas_test.go create mode 100644 cell/cell_test.go create mode 100644 terminal/faketerm/faketerm.go diff --git a/canvas/canvas.go b/canvas/canvas.go index 57cda0b..412aab5 100644 --- a/canvas/canvas.go +++ b/canvas/canvas.go @@ -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") +} diff --git a/canvas/canvas_test.go b/canvas/canvas_test.go new file mode 100644 index 0000000..0d1f015 --- /dev/null +++ b/canvas/canvas_test.go @@ -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) + } + }) + } +} diff --git a/cell/cell.go b/cell/cell.go index 731068e..2c26ebc 100644 --- a/cell/cell.go +++ b/cell/cell.go @@ -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) diff --git a/cell/cell_test.go b/cell/cell_test.go new file mode 100644 index 0000000..9359053 --- /dev/null +++ b/cell/cell_test.go @@ -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) + } + }) + } +} diff --git a/container/container.go b/container/container.go index 08d751b..36dec7a 100644 --- a/container/container.go +++ b/container/container.go @@ -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") +} diff --git a/container/options.go b/container/options.go index a2c0980..a477b04 100644 --- a/container/options.go +++ b/container/options.go @@ -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 +) diff --git a/doc/design.md b/doc/design.md index 03ee033..d10988a 100644 --- a/doc/design.md +++ b/doc/design.md @@ -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 diff --git a/terminal/faketerm/faketerm.go b/terminal/faketerm/faketerm.go new file mode 100644 index 0000000..7ce0357 --- /dev/null +++ b/terminal/faketerm/faketerm.go @@ -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() {} diff --git a/widget/widget.go b/widget/widget.go index 2b12997..57b153c 100644 --- a/widget/widget.go +++ b/widget/widget.go @@ -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.