Container now supports dynamic layout changes.

This commit is contained in:
Jakub Sobon 2019-03-29 00:24:22 -04:00
parent 85bcf9d8d9
commit 782d7c3117
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
4 changed files with 533 additions and 3 deletions

View File

@ -59,6 +59,12 @@ type Container struct {
// opts are the options provided to the container.
opts *options
// clearNeeded indicates if the terminal needs to be cleared next time we
// are clearNeeded the container.
// This is required if the container was updated and thus the layout might
// have changed.
clearNeeded bool
// mu protects the container tree.
// All containers in the tree share the same lock.
mu *sync.Mutex
@ -195,6 +201,13 @@ func (c *Container) Draw() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.clearNeeded {
if err := c.term.Clear(); err != nil {
return fmt.Errorf("term.Clear => error: %v", err)
}
c.clearNeeded = false
}
// Update the area we are tracking for focus in case the terminal size
// changed.
ar, err := area.FromSize(c.term.Size())
@ -205,6 +218,37 @@ func (c *Container) Draw() error {
return drawTree(c)
}
// Update updates container with the specified id by setting the provided
// options. This can be used to perform dynamic layout changes, i.e. anything
// between replacing the widget in the container and completely changing the
// layout and splits.
// The argument id must match exactly one container with that was created with
// matching ID() option. The argument id must not be an empty string.
func (c *Container) Update(id string, opts ...Option) error {
c.mu.Lock()
defer c.mu.Unlock()
target, err := findID(c, id)
if err != nil {
return err
}
c.clearNeeded = true
if err := applyOptions(target, opts...); err != nil {
return err
}
if err := validateOptions(c); err != nil {
return err
}
// The currently focused container might not be reachable anymore, because
// it was under the target. If that is so, move the focus up to the target.
if !c.focusTracker.reachableFrom(c) {
c.focusTracker.setActive(target)
}
return nil
}
// updateFocus processes the mouse event and determines if it changes the
// focused container.
func (c *Container) updateFocus(m *terminalapi.Mouse) {

View File

@ -91,6 +91,70 @@ func TestNew(t *testing.T) {
},
wantContainerErr: true,
},
{
desc: "fails on invalid option on the first vertical child container",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitVertical(
Left(
MarginTop(-1),
),
Right(),
),
)
},
wantContainerErr: true,
},
{
desc: "fails on invalid option on the second vertical child container",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitVertical(
Left(),
Right(
MarginTop(-1),
),
),
)
},
wantContainerErr: true,
},
{
desc: "fails on invalid option on the first horizontal child container",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitHorizontal(
Top(
MarginTop(-1),
),
Bottom(),
),
)
},
wantContainerErr: true,
},
{
desc: "fails on invalid option on the second horizontal child container",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitHorizontal(
Top(),
Bottom(
MarginTop(-1),
),
),
)
},
wantContainerErr: true,
},
{
desc: "fails on MarginTopPercent too low",
termSize: image.Point{10, 10},
@ -865,6 +929,8 @@ func TestNew(t *testing.T) {
if err != nil {
return
}
contStr := cont.String()
t.Logf("For container: %v", contStr)
if err := cont.Draw(); err != nil {
t.Fatalf("Draw => unexpected error: %v", err)
}
@ -1848,3 +1914,395 @@ func TestMouse(t *testing.T) {
})
}
}
func TestUpdate(t *testing.T) {
tests := []struct {
desc string
termSize image.Point
container func(ft *faketerm.Terminal) (*Container, error)
updateID string
updateOpts []Option
// eventGroups are events delivered before the update.
eventGroups []*eventGroup
wantUpdateErr bool
want func(size image.Point) *faketerm.Terminal
}{
{
desc: "fails on empty updateID",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(ft)
},
wantUpdateErr: true,
},
{
desc: "fails when no container with the ID is found",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(ft)
},
updateID: "myID",
wantUpdateErr: true,
},
{
desc: "no changes when no options are provided",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myID"),
Border(linestyle.Light),
)
},
updateID: "myID",
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(
cvs,
image.Rect(0, 0, 10, 10),
draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
)
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "fails on invalid options",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myID"),
Border(linestyle.Light),
)
},
updateID: "myID",
updateOpts: []Option{
MarginTop(-1),
},
wantUpdateErr: true,
},
{
desc: "fails when update introduces a duplicate ID",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myID"),
Border(linestyle.Light),
)
},
updateID: "myID",
updateOpts: []Option{
SplitVertical(
Left(
ID("left"),
),
Right(
ID("myID"),
),
),
},
wantUpdateErr: true,
},
{
desc: "removes border from the container",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myID"),
Border(linestyle.Light),
)
},
updateID: "myID",
updateOpts: []Option{
Border(linestyle.None),
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "places widget into a sub-container container",
termSize: image.Point{20, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myRoot"),
SplitVertical(
Left(
ID("left"),
),
Right(
ID("right"),
),
),
)
},
updateID: "right",
updateOpts: []Option{
PlaceWidget(fakewidget.New(widgetapi.Options{})),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
wAr := image.Rect(10, 0, 20, 10)
wCvs := testcanvas.MustNew(wAr)
fakewidget.MustDraw(ft, wCvs, widgetapi.Options{})
testcanvas.MustCopyTo(wCvs, cvs)
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "places widget into root which removes children",
termSize: image.Point{20, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myRoot"),
SplitVertical(
Left(
ID("left"),
Border(linestyle.Light),
),
Right(
ID("right"),
Border(linestyle.Light),
),
),
)
},
updateID: "myRoot",
updateOpts: []Option{
PlaceWidget(fakewidget.New(widgetapi.Options{})),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
fakewidget.MustDraw(ft, cvs, widgetapi.Options{})
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "changes container splits",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myRoot"),
SplitVertical(
Left(
ID("left"),
Border(linestyle.Light),
),
Right(
ID("right"),
Border(linestyle.Light),
),
),
)
},
updateID: "myRoot",
updateOpts: []Option{
SplitHorizontal(
Top(
ID("left"),
Border(linestyle.Light),
),
Bottom(
ID("right"),
Border(linestyle.Light),
),
),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(cvs, image.Rect(0, 0, 10, 5))
testdraw.MustBorder(cvs, image.Rect(0, 5, 10, 10))
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "update retains original focused container if it still exists",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myRoot"),
SplitVertical(
Left(
ID("left"),
Border(linestyle.Light),
),
Right(
ID("right"),
Border(linestyle.Light),
SplitHorizontal(
Top(
ID("rightTop"),
Border(linestyle.Light),
),
Bottom(
ID("rightBottom"),
Border(linestyle.Light),
),
),
),
),
)
},
eventGroups: []*eventGroup{
// Move focus to container with ID "right".
// It will continue to exist after the update.
{
events: []terminalapi.Event{
&terminalapi.Mouse{Position: image.Point{5, 0}, Button: mouse.ButtonLeft},
&terminalapi.Mouse{Position: image.Point{5, 0}, Button: mouse.ButtonRelease},
},
wantProcessed: 2,
},
},
updateID: "right",
updateOpts: []Option{
Clear(),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(cvs, image.Rect(0, 0, 5, 10))
testdraw.MustBorder(cvs, image.Rect(5, 0, 10, 10), draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)))
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "update moves focus to the nearest parent when focused container is destroyed",
termSize: image.Point{10, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
ID("myRoot"),
SplitVertical(
Left(
ID("left"),
Border(linestyle.Light),
),
Right(
ID("right"),
Border(linestyle.Light),
SplitHorizontal(
Top(
ID("rightTop"),
Border(linestyle.Light),
),
Bottom(
ID("rightBottom"),
Border(linestyle.Light),
),
),
),
),
)
},
eventGroups: []*eventGroup{
// Move focus to container with ID "rightTop".
// It will be destroyed by calling update.
{
events: []terminalapi.Event{
&terminalapi.Mouse{Position: image.Point{6, 1}, Button: mouse.ButtonLeft},
&terminalapi.Mouse{Position: image.Point{6, 1}, Button: mouse.ButtonRelease},
},
wantProcessed: 2,
},
},
updateID: "right",
updateOpts: []Option{
Clear(),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(cvs, image.Rect(0, 0, 5, 10))
testdraw.MustBorder(cvs, image.Rect(5, 0, 10, 10), draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)))
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)
}
cont, err := tc.container(got)
if err != nil {
t.Fatalf("tc.container => unexpected error: %v", err)
}
eds := event.NewDistributionSystem()
eh := &errorHandler{}
// Subscribe to receive errors.
eds.Subscribe([]terminalapi.Event{terminalapi.NewError("")}, func(ev terminalapi.Event) {
eh.handle(ev.(*terminalapi.Error).Error())
})
cont.Subscribe(eds)
// Initial draw to determine sizes of containers.
if err := cont.Draw(); err != nil {
t.Fatalf("Draw => unexpected error: %v", err)
}
// Deliver the events.
for _, eg := range tc.eventGroups {
for _, ev := range eg.events {
eds.Event(ev)
}
if err := testevent.WaitFor(5*time.Second, func() error {
if got, want := eds.Processed(), eg.wantProcessed; got != want {
return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
}
return nil
}); err != nil {
t.Fatalf("testevent.WaitFor => %v", err)
}
}
{
err := cont.Update(tc.updateID, tc.updateOpts...)
if (err != nil) != tc.wantUpdateErr {
t.Errorf("Update => unexpected error:%v, wantErr:%v", err, tc.wantUpdateErr)
}
if err != nil {
return
}
}
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)
}
})
}
}

View File

@ -73,9 +73,9 @@ func (ft *focusTracker) isActive(c *Container) bool {
return ft.container == c
}
// active returns the currently focused container.
func (ft *focusTracker) active() *Container {
return ft.container
// setActive sets the currently active container to the one provided.
func (ft *focusTracker) setActive(c *Container) {
ft.container = c
}
// mouse identifies mouse events that change the focused container and track
@ -98,3 +98,19 @@ func (ft *focusTracker) mouse(target *Container, m *terminalapi.Mouse) {
func (ft *focusTracker) updateArea(ar image.Rectangle) {
ft.buttonFSM.UpdateArea(ar)
}
// reachableFrom asserts whether the currently focused container is reachable
// from the provided node in the tree.
func (ft *focusTracker) reachableFrom(node *Container) bool {
var (
errStr string
reachable bool
)
preOrder(node, &errStr, visitFunc(func(c *Container) error {
if c == ft.container {
reachable = true
}
return nil
}))
return reachable
}

View File

@ -271,6 +271,18 @@ func ID(id string) Option {
})
}
// Clear clears this container.
// If the container contains a widget, the widget is removed.
// If the container had any sub containers or splits, they are removed.
func Clear() Option {
return option(func(c *Container) error {
c.opts.widget = nil
c.first = nil
c.second = nil
return nil
})
}
// PlaceWidget places the provided widget into the container.
// The use of this option removes any sub containers. Containers with sub
// containers cannot have widgets.