diff --git a/container/container.go b/container/container.go index af91c87..ab3678e 100644 --- a/container/container.go +++ b/container/container.go @@ -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) { diff --git a/container/container_test.go b/container/container_test.go index 640d212..b4998fb 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -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) + } + }) + } + +} diff --git a/container/focus.go b/container/focus.go index 366f64e..6a87486 100644 --- a/container/focus.go +++ b/container/focus.go @@ -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 +} diff --git a/container/options.go b/container/options.go index 40b7831..7ce2cb4 100644 --- a/container/options.go +++ b/container/options.go @@ -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.