diff --git a/README.md b/README.md index 21ea96e..014de5a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ project reaches version 1.0.0. Any breaking changes will be published in the - Full support for terminal window resizing throughout the infrastructure. - Customizable layout, widget placement, borders, margins, padding, colors, etc. +- Binary tree and Grid forms of setting up the layout. - Focusable containers and widgets. - Processing of keyboard and mouse events. - Periodic and event driven screen redraw. diff --git a/container/grid/grid.go b/container/grid/grid.go new file mode 100644 index 0000000..11ce01f --- /dev/null +++ b/container/grid/grid.go @@ -0,0 +1,252 @@ +// Package grid helps to build grid layouts. +package grid + +import ( + "fmt" + + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/widgetapi" +) + +// Builder builds grid layouts. +type Builder struct { + elems []Element +} + +// New returns a new grid builder. +func New() *Builder { + return &Builder{} +} + +// Add adds the specified elements. +// The subElements can be either a single Widget or any combination of Rows and +// Columns. +// Rows are created using RowHeightPerc() and Columns are created using +// ColWidthPerc(). +// Can be called repeatedly, e.g. to add multiple Rows or Columns. +func (b *Builder) Add(subElements ...Element) { + b.elems = append(b.elems, subElements...) +} + +// Build builds the grid layout and returns the corresponding container +// options. +func (b *Builder) Build() ([]container.Option, error) { + if err := validate(b.elems); err != nil { + return nil, err + } + return build(b.elems, 100, 100), nil +} + +// validate recursively validates the elements that were added to the builder. +// Validates the following per each level of Rows or Columns.: +// The subElements are either exactly one Widget or any number of Rows and +// Columns. +// Each individual width or height is in the range 0 < v < 100. +// The sum of all widths is <= 100. +// The sum of all heights is <= 100. +func validate(elems []Element) error { + heightSum := 0 + widthSum := 0 + for _, elem := range elems { + switch e := elem.(type) { + case *row: + if min, max := 0, 100; e.heightPerc <= min || e.heightPerc >= max { + return fmt.Errorf("invalid row heightPerc(%d), must be a value in the range %d < v < %d", e.heightPerc, min, max) + } + heightSum += e.heightPerc + if err := validate(e.subElem); err != nil { + return err + } + + case *col: + if min, max := 0, 100; e.widthPerc <= min || e.widthPerc >= max { + return fmt.Errorf("invalid column widthPerc(%d), must be a value in the range %d < v < %d", e.widthPerc, min, max) + } + widthSum += e.widthPerc + if err := validate(e.subElem); err != nil { + return err + } + + case *widget: + if len(elems) > 1 { + return fmt.Errorf("when adding a widget, it must be the only added element at that level, got: %v", elems) + } + } + } + + if max := 100; heightSum > max || widthSum > max { + return fmt.Errorf("the sum of all height percentages(%d) and width percentages(%d) at one element level cannot be larger than %d", heightSum, widthSum, max) + } + return nil +} + +// build recursively builds the container options according to the elements +// that were added to the builder. +// The parentHeightPerc and parentWidthPerc percent indicate the relative size +// of the element we are building now in the parent element. See innerPerc() +// for more details. +func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.Option { + if len(elems) == 0 { + return nil + } + + elem := elems[0] + elems = elems[1:] + + switch e := elem.(type) { + case *row: + if len(elems) > 0 { + perc := innerPerc(e.heightPerc, parentHeightPerc) + childHeightPerc := parentHeightPerc - e.heightPerc + return []container.Option{ + container.SplitHorizontal( + container.Top(build(e.subElem, 100, parentWidthPerc)...), + container.Bottom(build(elems, childHeightPerc, parentWidthPerc)...), + container.SplitPercent(perc), + ), + } + } else { + return build(e.subElem, 100, parentWidthPerc) + } + + case *col: + if len(elems) > 0 { + perc := innerPerc(e.widthPerc, parentWidthPerc) + childWidthPerc := parentWidthPerc - e.widthPerc + return []container.Option{ + container.SplitVertical( + container.Left(build(e.subElem, parentHeightPerc, 100)...), + container.Right(build(elems, parentHeightPerc, childWidthPerc)...), + container.SplitPercent(perc), + ), + } + } else { + return build(e.subElem, parentHeightPerc, 100) + } + + case *widget: + opts := e.cOpts + opts = append(opts, container.PlaceWidget(e.widget)) + return opts + } + return nil +} + +// innerPerc translates the outer split percentage into the inner one. +// E.g. multiple rows would specify that they want the outer split percentage +// of 25% each, but we are representing them in a tree of containers so the +// inner splits vary: +// ╭─────────╮ +// 25% │ 25% │ +// │╭───────╮│ --- +// 25% ││ 33% ││ +// ││╭─────╮││ +// 25% │││ 50% │││ +// ││├─────┤││ 75% +// 25% │││ 50% │││ +// ││╰─────╯││ +// │╰───────╯│ +// ╰─────────╯ --- +// +// Argument outerPerc is the user specified percentage for the split, i.e. the +// 25% in the example above. +// Argument parentPerc is the percentage this container has in the parent, i.e. +// 75% for the first inner container in the example above. +func innerPerc(outerPerc, parentPerc int) int { + // parentPerc * parentHeightCells = childHeightCells + // innerPerc * childHeightCells = outerPerc * parentHeightCells + // innerPerc * parentPerc * parentHeightCells = outerPerc * parentHeightCells + // innerPerc * parentPerc = outerPerc + // innerPerc = outerPerc / parentPerc + return int(float64(outerPerc) / float64(parentPerc) * 100) +} + +// Element is an element that can be added to the grid. +type Element interface { + isElement() +} + +// row is a row in the grid. +// row implements Element. +type row struct { + // heightPerc is the height percentage this row occupies. + heightPerc int + + // subElem are the sub Rows or Columns or a single widget. + subElem []Element +} + +// isElement implements Element.isElement. +func (row) isElement() {} + +// String implements fmt.Stringer. +func (r *row) String() string { + return fmt.Sprintf("row{height:%d, sub:%v}", r.heightPerc, r.subElem) +} + +// col is a column in the grid. +// col implements Element. +type col struct { + // widthPerc is the width percentage this column occupies. + widthPerc int + + // subElem are the sub Rows or Columns or a single widget. + subElem []Element +} + +// isElement implements Element.isElement. +func (col) isElement() {} + +// String implements fmt.Stringer. +func (c *col) String() string { + return fmt.Sprintf("col{width:%d, sub:%v}", c.widthPerc, c.subElem) +} + +// widget is a widget placed into the grid. +// widget implements Element. +type widget struct { + // widget is the widget instance. + widget widgetapi.Widget + // cOpts are the options for the widget's container. + cOpts []container.Option +} + +// String implements fmt.Stringer. +func (w *widget) String() string { + return fmt.Sprintf("widget{type:%T}", w.widget) +} + +// isElement implements Element.isElement. +func (widget) isElement() {} + +// RowHeightPerc creates a row of the specified height. +// The height is supplied as height percentage of the outer container. +// The subElements can be either a single Widget or any combination of Rows and +// Columns. +func RowHeightPerc(heightPerc int, subElements ...Element) Element { + return &row{ + heightPerc: heightPerc, + subElem: subElements, + } +} + +// ColWidthPerc creates a column of the specified width. +// The width is supplied as width percentage of the outer container. +// The subElements can be either a single Widget or any combination of Rows and +// Columns. +func ColWidthPerc(widthPerc int, subElements ...Element) Element { + return &col{ + widthPerc: widthPerc, + subElem: subElements, + } +} + +// Widget adds a widget into the Row or Column. +// The options will be applied to the container that directly holds this +// widget. +func Widget(w widgetapi.Widget, cOpts ...container.Option) Element { + return &widget{ + widget: w, + cOpts: cOpts, + } +} diff --git a/container/grid/grid_test.go b/container/grid/grid_test.go new file mode 100644 index 0000000..d2e152c --- /dev/null +++ b/container/grid/grid_test.go @@ -0,0 +1,740 @@ +package grid + +import ( + "context" + "image" + "testing" + "time" + + "github.com/mum4k/termdash" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/internal/area" + "github.com/mum4k/termdash/internal/canvas/testcanvas" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/draw/testdraw" + "github.com/mum4k/termdash/internal/faketerm" + "github.com/mum4k/termdash/internal/fakewidget" + "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/terminal/termbox" + "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/widgets/barchart" +) + +// Shows how to create a simple 4x4 grid with four widgets. +// All the cells in the grid contain the same widget in this example. +func Example() { + tbx, err := termbox.New() + if err != nil { + panic(err) + } + defer tbx.Close() + + bc, err := barchart.New() + if err != nil { + panic(err) + } + + builder := New() + builder.Add( + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(bc)), + ColWidthPerc(50, Widget(bc)), + ), + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(bc)), + ColWidthPerc(50, Widget(bc)), + ), + ) + gridOpts, err := builder.Build() + if err != nil { + panic(err) + } + + cont, err := container.New(tbx, gridOpts...) + if err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := termdash.Run(ctx, tbx, cont); err != nil { + panic(err) + } +} + +// Shows how to create rows iteratively. Each row contains two columns and each +// column contains the same widget. +func Example_iterative() { + tbx, err := termbox.New() + if err != nil { + panic(err) + } + defer tbx.Close() + + bc, err := barchart.New() + if err != nil { + panic(err) + } + + builder := New() + for i := 0; i < 5; i++ { + builder.Add( + RowHeightPerc( + 20, + ColWidthPerc(50, Widget(bc)), + ColWidthPerc(50, Widget(bc)), + ), + ) + } + gridOpts, err := builder.Build() + if err != nil { + panic(err) + } + + cont, err := container.New(tbx, gridOpts...) + if err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := termdash.Run(ctx, tbx, cont); err != nil { + panic(err) + } +} + +// mirror returns a new fake widget. +func mirror() *fakewidget.Mirror { + return fakewidget.New(widgetapi.Options{}) +} + +// mustHSplit splits the area or panics. +func mustHSplit(ar image.Rectangle, heightPerc int) (top image.Rectangle, bottom image.Rectangle) { + t, b, err := area.HSplit(ar, heightPerc) + if err != nil { + panic(err) + } + return t, b +} + +// mustVSplit splits the area or panics. +func mustVSplit(ar image.Rectangle, widthPerc int) (left image.Rectangle, right image.Rectangle) { + l, r, err := area.VSplit(ar, widthPerc) + if err != nil { + panic(err) + } + return l, r +} + +func TestBuilder(t *testing.T) { + tests := []struct { + desc string + termSize image.Point + builder *Builder + want func(size image.Point) *faketerm.Terminal + wantErr bool + }{ + { + desc: "fails when Widget is mixed with Rows and Columns at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc(50), + Widget(mirror()), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Widget is mixed with Rows and Columns at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + RowHeightPerc(50), + Widget(mirror()), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Row heightPerc is too low at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc(0), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Row heightPerc is too low at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + RowHeightPerc(0), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Row heightPerc is too high at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc(100), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Row heightPerc is too high at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + RowHeightPerc(100), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Col widthPerc is too low at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc(0), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Col widthPerc is too low at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc( + 50, + ColWidthPerc(0), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Col widthPerc is too high at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc(100), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when Col widthPerc is too high at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc( + 50, + ColWidthPerc(100), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when height sum is too large at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc(50), + RowHeightPerc(50), + RowHeightPerc(1), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when height sum is too large at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + RowHeightPerc(50), + RowHeightPerc(50), + RowHeightPerc(1), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when width sum is too large at top level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc(50), + ColWidthPerc(50), + ColWidthPerc(1), + ) + return b + }(), + wantErr: true, + }, + { + desc: "fails when width sum is too large at sub level", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc( + 50, + ColWidthPerc(50), + ColWidthPerc(50), + ColWidthPerc(1), + ), + ) + return b + }(), + wantErr: true, + }, + { + desc: "empty container when nothing is added", + termSize: image.Point{10, 10}, + builder: func() *Builder { + return New() + }(), + }, + { + desc: "widget in the outer most container", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add(Widget(mirror())) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + fakewidget.MustDraw(ft, cvs, widgetapi.Options{}) + return ft + }, + }, + { + desc: "two equal rows", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add(RowHeightPerc(50, Widget(mirror()))) + b.Add(RowHeightPerc(50, Widget(mirror()))) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 50) + fakewidget.MustDraw(ft, testcanvas.MustNew(top), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(bot), widgetapi.Options{}) + return ft + }, + }, + { + desc: "two unequal rows", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add(RowHeightPerc(20, Widget(mirror()))) + b.Add(RowHeightPerc(80, Widget(mirror()))) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 20) + fakewidget.MustDraw(ft, testcanvas.MustNew(top), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(bot), widgetapi.Options{}) + return ft + }, + }, + { + desc: "two equal columns", + termSize: image.Point{20, 10}, + builder: func() *Builder { + b := New() + b.Add(ColWidthPerc(50, Widget(mirror()))) + b.Add(ColWidthPerc(50, Widget(mirror()))) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + left, right := mustVSplit(ft.Area(), 50) + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(right), widgetapi.Options{}) + return ft + }, + }, + { + desc: "two unequal columns", + termSize: image.Point{40, 10}, + builder: func() *Builder { + b := New() + b.Add(ColWidthPerc(20, Widget(mirror()))) + b.Add(ColWidthPerc(80, Widget(mirror()))) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + left, right := mustVSplit(ft.Area(), 20) + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(right), widgetapi.Options{}) + return ft + }, + }, + { + desc: "rows with columns (equal)", + termSize: image.Point{20, 20}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(mirror())), + ColWidthPerc(50, Widget(mirror())), + ), + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(mirror())), + ColWidthPerc(50, Widget(mirror())), + ), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 50) + + topLeft, topRight := mustVSplit(top, 50) + botLeft, botRight := mustVSplit(bot, 50) + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "rows with columns (unequal)", + termSize: image.Point{40, 20}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 20, + ColWidthPerc(20, Widget(mirror())), + ColWidthPerc(80, Widget(mirror())), + ), + RowHeightPerc( + 80, + ColWidthPerc(80, Widget(mirror())), + ColWidthPerc(20, Widget(mirror())), + ), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 20) + + topLeft, topRight := mustVSplit(top, 20) + botLeft, botRight := mustVSplit(bot, 80) + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "columns with rows (equal)", + termSize: image.Point{20, 20}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc( + 50, + RowHeightPerc(50, Widget(mirror())), + RowHeightPerc(50, Widget(mirror())), + ), + ColWidthPerc( + 50, + RowHeightPerc(50, Widget(mirror())), + RowHeightPerc(50, Widget(mirror())), + ), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 50) + + topLeft, topRight := mustVSplit(top, 50) + botLeft, botRight := mustVSplit(bot, 50) + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "columns with rows (unequal)", + termSize: image.Point{40, 20}, + builder: func() *Builder { + b := New() + b.Add( + ColWidthPerc( + 20, + RowHeightPerc(20, Widget(mirror())), + RowHeightPerc(80, Widget(mirror())), + ), + ColWidthPerc( + 80, + RowHeightPerc(80, Widget(mirror())), + RowHeightPerc(20, Widget(mirror())), + ), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + left, right := mustVSplit(ft.Area(), 20) + + topLeft, topRight := mustHSplit(left, 20) + botLeft, botRight := mustHSplit(right, 80) + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "rows with rows with columns", + termSize: image.Point{40, 40}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(mirror())), + ColWidthPerc(50, Widget(mirror())), + ), + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(mirror())), + ColWidthPerc(50, Widget(mirror())), + ), + ), + RowHeightPerc( + 50, + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(mirror())), + ColWidthPerc(50, Widget(mirror())), + ), + RowHeightPerc( + 50, + ColWidthPerc(50, Widget(mirror())), + ColWidthPerc(50, Widget(mirror())), + ), + ), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 50) + topTop, topBot := mustHSplit(top, 50) + botTop, botBot := mustHSplit(bot, 50) + + topTopLeft, topTopRight := mustVSplit(topTop, 50) + topBotLeft, topBotRight := mustVSplit(topBot, 50) + botTopLeft, botTopRight := mustVSplit(botTop, 50) + botBotLeft, botBotRight := mustVSplit(botBot, 50) + fakewidget.MustDraw(ft, testcanvas.MustNew(topTopLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topTopRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topBotLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topBotRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botTopLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botTopRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botBotLeft), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botBotRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "rows mixed with columns at top level", + termSize: image.Point{40, 30}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc(20, Widget(mirror())), + ColWidthPerc(20, Widget(mirror())), + RowHeightPerc(20, Widget(mirror())), + ColWidthPerc(20, Widget(mirror())), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 20) + + left, right := mustVSplit(bot, 20) + topRight, botRight := mustHSplit(right, 25) + fakewidget.MustDraw(ft, testcanvas.MustNew(top), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "rows mixed with columns at sub level", + termSize: image.Point{40, 30}, + builder: func() *Builder { + b := New() + b.Add( + RowHeightPerc( + 50, + RowHeightPerc(20, Widget(mirror())), + ColWidthPerc(20, Widget(mirror())), + RowHeightPerc(20, Widget(mirror())), + ColWidthPerc(20, Widget(mirror())), + ), + RowHeightPerc(50, Widget(mirror())), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + top, bot := mustHSplit(ft.Area(), 50) + fakewidget.MustDraw(ft, testcanvas.MustNew(bot), widgetapi.Options{}) + + topTop, topBot := mustHSplit(top, 20) + left, right := mustVSplit(topBot, 20) + topRight, botRight := mustHSplit(right, 25) + fakewidget.MustDraw(ft, testcanvas.MustNew(topTop), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{}) + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{}) + return ft + }, + }, + { + desc: "widget's container can have options", + termSize: image.Point{20, 20}, + builder: func() *Builder { + b := New() + b.Add( + Widget( + mirror(), + container.Border(linestyle.Double), + ), + ) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustBorder( + cvs, + cvs.Area(), + draw.BorderLineStyle(linestyle.Double), + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)), + ) + wCvs := testcanvas.MustNew(area.ExcludeBorder(cvs.Area())) + fakewidget.MustDraw(ft, wCvs, widgetapi.Options{}) + testcanvas.MustCopyTo(wCvs, cvs) + testcanvas.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) + } + + gridOpts, err := tc.builder.Build() + if (err != nil) != tc.wantErr { + t.Errorf("tc.builder => unexpected error:%v, wantErr:%v", err, tc.wantErr) + } + if err != nil { + return + } + + cont, err := container.New(got, gridOpts...) + if err != nil { + t.Fatalf("container.New => unexpected error: %v", err) + } + if err := cont.Draw(); err != nil { + t.Fatalf("Draw => unexpected error: %v", err) + } + + var want *faketerm.Terminal + if tc.want != nil { + want = tc.want(tc.termSize) + } else { + w, err := faketerm.New(tc.termSize) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + want = w + } + if diff := faketerm.Diff(want, got); diff != "" { + t.Errorf("Draw => %v", diff) + } + }) + } +}