diff options
Diffstat (limited to 'examples/go-dashboard/src/github.com/mum4k/termdash/container/container.go')
-rw-r--r-- | examples/go-dashboard/src/github.com/mum4k/termdash/container/container.go | 471 |
1 files changed, 471 insertions, 0 deletions
diff --git a/examples/go-dashboard/src/github.com/mum4k/termdash/container/container.go b/examples/go-dashboard/src/github.com/mum4k/termdash/container/container.go new file mode 100644 index 000000000..54cef7856 --- /dev/null +++ b/examples/go-dashboard/src/github.com/mum4k/termdash/container/container.go @@ -0,0 +1,471 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package container defines a type that wraps other containers or widgets. + +The container supports splitting container into sub containers, defining +container styles and placing widgets. The container also creates and manages +canvases assigned to the placed widgets. +*/ +package container + +import ( + "errors" + "fmt" + "image" + "sync" + + "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/private/alignfor" + "github.com/mum4k/termdash/private/area" + "github.com/mum4k/termdash/private/event" + "github.com/mum4k/termdash/terminal/terminalapi" + "github.com/mum4k/termdash/widgetapi" +) + +// Container wraps either sub containers or widgets and positions them on the +// terminal. +// This is thread-safe. +type Container struct { + // parent is the parent container, nil if this is the root container. + parent *Container + // The sub containers, if these aren't nil, the widget must be. + first *Container + second *Container + + // term is the terminal this container is placed on. + // All containers in the tree share the same terminal. + term terminalapi.Terminal + + // focusTracker tracks the active (focused) container. + // All containers in the tree share the same tracker. + focusTracker *focusTracker + + // area is the area of the terminal this container has access to. + // Initialized the first time Draw is called. + area image.Rectangle + + // 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 +} + +// 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, error) { + root := &Container{ + term: t, + opts: newOptions( /* parent = */ nil), + mu: &sync.Mutex{}, + } + + // Initially the root is focused. + root.focusTracker = newFocusTracker(root) + if err := applyOptions(root, opts...); err != nil { + return nil, err + } + if err := validateOptions(root); err != nil { + return nil, err + } + return root, nil +} + +// newChild creates a new child container of the given parent. +func newChild(parent *Container, opts []Option) (*Container, error) { + child := &Container{ + parent: parent, + term: parent.term, + focusTracker: parent.focusTracker, + opts: newOptions(parent.opts), + mu: parent.mu, + } + if err := applyOptions(child, opts...); err != nil { + return nil, err + } + return child, nil +} + +// hasBorder determines if this container has a border. +func (c *Container) hasBorder() bool { + return c.opts.border != linestyle.None +} + +// hasWidget determines if this container has a widget. +func (c *Container) hasWidget() bool { + return c.opts.widget != nil +} + +// 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) + } + return c.area +} + +// widgetArea returns the area in the container that is available for the +// widget's canvas. Takes the container border, widget's requested maximum size +// and ratio and container's alignment into account. +// Returns a zero area if the container has no widget. +func (c *Container) widgetArea() (image.Rectangle, error) { + if !c.hasWidget() { + return image.ZR, nil + } + + padded, err := c.opts.padding.apply(c.usable()) + if err != nil { + return image.ZR, err + } + wOpts := c.opts.widget.Options() + + adjusted := padded + if maxX := wOpts.MaximumSize.X; maxX > 0 && adjusted.Dx() > maxX { + adjusted.Max.X -= adjusted.Dx() - maxX + } + if maxY := wOpts.MaximumSize.Y; maxY > 0 && adjusted.Dy() > maxY { + adjusted.Max.Y -= adjusted.Dy() - maxY + } + + if wOpts.Ratio.X > 0 && wOpts.Ratio.Y > 0 { + adjusted = area.WithRatio(adjusted, wOpts.Ratio) + } + aligned, err := alignfor.Rectangle(padded, adjusted, c.opts.hAlign, c.opts.vAlign) + if err != nil { + return image.ZR, err + } + return aligned, nil +} + +// split splits the container's usable area into child areas. +// Panics if the container isn't configured for a split. +func (c *Container) split() (image.Rectangle, image.Rectangle, error) { + ar, err := c.opts.padding.apply(c.usable()) + if err != nil { + return image.ZR, image.ZR, err + } + if c.opts.splitFixed > DefaultSplitFixed { + if c.opts.split == splitTypeVertical { + return area.VSplitCells(ar, c.opts.splitFixed) + } + return area.HSplitCells(ar, c.opts.splitFixed) + } + + if c.opts.split == splitTypeVertical { + return area.VSplit(ar, c.opts.splitPercent) + } + return area.HSplit(ar, c.opts.splitPercent) +} + +// createFirst creates and returns the first sub container of this container. +func (c *Container) createFirst(opts []Option) error { + first, err := newChild(c, opts) + if err != nil { + return err + } + c.first = first + return nil +} + +// createSecond creates and returns the second sub container of this container. +func (c *Container) createSecond(opts []Option) error { + second, err := newChild(c, opts) + if err != nil { + return err + } + c.second = second + return nil +} + +// Draw draws this container and all of its sub containers. +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()) + if err != nil { + return err + } + c.focusTracker.updateArea(ar) + 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. +// Caller must hold c.mu. +func (c *Container) updateFocus(m *terminalapi.Mouse) { + target := pointCont(c, m.Position) + if target == nil { // Ignore mouse clicks where no containers are. + return + } + c.focusTracker.mouse(target, m) +} + +// processEvent processes events delivered to the container. +func (c *Container) processEvent(ev terminalapi.Event) error { + // This is done in two stages. + // 1) under lock we traverse the container and identify all targets + // (widgets) that should receive the event. + // 2) lock is released and events are delivered to the widgets. Widgets + // themselves are thread-safe. Lock must be releases when delivering, + // because some widgets might try to mutate the container when they + // receive the event, like dynamically change the layout. + c.mu.Lock() + sendFn, err := c.prepareEvTargets(ev) + c.mu.Unlock() + if err != nil { + return err + } + return sendFn() +} + +// prepareEvTargets returns a closure, that when called delivers the event to +// widgets that registered for it. +// Also processes the event on behalf of the container (tracks keyboard focus). +// Caller must hold c.mu. +func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error) { + switch e := ev.(type) { + case *terminalapi.Mouse: + c.updateFocus(ev.(*terminalapi.Mouse)) + + targets, err := c.mouseEvTargets(e) + if err != nil { + return nil, err + } + return func() error { + for _, mt := range targets { + if err := mt.widget.Mouse(mt.ev); err != nil { + return err + } + } + return nil + }, nil + + case *terminalapi.Keyboard: + targets := c.keyEvTargets() + return func() error { + for _, w := range targets { + if err := w.Keyboard(e); err != nil { + return err + } + } + return nil + }, nil + + default: + return nil, fmt.Errorf("container received an unsupported event type %T", ev) + } +} + +// keyEvTargets returns those widgets found in the container that should +// receive this keyboard event. +// Caller must hold c.mu. +func (c *Container) keyEvTargets() []widgetapi.Widget { + var ( + errStr string + widgets []widgetapi.Widget + ) + + // All the widgets that should receive this event. + // For now stable ordering (preOrder). + preOrder(c, &errStr, visitFunc(func(cur *Container) error { + if !cur.hasWidget() { + return nil + } + + wOpt := cur.opts.widget.Options() + switch wOpt.WantKeyboard { + case widgetapi.KeyScopeNone: + // Widget doesn't want any keyboard events. + return nil + + case widgetapi.KeyScopeFocused: + if cur.focusTracker.isActive(cur) { + widgets = append(widgets, cur.opts.widget) + } + + case widgetapi.KeyScopeGlobal: + widgets = append(widgets, cur.opts.widget) + } + return nil + })) + return widgets +} + +// mouseEvTarget contains a mouse event adjusted relative to the widget's area +// and the widget that should receive it. +type mouseEvTarget struct { + // widget is the widget that should receive the mouse event. + widget widgetapi.Widget + // ev is the adjusted mouse event. + ev *terminalapi.Mouse +} + +// newMouseEvTarget returns a new newMouseEvTarget. +func newMouseEvTarget(w widgetapi.Widget, wArea image.Rectangle, ev *terminalapi.Mouse) *mouseEvTarget { + return &mouseEvTarget{ + widget: w, + ev: adjustMouseEv(ev, wArea), + } +} + +// mouseEvTargets returns those widgets found in the container that should +// receive this mouse event. +// Caller must hold c.mu. +func (c *Container) mouseEvTargets(m *terminalapi.Mouse) ([]*mouseEvTarget, error) { + var ( + errStr string + widgets []*mouseEvTarget + ) + + // All the widgets that should receive this event. + // For now stable ordering (preOrder). + preOrder(c, &errStr, visitFunc(func(cur *Container) error { + if !cur.hasWidget() { + return nil + } + + wOpts := cur.opts.widget.Options() + wa, err := cur.widgetArea() + if err != nil { + return err + } + + switch wOpts.WantMouse { + case widgetapi.MouseScopeNone: + // Widget doesn't want any mouse events. + return nil + + case widgetapi.MouseScopeWidget: + // Only if the event falls inside of the widget's canvas. + if m.Position.In(wa) { + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m)) + } + + case widgetapi.MouseScopeContainer: + // Only if the event falls inside the widget's parent container. + if m.Position.In(cur.area) { + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m)) + } + + case widgetapi.MouseScopeGlobal: + // Widget wants all mouse events. + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m)) + } + return nil + })) + + if errStr != "" { + return nil, errors.New(errStr) + } + return widgets, nil +} + +// Subscribe tells the container to subscribe itself and widgets to the +// provided event distribution system. +// This method is private to termdash, stability isn't guaranteed and changes +// won't be backward compatible. +func (c *Container) Subscribe(eds *event.DistributionSystem) { + c.mu.Lock() + defer c.mu.Unlock() + + // maxReps is the maximum number of repetitive events towards widgets + // before we throttle them. + const maxReps = 10 + + // Subscriber the container itself in order to track keyboard focus. + want := []terminalapi.Event{ + &terminalapi.Keyboard{}, + &terminalapi.Mouse{}, + } + eds.Subscribe(want, func(ev terminalapi.Event) { + if err := c.processEvent(ev); err != nil { + eds.Event(terminalapi.NewErrorf("failed to process event %v: %v", ev, err)) + } + }, event.MaxRepetitive(maxReps)) +} + +// adjustMouseEv adjusts the mouse event relative to the widget area. +func adjustMouseEv(m *terminalapi.Mouse, wArea image.Rectangle) *terminalapi.Mouse { + // The sent mouse coordinate is relative to the widget canvas, i.e. zero + // based, even though the widget might not be in the top left corner on the + // terminal. + offset := wArea.Min + if m.Position.In(wArea) { + return &terminalapi.Mouse{ + Position: m.Position.Sub(offset), + Button: m.Button, + } + } + return &terminalapi.Mouse{ + Position: image.Point{-1, -1}, + Button: m.Button, + } +} |