From 3a3531d7e10461d8196420b3d38422dd728f3f40 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sun, 1 Apr 2018 00:57:33 +0200 Subject: [PATCH] Implementation of container and its tests. Including a diff utility for unit tests. --- area/area.go | 47 ++++++ area/area_test.go | 125 +++++++++++++++ container/container.go | 132 ++++++++++++++-- container/container_test.go | 280 +++++++++++++++++++++++++++++++++- draw/line_style.go | 2 +- experimental/boxes.go | 44 ++++++ terminal/faketerm/diff.go | 40 +++++ terminal/faketerm/faketerm.go | 11 +- 8 files changed, 663 insertions(+), 18 deletions(-) create mode 100644 experimental/boxes.go create mode 100644 terminal/faketerm/diff.go diff --git a/area/area.go b/area/area.go index 6372115..1185478 100644 --- a/area/area.go +++ b/area/area.go @@ -21,3 +21,50 @@ func FromSize(size image.Point) (image.Rectangle, error) { } return image.Rect(0, 0, size.X, size.Y), nil } + +// HSplit returns two new areas created by splitting the provided area in the +// middle along the horizontal axis. Can return zero size areas. +func HSplit(area image.Rectangle) (image.Rectangle, image.Rectangle) { + height := area.Dy() / 2 + if height == 0 { + return image.ZR, image.ZR + } + return image.Rect(area.Min.X, area.Min.Y, area.Max.X, area.Min.Y+height), + image.Rect(area.Min.X, area.Min.Y+height, area.Max.X, area.Max.Y) +} + +// VSplit returns two new areas created by splitting the provided area in the +// middle along the vertical axis. Can return zero size areas. +func VSplit(area image.Rectangle) (image.Rectangle, image.Rectangle) { + width := area.Dx() / 2 + if width == 0 { + return image.ZR, image.ZR + } + return image.Rect(area.Min.X, area.Min.Y, area.Min.X+width, area.Max.Y), + image.Rect(area.Min.X+width, area.Min.Y, area.Max.X, area.Max.Y) +} + +// abs returns the absolute value of x. +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// ExcludeBorder returns a new area created by subtracting a border around the +// provided area. Can return a zero area. +func ExcludeBorder(area image.Rectangle) image.Rectangle { + // If the area dimensions are smaller than this, subtracting a point for the + // border on each of its sides results in a zero area. + const minDim = 3 + if area.Dx() < minDim || area.Dy() < minDim { + return image.ZR + } + return image.Rect( + abs(area.Min.X+1), + abs(area.Min.Y+1), + abs(area.Max.X-1), + abs(area.Max.Y-1), + ) +} diff --git a/area/area_test.go b/area/area_test.go index 391d5d1..88dc1f6 100644 --- a/area/area_test.go +++ b/area/area_test.go @@ -102,3 +102,128 @@ func TestFromSize(t *testing.T) { }) } } + +func TestHSplit(t *testing.T) { + tests := []struct { + desc string + area image.Rectangle + want1 image.Rectangle + want2 image.Rectangle + }{ + { + desc: "zero area to begin with", + area: image.ZR, + want1: image.ZR, + want2: image.ZR, + }, + { + desc: "splitting results in zero height area", + area: image.Rect(1, 1, 2, 2), + want1: image.ZR, + want2: image.ZR, + }, + { + desc: "splits area with even height", + area: image.Rect(1, 1, 3, 3), + want1: image.Rect(1, 1, 3, 2), + want2: image.Rect(1, 2, 3, 3), + }, + { + desc: "splits area with odd height", + area: image.Rect(1, 1, 4, 4), + want1: image.Rect(1, 1, 4, 2), + want2: image.Rect(1, 2, 4, 4), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got1, got2 := HSplit(tc.area) + if diff := pretty.Compare(tc.want1, got1); diff != "" { + t.Errorf("HSplit => first value unexpected diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.want2, got2); diff != "" { + t.Errorf("HSplit => second value unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestVSplit(t *testing.T) { + tests := []struct { + desc string + area image.Rectangle + want1 image.Rectangle + want2 image.Rectangle + }{ + { + desc: "zero area to begin with", + area: image.ZR, + want1: image.ZR, + want2: image.ZR, + }, + { + desc: "splitting results in zero width area", + area: image.Rect(1, 1, 2, 2), + want1: image.ZR, + want2: image.ZR, + }, + { + desc: "splits area with even width", + area: image.Rect(1, 1, 3, 3), + want1: image.Rect(1, 1, 2, 3), + want2: image.Rect(2, 1, 3, 3), + }, + { + desc: "splits area with odd width", + area: image.Rect(1, 1, 4, 4), + want1: image.Rect(1, 1, 2, 4), + want2: image.Rect(2, 1, 4, 4), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got1, got2 := VSplit(tc.area) + if diff := pretty.Compare(tc.want1, got1); diff != "" { + t.Errorf("VSplit => first value unexpected diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.want2, got2); diff != "" { + t.Errorf("VSplit => second value unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestExcludeBorder(t *testing.T) { + tests := []struct { + desc string + area image.Rectangle + want image.Rectangle + }{ + { + desc: "zero area to begin with", + area: image.ZR, + want: image.ZR, + }, + { + desc: "excluding results in zero area", + area: image.Rect(1, 1, 2, 2), + want: image.ZR, + }, + { + desc: "excludes border", + area: image.Rect(1, 1, 4, 5), + want: image.Rect(2, 2, 3, 4), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := ExcludeBorder(tc.area) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("ExcludeBorder => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/container/container.go b/container/container.go index 36dec7a..b86a2d0 100644 --- a/container/container.go +++ b/container/container.go @@ -8,9 +8,12 @@ canvases assigned to the placed widgets. package container import ( - "errors" + "fmt" "image" + "github.com/mum4k/termdash/area" + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/draw" "github.com/mum4k/termdash/terminalapi" ) @@ -34,6 +37,12 @@ type Container struct { opts *options } +// String represents the container metadata in a human readable format. +// Implements fmt.Stringer. +func (c *Container) String() string { + return fmt.Sprintf("Container@%p{parent:%p, first:%p, second:%p, area:%+v}", c, c.parent, c.first, c.second, c.area) +} + // 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 { @@ -51,10 +60,26 @@ func New(t terminalapi.Terminal, opts ...Option) *Container { } } -// Returns the parent container of this container. -// Returns nil if this container is the root of the container tree. +// newChild creates a new child container of the given parent. +func newChild(parent *Container, area image.Rectangle, opts ...Option) *Container { + o := &options{} + for _, opt := range opts { + opt.set(o) + } + + return &Container{ + parent: parent, + term: parent.term, + area: area, + opts: o, + } +} + +// Returns the parent container of this container and applies the provided +// options to the parent container. Returns nil if this container is the root +// of the container tree. func (c *Container) Parent(opts ...Option) *Container { - if c == nil || c.parent == nil { + if c.parent == nil { return nil } @@ -65,6 +90,30 @@ func (c *Container) Parent(opts ...Option) *Container { return p } +// hasBorder determines if this container has a border. +func (c *Container) hasBorder() bool { + return c.opts.border != draw.LineStyleNone +} + +// usable returns the usable area in this container. +// This depends on whether the container has a border, etc. +func (c *Container) usable() image.Rectangle { + if c.hasBorder() { + return area.ExcludeBorder(c.area) + } else { + return c.area + } +} + +// split splits the container's usable area into child areas. +func (c *Container) split() (image.Rectangle, image.Rectangle) { + if ar := c.usable(); c.opts.split == splitTypeHorizontal { + return area.HSplit(ar) + } else { + return area.VSplit(ar) + } +} + // First returns the first sub container of this container. // This is the left sub container when using SplitVertical() or the top sub // container when using SplitHorizontal(). @@ -73,7 +122,7 @@ 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.opts.widget != nil { + if c.opts.widget != nil { return nil } @@ -84,8 +133,8 @@ func (c *Container) First(opts ...Option) *Container { return child } - c.first = New(c.term, opts...) - c.first.parent = c + ar, _ := c.split() + c.first = newChild(c, ar, opts...) return c.first } @@ -97,7 +146,7 @@ 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.opts.widget != nil { + if c.opts.widget != nil { return nil } @@ -108,13 +157,68 @@ func (c *Container) Second(opts ...Option) *Container { return child } - c.second = New(c.term, opts...) - c.second.parent = c + _, ar := c.split() + c.second = newChild(c, ar, opts...) 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") +// Root returns the root container and applies the provided options to the root +// container. +func (c *Container) Root(opts ...Option) *Container { + for p := c.Parent(); p != nil; p = c.Parent() { + c = p + } + + for _, opt := range opts { + opt.set(c.opts) + } + return c +} + +// draw draws this container and its widget. +// TODO(mum4k): Draw the widget. +func (c *Container) draw() error { + // TODO(mum4k): Should be verified against the min size reported by the + // widget. + if us := c.usable(); us.Dx() < 1 || us.Dy() < 1 { + return nil + } + + cvs, err := canvas.New(c.area) + if err != nil { + return err + } + + if c.hasBorder() { + ar, err := area.FromSize(cvs.Size()) + if err != nil { + return err + } + if err := draw.Box(cvs, ar, c.opts.border); err != nil { + return err + } + } + return cvs.Apply(c.term) +} + +// Draw draws this container and all of its sub containers. +func (c *Container) Draw() error { + // TODO(mum4k): Handle resize or split to area too small. + // TODO(mum4k): Propagate error. + // TODO(mum4k): Don't require .Root() at the end. + drawTree(c) + return nil +} + +// drawTree implements pre-order BST walk through the containers and draws each +// visited container. +func drawTree(c *Container) { + if c == nil { + return + } + if err := c.draw(); err != nil { + panic(err) + } + drawTree(c.first) + drawTree(c.second) } diff --git a/container/container_test.go b/container/container_test.go index 4293510..cd87133 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -1,5 +1,16 @@ package container +import ( + "image" + "log" + "testing" + + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/terminal/faketerm" +) + // Example demonstrates how to use the Container API. func Example() { New( // Create the root container. @@ -13,7 +24,272 @@ func Example() { ).Parent().Second( // Right side on the top. HorizontalAlignRight(), PlaceWidget( /* widget = */ nil), - ).Parent().Parent().Second( // Bottom half of the terminal. + ).Root().Second( // Bottom half of the terminal. PlaceWidget( /* widget = */ nil), - ) + ).Root() +} + +func TestParentAndRoot(t *testing.T) { + ft := faketerm.MustNew(image.Point{1, 1}) + tests := []struct { + desc string + container *Container + // Arg is the container defined above. + want func(c *Container) *Container + }{ + { + desc: "root container has no parent", + container: New(ft), + want: func(c *Container) *Container { + return nil + }, + }, + { + desc: "returns the parent", + container: New(ft).First(), + want: func(c *Container) *Container { + return c.Root() + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + if got := tc.container.Parent(); got != tc.want(tc.container) { + t.Errorf("Parent => unexpected container\n got: %v\n want: %v", got, tc.want) + } + }) + } +} + +// mustCanvas returns a new canvas or panics. +func mustCanvas(area image.Rectangle) *canvas.Canvas { + cvs, err := canvas.New(area) + if err != nil { + log.Fatalf("canvas.New => unexpected error: %v", err) + } + return cvs +} + +// mustBox draws box on the canvas or panics. +func mustBox(c *canvas.Canvas, box image.Rectangle, ls draw.LineStyle, opts ...cell.Option) { + if err := draw.Box(c, box, ls, opts...); err != nil { + log.Fatalf("draw.Box => unexpected error: %v", err) + } +} + +// mustApply applies the canvas on the terminal or panics. +func mustApply(c *canvas.Canvas, t *faketerm.Terminal) { + if err := c.Apply(t); err != nil { + log.Fatalf("canvas.Apply => unexpected error: %v", err) + } +} + +func TestDraw(t *testing.T) { + tests := []struct { + desc string + termSize image.Point + container func(ft *faketerm.Terminal) *Container + want func(size image.Point) *faketerm.Terminal + wantErr bool + }{ + // { + // desc: "empty container", + // termSize: image.Point{10, 10}, + // container: func(ft *faketerm.Terminal) *Container { + // return New(ft) + // }, + // want: func(size image.Point) *faketerm.Terminal { + // return faketerm.MustNew(size) + // }, + // }, + // { + // desc: "container with a border", + // termSize: image.Point{10, 10}, + // container: func(ft *faketerm.Terminal) *Container { + // return New( + // ft, + // Border(draw.LineStyleLight), + // ) + // }, + // want: func(size image.Point) *faketerm.Terminal { + // ft := faketerm.MustNew(size) + // cvs := mustCanvas(image.Rect(0, 0, 10, 10)) + // mustBox(cvs, image.Rect(0, 0, 10, 10), draw.LineStyleLight) + // mustApply(cvs, ft) + // return ft + // }, + // }, + // { + // desc: "horizontal split, children have borders", + // termSize: image.Point{10, 10}, + // container: func(ft *faketerm.Terminal) *Container { + // return New( + // ft, + // SplitHorizontal(), + // ).First( + // Border(draw.LineStyleLight), + // ).Root().Second( + // Border(draw.LineStyleLight), + // ).Root() + // }, + // want: func(size image.Point) *faketerm.Terminal { + // ft := faketerm.MustNew(size) + // cvs := mustCanvas(image.Rect(0, 0, 10, 10)) + // mustBox(cvs, image.Rect(0, 0, 10, 5), draw.LineStyleLight) + // mustBox(cvs, image.Rect(0, 5, 10, 10), draw.LineStyleLight) + // mustApply(cvs, ft) + // return ft + // }, + // }, + // { + // desc: "horizontal split, parent and children have borders", + // termSize: image.Point{10, 10}, + // container: func(ft *faketerm.Terminal) *Container { + // return New( + // ft, + // SplitHorizontal(), + // Border(draw.LineStyleLight), + // ).First( + // Border(draw.LineStyleLight), + // ).Root().Second( + // Border(draw.LineStyleLight), + // ).Root() + // }, + // want: func(size image.Point) *faketerm.Terminal { + // ft := faketerm.MustNew(size) + // cvs := mustCanvas(image.Rect(0, 0, 10, 10)) + // mustBox(cvs, image.Rect(0, 0, 10, 10), draw.LineStyleLight) + // mustBox(cvs, image.Rect(1, 1, 9, 5), draw.LineStyleLight) + // mustBox(cvs, image.Rect(1, 5, 9, 9), draw.LineStyleLight) + // mustApply(cvs, ft) + // return ft + // }, + // }, + // { + // desc: "vertical split, children have borders", + // termSize: image.Point{10, 10}, + // container: func(ft *faketerm.Terminal) *Container { + // return New( + // ft, + // SplitVertical(), + // ).First( + // Border(draw.LineStyleLight), + // ).Root().Second( + // Border(draw.LineStyleLight), + // ).Root() + // }, + // want: func(size image.Point) *faketerm.Terminal { + // ft := faketerm.MustNew(size) + // cvs := mustCanvas(image.Rect(0, 0, 10, 10)) + // mustBox(cvs, image.Rect(0, 0, 5, 10), draw.LineStyleLight) + // mustBox(cvs, image.Rect(5, 0, 10, 10), draw.LineStyleLight) + // mustApply(cvs, ft) + // return ft + // }, + // }, + // { + // desc: "vertical split, parent and children have borders", + // termSize: image.Point{10, 10}, + // container: func(ft *faketerm.Terminal) *Container { + // return New( + // ft, + // SplitVertical(), + // Border(draw.LineStyleLight), + // ).First( + // Border(draw.LineStyleLight), + // ).Root().Second( + // Border(draw.LineStyleLight), + // ).Root() + // }, + // want: func(size image.Point) *faketerm.Terminal { + // ft := faketerm.MustNew(size) + // cvs := mustCanvas(image.Rect(0, 0, 10, 10)) + // mustBox(cvs, image.Rect(0, 0, 10, 10), draw.LineStyleLight) + // mustBox(cvs, image.Rect(1, 1, 5, 9), draw.LineStyleLight) + // mustBox(cvs, image.Rect(5, 1, 9, 9), draw.LineStyleLight) + // mustApply(cvs, ft) + // return ft + // }, + // }, + { + desc: "multi level split", + termSize: image.Point{10, 11}, + container: func(ft *faketerm.Terminal) *Container { + return New( + ft, + SplitVertical(), + ).First( + SplitHorizontal(), + ).First( + Border(draw.LineStyleLight), + ).Parent().Second( + SplitHorizontal(), + ).First( + Border(draw.LineStyleLight), + ).Parent().Second( + Border(draw.LineStyleLight), + ).Root().Second( + Border(draw.LineStyleLight), + ).Root() + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := mustCanvas(image.Rect(0, 0, 10, 11)) + mustBox(cvs, image.Rect(0, 0, 5, 5), draw.LineStyleLight) + mustBox(cvs, image.Rect(0, 5, 5, 8), draw.LineStyleLight) + mustBox(cvs, image.Rect(0, 8, 5, 11), draw.LineStyleLight) + mustBox(cvs, image.Rect(5, 0, 10, 11), draw.LineStyleLight) + mustApply(cvs, ft) + return ft + }, + }, + { + desc: "container height too low", + termSize: image.Point{4, 7}, + container: func(ft *faketerm.Terminal) *Container { + return New( + ft, + SplitHorizontal(), + ).First( + Border(draw.LineStyleLight), + ).Parent().Second( + SplitHorizontal(), + ).First( + Border(draw.LineStyleLight), + ).Parent().Second( + Border(draw.LineStyleLight), + ).Root() + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := mustCanvas(image.Rect(0, 0, 4, 7)) + mustBox(cvs, image.Rect(0, 0, 4, 3), draw.LineStyleLight) + mustApply(cvs, ft) + return ft + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := faketerm.New(tc.termSize) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + + err = tc.container(got).Draw() + if (err != nil) != tc.wantErr { + t.Errorf("Draw => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if diff := faketerm.Diff(tc.want(tc.termSize), got); diff != "" { + t.Errorf("Draw => %v", diff) + } + }) + } + } diff --git a/draw/line_style.go b/draw/line_style.go index 321cd1b..37d8e76 100644 --- a/draw/line_style.go +++ b/draw/line_style.go @@ -43,7 +43,7 @@ var lineStyleNames = map[LineStyle]string{ } const ( - lineStyleUnknown LineStyle = iota + LineStyleNone LineStyle = iota LineStyleLight ) diff --git a/experimental/boxes.go b/experimental/boxes.go new file mode 100644 index 0000000..6b68373 --- /dev/null +++ b/experimental/boxes.go @@ -0,0 +1,44 @@ +// Binary boxes just creates containers with borders. +package main + +import ( + "time" + + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/terminal/termbox" +) + +func main() { + t, err := termbox.New() + if err != nil { + panic(err) + } + defer t.Close() + + c := container.New( + t, + container.SplitVertical(), + ).First( + container.SplitHorizontal(), + ).First( + container.Border(draw.LineStyleLight), + ).Parent().Second( + container.SplitHorizontal(), + ).First( + container.Border(draw.LineStyleLight), + ).Parent().Second( + container.Border(draw.LineStyleLight), + ).Root().Second( + container.Border(draw.LineStyleLight), + ).Root() + + if err := c.Draw(); err != nil { + panic(err) + } + + if err := t.Flush(); err != nil { + panic(err) + } + time.Sleep(30 * time.Second) +} diff --git a/terminal/faketerm/diff.go b/terminal/faketerm/diff.go new file mode 100644 index 0000000..01ff3c8 --- /dev/null +++ b/terminal/faketerm/diff.go @@ -0,0 +1,40 @@ +package faketerm + +import ( + "bytes" + "reflect" +) + +// diff.go provides functions that highlight differences between fake terminals. + +// Diff compares the two terminals, returning an empty string if there is not +// difference. If a difference is found, returns a human readable description +// of the differences. +func Diff(want, got *Terminal) string { + if reflect.DeepEqual(want, got) { + return "" + } + + var b bytes.Buffer + b.WriteString("found differences between the two fake terminals.\n") + b.WriteString(" got:\n") + b.WriteString(got.String()) + b.WriteString(" want:\n") + b.WriteString(want.String()) + b.WriteString(" diff (unexpected cells highlighted with rune '࿃'):\n") + + size := got.Size() + for row := 0; row < size.Y; row++ { + for col := 0; col < size.X; col++ { + r := got.BackBuffer()[col][row].Rune + if r != want.BackBuffer()[col][row].Rune { + r = '࿃' + } else if r == 0 { + r = ' ' + } + b.WriteRune(r) + } + b.WriteRune('\n') + } + return b.String() +} diff --git a/terminal/faketerm/faketerm.go b/terminal/faketerm/faketerm.go index c8e0e6f..c38605f 100644 --- a/terminal/faketerm/faketerm.go +++ b/terminal/faketerm/faketerm.go @@ -51,13 +51,22 @@ func New(size image.Point, opts ...Option) (*Terminal, error) { return t, nil } +// MustNew is like New, but panics on all errors. +func MustNew(size image.Point, opts ...Option) *Terminal { + ft, err := New(size, opts...) + if err != nil { + log.Fatalf("New => unexpected error: %v", err) + } + return ft +} + // BackBuffer returns the back buffer of the fake terminal. func (t *Terminal) BackBuffer() cell.Buffer { return t.buffer } // String prints out the buffer into a string. -// TODO(mum4k): Support printing of options. +// This includes the cell runes only, cell options are ignored. // Implements fmt.Stringer. func (t *Terminal) String() string { size := t.Size()