termdash/container/container.go

233 lines
6.9 KiB
Go

// 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 (
"fmt"
"image"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminalapi"
)
// Container wraps either sub containers or widgets and positions them on the
// terminal.
// This is not 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.
area image.Rectangle
// opts are the options provided to the container.
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, error) {
size := t.Size()
root := &Container{
term: t,
// The root container has access to the entire terminal.
area: image.Rect(0, 0, size.X, size.Y),
opts: newOptions( /* parent = */ nil),
}
// Initially the root is focused.
root.focusTracker = newFocusTracker(root)
if err := applyOptions(root, opts...); err != nil {
return nil, err
}
return root, nil
}
// newChild creates a new child container of the given parent.
func newChild(parent *Container, area image.Rectangle) *Container {
return &Container{
parent: parent,
term: parent.term,
focusTracker: parent.focusTracker,
area: area,
opts: newOptions(parent.opts),
}
}
// hasBorder determines if this container has a border.
func (c *Container) hasBorder() bool {
return c.opts.border != draw.LineStyleNone
}
// 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
}
adjusted := c.usable()
wOpts := c.opts.widget.Options()
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)
}
adjusted, err := align.Rectangle(c.usable(), adjusted, c.opts.hAlign, c.opts.vAlign)
if err != nil {
return image.ZR, err
}
return adjusted, 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 := c.usable()
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() (*Container, error) {
ar, _, err := c.split()
if err != nil {
return nil, err
}
c.first = newChild(c, ar)
return c.first, nil
}
// createSecond creates and returns the second sub container of this container.
func (c *Container) createSecond() (*Container, error) {
_, ar, err := c.split()
if err != nil {
return nil, err
}
c.second = newChild(c, ar)
return c.second, nil
}
// Draw draws this container and all of its sub containers.
func (c *Container) Draw() error {
return drawTree(c)
}
// Keyboard is used to forward a keyboard event to the container.
// Keyboard events are forwarded to the widget in the currently focused
// container, assuming that the widget registered for keyboard events.
func (c *Container) Keyboard(k *terminalapi.Keyboard) error {
w := c.focusTracker.active().opts.widget
if w == nil || !w.Options().WantKeyboard {
return nil
}
return w.Keyboard(k)
}
// Mouse is used to forward a mouse event to the container.
// Container uses mouse events to track and change which is the active
// (focused) container.
//
// If the container that receives the mouse click contains a widget that
// registered for mouse events, the mouse event is further forwarded to that
// widget. Only mouse events that fall within the widget's canvas are forwarded
// and the coordinates are adjusted relative to the widget's canvas.
func (c *Container) Mouse(m *terminalapi.Mouse) error {
c.focusTracker.mouse(m)
target := pointCont(c, m.Position)
if target == nil { // Ignore mouse clicks where no containers are.
return nil
}
w := target.opts.widget
if w == nil || !w.Options().WantMouse {
return nil
}
// Ignore clicks falling outside of the container.
if !m.Position.In(target.usable()) {
return nil
}
// Ignore clicks falling outside of the widget's canvas.
wa, err := target.widgetArea()
if err != nil {
return err
}
if !m.Position.In(wa) {
return nil
}
// 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 := wa.Min
wm := &terminalapi.Mouse{
Position: m.Position.Sub(offset),
Button: m.Button,
}
return w.Mouse(wm)
}