Projects STRLCPY termdash Commits 42375120
🤬
Revision indexing in progress... (symbol navigation in revisions will be accurate after indexed)
Showing first 24 files as there are too many
  • ■ ■ ■ ■
    .travis.yml
    skipped 13 lines
    14 14   - diff -u <(echo -n) <(./internal/scripts/autogen_licences.sh .)
    15 15   - diff -u <(echo -n) <(golint ./...)
    16 16  after_success:
    17  - - ./scripts/coverage.sh
     17 + - ./internal/scripts/coverage.sh
    18 18   
  • ■ ■ ■ ■ ■ ■
    CHANGELOG.md
    skipped 6 lines
    7 7   
    8 8  ## [Unreleased]
    9 9   
     10 +## [0.8.0] - 30-Mar-2019
     11 + 
     12 +### Added
     13 + 
     14 +- New API for building layouts, a grid.Builder. Allows defining the layout
     15 + iteratively as repetitive Elements, Rows and Columns.
     16 +- Containers now support margin around them and padding of their content.
     17 +- Container now supports dynamic layout changes via the new Update method.
     18 + 
     19 +### Changed
     20 + 
     21 +- The Text widget now supports content wrapping on word boundaries.
     22 +- The BarChart and SparkLine widgets now have a method that returns the
     23 + observed value capacity the last time Draw was called.
     24 +- Moving widgetapi out of the internal directory to allow external users to
     25 + develop their own widgets.
     26 +- Event delivery to widgets now has a stable defined order and happens when the
     27 + container is unlocked so that widgets can trigger dynamic layout changes.
     28 + 
     29 +### Fixed
     30 + 
     31 +- The termdash_test now correctly waits until all subscribers processed events,
     32 + not just received them.
     33 +- Container focus tracker now correctly tracks focus changes in enlarged areas,
     34 + i.e. when the terminal size increased.
     35 +- The BarChart, LineChart and SegmentDisplay widgets now protect against
     36 + external mutation of the values passed into them by copying the data they
     37 + receive.
     38 + 
    10 39  ## [0.7.2] - 25-Feb-2019
    11 40   
    12 41  ### Added
    skipped 194 lines
    207 236  - The Gauge widget.
    208 237  - The Text widget.
    209 238   
    210  -[Unreleased]: https://github.com/mum4k/termdash/compare/v0.7.2...devel
     239 +[Unreleased]: https://github.com/mum4k/termdash/compare/v0.8.0...devel
     240 +[0.8.0]: https://github.com/mum4k/termdash/compare/v0.7.2...v0.8.0
    211 241  [0.7.2]: https://github.com/mum4k/termdash/compare/v0.7.1...v0.7.2
    212 242  [0.7.1]: https://github.com/mum4k/termdash/compare/v0.7.0...v0.7.1
    213 243  [0.7.0]: https://github.com/mum4k/termdash/compare/v0.6.1...v0.7.0
    skipped 7 lines
  • ■ ■ ■ ■ ■ ■
    README.md
    skipped 9 lines
    10 10   
    11 11  Termdash is a cross-platform customizable terminal based dashboard.
    12 12   
    13  -[<img src="./doc/images/termdashdemo_0_7_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
     13 +[<img src="./doc/images/termdashdemo_0_8_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
    14 14   
    15 15  The feature set is inspired by the
    16 16  [gizak/termui](http://github.com/gizak/termui) project, which in turn was
    skipped 21 lines
    38 38  # Current feature set
    39 39   
    40 40  - Full support for terminal window resizing throughout the infrastructure.
    41  -- Customizable layout, widget placement, borders, colors, etc.
     41 +- Customizable layout, widget placement, borders, margins, padding, colors, etc.
     42 +- Dynamic layout changes at runtime.
     43 +- Binary tree and Grid forms of setting up the layout.
    42 44  - Focusable containers and widgets.
    43 45  - Processing of keyboard and mouse events.
    44 46  - Periodic and event driven screen redraw.
    skipped 143 lines
  • ■ ■ ■ ■ ■ ■
    container/container.go
    skipped 21 lines
    22 22  package container
    23 23   
    24 24  import (
     25 + "errors"
    25 26   "fmt"
    26 27   "image"
    27 28   "sync"
    skipped 1 lines
    29 30   "github.com/mum4k/termdash/internal/alignfor"
    30 31   "github.com/mum4k/termdash/internal/area"
    31 32   "github.com/mum4k/termdash/internal/event"
    32  - "github.com/mum4k/termdash/internal/widgetapi"
    33 33   "github.com/mum4k/termdash/linestyle"
    34 34   "github.com/mum4k/termdash/terminal/terminalapi"
     35 + "github.com/mum4k/termdash/widgetapi"
    35 36  )
    36 37   
    37 38  // Container wraps either sub containers or widgets and positions them on the
    skipped 15 lines
    53 54   focusTracker *focusTracker
    54 55   
    55 56   // area is the area of the terminal this container has access to.
     57 + // Initialized the first time Draw is called.
    56 58   area image.Rectangle
    57 59   
    58 60   // opts are the options provided to the container.
    59 61   opts *options
    60 62   
     63 + // clearNeeded indicates if the terminal needs to be cleared next time we
     64 + // are clearNeeded the container.
     65 + // This is required if the container was updated and thus the layout might
     66 + // have changed.
     67 + clearNeeded bool
     68 + 
    61 69   // mu protects the container tree.
    62 70   // All containers in the tree share the same lock.
    63 71   mu *sync.Mutex
    skipped 8 lines
    72 80  // New returns a new root container that will use the provided terminal and
    73 81  // applies the provided options.
    74 82  func New(t terminalapi.Terminal, opts ...Option) (*Container, error) {
    75  - size := t.Size()
    76 83   root := &Container{
    77 84   term: t,
    78  - // The root container has access to the entire terminal.
    79  - area: image.Rect(0, 0, size.X, size.Y),
    80 85   opts: newOptions( /* parent = */ nil),
    81 86   mu: &sync.Mutex{},
    82 87   }
    skipped 3 lines
    86 91   if err := applyOptions(root, opts...); err != nil {
    87 92   return nil, err
    88 93   }
     94 + if err := validateOptions(root); err != nil {
     95 + return nil, err
     96 + }
    89 97   return root, nil
    90 98  }
    91 99   
    92 100  // newChild creates a new child container of the given parent.
    93  -func newChild(parent *Container, area image.Rectangle) *Container {
    94  - return &Container{
     101 +func newChild(parent *Container, opts []Option) (*Container, error) {
     102 + child := &Container{
    95 103   parent: parent,
    96 104   term: parent.term,
    97 105   focusTracker: parent.focusTracker,
    98  - area: area,
    99 106   opts: newOptions(parent.opts),
    100 107   mu: parent.mu,
    101 108   }
     109 + if err := applyOptions(child, opts...); err != nil {
     110 + return nil, err
     111 + }
     112 + return child, nil
    102 113  }
    103 114   
    104 115  // hasBorder determines if this container has a border.
    skipped 24 lines
    129 140   return image.ZR, nil
    130 141   }
    131 142   
    132  - adjusted := c.usable()
     143 + padded, err := c.opts.padding.apply(c.usable())
     144 + if err != nil {
     145 + return image.ZR, err
     146 + }
    133 147   wOpts := c.opts.widget.Options()
    134 148   
     149 + adjusted := padded
    135 150   if maxX := wOpts.MaximumSize.X; maxX > 0 && adjusted.Dx() > maxX {
    136 151   adjusted.Max.X -= adjusted.Dx() - maxX
    137 152   }
    skipped 4 lines
    142 157   if wOpts.Ratio.X > 0 && wOpts.Ratio.Y > 0 {
    143 158   adjusted = area.WithRatio(adjusted, wOpts.Ratio)
    144 159   }
    145  - adjusted, err := alignfor.Rectangle(c.usable(), adjusted, c.opts.hAlign, c.opts.vAlign)
     160 + aligned, err := alignfor.Rectangle(padded, adjusted, c.opts.hAlign, c.opts.vAlign)
    146 161   if err != nil {
    147 162   return image.ZR, err
    148 163   }
    149  - return adjusted, nil
     164 + return aligned, nil
    150 165  }
    151 166   
    152 167  // split splits the container's usable area into child areas.
    153 168  // Panics if the container isn't configured for a split.
    154 169  func (c *Container) split() (image.Rectangle, image.Rectangle, error) {
    155  - ar := c.usable()
     170 + ar, err := c.opts.padding.apply(c.usable())
     171 + if err != nil {
     172 + return image.ZR, image.ZR, err
     173 + }
    156 174   if c.opts.split == splitTypeVertical {
    157 175   return area.VSplit(ar, c.opts.splitPercent)
    158 176   }
    skipped 1 lines
    160 178  }
    161 179   
    162 180  // createFirst creates and returns the first sub container of this container.
    163  -func (c *Container) createFirst() (*Container, error) {
    164  - ar, _, err := c.split()
     181 +func (c *Container) createFirst(opts []Option) error {
     182 + first, err := newChild(c, opts)
    165 183   if err != nil {
    166  - return nil, err
     184 + return err
    167 185   }
    168  - c.first = newChild(c, ar)
    169  - return c.first, nil
     186 + c.first = first
     187 + return nil
    170 188  }
    171 189   
    172 190  // createSecond creates and returns the second sub container of this container.
    173  -func (c *Container) createSecond() (*Container, error) {
    174  - _, ar, err := c.split()
     191 +func (c *Container) createSecond(opts []Option) error {
     192 + second, err := newChild(c, opts)
    175 193   if err != nil {
    176  - return nil, err
     194 + return err
    177 195   }
    178  - c.second = newChild(c, ar)
    179  - return c.second, nil
     196 + c.second = second
     197 + return nil
    180 198  }
    181 199   
    182 200  // Draw draws this container and all of its sub containers.
    183 201  func (c *Container) Draw() error {
    184 202   c.mu.Lock()
    185 203   defer c.mu.Unlock()
     204 + 
     205 + if c.clearNeeded {
     206 + if err := c.term.Clear(); err != nil {
     207 + return fmt.Errorf("term.Clear => error: %v", err)
     208 + }
     209 + c.clearNeeded = false
     210 + }
     211 + 
     212 + // Update the area we are tracking for focus in case the terminal size
     213 + // changed.
     214 + ar, err := area.FromSize(c.term.Size())
     215 + if err != nil {
     216 + return err
     217 + }
     218 + c.focusTracker.updateArea(ar)
    186 219   return drawTree(c)
    187 220  }
    188 221   
    189  -// updateFocus processes the mouse event and determines if it changes the
    190  -// focused container.
    191  -func (c *Container) updateFocus(m *terminalapi.Mouse) {
     222 +// Update updates container with the specified id by setting the provided
     223 +// options. This can be used to perform dynamic layout changes, i.e. anything
     224 +// between replacing the widget in the container and completely changing the
     225 +// layout and splits.
     226 +// The argument id must match exactly one container with that was created with
     227 +// matching ID() option. The argument id must not be an empty string.
     228 +func (c *Container) Update(id string, opts ...Option) error {
    192 229   c.mu.Lock()
    193 230   defer c.mu.Unlock()
    194 231   
     232 + target, err := findID(c, id)
     233 + if err != nil {
     234 + return err
     235 + }
     236 + c.clearNeeded = true
     237 + 
     238 + if err := applyOptions(target, opts...); err != nil {
     239 + return err
     240 + }
     241 + if err := validateOptions(c); err != nil {
     242 + return err
     243 + }
     244 + 
     245 + // The currently focused container might not be reachable anymore, because
     246 + // it was under the target. If that is so, move the focus up to the target.
     247 + if !c.focusTracker.reachableFrom(c) {
     248 + c.focusTracker.setActive(target)
     249 + }
     250 + return nil
     251 +}
     252 + 
     253 +// updateFocus processes the mouse event and determines if it changes the
     254 +// focused container.
     255 +// Caller must hold c.mu.
     256 +func (c *Container) updateFocus(m *terminalapi.Mouse) {
    195 257   target := pointCont(c, m.Position)
    196 258   if target == nil { // Ignore mouse clicks where no containers are.
    197 259   return
    skipped 1 lines
    199 261   c.focusTracker.mouse(target, m)
    200 262  }
    201 263   
    202  -// keyboardToWidget forwards the keyboard event to the widget unconditionally.
    203  -func (c *Container) keyboardToWidget(k *terminalapi.Keyboard, scope widgetapi.KeyScope) error {
     264 +// processEvent processes events delivered to the container.
     265 +func (c *Container) processEvent(ev terminalapi.Event) error {
     266 + // This is done in two stages.
     267 + // 1) under lock we traverse the container and identify all targets
     268 + // (widgets) that should receive the event.
     269 + // 2) lock is released and events are delivered to the widgets. Widgets
     270 + // themselves are thread-safe. Lock must be releases when delivering,
     271 + // because some widgets might try to mutate the container when they
     272 + // receive the event, like dynamically change the layout.
    204 273   c.mu.Lock()
    205  - defer c.mu.Unlock()
     274 + sendFn, err := c.prepareEvTargets(ev)
     275 + c.mu.Unlock()
     276 + if err != nil {
     277 + return err
     278 + }
     279 + return sendFn()
     280 +}
    206 281   
    207  - if scope == widgetapi.KeyScopeFocused && !c.focusTracker.isActive(c) {
    208  - return nil
     282 +// prepareEvTargets returns a closure, that when called delivers the event to
     283 +// widgets that registered for it.
     284 +// Also processes the event on behalf of the container (tracks keyboard focus).
     285 +// Caller must hold c.mu.
     286 +func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error) {
     287 + switch e := ev.(type) {
     288 + case *terminalapi.Mouse:
     289 + c.updateFocus(ev.(*terminalapi.Mouse))
     290 + 
     291 + targets, err := c.mouseEvTargets(e)
     292 + if err != nil {
     293 + return nil, err
     294 + }
     295 + return func() error {
     296 + for _, mt := range targets {
     297 + if err := mt.widget.Mouse(mt.ev); err != nil {
     298 + return err
     299 + }
     300 + }
     301 + return nil
     302 + }, nil
     303 + 
     304 + case *terminalapi.Keyboard:
     305 + targets := c.keyEvTargets()
     306 + return func() error {
     307 + for _, w := range targets {
     308 + if err := w.Keyboard(e); err != nil {
     309 + return err
     310 + }
     311 + }
     312 + return nil
     313 + }, nil
     314 + 
     315 + default:
     316 + return nil, fmt.Errorf("container received an unsupported event type %T", ev)
    209 317   }
    210  - return c.opts.widget.Keyboard(k)
    211 318  }
    212 319   
    213  -// mouseToWidget forwards the mouse event to the widget.
    214  -func (c *Container) mouseToWidget(m *terminalapi.Mouse, scope widgetapi.MouseScope) error {
    215  - c.mu.Lock()
    216  - defer c.mu.Unlock()
     320 +// keyEvTargets returns those widgets found in the container that should
     321 +// receive this keyboard event.
     322 +// Caller must hold c.mu.
     323 +func (c *Container) keyEvTargets() []widgetapi.Widget {
     324 + var (
     325 + errStr string
     326 + widgets []widgetapi.Widget
     327 + )
     328 + 
     329 + // All the widgets that should receive this event.
     330 + // For now stable ordering (preOrder).
     331 + preOrder(c, &errStr, visitFunc(func(cur *Container) error {
     332 + if !cur.hasWidget() {
     333 + return nil
     334 + }
    217 335   
    218  - target := pointCont(c, m.Position)
    219  - if target == nil { // Ignore mouse clicks where no containers are.
    220  - return nil
    221  - }
     336 + wOpt := cur.opts.widget.Options()
     337 + switch wOpt.WantKeyboard {
     338 + case widgetapi.KeyScopeNone:
     339 + // Widget doesn't want any keyboard events.
     340 + return nil
    222 341   
    223  - // Ignore clicks falling outside of the container.
    224  - if scope != widgetapi.MouseScopeGlobal && !m.Position.In(c.area) {
    225  - return nil
    226  - }
     342 + case widgetapi.KeyScopeFocused:
     343 + if cur.focusTracker.isActive(cur) {
     344 + widgets = append(widgets, cur.opts.widget)
     345 + }
    227 346   
    228  - // Ignore clicks falling outside of the widget's canvas.
    229  - wa, err := c.widgetArea()
    230  - if err != nil {
    231  - return err
    232  - }
    233  - if scope == widgetapi.MouseScopeWidget && !m.Position.In(wa) {
     347 + case widgetapi.KeyScopeGlobal:
     348 + widgets = append(widgets, cur.opts.widget)
     349 + }
    234 350   return nil
     351 + }))
     352 + return widgets
     353 +}
     354 + 
     355 +// mouseEvTarget contains a mouse event adjusted relative to the widget's area
     356 +// and the widget that should receive it.
     357 +type mouseEvTarget struct {
     358 + // widget is the widget that should receive the mouse event.
     359 + widget widgetapi.Widget
     360 + // ev is the adjusted mouse event.
     361 + ev *terminalapi.Mouse
     362 +}
     363 + 
     364 +// newMouseEvTarget returns a new newMouseEvTarget.
     365 +func newMouseEvTarget(w widgetapi.Widget, wArea image.Rectangle, ev *terminalapi.Mouse) *mouseEvTarget {
     366 + return &mouseEvTarget{
     367 + widget: w,
     368 + ev: adjustMouseEv(ev, wArea),
    235 369   }
     370 +}
    236 371   
    237  - // The sent mouse coordinate is relative to the widget canvas, i.e. zero
    238  - // based, even though the widget might not be in the top left corner on the
    239  - // terminal.
    240  - offset := wa.Min
    241  - var wm *terminalapi.Mouse
    242  - if m.Position.In(wa) {
    243  - wm = &terminalapi.Mouse{
    244  - Position: m.Position.Sub(offset),
    245  - Button: m.Button,
     372 +// mouseEvTargets returns those widgets found in the container that should
     373 +// receive this mouse event.
     374 +// Caller must hold c.mu.
     375 +func (c *Container) mouseEvTargets(m *terminalapi.Mouse) ([]*mouseEvTarget, error) {
     376 + var (
     377 + errStr string
     378 + widgets []*mouseEvTarget
     379 + )
     380 + 
     381 + // All the widgets that should receive this event.
     382 + // For now stable ordering (preOrder).
     383 + preOrder(c, &errStr, visitFunc(func(cur *Container) error {
     384 + if !cur.hasWidget() {
     385 + return nil
    246 386   }
    247  - } else {
    248  - wm = &terminalapi.Mouse{
    249  - Position: image.Point{-1, -1},
    250  - Button: m.Button,
     387 + 
     388 + wOpts := cur.opts.widget.Options()
     389 + wa, err := cur.widgetArea()
     390 + if err != nil {
     391 + return err
     392 + }
     393 + 
     394 + switch wOpts.WantMouse {
     395 + case widgetapi.MouseScopeNone:
     396 + // Widget doesn't want any mouse events.
     397 + return nil
     398 + 
     399 + case widgetapi.MouseScopeWidget:
     400 + // Only if the event falls inside of the widget's canvas.
     401 + if m.Position.In(wa) {
     402 + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
     403 + }
     404 + 
     405 + case widgetapi.MouseScopeContainer:
     406 + // Only if the event falls inside the widget's parent container.
     407 + if m.Position.In(cur.area) {
     408 + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
     409 + }
     410 + 
     411 + case widgetapi.MouseScopeGlobal:
     412 + // Widget wants all mouse events.
     413 + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
    251 414   }
     415 + return nil
     416 + }))
     417 + 
     418 + if errStr != "" {
     419 + return nil, errors.New(errStr)
    252 420   }
    253  - return c.opts.widget.Mouse(wm)
     421 + return widgets, nil
    254 422  }
    255 423   
    256 424  // Subscribe tells the container to subscribe itself and widgets to the
    skipped 8 lines
    265 433   // before we throttle them.
    266 434   const maxReps = 10
    267 435   
    268  - root := rootCont(c)
    269 436   // Subscriber the container itself in order to track keyboard focus.
    270  - eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) {
    271  - root.updateFocus(ev.(*terminalapi.Mouse))
    272  - }, event.MaxRepetitive(0)) // One event is enough to change the focus.
     437 + want := []terminalapi.Event{
     438 + &terminalapi.Keyboard{},
     439 + &terminalapi.Mouse{},
     440 + }
     441 + eds.Subscribe(want, func(ev terminalapi.Event) {
     442 + if err := c.processEvent(ev); err != nil {
     443 + eds.Event(terminalapi.NewErrorf("failed to process event %v: %v", ev, err))
     444 + }
     445 + }, event.MaxRepetitive(maxReps))
     446 +}
    273 447   
    274  - // Subscribe any widgets that specify Keyboard or Mouse in their options.
    275  - var errStr string
    276  - preOrder(root, &errStr, visitFunc(func(c *Container) error {
    277  - if c.hasWidget() {
    278  - wOpt := c.opts.widget.Options()
    279  - switch scope := wOpt.WantKeyboard; scope {
    280  - case widgetapi.KeyScopeNone:
    281  - // Widget doesn't want any keyboard events.
    282  - 
    283  - default:
    284  - eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) {
    285  - if err := c.keyboardToWidget(ev.(*terminalapi.Keyboard), scope); err != nil {
    286  - eds.Event(terminalapi.NewErrorf("failed to send global keyboard event %v to widget %T: %v", ev, c.opts.widget, err))
    287  - }
    288  - }, event.MaxRepetitive(maxReps))
    289  - }
    290  - 
    291  - switch scope := wOpt.WantMouse; scope {
    292  - case widgetapi.MouseScopeNone:
    293  - // Widget doesn't want any mouse events.
    294  - 
    295  - default:
    296  - eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) {
    297  - if err := c.mouseToWidget(ev.(*terminalapi.Mouse), scope); err != nil {
    298  - eds.Event(terminalapi.NewErrorf("failed to send mouse event %v to widget %T: %v", ev, c.opts.widget, err))
    299  - }
    300  - }, event.MaxRepetitive(maxReps))
    301  - }
     448 +// adjustMouseEv adjusts the mouse event relative to the widget area.
     449 +func adjustMouseEv(m *terminalapi.Mouse, wArea image.Rectangle) *terminalapi.Mouse {
     450 + // The sent mouse coordinate is relative to the widget canvas, i.e. zero
     451 + // based, even though the widget might not be in the top left corner on the
     452 + // terminal.
     453 + offset := wArea.Min
     454 + if m.Position.In(wArea) {
     455 + return &terminalapi.Mouse{
     456 + Position: m.Position.Sub(offset),
     457 + Button: m.Button,
    302 458   }
    303  - return nil
    304  - }))
     459 + }
     460 + return &terminalapi.Mouse{
     461 + Position: image.Point{-1, -1},
     462 + Button: m.Button,
     463 + }
    305 464  }
    306 465   
  • ■ ■ ■ ■ ■
    container/container_test.go
    skipped 28 lines
    29 29   "github.com/mum4k/termdash/internal/event"
    30 30   "github.com/mum4k/termdash/internal/event/testevent"
    31 31   "github.com/mum4k/termdash/internal/faketerm"
    32  - "github.com/mum4k/termdash/internal/widgetapi"
     32 + "github.com/mum4k/termdash/internal/fakewidget"
    33 33   "github.com/mum4k/termdash/keyboard"
    34 34   "github.com/mum4k/termdash/linestyle"
    35 35   "github.com/mum4k/termdash/mouse"
    36 36   "github.com/mum4k/termdash/terminal/terminalapi"
     37 + "github.com/mum4k/termdash/widgetapi"
    37 38   "github.com/mum4k/termdash/widgets/barchart"
    38  - "github.com/mum4k/termdash/widgets/fakewidget"
    39 39  )
    40 40   
    41 41  // Example demonstrates how to use the Container API.
    skipped 42 lines
    84 84   want func(size image.Point) *faketerm.Terminal
    85 85   }{
    86 86   {
     87 + desc: "fails on MarginTop too low",
     88 + termSize: image.Point{10, 10},
     89 + container: func(ft *faketerm.Terminal) (*Container, error) {
     90 + return New(ft, MarginTop(-1))
     91 + },
     92 + wantContainerErr: true,
     93 + },
     94 + {
     95 + desc: "fails on invalid option on the first vertical child container",
     96 + termSize: image.Point{10, 10},
     97 + container: func(ft *faketerm.Terminal) (*Container, error) {
     98 + return New(
     99 + ft,
     100 + SplitVertical(
     101 + Left(
     102 + MarginTop(-1),
     103 + ),
     104 + Right(),
     105 + ),
     106 + )
     107 + },
     108 + wantContainerErr: true,
     109 + },
     110 + {
     111 + desc: "fails on invalid option on the second vertical child container",
     112 + termSize: image.Point{10, 10},
     113 + container: func(ft *faketerm.Terminal) (*Container, error) {
     114 + return New(
     115 + ft,
     116 + SplitVertical(
     117 + Left(),
     118 + Right(
     119 + MarginTop(-1),
     120 + ),
     121 + ),
     122 + )
     123 + },
     124 + wantContainerErr: true,
     125 + },
     126 + {
     127 + desc: "fails on invalid option on the first horizontal child container",
     128 + termSize: image.Point{10, 10},
     129 + container: func(ft *faketerm.Terminal) (*Container, error) {
     130 + return New(
     131 + ft,
     132 + SplitHorizontal(
     133 + Top(
     134 + MarginTop(-1),
     135 + ),
     136 + Bottom(),
     137 + ),
     138 + )
     139 + },
     140 + wantContainerErr: true,
     141 + },
     142 + {
     143 + desc: "fails on invalid option on the second horizontal child container",
     144 + termSize: image.Point{10, 10},
     145 + container: func(ft *faketerm.Terminal) (*Container, error) {
     146 + return New(
     147 + ft,
     148 + SplitHorizontal(
     149 + Top(),
     150 + Bottom(
     151 + MarginTop(-1),
     152 + ),
     153 + ),
     154 + )
     155 + },
     156 + wantContainerErr: true,
     157 + },
     158 + {
     159 + desc: "fails on MarginTopPercent too low",
     160 + termSize: image.Point{10, 10},
     161 + container: func(ft *faketerm.Terminal) (*Container, error) {
     162 + return New(ft, MarginTopPercent(-1))
     163 + },
     164 + wantContainerErr: true,
     165 + },
     166 + {
     167 + desc: "fails on MarginTopPercent too high",
     168 + termSize: image.Point{10, 10},
     169 + container: func(ft *faketerm.Terminal) (*Container, error) {
     170 + return New(ft, MarginTopPercent(101))
     171 + },
     172 + wantContainerErr: true,
     173 + },
     174 + {
     175 + desc: "fails when both MarginTop and MarginTopPercent specified",
     176 + termSize: image.Point{10, 10},
     177 + container: func(ft *faketerm.Terminal) (*Container, error) {
     178 + return New(ft, MarginTop(1), MarginTopPercent(1))
     179 + },
     180 + wantContainerErr: true,
     181 + },
     182 + {
     183 + desc: "fails when both MarginTopPercent and MarginTop specified",
     184 + termSize: image.Point{10, 10},
     185 + container: func(ft *faketerm.Terminal) (*Container, error) {
     186 + return New(ft, MarginTopPercent(1), MarginTop(1))
     187 + },
     188 + wantContainerErr: true,
     189 + },
     190 + {
     191 + desc: "fails on MarginRight too low",
     192 + termSize: image.Point{10, 10},
     193 + container: func(ft *faketerm.Terminal) (*Container, error) {
     194 + return New(ft, MarginRight(-1))
     195 + },
     196 + wantContainerErr: true,
     197 + },
     198 + {
     199 + desc: "fails on MarginRightPercent too low",
     200 + termSize: image.Point{10, 10},
     201 + container: func(ft *faketerm.Terminal) (*Container, error) {
     202 + return New(ft, MarginRightPercent(-1))
     203 + },
     204 + wantContainerErr: true,
     205 + },
     206 + {
     207 + desc: "fails on MarginRightPercent too high",
     208 + termSize: image.Point{10, 10},
     209 + container: func(ft *faketerm.Terminal) (*Container, error) {
     210 + return New(ft, MarginRightPercent(101))
     211 + },
     212 + wantContainerErr: true,
     213 + },
     214 + {
     215 + desc: "fails when both MarginRight and MarginRightPercent specified",
     216 + termSize: image.Point{10, 10},
     217 + container: func(ft *faketerm.Terminal) (*Container, error) {
     218 + return New(ft, MarginRight(1), MarginRightPercent(1))
     219 + },
     220 + wantContainerErr: true,
     221 + },
     222 + {
     223 + desc: "fails when both MarginRightPercent and MarginRight specified",
     224 + termSize: image.Point{10, 10},
     225 + container: func(ft *faketerm.Terminal) (*Container, error) {
     226 + return New(ft, MarginRightPercent(1), MarginRight(1))
     227 + },
     228 + wantContainerErr: true,
     229 + },
     230 + {
     231 + desc: "fails on MarginBottom too low",
     232 + termSize: image.Point{10, 10},
     233 + container: func(ft *faketerm.Terminal) (*Container, error) {
     234 + return New(ft, MarginBottom(-1))
     235 + },
     236 + wantContainerErr: true,
     237 + },
     238 + {
     239 + desc: "fails on MarginBottomPercent too low",
     240 + termSize: image.Point{10, 10},
     241 + container: func(ft *faketerm.Terminal) (*Container, error) {
     242 + return New(ft, MarginBottomPercent(-1))
     243 + },
     244 + wantContainerErr: true,
     245 + },
     246 + {
     247 + desc: "fails on MarginBottomPercent too high",
     248 + termSize: image.Point{10, 10},
     249 + container: func(ft *faketerm.Terminal) (*Container, error) {
     250 + return New(ft, MarginBottomPercent(101))
     251 + },
     252 + wantContainerErr: true,
     253 + },
     254 + {
     255 + desc: "fails when both MarginBottom and MarginBottomPercent specified",
     256 + termSize: image.Point{10, 10},
     257 + container: func(ft *faketerm.Terminal) (*Container, error) {
     258 + return New(ft, MarginBottom(1), MarginBottomPercent(1))
     259 + },
     260 + wantContainerErr: true,
     261 + },
     262 + {
     263 + desc: "fails when both MarginBottomPercent and MarginBottom specified",
     264 + termSize: image.Point{10, 10},
     265 + container: func(ft *faketerm.Terminal) (*Container, error) {
     266 + return New(ft, MarginBottomPercent(1), MarginBottom(1))
     267 + },
     268 + wantContainerErr: true,
     269 + },
     270 + {
     271 + desc: "fails on MarginLeft too low",
     272 + termSize: image.Point{10, 10},
     273 + container: func(ft *faketerm.Terminal) (*Container, error) {
     274 + return New(ft, MarginLeft(-1))
     275 + },
     276 + wantContainerErr: true,
     277 + },
     278 + {
     279 + desc: "fails on MarginLeftPercent too low",
     280 + termSize: image.Point{10, 10},
     281 + container: func(ft *faketerm.Terminal) (*Container, error) {
     282 + return New(ft, MarginLeftPercent(-1))
     283 + },
     284 + wantContainerErr: true,
     285 + },
     286 + {
     287 + desc: "fails on MarginLeftPercent too high",
     288 + termSize: image.Point{10, 10},
     289 + container: func(ft *faketerm.Terminal) (*Container, error) {
     290 + return New(ft, MarginLeftPercent(101))
     291 + },
     292 + wantContainerErr: true,
     293 + },
     294 + {
     295 + desc: "fails when both MarginLeft and MarginLeftPercent specified",
     296 + termSize: image.Point{10, 10},
     297 + container: func(ft *faketerm.Terminal) (*Container, error) {
     298 + return New(ft, MarginLeft(1), MarginLeftPercent(1))
     299 + },
     300 + wantContainerErr: true,
     301 + },
     302 + {
     303 + desc: "fails when both MarginLeftPercent and MarginLeft specified",
     304 + termSize: image.Point{10, 10},
     305 + container: func(ft *faketerm.Terminal) (*Container, error) {
     306 + return New(ft, MarginLeftPercent(1), MarginLeft(1))
     307 + },
     308 + wantContainerErr: true,
     309 + },
     310 + {
     311 + desc: "fails on PaddingTop too low",
     312 + termSize: image.Point{10, 10},
     313 + container: func(ft *faketerm.Terminal) (*Container, error) {
     314 + return New(ft, PaddingTop(-1))
     315 + },
     316 + wantContainerErr: true,
     317 + },
     318 + {
     319 + desc: "fails on PaddingTopPercent too low",
     320 + termSize: image.Point{10, 10},
     321 + container: func(ft *faketerm.Terminal) (*Container, error) {
     322 + return New(ft, PaddingTopPercent(-1))
     323 + },
     324 + wantContainerErr: true,
     325 + },
     326 + {
     327 + desc: "fails on PaddingTopPercent too high",
     328 + termSize: image.Point{10, 10},
     329 + container: func(ft *faketerm.Terminal) (*Container, error) {
     330 + return New(ft, PaddingTopPercent(101))
     331 + },
     332 + wantContainerErr: true,
     333 + },
     334 + {
     335 + desc: "fails when both PaddingTop and PaddingTopPercent specified",
     336 + termSize: image.Point{10, 10},
     337 + container: func(ft *faketerm.Terminal) (*Container, error) {
     338 + return New(ft, PaddingTop(1), PaddingTopPercent(1))
     339 + },
     340 + wantContainerErr: true,
     341 + },
     342 + {
     343 + desc: "fails when both PaddingTopPercent and PaddingTop specified",
     344 + termSize: image.Point{10, 10},
     345 + container: func(ft *faketerm.Terminal) (*Container, error) {
     346 + return New(ft, PaddingTopPercent(1), PaddingTop(1))
     347 + },
     348 + wantContainerErr: true,
     349 + },
     350 + {
     351 + desc: "fails on PaddingRight too low",
     352 + termSize: image.Point{10, 10},
     353 + container: func(ft *faketerm.Terminal) (*Container, error) {
     354 + return New(ft, PaddingRight(-1))
     355 + },
     356 + wantContainerErr: true,
     357 + },
     358 + {
     359 + desc: "fails on PaddingRightPercent too low",
     360 + termSize: image.Point{10, 10},
     361 + container: func(ft *faketerm.Terminal) (*Container, error) {
     362 + return New(ft, PaddingRightPercent(-1))
     363 + },
     364 + wantContainerErr: true,
     365 + },
     366 + {
     367 + desc: "fails on PaddingRightPercent too high",
     368 + termSize: image.Point{10, 10},
     369 + container: func(ft *faketerm.Terminal) (*Container, error) {
     370 + return New(ft, PaddingRightPercent(101))
     371 + },
     372 + wantContainerErr: true,
     373 + },
     374 + {
     375 + desc: "fails when both PaddingRight and PaddingRightPercent specified",
     376 + termSize: image.Point{10, 10},
     377 + container: func(ft *faketerm.Terminal) (*Container, error) {
     378 + return New(ft, PaddingRight(1), PaddingRightPercent(1))
     379 + },
     380 + wantContainerErr: true,
     381 + },
     382 + {
     383 + desc: "fails when both PaddingRightPercent and PaddingRight specified",
     384 + termSize: image.Point{10, 10},
     385 + container: func(ft *faketerm.Terminal) (*Container, error) {
     386 + return New(ft, PaddingRightPercent(1), PaddingRight(1))
     387 + },
     388 + wantContainerErr: true,
     389 + },
     390 + {
     391 + desc: "fails on PaddingBottom too low",
     392 + termSize: image.Point{10, 10},
     393 + container: func(ft *faketerm.Terminal) (*Container, error) {
     394 + return New(ft, PaddingBottom(-1))
     395 + },
     396 + wantContainerErr: true,
     397 + },
     398 + {
     399 + desc: "fails on PaddingBottomPercent too low",
     400 + termSize: image.Point{10, 10},
     401 + container: func(ft *faketerm.Terminal) (*Container, error) {
     402 + return New(ft, PaddingBottomPercent(-1))
     403 + },
     404 + wantContainerErr: true,
     405 + },
     406 + {
     407 + desc: "fails on PaddingBottomPercent too high",
     408 + termSize: image.Point{10, 10},
     409 + container: func(ft *faketerm.Terminal) (*Container, error) {
     410 + return New(ft, PaddingBottomPercent(101))
     411 + },
     412 + wantContainerErr: true,
     413 + },
     414 + {
     415 + desc: "fails when both PaddingBottom and PaddingBottomPercent specified",
     416 + termSize: image.Point{10, 10},
     417 + container: func(ft *faketerm.Terminal) (*Container, error) {
     418 + return New(ft, PaddingBottom(1), PaddingBottomPercent(1))
     419 + },
     420 + wantContainerErr: true,
     421 + },
     422 + {
     423 + desc: "fails when both PaddingBottomPercent and PaddingBottom specified",
     424 + termSize: image.Point{10, 10},
     425 + container: func(ft *faketerm.Terminal) (*Container, error) {
     426 + return New(ft, PaddingBottomPercent(1), PaddingBottom(1))
     427 + },
     428 + wantContainerErr: true,
     429 + },
     430 + {
     431 + desc: "fails on PaddingLeft too low",
     432 + termSize: image.Point{10, 10},
     433 + container: func(ft *faketerm.Terminal) (*Container, error) {
     434 + return New(ft, PaddingLeft(-1))
     435 + },
     436 + wantContainerErr: true,
     437 + },
     438 + {
     439 + desc: "fails on PaddingLeftPercent too low",
     440 + termSize: image.Point{10, 10},
     441 + container: func(ft *faketerm.Terminal) (*Container, error) {
     442 + return New(ft, PaddingLeftPercent(-1))
     443 + },
     444 + wantContainerErr: true,
     445 + },
     446 + {
     447 + desc: "fails on PaddingLeftPercent too high",
     448 + termSize: image.Point{10, 10},
     449 + container: func(ft *faketerm.Terminal) (*Container, error) {
     450 + return New(ft, PaddingLeftPercent(101))
     451 + },
     452 + wantContainerErr: true,
     453 + },
     454 + {
     455 + desc: "fails when both PaddingLeft and PaddingLeftPercent specified",
     456 + termSize: image.Point{10, 10},
     457 + container: func(ft *faketerm.Terminal) (*Container, error) {
     458 + return New(ft, PaddingLeft(1), PaddingLeftPercent(1))
     459 + },
     460 + wantContainerErr: true,
     461 + },
     462 + {
     463 + desc: "fails when both PaddingLeftPercent and PaddingLeft specified",
     464 + termSize: image.Point{10, 10},
     465 + container: func(ft *faketerm.Terminal) (*Container, error) {
     466 + return New(ft, PaddingLeftPercent(1), PaddingLeft(1))
     467 + },
     468 + wantContainerErr: true,
     469 + },
     470 + {
     471 + desc: "fails on empty ID specified",
     472 + termSize: image.Point{10, 10},
     473 + container: func(ft *faketerm.Terminal) (*Container, error) {
     474 + return New(ft, ID(""))
     475 + },
     476 + wantContainerErr: true,
     477 + },
     478 + {
     479 + desc: "fails on empty duplicate ID specified",
     480 + termSize: image.Point{10, 10},
     481 + container: func(ft *faketerm.Terminal) (*Container, error) {
     482 + return New(
     483 + ft,
     484 + ID("0"),
     485 + SplitHorizontal(
     486 + Top(ID("1")),
     487 + Bottom(ID("1")),
     488 + ),
     489 + )
     490 + },
     491 + wantContainerErr: true,
     492 + },
     493 + {
    87 494   desc: "empty container",
    88 495   termSize: image.Point{10, 10},
    89 496   container: func(ft *faketerm.Terminal) (*Container, error) {
    skipped 432 lines
    522 929   if err != nil {
    523 930   return
    524 931   }
     932 + contStr := cont.String()
     933 + t.Logf("For container: %v", contStr)
    525 934   if err := cont.Draw(); err != nil {
    526 935   t.Fatalf("Draw => unexpected error: %v", err)
    527 936   }
    528 937   
    529  - if diff := faketerm.Diff(tc.want(tc.termSize), got); diff != "" {
     938 + var want *faketerm.Terminal
     939 + if tc.want != nil {
     940 + want = tc.want(tc.termSize)
     941 + } else {
     942 + w, err := faketerm.New(tc.termSize)
     943 + if err != nil {
     944 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     945 + }
     946 + want = w
     947 + }
     948 + if diff := faketerm.Diff(want, got); diff != "" {
    530 949   t.Errorf("Draw => %v", diff)
    531 950   }
    532 951   })
    skipped 1 lines
    534 953   
    535 954  }
    536 955   
    537  -// eventGroup is a group of events to be delivered with synchronization.
    538  -// I.e. the test execution waits until the specified number is processed before
    539  -// proceeding with test execution.
    540  -type eventGroup struct {
    541  - events []terminalapi.Event
    542  - wantProcessed int
    543  -}
    544  - 
    545 956  // errorHandler just stores the last error received.
    546 957  type errorHandler struct {
    547 958   err error
    skipped 14 lines
    562 973   
    563 974  func TestKeyboard(t *testing.T) {
    564 975   tests := []struct {
    565  - desc string
    566  - termSize image.Point
    567  - container func(ft *faketerm.Terminal) (*Container, error)
    568  - eventGroups []*eventGroup
    569  - want func(size image.Point) *faketerm.Terminal
    570  - wantErr bool
     976 + desc string
     977 + termSize image.Point
     978 + container func(ft *faketerm.Terminal) (*Container, error)
     979 + events []terminalapi.Event
     980 + // If specified, waits for this number of events.
     981 + // Otherwise waits for len(events).
     982 + wantProcessed int
     983 + want func(size image.Point) *faketerm.Terminal
     984 + wantErr bool
    571 985   }{
    572 986   {
    573 987   desc: "event not forwarded if container has no widget",
    skipped 1 lines
    575 989   container: func(ft *faketerm.Terminal) (*Container, error) {
    576 990   return New(ft)
    577 991   },
    578  - eventGroups: []*eventGroup{
    579  - {
    580  - events: []terminalapi.Event{
    581  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    582  - },
    583  - wantProcessed: 0,
    584  - },
     992 + events: []terminalapi.Event{
     993 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    585 994   },
    586 995   want: func(size image.Point) *faketerm.Terminal {
    587 996   return faketerm.MustNew(size)
    skipped 22 lines
    610 1019   ),
    611 1020   )
    612 1021   },
    613  - eventGroups: []*eventGroup{
     1022 + events: []terminalapi.Event{
    614 1023   // Move focus to the target container.
    615  - {
    616  - events: []terminalapi.Event{
    617  - &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
    618  - &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
    619  - },
    620  - wantProcessed: 2,
    621  - },
     1024 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
     1025 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
    622 1026   // Send the keyboard event.
    623  - {
    624  - events: []terminalapi.Event{
    625  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    626  - },
    627  - wantProcessed: 5,
    628  - },
     1027 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    629 1028   },
    630  - 
    631 1029   want: func(size image.Point) *faketerm.Terminal {
    632 1030   ft := faketerm.MustNew(size)
    633 1031   
    skipped 42 lines
    676 1074   ),
    677 1075   )
    678 1076   },
    679  - eventGroups: []*eventGroup{
     1077 + events: []terminalapi.Event{
    680 1078   // Move focus to the target container.
    681  - {
    682  - events: []terminalapi.Event{
    683  - &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
    684  - &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
    685  - },
    686  - wantProcessed: 2,
    687  - },
     1079 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
     1080 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
    688 1081   // Send the keyboard event.
    689  - {
    690  - events: []terminalapi.Event{
    691  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    692  - },
    693  - wantProcessed: 5,
    694  - },
     1082 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    695 1083   },
    696  - 
    697 1084   want: func(size image.Point) *faketerm.Terminal {
    698 1085   ft := faketerm.MustNew(size)
    699 1086   
    skipped 32 lines
    732 1119   PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeNone})),
    733 1120   )
    734 1121   },
    735  - eventGroups: []*eventGroup{
    736  - {
    737  - events: []terminalapi.Event{
    738  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    739  - },
    740  - wantProcessed: 0,
    741  - },
     1122 + events: []terminalapi.Event{
     1123 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    742 1124   },
    743 1125   want: func(size image.Point) *faketerm.Terminal {
    744 1126   ft := faketerm.MustNew(size)
    skipped 15 lines
    760 1142   PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
    761 1143   )
    762 1144   },
    763  - eventGroups: []*eventGroup{
    764  - {
    765  - events: []terminalapi.Event{
    766  - &terminalapi.Keyboard{Key: keyboard.KeyEsc},
    767  - },
    768  - wantProcessed: 2,
    769  - },
     1145 + events: []terminalapi.Event{
     1146 + &terminalapi.Keyboard{Key: keyboard.KeyEsc},
    770 1147   },
     1148 + wantProcessed: 2, // The error is also an event.
    771 1149   want: func(size image.Point) *faketerm.Terminal {
    772 1150   ft := faketerm.MustNew(size)
    773 1151   
    skipped 28 lines
    802 1180   })
    803 1181   
    804 1182   c.Subscribe(eds)
    805  - for _, eg := range tc.eventGroups {
    806  - for _, ev := range eg.events {
    807  - eds.Event(ev)
     1183 + // Initial draw to determine sizes of containers.
     1184 + if err := c.Draw(); err != nil {
     1185 + t.Fatalf("Draw => unexpected error: %v", err)
     1186 + }
     1187 + for _, ev := range tc.events {
     1188 + eds.Event(ev)
     1189 + }
     1190 + var wantEv int
     1191 + if tc.wantProcessed != 0 {
     1192 + wantEv = tc.wantProcessed
     1193 + } else {
     1194 + wantEv = len(tc.events)
     1195 + }
     1196 + 
     1197 + if err := testevent.WaitFor(5*time.Second, func() error {
     1198 + if got, want := eds.Processed(), wantEv; got != want {
     1199 + return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
    808 1200   }
    809  - if err := testevent.WaitFor(5*time.Second, func() error {
    810  - if got, want := eds.Processed(), eg.wantProcessed; got != want {
    811  - return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
    812  - }
    813  - return nil
    814  - }); err != nil {
    815  - t.Fatalf("testevent.WaitFor => %v", err)
    816  - }
     1201 + return nil
     1202 + }); err != nil {
     1203 + t.Fatalf("testevent.WaitFor => %v", err)
    817 1204   }
    818 1205   
    819 1206   if err := c.Draw(); err != nil {
    skipped 13 lines
    833 1220   
    834 1221  func TestMouse(t *testing.T) {
    835 1222   tests := []struct {
    836  - desc string
    837  - termSize image.Point
    838  - container func(ft *faketerm.Terminal) (*Container, error)
    839  - events []terminalapi.Event
     1223 + desc string
     1224 + termSize image.Point
     1225 + container func(ft *faketerm.Terminal) (*Container, error)
     1226 + events []terminalapi.Event
     1227 + // If specified, waits for this number of events.
     1228 + // Otherwise waits for len(events).
     1229 + wantProcessed int
    840 1230   want func(size image.Point) *faketerm.Terminal
    841  - wantProcessed int
    842 1231   wantErr bool
    843 1232   }{
    844 1233   {
    skipped 19 lines
    864 1253   )
    865 1254   return ft
    866 1255   },
    867  - wantProcessed: 4,
    868 1256   },
    869 1257   {
    870 1258   desc: "event not forwarded if container has no widget",
    skipped 8 lines
    879 1267   want: func(size image.Point) *faketerm.Terminal {
    880 1268   return faketerm.MustNew(size)
    881 1269   },
    882  - wantProcessed: 2,
    883 1270   },
    884 1271   {
    885 1272   desc: "event forwarded to container at that point",
    skipped 24 lines
    910 1297   },
    911 1298   want: func(size image.Point) *faketerm.Terminal {
    912 1299   ft := faketerm.MustNew(size)
    913  - // Widgets that aren't focused don't get the mouse clicks.
     1300 + // Widgets that aren't targeted don't get the mouse clicks.
    914 1301   fakewidget.MustDraw(
    915 1302   ft,
    916 1303   testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
    skipped 6 lines
    923 1310   &terminalapi.Keyboard{},
    924 1311   )
    925 1312   
    926  - // The focused widget receives the key.
     1313 + // The target widget receives the mouse event.
    927 1314   fakewidget.MustDraw(
    928 1315   ft,
    929 1316   testcanvas.MustNew(image.Rect(25, 0, 50, 10)),
    skipped 3 lines
    933 1320   )
    934 1321   return ft
    935 1322   },
    936  - wantProcessed: 8,
     1323 + },
     1324 + {
     1325 + desc: "event focuses the target container after terminal resize (falls onto the new area), regression for #169",
     1326 + termSize: image.Point{50, 20},
     1327 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1328 + // Decrease the terminal size, so when container is created, it
     1329 + // only sees width of 30.
     1330 + if err := ft.Resize(image.Point{30, 20}); err != nil {
     1331 + return nil, err
     1332 + }
     1333 + c, err := New(
     1334 + ft,
     1335 + SplitVertical(
     1336 + Left(
     1337 + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
     1338 + ),
     1339 + Right(
     1340 + SplitHorizontal(
     1341 + Top(
     1342 + Border(linestyle.Light),
     1343 + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
     1344 + ),
     1345 + Bottom(
     1346 + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
     1347 + ),
     1348 + ),
     1349 + ),
     1350 + ),
     1351 + )
     1352 + if err != nil {
     1353 + return nil, err
     1354 + }
     1355 + // Increase the width back to 50 so the mouse clicks land on the "new" area.
     1356 + if err := ft.Resize(image.Point{50, 20}); err != nil {
     1357 + return nil, err
     1358 + }
     1359 + // Draw once so the container has a chance to update the tracked area.
     1360 + if err := c.Draw(); err != nil {
     1361 + return nil, err
     1362 + }
     1363 + return c, nil
     1364 + },
     1365 + events: []terminalapi.Event{
     1366 + &terminalapi.Mouse{Position: image.Point{48, 8}, Button: mouse.ButtonLeft},
     1367 + &terminalapi.Mouse{Position: image.Point{48, 8}, Button: mouse.ButtonRelease},
     1368 + },
     1369 + want: func(size image.Point) *faketerm.Terminal {
     1370 + ft := faketerm.MustNew(size)
     1371 + // The yellow border signifies that the container was focused.
     1372 + cvs := testcanvas.MustNew(ft.Area())
     1373 + testdraw.MustBorder(
     1374 + cvs,
     1375 + image.Rect(25, 0, 50, 10),
     1376 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     1377 + )
     1378 + testcanvas.MustApply(cvs, ft)
     1379 + 
     1380 + // Widgets that aren't targeted don't get the mouse clicks.
     1381 + fakewidget.MustDraw(
     1382 + ft,
     1383 + testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
     1384 + widgetapi.Options{},
     1385 + )
     1386 + fakewidget.MustDraw(
     1387 + ft,
     1388 + testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
     1389 + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
     1390 + &terminalapi.Keyboard{},
     1391 + )
     1392 + 
     1393 + // The target widget receives the mouse event.
     1394 + fakewidget.MustDraw(
     1395 + ft,
     1396 + testcanvas.MustNew(image.Rect(26, 1, 49, 9)),
     1397 + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
     1398 + &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonLeft},
     1399 + &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonRelease},
     1400 + )
     1401 + return ft
     1402 + },
    937 1403   },
    938 1404   {
    939 1405   desc: "event not forwarded if the widget didn't request it",
    skipped 17 lines
    957 1423   )
    958 1424   return ft
    959 1425   },
    960  - wantProcessed: 1,
    961 1426   },
    962 1427   {
    963 1428   desc: "MouseScopeWidget, event not forwarded if it falls on the container's border",
    skipped 28 lines
    992 1457   )
    993 1458   return ft
    994 1459   },
    995  - wantProcessed: 2,
    996 1460   },
    997 1461   {
    998 1462   desc: "MouseScopeContainer, event forwarded if it falls on the container's border",
    skipped 29 lines
    1028 1492   )
    1029 1493   return ft
    1030 1494   },
    1031  - wantProcessed: 2,
    1032 1495   },
    1033 1496   {
    1034 1497   desc: "MouseScopeGlobal, event forwarded if it falls on the container's border",
    skipped 29 lines
    1064 1527   )
    1065 1528   return ft
    1066 1529   },
    1067  - wantProcessed: 2,
    1068 1530   },
    1069 1531   {
    1070 1532   desc: "MouseScopeWidget event not forwarded if it falls outside of widget's canvas",
    skipped 24 lines
    1095 1557   )
    1096 1558   return ft
    1097 1559   },
    1098  - wantProcessed: 2,
    1099 1560   },
    1100 1561   {
    1101 1562   desc: "MouseScopeContainer event forwarded if it falls outside of widget's canvas",
    skipped 25 lines
    1127 1588   )
    1128 1589   return ft
    1129 1590   },
    1130  - wantProcessed: 2,
    1131 1591   },
    1132 1592   {
    1133 1593   desc: "MouseScopeGlobal event forwarded if it falls outside of widget's canvas",
    skipped 25 lines
    1159 1619   )
    1160 1620   return ft
    1161 1621   },
    1162  - wantProcessed: 2,
    1163 1622   },
    1164 1623   {
    1165 1624   desc: "MouseScopeWidget event not forwarded if it falls to another container",
    skipped 29 lines
    1195 1654   )
    1196 1655   return ft
    1197 1656   },
    1198  - wantProcessed: 2,
    1199 1657   },
    1200 1658   {
    1201 1659   desc: "MouseScopeContainer event not forwarded if it falls to another container",
    skipped 29 lines
    1231 1689   )
    1232 1690   return ft
    1233 1691   },
    1234  - wantProcessed: 2,
    1235 1692   },
    1236 1693   {
    1237 1694   desc: "MouseScopeGlobal event forwarded if it falls to another container",
    skipped 30 lines
    1268 1725   )
    1269 1726   return ft
    1270 1727   },
    1271  - wantProcessed: 2,
    1272 1728   },
    1273 1729   {
    1274 1730   desc: "mouse position adjusted relative to widget's canvas, vertical offset",
    skipped 25 lines
    1300 1756   )
    1301 1757   return ft
    1302 1758   },
    1303  - wantProcessed: 2,
    1304 1759   },
    1305 1760   {
    1306  - desc: "mouse poisition adjusted relative to widget's canvas, horizontal offset",
     1761 + desc: "mouse position adjusted relative to widget's canvas, horizontal offset",
    1307 1762   termSize: image.Point{30, 20},
    1308 1763   container: func(ft *faketerm.Terminal) (*Container, error) {
    1309 1764   return New(
    skipped 22 lines
    1332 1787   )
    1333 1788   return ft
    1334 1789   },
    1335  - wantProcessed: 2,
    1336 1790   },
    1337 1791   {
    1338 1792   desc: "widget returns an error when processing the event",
    skipped 7 lines
    1346 1800   events: []terminalapi.Event{
    1347 1801   &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRight},
    1348 1802   },
     1803 + wantProcessed: 2, // The error is also an event.
    1349 1804   want: func(size image.Point) *faketerm.Terminal {
    1350 1805   ft := faketerm.MustNew(size)
    1351 1806   
    skipped 4 lines
    1356 1811   )
    1357 1812   return ft
    1358 1813   },
    1359  - wantProcessed: 3,
    1360  - wantErr: true,
     1814 + wantErr: true,
    1361 1815   },
    1362 1816   }
    1363 1817   
    skipped 16 lines
    1380 1834   eh.handle(ev.(*terminalapi.Error).Error())
    1381 1835   })
    1382 1836   c.Subscribe(eds)
     1837 + // Initial draw to determine sizes of containers.
     1838 + if err := c.Draw(); err != nil {
     1839 + t.Fatalf("Draw => unexpected error: %v", err)
     1840 + }
    1383 1841   for _, ev := range tc.events {
    1384 1842   eds.Event(ev)
    1385 1843   }
     1844 + var wantEv int
     1845 + if tc.wantProcessed != 0 {
     1846 + wantEv = tc.wantProcessed
     1847 + } else {
     1848 + wantEv = len(tc.events)
     1849 + }
     1850 + 
    1386 1851   if err := testevent.WaitFor(5*time.Second, func() error {
    1387  - if got, want := eds.Processed(), tc.wantProcessed; got != want {
     1852 + if got, want := eds.Processed(), wantEv; got != want {
    1388 1853   return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
    1389 1854   }
    1390 1855   return nil
    skipped 16 lines
    1407 1872   }
    1408 1873  }
    1409 1874   
     1875 +func TestUpdate(t *testing.T) {
     1876 + tests := []struct {
     1877 + desc string
     1878 + termSize image.Point
     1879 + container func(ft *faketerm.Terminal) (*Container, error)
     1880 + updateID string
     1881 + updateOpts []Option
     1882 + // events are events delivered before the update.
     1883 + beforeEvents []terminalapi.Event
     1884 + // events are events delivered after the update.
     1885 + afterEvents []terminalapi.Event
     1886 + wantUpdateErr bool
     1887 + want func(size image.Point) *faketerm.Terminal
     1888 + }{
     1889 + {
     1890 + desc: "fails on empty updateID",
     1891 + termSize: image.Point{10, 10},
     1892 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1893 + return New(ft)
     1894 + },
     1895 + wantUpdateErr: true,
     1896 + },
     1897 + {
     1898 + desc: "fails when no container with the ID is found",
     1899 + termSize: image.Point{10, 10},
     1900 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1901 + return New(ft)
     1902 + },
     1903 + updateID: "myID",
     1904 + wantUpdateErr: true,
     1905 + },
     1906 + {
     1907 + desc: "no changes when no options are provided",
     1908 + termSize: image.Point{10, 10},
     1909 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1910 + return New(
     1911 + ft,
     1912 + ID("myID"),
     1913 + Border(linestyle.Light),
     1914 + )
     1915 + },
     1916 + updateID: "myID",
     1917 + want: func(size image.Point) *faketerm.Terminal {
     1918 + ft := faketerm.MustNew(size)
     1919 + cvs := testcanvas.MustNew(ft.Area())
     1920 + testdraw.MustBorder(
     1921 + cvs,
     1922 + image.Rect(0, 0, 10, 10),
     1923 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     1924 + )
     1925 + testcanvas.MustApply(cvs, ft)
     1926 + return ft
     1927 + },
     1928 + },
     1929 + {
     1930 + desc: "fails on invalid options",
     1931 + termSize: image.Point{10, 10},
     1932 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1933 + return New(
     1934 + ft,
     1935 + ID("myID"),
     1936 + Border(linestyle.Light),
     1937 + )
     1938 + },
     1939 + updateID: "myID",
     1940 + updateOpts: []Option{
     1941 + MarginTop(-1),
     1942 + },
     1943 + wantUpdateErr: true,
     1944 + },
     1945 + {
     1946 + desc: "fails when update introduces a duplicate ID",
     1947 + termSize: image.Point{10, 10},
     1948 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1949 + return New(
     1950 + ft,
     1951 + ID("myID"),
     1952 + Border(linestyle.Light),
     1953 + )
     1954 + },
     1955 + updateID: "myID",
     1956 + updateOpts: []Option{
     1957 + SplitVertical(
     1958 + Left(
     1959 + ID("left"),
     1960 + ),
     1961 + Right(
     1962 + ID("myID"),
     1963 + ),
     1964 + ),
     1965 + },
     1966 + wantUpdateErr: true,
     1967 + },
     1968 + {
     1969 + desc: "removes border from the container",
     1970 + termSize: image.Point{10, 10},
     1971 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1972 + return New(
     1973 + ft,
     1974 + ID("myID"),
     1975 + Border(linestyle.Light),
     1976 + )
     1977 + },
     1978 + updateID: "myID",
     1979 + updateOpts: []Option{
     1980 + Border(linestyle.None),
     1981 + },
     1982 + want: func(size image.Point) *faketerm.Terminal {
     1983 + return faketerm.MustNew(size)
     1984 + },
     1985 + },
     1986 + {
     1987 + desc: "places widget into a sub-container container",
     1988 + termSize: image.Point{20, 10},
     1989 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1990 + return New(
     1991 + ft,
     1992 + ID("myRoot"),
     1993 + SplitVertical(
     1994 + Left(
     1995 + ID("left"),
     1996 + ),
     1997 + Right(
     1998 + ID("right"),
     1999 + ),
     2000 + ),
     2001 + )
     2002 + },
     2003 + updateID: "right",
     2004 + updateOpts: []Option{
     2005 + PlaceWidget(fakewidget.New(widgetapi.Options{})),
     2006 + },
     2007 + want: func(size image.Point) *faketerm.Terminal {
     2008 + ft := faketerm.MustNew(size)
     2009 + cvs := testcanvas.MustNew(ft.Area())
     2010 + wAr := image.Rect(10, 0, 20, 10)
     2011 + wCvs := testcanvas.MustNew(wAr)
     2012 + fakewidget.MustDraw(ft, wCvs, widgetapi.Options{})
     2013 + testcanvas.MustCopyTo(wCvs, cvs)
     2014 + testcanvas.MustApply(cvs, ft)
     2015 + return ft
     2016 + },
     2017 + },
     2018 + {
     2019 + desc: "places widget into root which removes children",
     2020 + termSize: image.Point{20, 10},
     2021 + container: func(ft *faketerm.Terminal) (*Container, error) {
     2022 + return New(
     2023 + ft,
     2024 + ID("myRoot"),
     2025 + SplitVertical(
     2026 + Left(
     2027 + ID("left"),
     2028 + Border(linestyle.Light),
     2029 + ),
     2030 + Right(
     2031 + ID("right"),
     2032 + Border(linestyle.Light),
     2033 + ),
     2034 + ),
     2035 + )
     2036 + },
     2037 + updateID: "myRoot",
     2038 + updateOpts: []Option{
     2039 + PlaceWidget(fakewidget.New(widgetapi.Options{})),
     2040 + },
     2041 + want: func(size image.Point) *faketerm.Terminal {
     2042 + ft := faketerm.MustNew(size)
     2043 + cvs := testcanvas.MustNew(ft.Area())
     2044 + fakewidget.MustDraw(ft, cvs, widgetapi.Options{})
     2045 + testcanvas.MustApply(cvs, ft)
     2046 + return ft
     2047 + },
     2048 + },
     2049 + {
     2050 + desc: "changes container splits",
     2051 + termSize: image.Point{10, 10},
     2052 + container: func(ft *faketerm.Terminal) (*Container, error) {
     2053 + return New(
     2054 + ft,
     2055 + ID("myRoot"),
     2056 + SplitVertical(
     2057 + Left(
     2058 + ID("left"),
     2059 + Border(linestyle.Light),
     2060 + ),
     2061 + Right(
     2062 + ID("right"),
     2063 + Border(linestyle.Light),
     2064 + ),
     2065 + ),
     2066 + )
     2067 + },
     2068 + updateID: "myRoot",
     2069 + updateOpts: []Option{
     2070 + SplitHorizontal(
     2071 + Top(
     2072 + ID("left"),
     2073 + Border(linestyle.Light),
     2074 + ),
     2075 + Bottom(
     2076 + ID("right"),
     2077 + Border(linestyle.Light),
     2078 + ),
     2079 + ),
     2080 + },
     2081 + want: func(size image.Point) *faketerm.Terminal {
     2082 + ft := faketerm.MustNew(size)
     2083 + cvs := testcanvas.MustNew(ft.Area())
     2084 + testdraw.MustBorder(cvs, image.Rect(0, 0, 10, 5))
     2085 + testdraw.MustBorder(cvs, image.Rect(0, 5, 10, 10))
     2086 + testcanvas.MustApply(cvs, ft)
     2087 + return ft
     2088 + },
     2089 + },
     2090 + {
     2091 + desc: "update retains original focused container if it still exists",
     2092 + termSize: image.Point{10, 10},
     2093 + container: func(ft *faketerm.Terminal) (*Container, error) {
     2094 + return New(
     2095 + ft,
     2096 + ID("myRoot"),
     2097 + SplitVertical(
     2098 + Left(
     2099 + ID("left"),
     2100 + Border(linestyle.Light),
     2101 + ),
     2102 + Right(
     2103 + ID("right"),
     2104 + Border(linestyle.Light),
     2105 + SplitHorizontal(
     2106 + Top(
     2107 + ID("rightTop"),
     2108 + Border(linestyle.Light),
     2109 + ),
     2110 + Bottom(
     2111 + ID("rightBottom"),
     2112 + Border(linestyle.Light),
     2113 + ),
     2114 + ),
     2115 + ),
     2116 + ),
     2117 + )
     2118 + },
     2119 + beforeEvents: []terminalapi.Event{
     2120 + // Move focus to container with ID "right".
     2121 + // It will continue to exist after the update.
     2122 + &terminalapi.Mouse{Position: image.Point{5, 0}, Button: mouse.ButtonLeft},
     2123 + &terminalapi.Mouse{Position: image.Point{5, 0}, Button: mouse.ButtonRelease},
     2124 + },
     2125 + updateID: "right",
     2126 + updateOpts: []Option{
     2127 + Clear(),
     2128 + },
     2129 + want: func(size image.Point) *faketerm.Terminal {
     2130 + ft := faketerm.MustNew(size)
     2131 + cvs := testcanvas.MustNew(ft.Area())
     2132 + testdraw.MustBorder(cvs, image.Rect(0, 0, 5, 10))
     2133 + testdraw.MustBorder(cvs, image.Rect(5, 0, 10, 10), draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)))
     2134 + testcanvas.MustApply(cvs, ft)
     2135 + return ft
     2136 + },
     2137 + },
     2138 + {
     2139 + desc: "update moves focus to the nearest parent when focused container is destroyed",
     2140 + termSize: image.Point{10, 10},
     2141 + container: func(ft *faketerm.Terminal) (*Container, error) {
     2142 + return New(
     2143 + ft,
     2144 + ID("myRoot"),
     2145 + SplitVertical(
     2146 + Left(
     2147 + ID("left"),
     2148 + Border(linestyle.Light),
     2149 + ),
     2150 + Right(
     2151 + ID("right"),
     2152 + Border(linestyle.Light),
     2153 + SplitHorizontal(
     2154 + Top(
     2155 + ID("rightTop"),
     2156 + Border(linestyle.Light),
     2157 + ),
     2158 + Bottom(
     2159 + ID("rightBottom"),
     2160 + Border(linestyle.Light),
     2161 + ),
     2162 + ),
     2163 + ),
     2164 + ),
     2165 + )
     2166 + },
     2167 + beforeEvents: []terminalapi.Event{
     2168 + // Move focus to container with ID "rightTop".
     2169 + // It will be destroyed by calling update.
     2170 + &terminalapi.Mouse{Position: image.Point{6, 1}, Button: mouse.ButtonLeft},
     2171 + &terminalapi.Mouse{Position: image.Point{6, 1}, Button: mouse.ButtonRelease},
     2172 + },
     2173 + updateID: "right",
     2174 + updateOpts: []Option{
     2175 + Clear(),
     2176 + },
     2177 + want: func(size image.Point) *faketerm.Terminal {
     2178 + ft := faketerm.MustNew(size)
     2179 + cvs := testcanvas.MustNew(ft.Area())
     2180 + testdraw.MustBorder(cvs, image.Rect(0, 0, 5, 10))
     2181 + testdraw.MustBorder(cvs, image.Rect(5, 0, 10, 10), draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)))
     2182 + testcanvas.MustApply(cvs, ft)
     2183 + return ft
     2184 + },
     2185 + },
     2186 + {
     2187 + desc: "newly placed widget gets keyboard events",
     2188 + termSize: image.Point{10, 10},
     2189 + container: func(ft *faketerm.Terminal) (*Container, error) {
     2190 + return New(
     2191 + ft,
     2192 + ID("myRoot"),
     2193 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     2194 + )
     2195 + },
     2196 + beforeEvents: []terminalapi.Event{
     2197 + // Move focus to the target container.
     2198 + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     2199 + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     2200 + },
     2201 + afterEvents: []terminalapi.Event{
     2202 + // Send the keyboard event.
     2203 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     2204 + },
     2205 + updateID: "myRoot",
     2206 + updateOpts: []Option{
     2207 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     2208 + },
     2209 + want: func(size image.Point) *faketerm.Terminal {
     2210 + ft := faketerm.MustNew(size)
     2211 + cvs := testcanvas.MustNew(ft.Area())
     2212 + fakewidget.MustDraw(
     2213 + ft,
     2214 + cvs,
     2215 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     2216 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     2217 + )
     2218 + testcanvas.MustApply(cvs, ft)
     2219 + return ft
     2220 + },
     2221 + },
     2222 + {
     2223 + desc: "newly placed widget gets mouse events",
     2224 + termSize: image.Point{20, 10},
     2225 + container: func(ft *faketerm.Terminal) (*Container, error) {
     2226 + return New(
     2227 + ft,
     2228 + ID("myRoot"),
     2229 + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
     2230 + )
     2231 + },
     2232 + afterEvents: []terminalapi.Event{
     2233 + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     2234 + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     2235 + },
     2236 + updateID: "myRoot",
     2237 + updateOpts: []Option{
     2238 + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
     2239 + },
     2240 + want: func(size image.Point) *faketerm.Terminal {
     2241 + ft := faketerm.MustNew(size)
     2242 + cvs := testcanvas.MustNew(ft.Area())
     2243 + fakewidget.MustDraw(
     2244 + ft,
     2245 + cvs,
     2246 + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
     2247 + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     2248 + )
     2249 + testcanvas.MustApply(cvs, ft)
     2250 + return ft
     2251 + },
     2252 + },
     2253 + }
     2254 + 
     2255 + for _, tc := range tests {
     2256 + t.Run(tc.desc, func(t *testing.T) {
     2257 + got, err := faketerm.New(tc.termSize)
     2258 + if err != nil {
     2259 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     2260 + }
     2261 + 
     2262 + cont, err := tc.container(got)
     2263 + if err != nil {
     2264 + t.Fatalf("tc.container => unexpected error: %v", err)
     2265 + }
     2266 + 
     2267 + eds := event.NewDistributionSystem()
     2268 + eh := &errorHandler{}
     2269 + // Subscribe to receive errors.
     2270 + eds.Subscribe([]terminalapi.Event{terminalapi.NewError("")}, func(ev terminalapi.Event) {
     2271 + eh.handle(ev.(*terminalapi.Error).Error())
     2272 + })
     2273 + cont.Subscribe(eds)
     2274 + // Initial draw to determine sizes of containers.
     2275 + if err := cont.Draw(); err != nil {
     2276 + t.Fatalf("Draw => unexpected error: %v", err)
     2277 + }
     2278 + 
     2279 + // Deliver the before events.
     2280 + for _, ev := range tc.beforeEvents {
     2281 + eds.Event(ev)
     2282 + }
     2283 + if err := testevent.WaitFor(5*time.Second, func() error {
     2284 + if got, want := eds.Processed(), len(tc.beforeEvents); got != want {
     2285 + return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
     2286 + }
     2287 + return nil
     2288 + }); err != nil {
     2289 + t.Fatalf("testevent.WaitFor => %v", err)
     2290 + }
     2291 + 
     2292 + {
     2293 + err := cont.Update(tc.updateID, tc.updateOpts...)
     2294 + if (err != nil) != tc.wantUpdateErr {
     2295 + t.Errorf("Update => unexpected error:%v, wantErr:%v", err, tc.wantUpdateErr)
     2296 + }
     2297 + if err != nil {
     2298 + return
     2299 + }
     2300 + }
     2301 + 
     2302 + // Deliver the after events.
     2303 + for _, ev := range tc.afterEvents {
     2304 + eds.Event(ev)
     2305 + }
     2306 + wantEv := len(tc.beforeEvents) + len(tc.afterEvents)
     2307 + if err := testevent.WaitFor(5*time.Second, func() error {
     2308 + if got, want := eds.Processed(), wantEv; got != want {
     2309 + return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
     2310 + }
     2311 + return nil
     2312 + }); err != nil {
     2313 + t.Fatalf("testevent.WaitFor => %v", err)
     2314 + }
     2315 + 
     2316 + if err := cont.Draw(); err != nil {
     2317 + t.Fatalf("Draw => unexpected error: %v", err)
     2318 + }
     2319 + 
     2320 + var want *faketerm.Terminal
     2321 + if tc.want != nil {
     2322 + want = tc.want(tc.termSize)
     2323 + } else {
     2324 + w, err := faketerm.New(tc.termSize)
     2325 + if err != nil {
     2326 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     2327 + }
     2328 + want = w
     2329 + }
     2330 + if diff := faketerm.Diff(want, got); diff != "" {
     2331 + t.Errorf("Draw => %v", diff)
     2332 + }
     2333 + })
     2334 + }
     2335 + 
     2336 +}
     2337 + 
  • ■ ■ ■ ■ ■
    container/draw.go
    skipped 32 lines
    33 33   
    34 34   root := rootCont(c)
    35 35   size := root.term.Size()
    36  - root.area = image.Rect(0, 0, size.X, size.Y)
     36 + ar, err := root.opts.margin.apply(image.Rect(0, 0, size.X, size.Y))
     37 + if err != nil {
     38 + return err
     39 + }
     40 + root.area = ar
    37 41   
    38 42   preOrder(root, &errStr, visitFunc(func(c *Container) error {
    39 43   first, second, err := c.split()
    skipped 1 lines
    41 45   return err
    42 46   }
    43 47   if c.first != nil {
    44  - c.first.area = first
     48 + ar, err := c.first.opts.margin.apply(first)
     49 + if err != nil {
     50 + return err
     51 + }
     52 + c.first.area = ar
    45 53   }
    46 54   
    47 55   if c.second != nil {
    48  - c.second.area = second
     56 + ar, err := c.second.opts.margin.apply(second)
     57 + if err != nil {
     58 + return err
     59 + }
     60 + c.second.area = ar
    49 61   }
    50 62   return drawCont(c)
    51 63   }))
    skipped 108 lines
  • ■ ■ ■ ■ ■ ■
    container/draw_test.go
    skipped 23 lines
    24 24   "github.com/mum4k/termdash/internal/draw"
    25 25   "github.com/mum4k/termdash/internal/draw/testdraw"
    26 26   "github.com/mum4k/termdash/internal/faketerm"
    27  - "github.com/mum4k/termdash/internal/widgetapi"
     27 + "github.com/mum4k/termdash/internal/fakewidget"
    28 28   "github.com/mum4k/termdash/linestyle"
    29  - "github.com/mum4k/termdash/widgets/fakewidget"
     29 + "github.com/mum4k/termdash/widgetapi"
    30 30  )
    31 31   
    32 32  func TestDrawWidget(t *testing.T) {
    skipped 27 lines
    60 60   // Fake widget border.
    61 61   testdraw.MustBorder(cvs, image.Rect(1, 1, 8, 4))
    62 62   testdraw.MustText(cvs, "(7,3)", image.Point{2, 2})
     63 + testcanvas.MustApply(cvs, ft)
     64 + return ft
     65 + },
     66 + },
     67 + {
     68 + desc: "absolute margin on root container",
     69 + termSize: image.Point{20, 10},
     70 + container: func(ft *faketerm.Terminal) (*Container, error) {
     71 + return New(
     72 + ft,
     73 + Border(linestyle.Light),
     74 + MarginTop(1),
     75 + MarginRight(2),
     76 + MarginBottom(3),
     77 + MarginLeft(4),
     78 + )
     79 + },
     80 + want: func(size image.Point) *faketerm.Terminal {
     81 + ft := faketerm.MustNew(size)
     82 + cvs := testcanvas.MustNew(image.Rect(4, 1, 18, 7))
     83 + // Container border.
     84 + testdraw.MustBorder(
     85 + cvs,
     86 + cvs.Area(),
     87 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     88 + )
     89 + 
     90 + testcanvas.MustApply(cvs, ft)
     91 + return ft
     92 + },
     93 + },
     94 + {
     95 + desc: "relative margin on root container",
     96 + termSize: image.Point{20, 20},
     97 + container: func(ft *faketerm.Terminal) (*Container, error) {
     98 + return New(
     99 + ft,
     100 + Border(linestyle.Light),
     101 + MarginTopPercent(10),
     102 + MarginRightPercent(20),
     103 + MarginBottomPercent(50),
     104 + MarginLeftPercent(40),
     105 + )
     106 + },
     107 + want: func(size image.Point) *faketerm.Terminal {
     108 + ft := faketerm.MustNew(size)
     109 + cvs := testcanvas.MustNew(image.Rect(8, 2, 16, 10))
     110 + // Container border.
     111 + testdraw.MustBorder(
     112 + cvs,
     113 + cvs.Area(),
     114 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     115 + )
     116 + 
     117 + testcanvas.MustApply(cvs, ft)
     118 + return ft
     119 + },
     120 + },
     121 + {
     122 + desc: "draws vertical sub-containers with margin",
     123 + termSize: image.Point{20, 10},
     124 + container: func(ft *faketerm.Terminal) (*Container, error) {
     125 + return New(
     126 + ft,
     127 + Border(linestyle.Light),
     128 + SplitVertical(
     129 + Left(
     130 + Border(linestyle.Double),
     131 + MarginTop(1),
     132 + MarginRight(2),
     133 + MarginBottom(3),
     134 + MarginLeft(4),
     135 + ),
     136 + Right(
     137 + Border(linestyle.Double),
     138 + MarginTop(3),
     139 + MarginRight(4),
     140 + MarginBottom(1),
     141 + MarginLeft(2),
     142 + ),
     143 + ),
     144 + )
     145 + },
     146 + want: func(size image.Point) *faketerm.Terminal {
     147 + ft := faketerm.MustNew(size)
     148 + cvs := testcanvas.MustNew(ft.Area())
     149 + // Outer container border.
     150 + testdraw.MustBorder(
     151 + cvs,
     152 + cvs.Area(),
     153 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     154 + )
     155 + 
     156 + // Borders around the sub-containers.
     157 + testdraw.MustBorder(
     158 + cvs,
     159 + image.Rect(5, 2, 8, 6),
     160 + draw.BorderLineStyle(linestyle.Double),
     161 + )
     162 + testdraw.MustBorder(
     163 + cvs,
     164 + image.Rect(12, 4, 15, 8),
     165 + draw.BorderLineStyle(linestyle.Double),
     166 + )
     167 + testcanvas.MustApply(cvs, ft)
     168 + return ft
     169 + },
     170 + },
     171 + {
     172 + desc: "draws horizontal sub-containers with margin",
     173 + termSize: image.Point{20, 20},
     174 + container: func(ft *faketerm.Terminal) (*Container, error) {
     175 + return New(
     176 + ft,
     177 + Border(linestyle.Light),
     178 + SplitHorizontal(
     179 + Top(
     180 + Border(linestyle.Double),
     181 + MarginTop(1),
     182 + MarginRight(2),
     183 + MarginBottom(3),
     184 + MarginLeft(4),
     185 + ),
     186 + Bottom(
     187 + Border(linestyle.Double),
     188 + MarginTop(3),
     189 + MarginRight(4),
     190 + MarginBottom(1),
     191 + MarginLeft(2),
     192 + ),
     193 + ),
     194 + )
     195 + },
     196 + want: func(size image.Point) *faketerm.Terminal {
     197 + ft := faketerm.MustNew(size)
     198 + cvs := testcanvas.MustNew(ft.Area())
     199 + // Outer container border.
     200 + testdraw.MustBorder(
     201 + cvs,
     202 + cvs.Area(),
     203 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     204 + )
     205 + 
     206 + // Borders around the sub-containers.
     207 + testdraw.MustBorder(
     208 + cvs,
     209 + image.Rect(5, 2, 17, 7),
     210 + draw.BorderLineStyle(linestyle.Double),
     211 + )
     212 + testdraw.MustBorder(
     213 + cvs,
     214 + image.Rect(3, 13, 15, 18),
     215 + draw.BorderLineStyle(linestyle.Double),
     216 + )
     217 + testcanvas.MustApply(cvs, ft)
     218 + return ft
     219 + },
     220 + },
     221 + {
     222 + desc: "draws padded widget, absolute padding",
     223 + termSize: image.Point{20, 10},
     224 + container: func(ft *faketerm.Terminal) (*Container, error) {
     225 + return New(
     226 + ft,
     227 + Border(linestyle.Light),
     228 + PlaceWidget(fakewidget.New(widgetapi.Options{})),
     229 + PaddingTop(1),
     230 + PaddingRight(2),
     231 + PaddingBottom(3),
     232 + PaddingLeft(4),
     233 + )
     234 + },
     235 + want: func(size image.Point) *faketerm.Terminal {
     236 + ft := faketerm.MustNew(size)
     237 + cvs := testcanvas.MustNew(ft.Area())
     238 + // Container border.
     239 + testdraw.MustBorder(
     240 + cvs,
     241 + cvs.Area(),
     242 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     243 + )
     244 + 
     245 + wAr := image.Rect(5, 2, 17, 6)
     246 + wCvs := testcanvas.MustNew(wAr)
     247 + // Fake widget border.
     248 + fakewidget.MustDraw(ft, wCvs, widgetapi.Options{})
     249 + testcanvas.MustCopyTo(wCvs, cvs)
     250 + testcanvas.MustApply(cvs, ft)
     251 + return ft
     252 + },
     253 + },
     254 + {
     255 + desc: "draws padded widget, relative padding",
     256 + termSize: image.Point{20, 20},
     257 + container: func(ft *faketerm.Terminal) (*Container, error) {
     258 + return New(
     259 + ft,
     260 + Border(linestyle.Light),
     261 + PlaceWidget(fakewidget.New(widgetapi.Options{})),
     262 + PaddingTopPercent(10),
     263 + PaddingRightPercent(30),
     264 + PaddingBottomPercent(20),
     265 + PaddingLeftPercent(20),
     266 + )
     267 + },
     268 + want: func(size image.Point) *faketerm.Terminal {
     269 + ft := faketerm.MustNew(size)
     270 + cvs := testcanvas.MustNew(ft.Area())
     271 + // Container border.
     272 + testdraw.MustBorder(
     273 + cvs,
     274 + cvs.Area(),
     275 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     276 + )
     277 + 
     278 + wAr := image.Rect(4, 2, 14, 16)
     279 + wCvs := testcanvas.MustNew(wAr)
     280 + // Fake widget border.
     281 + fakewidget.MustDraw(ft, wCvs, widgetapi.Options{})
     282 + testcanvas.MustCopyTo(wCvs, cvs)
     283 + testcanvas.MustApply(cvs, ft)
     284 + return ft
     285 + },
     286 + },
     287 + {
     288 + desc: "draws padded sub-containers",
     289 + termSize: image.Point{20, 10},
     290 + container: func(ft *faketerm.Terminal) (*Container, error) {
     291 + return New(
     292 + ft,
     293 + PaddingTop(1),
     294 + PaddingRight(2),
     295 + PaddingBottom(3),
     296 + PaddingLeft(4),
     297 + Border(linestyle.Light),
     298 + SplitVertical(
     299 + Left(Border(linestyle.Double)),
     300 + Right(Border(linestyle.Double)),
     301 + ),
     302 + )
     303 + },
     304 + want: func(size image.Point) *faketerm.Terminal {
     305 + ft := faketerm.MustNew(size)
     306 + cvs := testcanvas.MustNew(ft.Area())
     307 + // Outer container border.
     308 + testdraw.MustBorder(
     309 + cvs,
     310 + cvs.Area(),
     311 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     312 + )
     313 + 
     314 + // Borders around the sub-containers.
     315 + testdraw.MustBorder(
     316 + cvs,
     317 + image.Rect(5, 2, 11, 6),
     318 + draw.BorderLineStyle(linestyle.Double),
     319 + )
     320 + testdraw.MustBorder(
     321 + cvs,
     322 + image.Rect(11, 2, 17, 6),
     323 + draw.BorderLineStyle(linestyle.Double),
     324 + )
     325 + testcanvas.MustApply(cvs, ft)
     326 + return ft
     327 + },
     328 + },
     329 + {
     330 + desc: "draws with both padding and margin enabled",
     331 + termSize: image.Point{30, 20},
     332 + container: func(ft *faketerm.Terminal) (*Container, error) {
     333 + return New(
     334 + ft,
     335 + Border(linestyle.Light),
     336 + PlaceWidget(fakewidget.New(widgetapi.Options{})),
     337 + PaddingTop(1),
     338 + PaddingRight(2),
     339 + PaddingBottom(3),
     340 + PaddingLeft(4),
     341 + MarginTop(1),
     342 + MarginRight(2),
     343 + MarginBottom(3),
     344 + MarginLeft(4),
     345 + )
     346 + },
     347 + want: func(size image.Point) *faketerm.Terminal {
     348 + ft := faketerm.MustNew(size)
     349 + cvs := testcanvas.MustNew(ft.Area())
     350 + // Container border.
     351 + testdraw.MustBorder(
     352 + cvs,
     353 + image.Rect(4, 1, 28, 17),
     354 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     355 + )
     356 + 
     357 + wAr := image.Rect(9, 3, 25, 13)
     358 + wCvs := testcanvas.MustNew(wAr)
     359 + // Fake widget border.
     360 + fakewidget.MustDraw(ft, wCvs, widgetapi.Options{})
     361 + testcanvas.MustCopyTo(wCvs, cvs)
    63 362   testcanvas.MustApply(cvs, ft)
    64 363   return ft
    65 364   },
    skipped 730 lines
  • ■ ■ ■ ■ ■
    container/focus.go
    skipped 72 lines
    73 73   return ft.container == c
    74 74  }
    75 75   
    76  -// active returns the currently focused container.
    77  -func (ft *focusTracker) active() *Container {
    78  - return ft.container
     76 +// setActive sets the currently active container to the one provided.
     77 +func (ft *focusTracker) setActive(c *Container) {
     78 + ft.container = c
    79 79  }
    80 80   
    81 81  // mouse identifies mouse events that change the focused container and track
    skipped 11 lines
    93 93   }
    94 94  }
    95 95   
     96 +// updateArea updates the area that the focus tracker considers active for
     97 +// mouse clicks.
     98 +func (ft *focusTracker) updateArea(ar image.Rectangle) {
     99 + ft.buttonFSM.UpdateArea(ar)
     100 +}
     101 + 
     102 +// reachableFrom asserts whether the currently focused container is reachable
     103 +// from the provided node in the tree.
     104 +func (ft *focusTracker) reachableFrom(node *Container) bool {
     105 + var (
     106 + errStr string
     107 + reachable bool
     108 + )
     109 + preOrder(node, &errStr, visitFunc(func(c *Container) error {
     110 + if c == ft.container {
     111 + reachable = true
     112 + }
     113 + return nil
     114 + }))
     115 + return reachable
     116 +}
     117 + 
  • ■ ■ ■ ■ ■ ■
    container/focus_test.go
    skipped 236 lines
    237 237   if err != nil {
    238 238   t.Fatalf("tc.container => unexpected error: %v", err)
    239 239   }
     240 + // Initial draw to determine sizes of containers.
     241 + if err := cont.Draw(); err != nil {
     242 + t.Fatalf("Draw => unexpected error: %v", err)
     243 + }
    240 244   for _, pc := range tc.cases {
    241 245   gotCont := pointCont(cont, pc.point)
    242 246   if (gotCont == nil) != pc.wantNil {
    skipped 167 lines
    410 414   
    411 415   eds := event.NewDistributionSystem()
    412 416   root.Subscribe(eds)
     417 + // Initial draw to determine sizes of containers.
     418 + if err := root.Draw(); err != nil {
     419 + t.Fatalf("Draw => unexpected error: %v", err)
     420 + }
    413 421   for _, ev := range tc.events {
    414 422   eds.Event(ev)
    415 423   }
    skipped 33 lines
  • ■ ■ ■ ■ ■ ■
    container/grid/grid.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +// Package grid helps to build grid layouts.
     16 +package grid
     17 + 
     18 +import (
     19 + "fmt"
     20 + 
     21 + "github.com/mum4k/termdash/container"
     22 + "github.com/mum4k/termdash/widgetapi"
     23 +)
     24 + 
     25 +// Builder builds grid layouts.
     26 +type Builder struct {
     27 + elems []Element
     28 +}
     29 + 
     30 +// New returns a new grid builder.
     31 +func New() *Builder {
     32 + return &Builder{}
     33 +}
     34 + 
     35 +// Add adds the specified elements.
     36 +// The subElements can be either a single Widget or any combination of Rows and
     37 +// Columns.
     38 +// Rows are created using RowHeightPerc() and Columns are created using
     39 +// ColWidthPerc().
     40 +// Can be called repeatedly, e.g. to add multiple Rows or Columns.
     41 +func (b *Builder) Add(subElements ...Element) {
     42 + b.elems = append(b.elems, subElements...)
     43 +}
     44 + 
     45 +// Build builds the grid layout and returns the corresponding container
     46 +// options.
     47 +func (b *Builder) Build() ([]container.Option, error) {
     48 + if err := validate(b.elems); err != nil {
     49 + return nil, err
     50 + }
     51 + return build(b.elems, 100, 100), nil
     52 +}
     53 + 
     54 +// validate recursively validates the elements that were added to the builder.
     55 +// Validates the following per each level of Rows or Columns.:
     56 +// The subElements are either exactly one Widget or any number of Rows and
     57 +// Columns.
     58 +// Each individual width or height is in the range 0 < v < 100.
     59 +// The sum of all widths is <= 100.
     60 +// The sum of all heights is <= 100.
     61 +func validate(elems []Element) error {
     62 + heightSum := 0
     63 + widthSum := 0
     64 + for _, elem := range elems {
     65 + switch e := elem.(type) {
     66 + case *row:
     67 + if min, max := 0, 100; e.heightPerc <= min || e.heightPerc >= max {
     68 + return fmt.Errorf("invalid row heightPerc(%d), must be a value in the range %d < v < %d", e.heightPerc, min, max)
     69 + }
     70 + heightSum += e.heightPerc
     71 + if err := validate(e.subElem); err != nil {
     72 + return err
     73 + }
     74 + 
     75 + case *col:
     76 + if min, max := 0, 100; e.widthPerc <= min || e.widthPerc >= max {
     77 + return fmt.Errorf("invalid column widthPerc(%d), must be a value in the range %d < v < %d", e.widthPerc, min, max)
     78 + }
     79 + widthSum += e.widthPerc
     80 + if err := validate(e.subElem); err != nil {
     81 + return err
     82 + }
     83 + 
     84 + case *widget:
     85 + if len(elems) > 1 {
     86 + return fmt.Errorf("when adding a widget, it must be the only added element at that level, got: %v", elems)
     87 + }
     88 + }
     89 + }
     90 + 
     91 + if max := 100; heightSum > max || widthSum > max {
     92 + 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)
     93 + }
     94 + return nil
     95 +}
     96 + 
     97 +// build recursively builds the container options according to the elements
     98 +// that were added to the builder.
     99 +// The parentHeightPerc and parentWidthPerc percent indicate the relative size
     100 +// of the element we are building now in the parent element. See innerPerc()
     101 +// for more details.
     102 +func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.Option {
     103 + if len(elems) == 0 {
     104 + return nil
     105 + }
     106 + 
     107 + elem := elems[0]
     108 + elems = elems[1:]
     109 + 
     110 + switch e := elem.(type) {
     111 + case *row:
     112 + if len(elems) > 0 {
     113 + perc := innerPerc(e.heightPerc, parentHeightPerc)
     114 + childHeightPerc := parentHeightPerc - e.heightPerc
     115 + return []container.Option{
     116 + container.SplitHorizontal(
     117 + container.Top(build(e.subElem, 100, parentWidthPerc)...),
     118 + container.Bottom(build(elems, childHeightPerc, parentWidthPerc)...),
     119 + container.SplitPercent(perc),
     120 + ),
     121 + }
     122 + }
     123 + return build(e.subElem, 100, parentWidthPerc)
     124 + 
     125 + case *col:
     126 + if len(elems) > 0 {
     127 + perc := innerPerc(e.widthPerc, parentWidthPerc)
     128 + childWidthPerc := parentWidthPerc - e.widthPerc
     129 + return []container.Option{
     130 + container.SplitVertical(
     131 + container.Left(build(e.subElem, parentHeightPerc, 100)...),
     132 + container.Right(build(elems, parentHeightPerc, childWidthPerc)...),
     133 + container.SplitPercent(perc),
     134 + ),
     135 + }
     136 + }
     137 + return build(e.subElem, parentHeightPerc, 100)
     138 + 
     139 + case *widget:
     140 + opts := e.cOpts
     141 + opts = append(opts, container.PlaceWidget(e.widget))
     142 + return opts
     143 + }
     144 + return nil
     145 +}
     146 + 
     147 +// innerPerc translates the outer split percentage into the inner one.
     148 +// E.g. multiple rows would specify that they want the outer split percentage
     149 +// of 25% each, but we are representing them in a tree of containers so the
     150 +// inner splits vary:
     151 +// ╭─────────╮
     152 +// 25% │ 25% │
     153 +// │╭───────╮│ ---
     154 +// 25% ││ 33% ││
     155 +// ││╭─────╮││
     156 +// 25% │││ 50% │││
     157 +// ││├─────┤││ 75%
     158 +// 25% │││ 50% │││
     159 +// ││╰─────╯││
     160 +// │╰───────╯│
     161 +// ╰─────────╯ ---
     162 +//
     163 +// Argument outerPerc is the user specified percentage for the split, i.e. the
     164 +// 25% in the example above.
     165 +// Argument parentPerc is the percentage this container has in the parent, i.e.
     166 +// 75% for the first inner container in the example above.
     167 +func innerPerc(outerPerc, parentPerc int) int {
     168 + // parentPerc * parentHeightCells = childHeightCells
     169 + // innerPerc * childHeightCells = outerPerc * parentHeightCells
     170 + // innerPerc * parentPerc * parentHeightCells = outerPerc * parentHeightCells
     171 + // innerPerc * parentPerc = outerPerc
     172 + // innerPerc = outerPerc / parentPerc
     173 + return int(float64(outerPerc) / float64(parentPerc) * 100)
     174 +}
     175 + 
     176 +// Element is an element that can be added to the grid.
     177 +type Element interface {
     178 + isElement()
     179 +}
     180 + 
     181 +// row is a row in the grid.
     182 +// row implements Element.
     183 +type row struct {
     184 + // heightPerc is the height percentage this row occupies.
     185 + heightPerc int
     186 + 
     187 + // subElem are the sub Rows or Columns or a single widget.
     188 + subElem []Element
     189 +}
     190 + 
     191 +// isElement implements Element.isElement.
     192 +func (row) isElement() {}
     193 + 
     194 +// String implements fmt.Stringer.
     195 +func (r *row) String() string {
     196 + return fmt.Sprintf("row{height:%d, sub:%v}", r.heightPerc, r.subElem)
     197 +}
     198 + 
     199 +// col is a column in the grid.
     200 +// col implements Element.
     201 +type col struct {
     202 + // widthPerc is the width percentage this column occupies.
     203 + widthPerc int
     204 + 
     205 + // subElem are the sub Rows or Columns or a single widget.
     206 + subElem []Element
     207 +}
     208 + 
     209 +// isElement implements Element.isElement.
     210 +func (col) isElement() {}
     211 + 
     212 +// String implements fmt.Stringer.
     213 +func (c *col) String() string {
     214 + return fmt.Sprintf("col{width:%d, sub:%v}", c.widthPerc, c.subElem)
     215 +}
     216 + 
     217 +// widget is a widget placed into the grid.
     218 +// widget implements Element.
     219 +type widget struct {
     220 + // widget is the widget instance.
     221 + widget widgetapi.Widget
     222 + // cOpts are the options for the widget's container.
     223 + cOpts []container.Option
     224 +}
     225 + 
     226 +// String implements fmt.Stringer.
     227 +func (w *widget) String() string {
     228 + return fmt.Sprintf("widget{type:%T}", w.widget)
     229 +}
     230 + 
     231 +// isElement implements Element.isElement.
     232 +func (widget) isElement() {}
     233 + 
     234 +// RowHeightPerc creates a row of the specified height.
     235 +// The height is supplied as height percentage of the parent element.
     236 +// The sum of all heights at the same level cannot be larger than 100%. If it
     237 +// is less that 100%, the last element stretches to the edge of the screen.
     238 +// The subElements can be either a single Widget or any combination of Rows and
     239 +// Columns.
     240 +func RowHeightPerc(heightPerc int, subElements ...Element) Element {
     241 + return &row{
     242 + heightPerc: heightPerc,
     243 + subElem: subElements,
     244 + }
     245 +}
     246 + 
     247 +// ColWidthPerc creates a column of the specified width.
     248 +// The width is supplied as width percentage of the parent element.
     249 +// The sum of all widths at the same level cannot be larger than 100%. If it
     250 +// is less that 100%, the last element stretches to the edge of the screen.
     251 +// The subElements can be either a single Widget or any combination of Rows and
     252 +// Columns.
     253 +func ColWidthPerc(widthPerc int, subElements ...Element) Element {
     254 + return &col{
     255 + widthPerc: widthPerc,
     256 + subElem: subElements,
     257 + }
     258 +}
     259 + 
     260 +// Widget adds a widget into the Row or Column.
     261 +// The options will be applied to the container that directly holds this
     262 +// widget.
     263 +func Widget(w widgetapi.Widget, cOpts ...container.Option) Element {
     264 + return &widget{
     265 + widget: w,
     266 + cOpts: cOpts,
     267 + }
     268 +}
     269 + 
  • ■ ■ ■ ■ ■ ■
    container/grid/grid_test.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package grid
     16 + 
     17 +import (
     18 + "context"
     19 + "image"
     20 + "testing"
     21 + "time"
     22 + 
     23 + "github.com/mum4k/termdash"
     24 + "github.com/mum4k/termdash/cell"
     25 + "github.com/mum4k/termdash/container"
     26 + "github.com/mum4k/termdash/internal/area"
     27 + "github.com/mum4k/termdash/internal/canvas/testcanvas"
     28 + "github.com/mum4k/termdash/internal/draw"
     29 + "github.com/mum4k/termdash/internal/draw/testdraw"
     30 + "github.com/mum4k/termdash/internal/faketerm"
     31 + "github.com/mum4k/termdash/internal/fakewidget"
     32 + "github.com/mum4k/termdash/linestyle"
     33 + "github.com/mum4k/termdash/terminal/termbox"
     34 + "github.com/mum4k/termdash/widgetapi"
     35 + "github.com/mum4k/termdash/widgets/barchart"
     36 +)
     37 + 
     38 +// Shows how to create a simple 4x4 grid with four widgets.
     39 +// All the cells in the grid contain the same widget in this example.
     40 +func Example() {
     41 + tbx, err := termbox.New()
     42 + if err != nil {
     43 + panic(err)
     44 + }
     45 + defer tbx.Close()
     46 + 
     47 + bc, err := barchart.New()
     48 + if err != nil {
     49 + panic(err)
     50 + }
     51 + 
     52 + builder := New()
     53 + builder.Add(
     54 + RowHeightPerc(
     55 + 50,
     56 + ColWidthPerc(50, Widget(bc)),
     57 + ColWidthPerc(50, Widget(bc)),
     58 + ),
     59 + RowHeightPerc(
     60 + 50,
     61 + ColWidthPerc(50, Widget(bc)),
     62 + ColWidthPerc(50, Widget(bc)),
     63 + ),
     64 + )
     65 + gridOpts, err := builder.Build()
     66 + if err != nil {
     67 + panic(err)
     68 + }
     69 + 
     70 + cont, err := container.New(tbx, gridOpts...)
     71 + if err != nil {
     72 + panic(err)
     73 + }
     74 + 
     75 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
     76 + defer cancel()
     77 + if err := termdash.Run(ctx, tbx, cont); err != nil {
     78 + panic(err)
     79 + }
     80 +}
     81 + 
     82 +// Shows how to create rows iteratively. Each row contains two columns and each
     83 +// column contains the same widget.
     84 +func Example_iterative() {
     85 + tbx, err := termbox.New()
     86 + if err != nil {
     87 + panic(err)
     88 + }
     89 + defer tbx.Close()
     90 + 
     91 + bc, err := barchart.New()
     92 + if err != nil {
     93 + panic(err)
     94 + }
     95 + 
     96 + builder := New()
     97 + for i := 0; i < 5; i++ {
     98 + builder.Add(
     99 + RowHeightPerc(
     100 + 20,
     101 + ColWidthPerc(50, Widget(bc)),
     102 + ColWidthPerc(50, Widget(bc)),
     103 + ),
     104 + )
     105 + }
     106 + gridOpts, err := builder.Build()
     107 + if err != nil {
     108 + panic(err)
     109 + }
     110 + 
     111 + cont, err := container.New(tbx, gridOpts...)
     112 + if err != nil {
     113 + panic(err)
     114 + }
     115 + 
     116 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
     117 + defer cancel()
     118 + if err := termdash.Run(ctx, tbx, cont); err != nil {
     119 + panic(err)
     120 + }
     121 +}
     122 + 
     123 +// mirror returns a new fake widget.
     124 +func mirror() *fakewidget.Mirror {
     125 + return fakewidget.New(widgetapi.Options{})
     126 +}
     127 + 
     128 +// mustHSplit splits the area or panics.
     129 +func mustHSplit(ar image.Rectangle, heightPerc int) (top image.Rectangle, bottom image.Rectangle) {
     130 + t, b, err := area.HSplit(ar, heightPerc)
     131 + if err != nil {
     132 + panic(err)
     133 + }
     134 + return t, b
     135 +}
     136 + 
     137 +// mustVSplit splits the area or panics.
     138 +func mustVSplit(ar image.Rectangle, widthPerc int) (left image.Rectangle, right image.Rectangle) {
     139 + l, r, err := area.VSplit(ar, widthPerc)
     140 + if err != nil {
     141 + panic(err)
     142 + }
     143 + return l, r
     144 +}
     145 + 
     146 +func TestBuilder(t *testing.T) {
     147 + tests := []struct {
     148 + desc string
     149 + termSize image.Point
     150 + builder *Builder
     151 + want func(size image.Point) *faketerm.Terminal
     152 + wantErr bool
     153 + }{
     154 + {
     155 + desc: "fails when Widget is mixed with Rows and Columns at top level",
     156 + termSize: image.Point{10, 10},
     157 + builder: func() *Builder {
     158 + b := New()
     159 + b.Add(
     160 + RowHeightPerc(50),
     161 + Widget(mirror()),
     162 + )
     163 + return b
     164 + }(),
     165 + wantErr: true,
     166 + },
     167 + {
     168 + desc: "fails when Widget is mixed with Rows and Columns at sub level",
     169 + termSize: image.Point{10, 10},
     170 + builder: func() *Builder {
     171 + b := New()
     172 + b.Add(
     173 + RowHeightPerc(
     174 + 50,
     175 + RowHeightPerc(50),
     176 + Widget(mirror()),
     177 + ),
     178 + )
     179 + return b
     180 + }(),
     181 + wantErr: true,
     182 + },
     183 + {
     184 + desc: "fails when Row heightPerc is too low at top level",
     185 + termSize: image.Point{10, 10},
     186 + builder: func() *Builder {
     187 + b := New()
     188 + b.Add(
     189 + RowHeightPerc(0),
     190 + )
     191 + return b
     192 + }(),
     193 + wantErr: true,
     194 + },
     195 + {
     196 + desc: "fails when Row heightPerc is too low at sub level",
     197 + termSize: image.Point{10, 10},
     198 + builder: func() *Builder {
     199 + b := New()
     200 + b.Add(
     201 + RowHeightPerc(
     202 + 50,
     203 + RowHeightPerc(0),
     204 + ),
     205 + )
     206 + return b
     207 + }(),
     208 + wantErr: true,
     209 + },
     210 + {
     211 + desc: "fails when Row heightPerc is too high at top level",
     212 + termSize: image.Point{10, 10},
     213 + builder: func() *Builder {
     214 + b := New()
     215 + b.Add(
     216 + RowHeightPerc(100),
     217 + )
     218 + return b
     219 + }(),
     220 + wantErr: true,
     221 + },
     222 + {
     223 + desc: "fails when Row heightPerc is too high at sub level",
     224 + termSize: image.Point{10, 10},
     225 + builder: func() *Builder {
     226 + b := New()
     227 + b.Add(
     228 + RowHeightPerc(
     229 + 50,
     230 + RowHeightPerc(100),
     231 + ),
     232 + )
     233 + return b
     234 + }(),
     235 + wantErr: true,
     236 + },
     237 + {
     238 + desc: "fails when Col widthPerc is too low at top level",
     239 + termSize: image.Point{10, 10},
     240 + builder: func() *Builder {
     241 + b := New()
     242 + b.Add(
     243 + ColWidthPerc(0),
     244 + )
     245 + return b
     246 + }(),
     247 + wantErr: true,
     248 + },
     249 + {
     250 + desc: "fails when Col widthPerc is too low at sub level",
     251 + termSize: image.Point{10, 10},
     252 + builder: func() *Builder {
     253 + b := New()
     254 + b.Add(
     255 + ColWidthPerc(
     256 + 50,
     257 + ColWidthPerc(0),
     258 + ),
     259 + )
     260 + return b
     261 + }(),
     262 + wantErr: true,
     263 + },
     264 + {
     265 + desc: "fails when Col widthPerc is too high at top level",
     266 + termSize: image.Point{10, 10},
     267 + builder: func() *Builder {
     268 + b := New()
     269 + b.Add(
     270 + ColWidthPerc(100),
     271 + )
     272 + return b
     273 + }(),
     274 + wantErr: true,
     275 + },
     276 + {
     277 + desc: "fails when Col widthPerc is too high at sub level",
     278 + termSize: image.Point{10, 10},
     279 + builder: func() *Builder {
     280 + b := New()
     281 + b.Add(
     282 + ColWidthPerc(
     283 + 50,
     284 + ColWidthPerc(100),
     285 + ),
     286 + )
     287 + return b
     288 + }(),
     289 + wantErr: true,
     290 + },
     291 + {
     292 + desc: "fails when height sum is too large at top level",
     293 + termSize: image.Point{10, 10},
     294 + builder: func() *Builder {
     295 + b := New()
     296 + b.Add(
     297 + RowHeightPerc(50),
     298 + RowHeightPerc(50),
     299 + RowHeightPerc(1),
     300 + )
     301 + return b
     302 + }(),
     303 + wantErr: true,
     304 + },
     305 + {
     306 + desc: "fails when height sum is too large at sub level",
     307 + termSize: image.Point{10, 10},
     308 + builder: func() *Builder {
     309 + b := New()
     310 + b.Add(
     311 + RowHeightPerc(
     312 + 50,
     313 + RowHeightPerc(50),
     314 + RowHeightPerc(50),
     315 + RowHeightPerc(1),
     316 + ),
     317 + )
     318 + return b
     319 + }(),
     320 + wantErr: true,
     321 + },
     322 + {
     323 + desc: "fails when width sum is too large at top level",
     324 + termSize: image.Point{10, 10},
     325 + builder: func() *Builder {
     326 + b := New()
     327 + b.Add(
     328 + ColWidthPerc(50),
     329 + ColWidthPerc(50),
     330 + ColWidthPerc(1),
     331 + )
     332 + return b
     333 + }(),
     334 + wantErr: true,
     335 + },
     336 + {
     337 + desc: "fails when width sum is too large at sub level",
     338 + termSize: image.Point{10, 10},
     339 + builder: func() *Builder {
     340 + b := New()
     341 + b.Add(
     342 + ColWidthPerc(
     343 + 50,
     344 + ColWidthPerc(50),
     345 + ColWidthPerc(50),
     346 + ColWidthPerc(1),
     347 + ),
     348 + )
     349 + return b
     350 + }(),
     351 + wantErr: true,
     352 + },
     353 + {
     354 + desc: "empty container when nothing is added",
     355 + termSize: image.Point{10, 10},
     356 + builder: func() *Builder {
     357 + return New()
     358 + }(),
     359 + },
     360 + {
     361 + desc: "widget in the outer most container",
     362 + termSize: image.Point{10, 10},
     363 + builder: func() *Builder {
     364 + b := New()
     365 + b.Add(Widget(mirror()))
     366 + return b
     367 + }(),
     368 + want: func(size image.Point) *faketerm.Terminal {
     369 + ft := faketerm.MustNew(size)
     370 + cvs := testcanvas.MustNew(ft.Area())
     371 + fakewidget.MustDraw(ft, cvs, widgetapi.Options{})
     372 + return ft
     373 + },
     374 + },
     375 + {
     376 + desc: "two equal rows",
     377 + termSize: image.Point{10, 10},
     378 + builder: func() *Builder {
     379 + b := New()
     380 + b.Add(RowHeightPerc(50, Widget(mirror())))
     381 + b.Add(RowHeightPerc(50, Widget(mirror())))
     382 + return b
     383 + }(),
     384 + want: func(size image.Point) *faketerm.Terminal {
     385 + ft := faketerm.MustNew(size)
     386 + top, bot := mustHSplit(ft.Area(), 50)
     387 + fakewidget.MustDraw(ft, testcanvas.MustNew(top), widgetapi.Options{})
     388 + fakewidget.MustDraw(ft, testcanvas.MustNew(bot), widgetapi.Options{})
     389 + return ft
     390 + },
     391 + },
     392 + {
     393 + desc: "two unequal rows",
     394 + termSize: image.Point{10, 10},
     395 + builder: func() *Builder {
     396 + b := New()
     397 + b.Add(RowHeightPerc(20, Widget(mirror())))
     398 + b.Add(RowHeightPerc(80, Widget(mirror())))
     399 + return b
     400 + }(),
     401 + want: func(size image.Point) *faketerm.Terminal {
     402 + ft := faketerm.MustNew(size)
     403 + top, bot := mustHSplit(ft.Area(), 20)
     404 + fakewidget.MustDraw(ft, testcanvas.MustNew(top), widgetapi.Options{})
     405 + fakewidget.MustDraw(ft, testcanvas.MustNew(bot), widgetapi.Options{})
     406 + return ft
     407 + },
     408 + },
     409 + {
     410 + desc: "two equal columns",
     411 + termSize: image.Point{20, 10},
     412 + builder: func() *Builder {
     413 + b := New()
     414 + b.Add(ColWidthPerc(50, Widget(mirror())))
     415 + b.Add(ColWidthPerc(50, Widget(mirror())))
     416 + return b
     417 + }(),
     418 + want: func(size image.Point) *faketerm.Terminal {
     419 + ft := faketerm.MustNew(size)
     420 + left, right := mustVSplit(ft.Area(), 50)
     421 + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{})
     422 + fakewidget.MustDraw(ft, testcanvas.MustNew(right), widgetapi.Options{})
     423 + return ft
     424 + },
     425 + },
     426 + {
     427 + desc: "two unequal columns",
     428 + termSize: image.Point{40, 10},
     429 + builder: func() *Builder {
     430 + b := New()
     431 + b.Add(ColWidthPerc(20, Widget(mirror())))
     432 + b.Add(ColWidthPerc(80, Widget(mirror())))
     433 + return b
     434 + }(),
     435 + want: func(size image.Point) *faketerm.Terminal {
     436 + ft := faketerm.MustNew(size)
     437 + left, right := mustVSplit(ft.Area(), 20)
     438 + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{})
     439 + fakewidget.MustDraw(ft, testcanvas.MustNew(right), widgetapi.Options{})
     440 + return ft
     441 + },
     442 + },
     443 + {
     444 + desc: "rows with columns (equal)",
     445 + termSize: image.Point{20, 20},
     446 + builder: func() *Builder {
     447 + b := New()
     448 + b.Add(
     449 + RowHeightPerc(
     450 + 50,
     451 + ColWidthPerc(50, Widget(mirror())),
     452 + ColWidthPerc(50, Widget(mirror())),
     453 + ),
     454 + RowHeightPerc(
     455 + 50,
     456 + ColWidthPerc(50, Widget(mirror())),
     457 + ColWidthPerc(50, Widget(mirror())),
     458 + ),
     459 + )
     460 + return b
     461 + }(),
     462 + want: func(size image.Point) *faketerm.Terminal {
     463 + ft := faketerm.MustNew(size)
     464 + top, bot := mustHSplit(ft.Area(), 50)
     465 + 
     466 + topLeft, topRight := mustVSplit(top, 50)
     467 + botLeft, botRight := mustVSplit(bot, 50)
     468 + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{})
     469 + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{})
     470 + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{})
     471 + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{})
     472 + return ft
     473 + },
     474 + },
     475 + {
     476 + desc: "rows with columns (unequal)",
     477 + termSize: image.Point{40, 20},
     478 + builder: func() *Builder {
     479 + b := New()
     480 + b.Add(
     481 + RowHeightPerc(
     482 + 20,
     483 + ColWidthPerc(20, Widget(mirror())),
     484 + ColWidthPerc(80, Widget(mirror())),
     485 + ),
     486 + RowHeightPerc(
     487 + 80,
     488 + ColWidthPerc(80, Widget(mirror())),
     489 + ColWidthPerc(20, Widget(mirror())),
     490 + ),
     491 + )
     492 + return b
     493 + }(),
     494 + want: func(size image.Point) *faketerm.Terminal {
     495 + ft := faketerm.MustNew(size)
     496 + top, bot := mustHSplit(ft.Area(), 20)
     497 + 
     498 + topLeft, topRight := mustVSplit(top, 20)
     499 + botLeft, botRight := mustVSplit(bot, 80)
     500 + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{})
     501 + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{})
     502 + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{})
     503 + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{})
     504 + return ft
     505 + },
     506 + },
     507 + {
     508 + desc: "columns with rows (equal)",
     509 + termSize: image.Point{20, 20},
     510 + builder: func() *Builder {
     511 + b := New()
     512 + b.Add(
     513 + ColWidthPerc(
     514 + 50,
     515 + RowHeightPerc(50, Widget(mirror())),
     516 + RowHeightPerc(50, Widget(mirror())),
     517 + ),
     518 + ColWidthPerc(
     519 + 50,
     520 + RowHeightPerc(50, Widget(mirror())),
     521 + RowHeightPerc(50, Widget(mirror())),
     522 + ),
     523 + )
     524 + return b
     525 + }(),
     526 + want: func(size image.Point) *faketerm.Terminal {
     527 + ft := faketerm.MustNew(size)
     528 + top, bot := mustHSplit(ft.Area(), 50)
     529 + 
     530 + topLeft, topRight := mustVSplit(top, 50)
     531 + botLeft, botRight := mustVSplit(bot, 50)
     532 + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{})
     533 + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{})
     534 + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{})
     535 + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{})
     536 + return ft
     537 + },
     538 + },
     539 + {
     540 + desc: "columns with rows (unequal)",
     541 + termSize: image.Point{40, 20},
     542 + builder: func() *Builder {
     543 + b := New()
     544 + b.Add(
     545 + ColWidthPerc(
     546 + 20,
     547 + RowHeightPerc(20, Widget(mirror())),
     548 + RowHeightPerc(80, Widget(mirror())),
     549 + ),
     550 + ColWidthPerc(
     551 + 80,
     552 + RowHeightPerc(80, Widget(mirror())),
     553 + RowHeightPerc(20, Widget(mirror())),
     554 + ),
     555 + )
     556 + return b
     557 + }(),
     558 + want: func(size image.Point) *faketerm.Terminal {
     559 + ft := faketerm.MustNew(size)
     560 + left, right := mustVSplit(ft.Area(), 20)
     561 + 
     562 + topLeft, topRight := mustHSplit(left, 20)
     563 + botLeft, botRight := mustHSplit(right, 80)
     564 + fakewidget.MustDraw(ft, testcanvas.MustNew(topLeft), widgetapi.Options{})
     565 + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{})
     566 + fakewidget.MustDraw(ft, testcanvas.MustNew(botLeft), widgetapi.Options{})
     567 + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{})
     568 + return ft
     569 + },
     570 + },
     571 + {
     572 + desc: "rows with rows with columns",
     573 + termSize: image.Point{40, 40},
     574 + builder: func() *Builder {
     575 + b := New()
     576 + b.Add(
     577 + RowHeightPerc(
     578 + 50,
     579 + RowHeightPerc(
     580 + 50,
     581 + ColWidthPerc(50, Widget(mirror())),
     582 + ColWidthPerc(50, Widget(mirror())),
     583 + ),
     584 + RowHeightPerc(
     585 + 50,
     586 + ColWidthPerc(50, Widget(mirror())),
     587 + ColWidthPerc(50, Widget(mirror())),
     588 + ),
     589 + ),
     590 + RowHeightPerc(
     591 + 50,
     592 + RowHeightPerc(
     593 + 50,
     594 + ColWidthPerc(50, Widget(mirror())),
     595 + ColWidthPerc(50, Widget(mirror())),
     596 + ),
     597 + RowHeightPerc(
     598 + 50,
     599 + ColWidthPerc(50, Widget(mirror())),
     600 + ColWidthPerc(50, Widget(mirror())),
     601 + ),
     602 + ),
     603 + )
     604 + return b
     605 + }(),
     606 + want: func(size image.Point) *faketerm.Terminal {
     607 + ft := faketerm.MustNew(size)
     608 + top, bot := mustHSplit(ft.Area(), 50)
     609 + topTop, topBot := mustHSplit(top, 50)
     610 + botTop, botBot := mustHSplit(bot, 50)
     611 + 
     612 + topTopLeft, topTopRight := mustVSplit(topTop, 50)
     613 + topBotLeft, topBotRight := mustVSplit(topBot, 50)
     614 + botTopLeft, botTopRight := mustVSplit(botTop, 50)
     615 + botBotLeft, botBotRight := mustVSplit(botBot, 50)
     616 + fakewidget.MustDraw(ft, testcanvas.MustNew(topTopLeft), widgetapi.Options{})
     617 + fakewidget.MustDraw(ft, testcanvas.MustNew(topTopRight), widgetapi.Options{})
     618 + fakewidget.MustDraw(ft, testcanvas.MustNew(topBotLeft), widgetapi.Options{})
     619 + fakewidget.MustDraw(ft, testcanvas.MustNew(topBotRight), widgetapi.Options{})
     620 + fakewidget.MustDraw(ft, testcanvas.MustNew(botTopLeft), widgetapi.Options{})
     621 + fakewidget.MustDraw(ft, testcanvas.MustNew(botTopRight), widgetapi.Options{})
     622 + fakewidget.MustDraw(ft, testcanvas.MustNew(botBotLeft), widgetapi.Options{})
     623 + fakewidget.MustDraw(ft, testcanvas.MustNew(botBotRight), widgetapi.Options{})
     624 + return ft
     625 + },
     626 + },
     627 + {
     628 + desc: "rows mixed with columns at top level",
     629 + termSize: image.Point{40, 30},
     630 + builder: func() *Builder {
     631 + b := New()
     632 + b.Add(
     633 + RowHeightPerc(20, Widget(mirror())),
     634 + ColWidthPerc(20, Widget(mirror())),
     635 + RowHeightPerc(20, Widget(mirror())),
     636 + ColWidthPerc(20, Widget(mirror())),
     637 + )
     638 + return b
     639 + }(),
     640 + want: func(size image.Point) *faketerm.Terminal {
     641 + ft := faketerm.MustNew(size)
     642 + top, bot := mustHSplit(ft.Area(), 20)
     643 + 
     644 + left, right := mustVSplit(bot, 20)
     645 + topRight, botRight := mustHSplit(right, 25)
     646 + fakewidget.MustDraw(ft, testcanvas.MustNew(top), widgetapi.Options{})
     647 + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{})
     648 + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{})
     649 + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{})
     650 + return ft
     651 + },
     652 + },
     653 + {
     654 + desc: "rows mixed with columns at sub level",
     655 + termSize: image.Point{40, 30},
     656 + builder: func() *Builder {
     657 + b := New()
     658 + b.Add(
     659 + RowHeightPerc(
     660 + 50,
     661 + RowHeightPerc(20, Widget(mirror())),
     662 + ColWidthPerc(20, Widget(mirror())),
     663 + RowHeightPerc(20, Widget(mirror())),
     664 + ColWidthPerc(20, Widget(mirror())),
     665 + ),
     666 + RowHeightPerc(50, Widget(mirror())),
     667 + )
     668 + return b
     669 + }(),
     670 + want: func(size image.Point) *faketerm.Terminal {
     671 + ft := faketerm.MustNew(size)
     672 + top, bot := mustHSplit(ft.Area(), 50)
     673 + fakewidget.MustDraw(ft, testcanvas.MustNew(bot), widgetapi.Options{})
     674 + 
     675 + topTop, topBot := mustHSplit(top, 20)
     676 + left, right := mustVSplit(topBot, 20)
     677 + topRight, botRight := mustHSplit(right, 25)
     678 + fakewidget.MustDraw(ft, testcanvas.MustNew(topTop), widgetapi.Options{})
     679 + fakewidget.MustDraw(ft, testcanvas.MustNew(left), widgetapi.Options{})
     680 + fakewidget.MustDraw(ft, testcanvas.MustNew(topRight), widgetapi.Options{})
     681 + fakewidget.MustDraw(ft, testcanvas.MustNew(botRight), widgetapi.Options{})
     682 + return ft
     683 + },
     684 + },
     685 + {
     686 + desc: "widget's container can have options",
     687 + termSize: image.Point{20, 20},
     688 + builder: func() *Builder {
     689 + b := New()
     690 + b.Add(
     691 + Widget(
     692 + mirror(),
     693 + container.Border(linestyle.Double),
     694 + ),
     695 + )
     696 + return b
     697 + }(),
     698 + want: func(size image.Point) *faketerm.Terminal {
     699 + ft := faketerm.MustNew(size)
     700 + cvs := testcanvas.MustNew(ft.Area())
     701 + testdraw.MustBorder(
     702 + cvs,
     703 + cvs.Area(),
     704 + draw.BorderLineStyle(linestyle.Double),
     705 + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
     706 + )
     707 + wCvs := testcanvas.MustNew(area.ExcludeBorder(cvs.Area()))
     708 + fakewidget.MustDraw(ft, wCvs, widgetapi.Options{})
     709 + testcanvas.MustCopyTo(wCvs, cvs)
     710 + testcanvas.MustApply(cvs, ft)
     711 + return ft
     712 + },
     713 + },
     714 + }
     715 + 
     716 + for _, tc := range tests {
     717 + t.Run(tc.desc, func(t *testing.T) {
     718 + got, err := faketerm.New(tc.termSize)
     719 + if err != nil {
     720 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     721 + }
     722 + 
     723 + gridOpts, err := tc.builder.Build()
     724 + if (err != nil) != tc.wantErr {
     725 + t.Errorf("tc.builder => unexpected error:%v, wantErr:%v", err, tc.wantErr)
     726 + }
     727 + if err != nil {
     728 + return
     729 + }
     730 + 
     731 + cont, err := container.New(got, gridOpts...)
     732 + if err != nil {
     733 + t.Fatalf("container.New => unexpected error: %v", err)
     734 + }
     735 + if err := cont.Draw(); err != nil {
     736 + t.Fatalf("Draw => unexpected error: %v", err)
     737 + }
     738 + 
     739 + var want *faketerm.Terminal
     740 + if tc.want != nil {
     741 + want = tc.want(tc.termSize)
     742 + } else {
     743 + w, err := faketerm.New(tc.termSize)
     744 + if err != nil {
     745 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     746 + }
     747 + want = w
     748 + }
     749 + if diff := faketerm.Diff(want, got); diff != "" {
     750 + t.Errorf("Draw => %v", diff)
     751 + }
     752 + })
     753 + }
     754 +}
     755 + 
  • ■ ■ ■ ■ ■ ■
    container/options.go
    skipped 16 lines
    17 17  // options.go defines container options.
    18 18   
    19 19  import (
     20 + "errors"
    20 21   "fmt"
     22 + "image"
    21 23   
    22 24   "github.com/mum4k/termdash/align"
    23 25   "github.com/mum4k/termdash/cell"
    24  - "github.com/mum4k/termdash/internal/widgetapi"
     26 + "github.com/mum4k/termdash/internal/area"
    25 27   "github.com/mum4k/termdash/linestyle"
     28 + "github.com/mum4k/termdash/widgetapi"
    26 29  )
    27 30   
    28  -// applyOptions applies the options to the container.
     31 +// applyOptions applies the options to the container and validates them.
    29 32  func applyOptions(c *Container, opts ...Option) error {
    30 33   for _, opt := range opts {
    31 34   if err := opt.set(c); err != nil {
    skipped 3 lines
    35 38   return nil
    36 39  }
    37 40   
     41 +// validateOptions validates options set in the container tree.
     42 +func validateOptions(c *Container) error {
     43 + // ensure all the container identifiers are either empty or unique.
     44 + var errStr string
     45 + seenID := map[string]bool{}
     46 + preOrder(c, &errStr, func(c *Container) error {
     47 + if c.opts.id == "" {
     48 + return nil
     49 + }
     50 + 
     51 + if seenID[c.opts.id] {
     52 + return fmt.Errorf("duplicate container ID %q", c.opts.id)
     53 + }
     54 + seenID[c.opts.id] = true
     55 + return nil
     56 + })
     57 + if errStr != "" {
     58 + return errors.New(errStr)
     59 + }
     60 + return nil
     61 +}
     62 + 
    38 63  // Option is used to provide options to a container.
    39 64  type Option interface {
    40 65   // set sets the provided option.
    skipped 2 lines
    43 68   
    44 69  // options stores the options provided to the container.
    45 70  type options struct {
     71 + // id is the identifier provided by the user.
     72 + id string
     73 + 
    46 74   // inherited are options that are inherited by child containers.
    47 75   inherited inherited
    48 76   
    skipped 14 lines
    63 91   border linestyle.LineStyle
    64 92   borderTitle string
    65 93   borderTitleHAlign align.Horizontal
     94 + 
     95 + // padding is a space reserved between the outer edge of the container and
     96 + // its content (the widget or other sub-containers).
     97 + padding padding
     98 + 
     99 + // margin is a space reserved on the outside of the container.
     100 + margin margin
     101 +}
     102 + 
     103 +// margin stores the configured margin for the container.
     104 +// For each margin direction, only one of the percentage or cells is set.
     105 +type margin struct {
     106 + topCells int
     107 + topPerc int
     108 + rightCells int
     109 + rightPerc int
     110 + bottomCells int
     111 + bottomPerc int
     112 + leftCells int
     113 + leftPerc int
     114 +}
     115 + 
     116 +// apply applies the configured margin to the area.
     117 +func (p *margin) apply(ar image.Rectangle) (image.Rectangle, error) {
     118 + switch {
     119 + case p.topCells != 0 || p.rightCells != 0 || p.bottomCells != 0 || p.leftCells != 0:
     120 + return area.Shrink(ar, p.topCells, p.rightCells, p.bottomCells, p.leftCells)
     121 + case p.topPerc != 0 || p.rightPerc != 0 || p.bottomPerc != 0 || p.leftPerc != 0:
     122 + return area.ShrinkPercent(ar, p.topPerc, p.rightPerc, p.bottomPerc, p.leftPerc)
     123 + }
     124 + return ar, nil
     125 +}
     126 + 
     127 +// padding stores the configured padding for the container.
     128 +// For each padding direction, only one of the percentage or cells is set.
     129 +type padding struct {
     130 + topCells int
     131 + topPerc int
     132 + rightCells int
     133 + rightPerc int
     134 + bottomCells int
     135 + bottomPerc int
     136 + leftCells int
     137 + leftPerc int
     138 +}
     139 + 
     140 +// apply applies the configured padding to the area.
     141 +func (p *padding) apply(ar image.Rectangle) (image.Rectangle, error) {
     142 + switch {
     143 + case p.topCells != 0 || p.rightCells != 0 || p.bottomCells != 0 || p.leftCells != 0:
     144 + return area.Shrink(ar, p.topCells, p.rightCells, p.bottomCells, p.leftCells)
     145 + case p.topPerc != 0 || p.rightPerc != 0 || p.bottomPerc != 0 || p.leftPerc != 0:
     146 + return area.ShrinkPercent(ar, p.topPerc, p.rightPerc, p.bottomPerc, p.leftPerc)
     147 + }
     148 + return ar, nil
    66 149  }
    67 150   
    68 151  // inherited contains options that are inherited by child containers.
    skipped 77 lines
    146 229   }
    147 230   }
    148 231   
    149  - f, err := c.createFirst()
    150  - if err != nil {
     232 + if err := c.createFirst(l.lOpts()); err != nil {
    151 233   return err
    152 234   }
    153  - if err := applyOptions(f, l.lOpts()...); err != nil {
    154  - return err
    155  - }
    156  - 
    157  - s, err := c.createSecond()
    158  - if err != nil {
    159  - return err
    160  - }
    161  - return applyOptions(s, r.rOpts()...)
     235 + return c.createSecond(r.rOpts())
    162 236   })
    163 237  }
    164 238   
    skipped 10 lines
    175 249   }
    176 250   }
    177 251   
    178  - f, err := c.createFirst()
    179  - if err != nil {
     252 + if err := c.createFirst(t.tOpts()); err != nil {
    180 253   return err
    181 254   }
    182  - if err := applyOptions(f, t.tOpts()...); err != nil {
    183  - return err
     255 + 
     256 + return c.createSecond(b.bOpts())
     257 + })
     258 +}
     259 + 
     260 +// ID sets an identifier for this container.
     261 +// This ID can be later used to perform dynamic layout changes by passing new
     262 +// options to this container. When provided, it must be a non-empty string that
     263 +// is unique among all the containers.
     264 +func ID(id string) Option {
     265 + return option(func(c *Container) error {
     266 + if id == "" {
     267 + return errors.New("the ID cannot be an empty string")
    184 268   }
     269 + c.opts.id = id
     270 + return nil
     271 + })
     272 +}
    185 273   
    186  - s, err := c.createSecond()
    187  - if err != nil {
    188  - return err
    189  - }
    190  - return applyOptions(s, b.bOpts()...)
     274 +// Clear clears this container.
     275 +// If the container contains a widget, the widget is removed.
     276 +// If the container had any sub containers or splits, they are removed.
     277 +func Clear() Option {
     278 + return option(func(c *Container) error {
     279 + c.opts.widget = nil
     280 + c.first = nil
     281 + c.second = nil
     282 + return nil
    191 283   })
    192 284  }
    193 285   
    skipped 5 lines
    199 291   c.opts.widget = w
    200 292   c.first = nil
    201 293   c.second = nil
     294 + return nil
     295 + })
     296 +}
     297 + 
     298 +// MarginTop sets reserved space outside of the container at its top.
     299 +// The provided number is the absolute margin in cells and must be zero or a
     300 +// positive integer. Only one of MarginTop or MarginTopPercent can be specified.
     301 +func MarginTop(cells int) Option {
     302 + return option(func(c *Container) error {
     303 + if min := 0; cells < min {
     304 + return fmt.Errorf("invalid MarginTop(%d), must be in range %d <= value", cells, min)
     305 + }
     306 + if c.opts.margin.topPerc > 0 {
     307 + return fmt.Errorf("cannot specify both MarginTop(%d) and MarginTopPercent(%d)", cells, c.opts.margin.topPerc)
     308 + }
     309 + c.opts.margin.topCells = cells
     310 + return nil
     311 + })
     312 +}
     313 + 
     314 +// MarginRight sets reserved space outside of the container at its right.
     315 +// The provided number is the absolute margin in cells and must be zero or a
     316 +// positive integer. Only one of MarginRight or MarginRightPercent can be specified.
     317 +func MarginRight(cells int) Option {
     318 + return option(func(c *Container) error {
     319 + if min := 0; cells < min {
     320 + return fmt.Errorf("invalid MarginRight(%d), must be in range %d <= value", cells, min)
     321 + }
     322 + if c.opts.margin.rightPerc > 0 {
     323 + return fmt.Errorf("cannot specify both MarginRight(%d) and MarginRightPercent(%d)", cells, c.opts.margin.rightPerc)
     324 + }
     325 + c.opts.margin.rightCells = cells
     326 + return nil
     327 + })
     328 +}
     329 + 
     330 +// MarginBottom sets reserved space outside of the container at its bottom.
     331 +// The provided number is the absolute margin in cells and must be zero or a
     332 +// positive integer. Only one of MarginBottom or MarginBottomPercent can be specified.
     333 +func MarginBottom(cells int) Option {
     334 + return option(func(c *Container) error {
     335 + if min := 0; cells < min {
     336 + return fmt.Errorf("invalid MarginBottom(%d), must be in range %d <= value", cells, min)
     337 + }
     338 + if c.opts.margin.bottomPerc > 0 {
     339 + return fmt.Errorf("cannot specify both MarginBottom(%d) and MarginBottomPercent(%d)", cells, c.opts.margin.bottomPerc)
     340 + }
     341 + c.opts.margin.bottomCells = cells
     342 + return nil
     343 + })
     344 +}
     345 + 
     346 +// MarginLeft sets reserved space outside of the container at its left.
     347 +// The provided number is the absolute margin in cells and must be zero or a
     348 +// positive integer. Only one of MarginLeft or MarginLeftPercent can be specified.
     349 +func MarginLeft(cells int) Option {
     350 + return option(func(c *Container) error {
     351 + if min := 0; cells < min {
     352 + return fmt.Errorf("invalid MarginLeft(%d), must be in range %d <= value", cells, min)
     353 + }
     354 + if c.opts.margin.leftPerc > 0 {
     355 + return fmt.Errorf("cannot specify both MarginLeft(%d) and MarginLeftPercent(%d)", cells, c.opts.margin.leftPerc)
     356 + }
     357 + c.opts.margin.leftCells = cells
     358 + return nil
     359 + })
     360 +}
     361 + 
     362 +// MarginTopPercent sets reserved space outside of the container at its top.
     363 +// The provided number is a relative margin defined as percentage of the container's height.
     364 +// Only one of MarginTop or MarginTopPercent can be specified.
     365 +// The value must be in range 0 <= value <= 100.
     366 +func MarginTopPercent(perc int) Option {
     367 + return option(func(c *Container) error {
     368 + if min, max := 0, 100; perc < min || perc > max {
     369 + return fmt.Errorf("invalid MarginTopPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     370 + }
     371 + if c.opts.margin.topCells > 0 {
     372 + return fmt.Errorf("cannot specify both MarginTopPercent(%d) and MarginTop(%d)", perc, c.opts.margin.topCells)
     373 + }
     374 + c.opts.margin.topPerc = perc
     375 + return nil
     376 + })
     377 +}
     378 + 
     379 +// MarginRightPercent sets reserved space outside of the container at its right.
     380 +// The provided number is a relative margin defined as percentage of the container's height.
     381 +// Only one of MarginRight or MarginRightPercent can be specified.
     382 +// The value must be in range 0 <= value <= 100.
     383 +func MarginRightPercent(perc int) Option {
     384 + return option(func(c *Container) error {
     385 + if min, max := 0, 100; perc < min || perc > max {
     386 + return fmt.Errorf("invalid MarginRightPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     387 + }
     388 + if c.opts.margin.rightCells > 0 {
     389 + return fmt.Errorf("cannot specify both MarginRightPercent(%d) and MarginRight(%d)", perc, c.opts.margin.rightCells)
     390 + }
     391 + c.opts.margin.rightPerc = perc
     392 + return nil
     393 + })
     394 +}
     395 + 
     396 +// MarginBottomPercent sets reserved space outside of the container at its bottom.
     397 +// The provided number is a relative margin defined as percentage of the container's height.
     398 +// Only one of MarginBottom or MarginBottomPercent can be specified.
     399 +// The value must be in range 0 <= value <= 100.
     400 +func MarginBottomPercent(perc int) Option {
     401 + return option(func(c *Container) error {
     402 + if min, max := 0, 100; perc < min || perc > max {
     403 + return fmt.Errorf("invalid MarginBottomPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     404 + }
     405 + if c.opts.margin.bottomCells > 0 {
     406 + return fmt.Errorf("cannot specify both MarginBottomPercent(%d) and MarginBottom(%d)", perc, c.opts.margin.bottomCells)
     407 + }
     408 + c.opts.margin.bottomPerc = perc
     409 + return nil
     410 + })
     411 +}
     412 + 
     413 +// MarginLeftPercent sets reserved space outside of the container at its left.
     414 +// The provided number is a relative margin defined as percentage of the container's height.
     415 +// Only one of MarginLeft or MarginLeftPercent can be specified.
     416 +// The value must be in range 0 <= value <= 100.
     417 +func MarginLeftPercent(perc int) Option {
     418 + return option(func(c *Container) error {
     419 + if min, max := 0, 100; perc < min || perc > max {
     420 + return fmt.Errorf("invalid MarginLeftPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     421 + }
     422 + if c.opts.margin.leftCells > 0 {
     423 + return fmt.Errorf("cannot specify both MarginLeftPercent(%d) and MarginLeft(%d)", perc, c.opts.margin.leftCells)
     424 + }
     425 + c.opts.margin.leftPerc = perc
     426 + return nil
     427 + })
     428 +}
     429 + 
     430 +// PaddingTop sets reserved space between container and the top side of its widget.
     431 +// The widget's area size is decreased to accommodate the padding.
     432 +// The provided number is the absolute padding in cells and must be zero or a
     433 +// positive integer. Only one of PaddingTop or PaddingTopPercent can be specified.
     434 +func PaddingTop(cells int) Option {
     435 + return option(func(c *Container) error {
     436 + if min := 0; cells < min {
     437 + return fmt.Errorf("invalid PaddingTop(%d), must be in range %d <= value", cells, min)
     438 + }
     439 + if c.opts.padding.topPerc > 0 {
     440 + return fmt.Errorf("cannot specify both PaddingTop(%d) and PaddingTopPercent(%d)", cells, c.opts.padding.topPerc)
     441 + }
     442 + c.opts.padding.topCells = cells
     443 + return nil
     444 + })
     445 +}
     446 + 
     447 +// PaddingRight sets reserved space between container and the right side of its widget.
     448 +// The widget's area size is decreased to accommodate the padding.
     449 +// The provided number is the absolute padding in cells and must be zero or a
     450 +// positive integer. Only one of PaddingRight or PaddingRightPercent can be specified.
     451 +func PaddingRight(cells int) Option {
     452 + return option(func(c *Container) error {
     453 + if min := 0; cells < min {
     454 + return fmt.Errorf("invalid PaddingRight(%d), must be in range %d <= value", cells, min)
     455 + }
     456 + if c.opts.padding.rightPerc > 0 {
     457 + return fmt.Errorf("cannot specify both PaddingRight(%d) and PaddingRightPercent(%d)", cells, c.opts.padding.rightPerc)
     458 + }
     459 + c.opts.padding.rightCells = cells
     460 + return nil
     461 + })
     462 +}
     463 + 
     464 +// PaddingBottom sets reserved space between container and the bottom side of its widget.
     465 +// The widget's area size is decreased to accommodate the padding.
     466 +// The provided number is the absolute padding in cells and must be zero or a
     467 +// positive integer. Only one of PaddingBottom or PaddingBottomPercent can be specified.
     468 +func PaddingBottom(cells int) Option {
     469 + return option(func(c *Container) error {
     470 + if min := 0; cells < min {
     471 + return fmt.Errorf("invalid PaddingBottom(%d), must be in range %d <= value", cells, min)
     472 + }
     473 + if c.opts.padding.bottomPerc > 0 {
     474 + return fmt.Errorf("cannot specify both PaddingBottom(%d) and PaddingBottomPercent(%d)", cells, c.opts.padding.bottomPerc)
     475 + }
     476 + c.opts.padding.bottomCells = cells
     477 + return nil
     478 + })
     479 +}
     480 + 
     481 +// PaddingLeft sets reserved space between container and the left side of its widget.
     482 +// The widget's area size is decreased to accommodate the padding.
     483 +// The provided number is the absolute padding in cells and must be zero or a
     484 +// positive integer. Only one of PaddingLeft or PaddingLeftPercent can be specified.
     485 +func PaddingLeft(cells int) Option {
     486 + return option(func(c *Container) error {
     487 + if min := 0; cells < min {
     488 + return fmt.Errorf("invalid PaddingLeft(%d), must be in range %d <= value", cells, min)
     489 + }
     490 + if c.opts.padding.leftPerc > 0 {
     491 + return fmt.Errorf("cannot specify both PaddingLeft(%d) and PaddingLeftPercent(%d)", cells, c.opts.padding.leftPerc)
     492 + }
     493 + c.opts.padding.leftCells = cells
     494 + return nil
     495 + })
     496 +}
     497 + 
     498 +// PaddingTopPercent sets reserved space between container and the top side of
     499 +// its widget. The widget's area size is decreased to accommodate the padding.
     500 +// The provided number is a relative padding defined as percentage of the
     501 +// container's height. The value must be in range 0 <= value <= 100.
     502 +// Only one of PaddingTop or PaddingTopPercent can be specified.
     503 +func PaddingTopPercent(perc int) Option {
     504 + return option(func(c *Container) error {
     505 + if min, max := 0, 100; perc < min || perc > max {
     506 + return fmt.Errorf("invalid PaddingTopPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     507 + }
     508 + if c.opts.padding.topCells > 0 {
     509 + return fmt.Errorf("cannot specify both PaddingTopPercent(%d) and PaddingTop(%d)", perc, c.opts.padding.topCells)
     510 + }
     511 + c.opts.padding.topPerc = perc
     512 + return nil
     513 + })
     514 +}
     515 + 
     516 +// PaddingRightPercent sets reserved space between container and the right side of
     517 +// its widget. The widget's area size is decreased to accommodate the padding.
     518 +// The provided number is a relative padding defined as percentage of the
     519 +// container's width. The value must be in range 0 <= value <= 100.
     520 +// Only one of PaddingRight or PaddingRightPercent can be specified.
     521 +func PaddingRightPercent(perc int) Option {
     522 + return option(func(c *Container) error {
     523 + if min, max := 0, 100; perc < min || perc > max {
     524 + return fmt.Errorf("invalid PaddingRightPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     525 + }
     526 + if c.opts.padding.rightCells > 0 {
     527 + return fmt.Errorf("cannot specify both PaddingRightPercent(%d) and PaddingRight(%d)", perc, c.opts.padding.rightCells)
     528 + }
     529 + c.opts.padding.rightPerc = perc
     530 + return nil
     531 + })
     532 +}
     533 + 
     534 +// PaddingBottomPercent sets reserved space between container and the bottom side of
     535 +// its widget. The widget's area size is decreased to accommodate the padding.
     536 +// The provided number is a relative padding defined as percentage of the
     537 +// container's height. The value must be in range 0 <= value <= 100.
     538 +// Only one of PaddingBottom or PaddingBottomPercent can be specified.
     539 +func PaddingBottomPercent(perc int) Option {
     540 + return option(func(c *Container) error {
     541 + if min, max := 0, 100; perc < min || perc > max {
     542 + return fmt.Errorf("invalid PaddingBottomPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     543 + }
     544 + if c.opts.padding.bottomCells > 0 {
     545 + return fmt.Errorf("cannot specify both PaddingBottomPercent(%d) and PaddingBottom(%d)", perc, c.opts.padding.bottomCells)
     546 + }
     547 + c.opts.padding.bottomPerc = perc
     548 + return nil
     549 + })
     550 +}
     551 + 
     552 +// PaddingLeftPercent sets reserved space between container and the left side of
     553 +// its widget. The widget's area size is decreased to accommodate the padding.
     554 +// The provided number is a relative padding defined as percentage of the
     555 +// container's width. The value must be in range 0 <= value <= 100.
     556 +// Only one of PaddingLeft or PaddingLeftPercent can be specified.
     557 +func PaddingLeftPercent(perc int) Option {
     558 + return option(func(c *Container) error {
     559 + if min, max := 0, 100; perc < min || perc > max {
     560 + return fmt.Errorf("invalid PaddingLeftPercent(%d), must be in range %d <= value <= %d", perc, min, max)
     561 + }
     562 + if c.opts.padding.leftCells > 0 {
     563 + return fmt.Errorf("cannot specify both PaddingLeftPercent(%d) and PaddingLeft(%d)", perc, c.opts.padding.leftCells)
     564 + }
     565 + c.opts.padding.leftPerc = perc
    202 566   return nil
    203 567   })
    204 568  }
    skipped 202 lines
  • ■ ■ ■ ■ ■ ■
    container/traversal.go
    skipped 13 lines
    14 14   
    15 15  package container
    16 16   
     17 +import (
     18 + "errors"
     19 + "fmt"
     20 +)
     21 + 
    17 22  // traversal.go provides functions that navigate the container tree.
    18 23   
    19 24  // rootCont returns the root container.
    skipped 37 lines
    57 62   }
    58 63  }
    59 64   
     65 +// findID finds container with the provided ID.
     66 +// Returns an error of there is no container with the specified ID.
     67 +func findID(root *Container, id string) (*Container, error) {
     68 + if id == "" {
     69 + return nil, errors.New("the container ID must not be empty")
     70 + }
     71 + 
     72 + var (
     73 + errStr string
     74 + cont *Container
     75 + )
     76 + preOrder(root, &errStr, visitFunc(func(c *Container) error {
     77 + if c.opts.id == id {
     78 + cont = c
     79 + }
     80 + return nil
     81 + }))
     82 + if cont == nil {
     83 + return nil, fmt.Errorf("cannot find container with ID %q", id)
     84 + }
     85 + return cont, nil
     86 +}
     87 + 
  • ■ ■ ■ ■ ■ ■
    container/traversal_test.go
    skipped 165 lines
    166 166   }
    167 167  }
    168 168   
     169 +func TestFindID(t *testing.T) {
     170 + tests := []struct {
     171 + desc string
     172 + container func(ft *faketerm.Terminal) (*Container, error)
     173 + id string
     174 + wantFound bool
     175 + wantErr bool
     176 + }{
     177 + {
     178 + desc: "fails when searching with empty ID",
     179 + container: func(ft *faketerm.Terminal) (*Container, error) {
     180 + return New(ft)
     181 + },
     182 + wantErr: true,
     183 + },
     184 + {
     185 + desc: "no container with the specified ID",
     186 + container: func(ft *faketerm.Terminal) (*Container, error) {
     187 + return New(ft)
     188 + },
     189 + id: "mycont",
     190 + wantErr: true,
     191 + },
     192 + {
     193 + desc: "finds the container",
     194 + container: func(ft *faketerm.Terminal) (*Container, error) {
     195 + return New(ft, ID("mycont"))
     196 + },
     197 + id: "mycont",
     198 + wantFound: true,
     199 + },
     200 + }
     201 + 
     202 + for _, tc := range tests {
     203 + t.Run(tc.desc, func(t *testing.T) {
     204 + ft, err := faketerm.New(image.Point{10, 10})
     205 + if err != nil {
     206 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     207 + }
     208 + 
     209 + cont, err := tc.container(ft)
     210 + if err != nil {
     211 + t.Fatalf("tc.container => unexpected error: %v", err)
     212 + }
     213 + 
     214 + got, err := findID(cont, tc.id)
     215 + if (err != nil) != tc.wantErr {
     216 + t.Errorf("findID => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     217 + }
     218 + if err != nil {
     219 + return
     220 + }
     221 + 
     222 + if (got != nil) != tc.wantFound {
     223 + t.Errorf("findID returned %v, wantFound: %v", got, tc.wantFound)
     224 + }
     225 + })
     226 + }
     227 +}
     228 + 
  • doc/images/termdashdemo_0_8_0.gif
  • doc/images/textdemo.gif
  • ■ ■ ■ ■ ■ ■
    internal/area/area.go
    skipped 121 lines
    122 122   )
    123 123  }
    124 124   
     125 +// Shrink returns a new area whose size is reduced by the specified amount of
     126 +// cells. Can return a zero area if there is no space left in the area.
     127 +// The values must be zero or positive integers.
     128 +func Shrink(area image.Rectangle, topCells, rightCells, bottomCells, leftCells int) (image.Rectangle, error) {
     129 + for _, v := range []struct {
     130 + name string
     131 + value int
     132 + }{
     133 + {"topCells", topCells},
     134 + {"rightCells", rightCells},
     135 + {"bottomCells", bottomCells},
     136 + {"leftCells", leftCells},
     137 + } {
     138 + if min := 0; v.value < min {
     139 + return image.ZR, fmt.Errorf("invalid %s(%d), must be in range %d <= value", v.name, v.value, min)
     140 + }
     141 + }
     142 + 
     143 + shrinked := area
     144 + shrinked.Min.X, _ = numbers.MinMaxInts([]int{shrinked.Min.X + leftCells, shrinked.Max.X})
     145 + _, shrinked.Max.X = numbers.MinMaxInts([]int{shrinked.Max.X - rightCells, shrinked.Min.X})
     146 + shrinked.Min.Y, _ = numbers.MinMaxInts([]int{shrinked.Min.Y + topCells, shrinked.Max.Y})
     147 + _, shrinked.Max.Y = numbers.MinMaxInts([]int{shrinked.Max.Y - bottomCells, shrinked.Min.Y})
     148 + 
     149 + if shrinked.Dx() == 0 || shrinked.Dy() == 0 {
     150 + return image.ZR, nil
     151 + }
     152 + return shrinked, nil
     153 +}
     154 + 
     155 +// ShrinkPercent returns a new area whose size is reduced by percentage of its
     156 +// width or height. Can return a zero area if there is no space left in the area.
     157 +// The topPerc and bottomPerc indicate the percentage of area's height.
     158 +// The rightPerc and leftPerc indicate the percentage of area's width.
     159 +// The percentages must be in range 0 <= v <= 100.
     160 +func ShrinkPercent(area image.Rectangle, topPerc, rightPerc, bottomPerc, leftPerc int) (image.Rectangle, error) {
     161 + for _, v := range []struct {
     162 + name string
     163 + value int
     164 + }{
     165 + {"topPerc", topPerc},
     166 + {"rightPerc", rightPerc},
     167 + {"bottomPerc", bottomPerc},
     168 + {"leftPerc", leftPerc},
     169 + } {
     170 + if min, max := 0, 100; v.value < min || v.value > max {
     171 + return image.ZR, fmt.Errorf("invalid %s(%d), must be in range %d <= value <= %d", v.name, v.value, min, max)
     172 + }
     173 + }
     174 + 
     175 + top := area.Dy() * topPerc / 100
     176 + bottom := area.Dy() * bottomPerc / 100
     177 + right := area.Dx() * rightPerc / 100
     178 + left := area.Dx() * leftPerc / 100
     179 + return Shrink(area, top, right, bottom, left)
     180 +}
     181 + 
  • ■ ■ ■ ■ ■ ■
    internal/area/area_test.go
    skipped 398 lines
    399 399   }
    400 400  }
    401 401   
     402 +func TestShrink(t *testing.T) {
     403 + tests := []struct {
     404 + desc string
     405 + area image.Rectangle
     406 + top, right, bottom, left int
     407 + want image.Rectangle
     408 + wantErr bool
     409 + }{
     410 + {
     411 + desc: "fails for negative top",
     412 + area: image.Rect(0, 0, 1, 1),
     413 + top: -1,
     414 + right: 0,
     415 + bottom: 0,
     416 + left: 0,
     417 + wantErr: true,
     418 + },
     419 + {
     420 + desc: "fails for negative right",
     421 + area: image.Rect(0, 0, 1, 1),
     422 + top: 0,
     423 + right: -1,
     424 + bottom: 0,
     425 + left: 0,
     426 + wantErr: true,
     427 + },
     428 + {
     429 + desc: "fails for negative bottom",
     430 + area: image.Rect(0, 0, 1, 1),
     431 + top: 0,
     432 + right: 0,
     433 + bottom: -1,
     434 + left: 0,
     435 + wantErr: true,
     436 + },
     437 + {
     438 + desc: "fails for negative left",
     439 + area: image.Rect(0, 0, 1, 1),
     440 + top: 0,
     441 + right: 0,
     442 + bottom: 0,
     443 + left: -1,
     444 + wantErr: true,
     445 + },
     446 + {
     447 + desc: "area unchanged when all zero",
     448 + area: image.Rect(7, 8, 9, 10),
     449 + top: 0,
     450 + right: 0,
     451 + bottom: 0,
     452 + left: 0,
     453 + want: image.Rect(7, 8, 9, 10),
     454 + },
     455 + {
     456 + desc: "shrinks top",
     457 + area: image.Rect(7, 8, 17, 18),
     458 + top: 1,
     459 + right: 0,
     460 + bottom: 0,
     461 + left: 0,
     462 + want: image.Rect(7, 9, 17, 18),
     463 + },
     464 + {
     465 + desc: "zero area when top too large",
     466 + area: image.Rect(7, 8, 17, 18),
     467 + top: 10,
     468 + right: 0,
     469 + bottom: 0,
     470 + left: 0,
     471 + want: image.ZR,
     472 + },
     473 + {
     474 + desc: "shrinks bottom",
     475 + area: image.Rect(7, 8, 17, 18),
     476 + top: 0,
     477 + right: 0,
     478 + bottom: 1,
     479 + left: 0,
     480 + want: image.Rect(7, 8, 17, 17),
     481 + },
     482 + {
     483 + desc: "zero area when bottom too large",
     484 + area: image.Rect(7, 8, 17, 18),
     485 + top: 0,
     486 + right: 0,
     487 + bottom: 10,
     488 + left: 0,
     489 + want: image.ZR,
     490 + },
     491 + {
     492 + desc: "zero area when top and bottom cross",
     493 + area: image.Rect(7, 8, 17, 18),
     494 + top: 5,
     495 + right: 0,
     496 + bottom: 5,
     497 + left: 0,
     498 + want: image.ZR,
     499 + },
     500 + {
     501 + desc: "zero area when top and bottom overrun",
     502 + area: image.Rect(7, 8, 17, 18),
     503 + top: 50,
     504 + right: 0,
     505 + bottom: 50,
     506 + left: 0,
     507 + want: image.ZR,
     508 + },
     509 + {
     510 + desc: "shrinks right",
     511 + area: image.Rect(7, 8, 17, 18),
     512 + top: 0,
     513 + right: 1,
     514 + bottom: 0,
     515 + left: 0,
     516 + want: image.Rect(7, 8, 16, 18),
     517 + },
     518 + {
     519 + desc: "zero area when right too large",
     520 + area: image.Rect(7, 8, 17, 18),
     521 + top: 0,
     522 + right: 10,
     523 + bottom: 0,
     524 + left: 0,
     525 + want: image.ZR,
     526 + },
     527 + {
     528 + desc: "shrinks left",
     529 + area: image.Rect(7, 8, 17, 18),
     530 + top: 0,
     531 + right: 0,
     532 + bottom: 0,
     533 + left: 1,
     534 + want: image.Rect(8, 8, 17, 18),
     535 + },
     536 + {
     537 + desc: "zero area when left too large",
     538 + area: image.Rect(7, 8, 17, 18),
     539 + top: 0,
     540 + right: 0,
     541 + bottom: 0,
     542 + left: 10,
     543 + want: image.ZR,
     544 + },
     545 + {
     546 + desc: "zero area when right and left cross",
     547 + area: image.Rect(7, 8, 17, 18),
     548 + top: 0,
     549 + right: 5,
     550 + bottom: 0,
     551 + left: 5,
     552 + want: image.ZR,
     553 + },
     554 + {
     555 + desc: "zero area when right and left overrun",
     556 + area: image.Rect(7, 8, 17, 18),
     557 + top: 0,
     558 + right: 50,
     559 + bottom: 0,
     560 + left: 50,
     561 + want: image.ZR,
     562 + },
     563 + {
     564 + desc: "shrinks from all sides",
     565 + area: image.Rect(7, 8, 17, 18),
     566 + top: 1,
     567 + right: 2,
     568 + bottom: 3,
     569 + left: 4,
     570 + want: image.Rect(11, 9, 15, 15),
     571 + },
     572 + }
     573 + 
     574 + for _, tc := range tests {
     575 + t.Run(tc.desc, func(t *testing.T) {
     576 + got, err := Shrink(tc.area, tc.top, tc.right, tc.bottom, tc.left)
     577 + if (err != nil) != tc.wantErr {
     578 + t.Errorf("Shrink => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     579 + }
     580 + if err != nil {
     581 + return
     582 + }
     583 + 
     584 + if diff := pretty.Compare(tc.want, got); diff != "" {
     585 + t.Errorf("Shrink => unexpected diff (-want, +got):\n%s", diff)
     586 + }
     587 + })
     588 + }
     589 +}
     590 + 
     591 +func TestShrinkPercent(t *testing.T) {
     592 + tests := []struct {
     593 + desc string
     594 + area image.Rectangle
     595 + top, right, bottom, left int
     596 + want image.Rectangle
     597 + wantErr bool
     598 + }{
     599 + {
     600 + desc: "fails on top too low",
     601 + top: -1,
     602 + wantErr: true,
     603 + },
     604 + {
     605 + desc: "fails on top too high",
     606 + top: 101,
     607 + wantErr: true,
     608 + },
     609 + {
     610 + desc: "fails on right too low",
     611 + right: -1,
     612 + wantErr: true,
     613 + },
     614 + {
     615 + desc: "fails on right too high",
     616 + right: 101,
     617 + wantErr: true,
     618 + },
     619 + {
     620 + desc: "fails on bottom too low",
     621 + bottom: -1,
     622 + wantErr: true,
     623 + },
     624 + {
     625 + desc: "fails on bottom too high",
     626 + bottom: 101,
     627 + wantErr: true,
     628 + },
     629 + {
     630 + desc: "fails on left too low",
     631 + left: -1,
     632 + wantErr: true,
     633 + },
     634 + {
     635 + desc: "fails on left too high",
     636 + left: 101,
     637 + wantErr: true,
     638 + },
     639 + {
     640 + desc: "shrinks to zero area for top too large",
     641 + area: image.Rect(0, 0, 100, 100),
     642 + top: 100,
     643 + want: image.ZR,
     644 + },
     645 + {
     646 + desc: "shrinks to zero area for bottom too large",
     647 + area: image.Rect(0, 0, 100, 100),
     648 + bottom: 100,
     649 + want: image.ZR,
     650 + },
     651 + {
     652 + desc: "shrinks to zero area top and bottom that meet",
     653 + area: image.Rect(0, 0, 100, 100),
     654 + top: 50,
     655 + bottom: 50,
     656 + want: image.ZR,
     657 + },
     658 + {
     659 + desc: "shrinks to zero area for right too large",
     660 + area: image.Rect(0, 0, 100, 100),
     661 + right: 100,
     662 + want: image.ZR,
     663 + },
     664 + {
     665 + desc: "shrinks to zero area for left too large",
     666 + area: image.Rect(0, 0, 100, 100),
     667 + left: 100,
     668 + want: image.ZR,
     669 + },
     670 + {
     671 + desc: "shrinks to zero area right and left that meet",
     672 + area: image.Rect(0, 0, 100, 100),
     673 + right: 50,
     674 + left: 50,
     675 + want: image.ZR,
     676 + },
     677 + {
     678 + desc: "shrinks from all sides",
     679 + area: image.Rect(0, 0, 100, 100),
     680 + top: 10,
     681 + right: 20,
     682 + bottom: 30,
     683 + left: 40,
     684 + want: image.Rect(40, 10, 80, 70),
     685 + },
     686 + }
     687 + 
     688 + for _, tc := range tests {
     689 + t.Run(tc.desc, func(t *testing.T) {
     690 + got, err := ShrinkPercent(tc.area, tc.top, tc.right, tc.bottom, tc.left)
     691 + if (err != nil) != tc.wantErr {
     692 + t.Errorf("ShrinkPercent => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     693 + }
     694 + if err != nil {
     695 + return
     696 + }
     697 + 
     698 + if diff := pretty.Compare(tc.want, got); diff != "" {
     699 + t.Errorf("ShrinkPercent => unexpected diff (-want, +got):\n%s", diff)
     700 + }
     701 + })
     702 + }
     703 +}
     704 + 
  • ■ ■ ■ ■ ■ ■
    internal/canvas/buffer/buffer.go
    skipped 23 lines
    24 24   "github.com/mum4k/termdash/internal/runewidth"
    25 25  )
    26 26   
     27 +// NewCells breaks the provided text into cells and applies the options.
     28 +func NewCells(text string, opts ...cell.Option) []*Cell {
     29 + var res []*Cell
     30 + for _, r := range text {
     31 + res = append(res, NewCell(r, opts...))
     32 + }
     33 + return res
     34 +}
     35 + 
    27 36  // Cell represents a single cell on the terminal.
    28 37  type Cell struct {
    29 38   // Rune is the rune stored in the cell.
    skipped 1 lines
    31 40   
    32 41   // Opts are the cell options.
    33 42   Opts *cell.Options
     43 +}
     44 + 
     45 +// String implements fmt.Stringer.
     46 +func (c *Cell) String() string {
     47 + return fmt.Sprintf("{%q}", c.Rune)
    34 48  }
    35 49   
    36 50  // NewCell returns a new cell.
    skipped 134 lines
  • ■ ■ ■ ■ ■
    internal/canvas/buffer/buffer_test.go
    skipped 14 lines
    15 15  package buffer
    16 16   
    17 17  import (
     18 + "fmt"
    18 19   "image"
    19 20   "testing"
    20 21   
    skipped 1 lines
    22 23   "github.com/mum4k/termdash/cell"
    23 24  )
    24 25   
     26 +func TestNewCells(t *testing.T) {
     27 + tests := []struct {
     28 + desc string
     29 + text string
     30 + opts []cell.Option
     31 + want []*Cell
     32 + }{
     33 + {
     34 + desc: "no cells for empty text",
     35 + },
     36 + {
     37 + desc: "cells created from text with default options",
     38 + text: "hello",
     39 + want: []*Cell{
     40 + NewCell('h'),
     41 + NewCell('e'),
     42 + NewCell('l'),
     43 + NewCell('l'),
     44 + NewCell('o'),
     45 + },
     46 + },
     47 + {
     48 + desc: "cells with options",
     49 + text: "ha",
     50 + opts: []cell.Option{
     51 + cell.FgColor(cell.ColorCyan),
     52 + cell.BgColor(cell.ColorMagenta),
     53 + },
     54 + want: []*Cell{
     55 + NewCell('h', cell.FgColor(cell.ColorCyan), cell.BgColor(cell.ColorMagenta)),
     56 + NewCell('a', cell.FgColor(cell.ColorCyan), cell.BgColor(cell.ColorMagenta)),
     57 + },
     58 + },
     59 + }
     60 + 
     61 + for _, tc := range tests {
     62 + t.Run(tc.desc, func(t *testing.T) {
     63 + got := NewCells(tc.text, tc.opts...)
     64 + if diff := pretty.Compare(tc.want, got); diff != "" {
     65 + t.Errorf("NewCells => unexpected diff (-want, +got):\n%s", diff)
     66 + }
     67 + })
     68 + }
     69 +}
     70 + 
    25 71  func TestNewCell(t *testing.T) {
    26 72   tests := []struct {
    27 73   desc string
    28 74   r rune
    29 75   opts []cell.Option
    30  - want Cell
     76 + want *Cell
    31 77   }{
    32 78   {
    33 79   desc: "creates empty cell with default options",
    34  - want: Cell{
     80 + want: &Cell{
    35 81   Opts: &cell.Options{},
    36 82   },
    37 83   },
    38 84   {
    39 85   desc: "cell with the specified rune",
    40 86   r: 'X',
    41  - want: Cell{
     87 + want: &Cell{
    42 88   Rune: 'X',
    43 89   Opts: &cell.Options{},
    44 90   },
    skipped 5 lines
    50 96   cell.FgColor(cell.ColorCyan),
    51 97   cell.BgColor(cell.ColorMagenta),
    52 98   },
    53  - want: Cell{
     99 + want: &Cell{
    54 100   Rune: 'X',
    55 101   Opts: &cell.Options{
    56 102   FgColor: cell.ColorCyan,
    skipped 10 lines
    67 113   BgColor: cell.ColorBlue,
    68 114   },
    69 115   },
    70  - want: Cell{
     116 + want: &Cell{
    71 117   Rune: 'X',
    72 118   Opts: &cell.Options{
    73 119   FgColor: cell.ColorBlack,
    skipped 6 lines
    80 126   for _, tc := range tests {
    81 127   t.Run(tc.desc, func(t *testing.T) {
    82 128   got := NewCell(tc.r, tc.opts...)
     129 + t.Logf(fmt.Sprintf("%v", got))
    83 130   if diff := pretty.Compare(tc.want, got); diff != "" {
    84  - t.Errorf("New => unexpected diff (-want, +got):\n%s", diff)
     131 + t.Errorf("NewCell => unexpected diff (-want, +got):\n%s", diff)
    85 132   }
    86 133   })
    87 134   }
    skipped 494 lines
  • ■ ■ ■ ■
    widgets/fakewidget/fakewidget.go internal/fakewidget/fakewidget.go
    skipped 23 lines
    24 24   "github.com/mum4k/termdash/internal/area"
    25 25   "github.com/mum4k/termdash/internal/canvas"
    26 26   "github.com/mum4k/termdash/internal/draw"
    27  - "github.com/mum4k/termdash/internal/widgetapi"
    28 27   "github.com/mum4k/termdash/keyboard"
    29 28   "github.com/mum4k/termdash/mouse"
    30 29   "github.com/mum4k/termdash/terminal/terminalapi"
     30 + "github.com/mum4k/termdash/widgetapi"
    31 31  )
    32 32   
    33 33  // outputLines are the number of lines written by this plugin.
    skipped 173 lines
  • ■ ■ ■ ■
    widgets/fakewidget/fakewidget_test.go internal/fakewidget/fakewidget_test.go
    skipped 22 lines
    23 23   "github.com/mum4k/termdash/internal/canvas/testcanvas"
    24 24   "github.com/mum4k/termdash/internal/draw/testdraw"
    25 25   "github.com/mum4k/termdash/internal/faketerm"
    26  - "github.com/mum4k/termdash/internal/widgetapi"
    27 26   "github.com/mum4k/termdash/keyboard"
    28 27   "github.com/mum4k/termdash/mouse"
    29 28   "github.com/mum4k/termdash/terminal/terminalapi"
     29 + "github.com/mum4k/termdash/widgetapi"
    30 30  )
    31 31   
    32 32  // keyEvents are keyboard events to send to the widget.
    skipped 329 lines
  • ■ ■ ■ ■ ■ ■
    internal/wrap/wrap.go
     1 +// Copyright 2018 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +// Package wrap implements line wrapping at character or word boundaries.
     16 +package wrap
     17 + 
     18 +import (
     19 + "bytes"
     20 + "errors"
     21 + "fmt"
     22 + "unicode"
     23 + 
     24 + "github.com/mum4k/termdash/internal/canvas/buffer"
     25 + "github.com/mum4k/termdash/internal/runewidth"
     26 +)
     27 + 
     28 +// Mode sets the wrapping mode.
     29 +type Mode int
     30 + 
     31 +// String implements fmt.Stringer()
     32 +func (m Mode) String() string {
     33 + if n, ok := modeNames[m]; ok {
     34 + return n
     35 + }
     36 + return "ModeUnknown"
     37 +}
     38 + 
     39 +// modeNames maps Mode values to human readable names.
     40 +var modeNames = map[Mode]string{
     41 + Never: "WrapModeNever",
     42 + AtRunes: "WrapModeAtRunes",
     43 + AtWords: "WrapModeAtWords",
     44 +}
     45 + 
     46 +const (
     47 + // Never is the default wrapping mode, which disables line wrapping.
     48 + Never Mode = iota
     49 + 
     50 + // AtRunes is a wrapping mode where if the width of the text crosses the
     51 + // width of the canvas, wrapping is performed at rune boundaries.
     52 + AtRunes
     53 + 
     54 + // AtWords is a wrapping mode where if the width of the text crosses the
     55 + // width of the canvas, wrapping is performed at word boundaries. The
     56 + // wrapping still switches back to the AtRunes mode for any words that are
     57 + // longer than the width.
     58 + AtWords
     59 +)
     60 + 
     61 +// ValidText validates the provided text for wrapping.
     62 +// The text must not contain any control or space characters other
     63 +// than '\n' and ' '.
     64 +func ValidText(text string) error {
     65 + if text == "" {
     66 + return errors.New("the text cannot be empty")
     67 + }
     68 + 
     69 + for _, c := range text {
     70 + if c == ' ' || c == '\n' { // Allowed space and control runes.
     71 + continue
     72 + }
     73 + if unicode.IsControl(c) {
     74 + return fmt.Errorf("the provided text %q cannot contain control characters, found: %q", text, c)
     75 + }
     76 + if unicode.IsSpace(c) {
     77 + return fmt.Errorf("the provided text %q cannot contain space character %q", text, c)
     78 + }
     79 + }
     80 + return nil
     81 +}
     82 + 
     83 +// ValidCells validates the provided cells for wrapping.
     84 +// The text in the cells must follow the same rules as described for ValidText.
     85 +func ValidCells(cells []*buffer.Cell) error {
     86 + var b bytes.Buffer
     87 + for _, c := range cells {
     88 + b.WriteRune(c.Rune)
     89 + }
     90 + return ValidText(b.String())
     91 +}
     92 + 
     93 +// Cells returns the cells wrapped into individual lines according to the
     94 +// specified width and wrapping mode.
     95 +//
     96 +// This function consumes any cells that contain newline characters and uses
     97 +// them to start new lines.
     98 +//
     99 +// If the mode is AtWords, this function also drops cells with leading space
     100 +// character before a word at which the wrap occurs.
     101 +func Cells(cells []*buffer.Cell, width int, m Mode) ([][]*buffer.Cell, error) {
     102 + if err := ValidCells(cells); err != nil {
     103 + return nil, err
     104 + }
     105 + switch m {
     106 + case Never:
     107 + case AtRunes:
     108 + case AtWords:
     109 + default:
     110 + return nil, fmt.Errorf("unsupported wrapping mode %v(%d)", m, m)
     111 + }
     112 + if width <= 0 {
     113 + return nil, nil
     114 + }
     115 + 
     116 + cs := newCellScanner(cells, width, m)
     117 + for state := scanCellRunes; state != nil; state = state(cs) {
     118 + }
     119 + return cs.lines, nil
     120 +}
     121 + 
     122 +// cellScannerState is a state in the FSM that scans the input text and identifies
     123 +// newlines.
     124 +type cellScannerState func(*cellScanner) cellScannerState
     125 + 
     126 +// cellScanner tracks the progress of scanning the input cells when finding
     127 +// lines.
     128 +type cellScanner struct {
     129 + // cells are the cells being scanned.
     130 + cells []*buffer.Cell
     131 + 
     132 + // nextIdx is the index of the cell that will be returned by next.
     133 + nextIdx int
     134 + 
     135 + // wordStartIdx stores the starting index of the current word.
     136 + // A starting position of a word includes any leading space characters.
     137 + // E.g.: hello world
     138 + // ^
     139 + // lastWordIdx
     140 + wordStartIdx int
     141 + // wordEndIdx stores the ending index of the current word.
     142 + // The word consists of all indexes that are
     143 + // wordStartIdx <= idx < wordEndIdx.
     144 + // A word also includes any punctuation after it.
     145 + wordEndIdx int
     146 + 
     147 + // width is the width of the canvas the text will be drawn on.
     148 + width int
     149 + 
     150 + // posX tracks the horizontal position of the current cell on the canvas.
     151 + posX int
     152 + 
     153 + // mode is the wrapping mode.
     154 + mode Mode
     155 + 
     156 + // atRunesInWord overrides the mode back to AtRunes.
     157 + atRunesInWord bool
     158 + 
     159 + // lines are the identified lines.
     160 + lines [][]*buffer.Cell
     161 + 
     162 + // line is the current line.
     163 + line []*buffer.Cell
     164 +}
     165 + 
     166 +// newCellScanner returns a scanner of the provided cells.
     167 +func newCellScanner(cells []*buffer.Cell, width int, m Mode) *cellScanner {
     168 + return &cellScanner{
     169 + cells: cells,
     170 + width: width,
     171 + mode: m,
     172 + }
     173 +}
     174 + 
     175 +// next returns the next cell and advances the scanner.
     176 +// Returns nil when there are no more cells to scan.
     177 +func (cs *cellScanner) next() *buffer.Cell {
     178 + c := cs.peek()
     179 + if c != nil {
     180 + cs.nextIdx++
     181 + }
     182 + return c
     183 +}
     184 + 
     185 +// peek returns the next cell without advancing the scanner's position.
     186 +// Returns nil when there are no more cells to peek at.
     187 +func (cs *cellScanner) peek() *buffer.Cell {
     188 + if cs.nextIdx >= len(cs.cells) {
     189 + return nil
     190 + }
     191 + return cs.cells[cs.nextIdx]
     192 +}
     193 + 
     194 +// peekPrev returns the previous cell without changing the scanner's position.
     195 +// Returns nil if the scanner is at the first cell.
     196 +func (cs *cellScanner) peekPrev() *buffer.Cell {
     197 + if cs.nextIdx == 0 {
     198 + return nil
     199 + }
     200 + return cs.cells[cs.nextIdx-1]
     201 +}
     202 + 
     203 +// wordCells returns all the cells that belong to the current word.
     204 +func (cs *cellScanner) wordCells() []*buffer.Cell {
     205 + return cs.cells[cs.wordStartIdx:cs.wordEndIdx]
     206 +}
     207 + 
     208 +// wordWidth returns the width of the current word in cells when printed on the
     209 +// terminal.
     210 +func (cs *cellScanner) wordWidth() int {
     211 + var b bytes.Buffer
     212 + for _, wc := range cs.wordCells() {
     213 + b.WriteRune(wc.Rune)
     214 + }
     215 + return runewidth.StringWidth(b.String())
     216 +}
     217 + 
     218 +// isWordStart determines if the scanner is at the beginning of a word.
     219 +func (cs *cellScanner) isWordStart() bool {
     220 + if cs.mode != AtWords {
     221 + return false
     222 + }
     223 + 
     224 + current := cs.peekPrev()
     225 + next := cs.peek()
     226 + if current == nil || next == nil {
     227 + return false
     228 + }
     229 + 
     230 + switch nr := next.Rune; {
     231 + case nr == '\n':
     232 + case nr == ' ':
     233 + default:
     234 + return true
     235 + }
     236 + return false
     237 +}
     238 + 
     239 +// scanCellRunes scans the cells a rune at a time.
     240 +func scanCellRunes(cs *cellScanner) cellScannerState {
     241 + for {
     242 + cell := cs.next()
     243 + if cell == nil {
     244 + return scanEOF
     245 + }
     246 + 
     247 + r := cell.Rune
     248 + if r == '\n' {
     249 + return newLineForLineBreak
     250 + }
     251 + 
     252 + if cs.mode == Never {
     253 + return runeToCurrentLine
     254 + }
     255 + 
     256 + if cs.atRunesInWord && !isWordCell(cell) {
     257 + cs.atRunesInWord = false
     258 + }
     259 + 
     260 + if !cs.atRunesInWord && cs.isWordStart() {
     261 + return markWordStart
     262 + }
     263 + 
     264 + if runeWrapNeeded(r, cs.posX, cs.width) {
     265 + return newLineForAtRunes
     266 + }
     267 + 
     268 + return runeToCurrentLine
     269 + }
     270 +}
     271 + 
     272 +// runeToCurrentLine scans a single cell rune onto the current line.
     273 +func runeToCurrentLine(cs *cellScanner) cellScannerState {
     274 + cell := cs.peekPrev()
     275 + // Move horizontally within the line for each scanned cell.
     276 + cs.posX += runewidth.RuneWidth(cell.Rune)
     277 + 
     278 + // Copy the cell into the current line.
     279 + cs.line = append(cs.line, cell)
     280 + return scanCellRunes
     281 +}
     282 + 
     283 +// newLineForLineBreak processes a newline character cell.
     284 +func newLineForLineBreak(cs *cellScanner) cellScannerState {
     285 + cs.lines = append(cs.lines, cs.line)
     286 + cs.posX = 0
     287 + cs.line = nil
     288 + return scanCellRunes
     289 +}
     290 + 
     291 +// newLineForAtRunes processes a line wrap at rune boundaries due to canvas width.
     292 +func newLineForAtRunes(cs *cellScanner) cellScannerState {
     293 + // The character on which we wrapped will be printed and is the start of
     294 + // new line.
     295 + cs.lines = append(cs.lines, cs.line)
     296 + cs.posX = runewidth.RuneWidth(cs.peekPrev().Rune)
     297 + cs.line = []*buffer.Cell{cs.peekPrev()}
     298 + return scanCellRunes
     299 +}
     300 + 
     301 +// scanEOF terminates the scanning.
     302 +func scanEOF(cs *cellScanner) cellScannerState {
     303 + // Need to add the current line if it isn't empty, or if the previous rune
     304 + // was a newline.
     305 + // Newlines aren't copied onto the lines so just checking for emptiness
     306 + // isn't enough. We still want to include trailing empty newlines if
     307 + // they are in the input text.
     308 + if len(cs.line) > 0 || cs.peekPrev().Rune == '\n' {
     309 + cs.lines = append(cs.lines, cs.line)
     310 + }
     311 + return nil
     312 +}
     313 + 
     314 +// markWordStart stores the starting position of the current word.
     315 +func markWordStart(cs *cellScanner) cellScannerState {
     316 + cs.wordStartIdx = cs.nextIdx - 1
     317 + cs.wordEndIdx = cs.nextIdx
     318 + return scanWord
     319 +}
     320 + 
     321 +// scanWord scans the entire word until it finds its end.
     322 +func scanWord(cs *cellScanner) cellScannerState {
     323 + for {
     324 + if isWordCell(cs.peek()) {
     325 + cs.next()
     326 + cs.wordEndIdx++
     327 + continue
     328 + }
     329 + return wordToCurrentLine
     330 + }
     331 +}
     332 + 
     333 +// wordToCurrentLine decides how to place the word into the output.
     334 +func wordToCurrentLine(cs *cellScanner) cellScannerState {
     335 + wordCells := cs.wordCells()
     336 + wordWidth := cs.wordWidth()
     337 + 
     338 + if cs.posX+wordWidth <= cs.width {
     339 + // Place the word onto the current line.
     340 + cs.posX += wordWidth
     341 + cs.line = append(cs.line, wordCells...)
     342 + return scanCellRunes
     343 + }
     344 + return wrapWord
     345 +}
     346 + 
     347 +// wrapWord wraps the word onto the next line or lines.
     348 +func wrapWord(cs *cellScanner) cellScannerState {
     349 + // Edge-case - the word starts the line and immediately doesn't fit.
     350 + if cs.posX > 0 {
     351 + cs.lines = append(cs.lines, cs.line)
     352 + cs.posX = 0
     353 + cs.line = nil
     354 + }
     355 + 
     356 + for i, wc := range cs.wordCells() {
     357 + if i == 0 && wc.Rune == ' ' {
     358 + // Skip the leading space when word wrapping.
     359 + continue
     360 + }
     361 + 
     362 + if !runeWrapNeeded(wc.Rune, cs.posX, cs.width) {
     363 + cs.posX += runewidth.RuneWidth(wc.Rune)
     364 + cs.line = append(cs.line, wc)
     365 + continue
     366 + }
     367 + 
     368 + // Replace the last placed rune with a dash indicating we wrapped the
     369 + // word. Only do this for half-width runes.
     370 + lastIdx := len(cs.line) - 1
     371 + last := cs.line[lastIdx]
     372 + lastRW := runewidth.RuneWidth(last.Rune)
     373 + if cs.width > 1 && lastRW == 1 {
     374 + cs.line[lastIdx] = buffer.NewCell('-', last.Opts)
     375 + // Reset the scanner's position back to start scanning at the first
     376 + // rune of this word that wasn't placed.
     377 + cs.nextIdx = cs.wordStartIdx + i - 1
     378 + } else {
     379 + // Edge-case width is one, no space to put the dash rune.
     380 + cs.nextIdx = cs.wordStartIdx + i
     381 + }
     382 + cs.atRunesInWord = true
     383 + return scanCellRunes
     384 + }
     385 + 
     386 + cs.nextIdx = cs.wordEndIdx
     387 + return scanCellRunes
     388 +}
     389 + 
     390 +// isWordCell determines if the cell contains a rune that belongs to a word.
     391 +func isWordCell(c *buffer.Cell) bool {
     392 + if c == nil {
     393 + return false
     394 + }
     395 + switch r := c.Rune; {
     396 + case r == '\n':
     397 + case r == ' ':
     398 + default:
     399 + return true
     400 + }
     401 + return false
     402 +}
     403 + 
     404 +// runeWrapNeeded returns true if wrapping is needed for the rune at the horizontal
     405 +// position on the canvas that has the specified width.
     406 +func runeWrapNeeded(r rune, posX, width int) bool {
     407 + rw := runewidth.RuneWidth(r)
     408 + return posX > width-rw
     409 +}
     410 + 
  • ■ ■ ■ ■ ■ ■
    internal/wrap/wrap_test.go
     1 +// Copyright 2018 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package wrap
     16 + 
     17 +import (
     18 + "bytes"
     19 + "fmt"
     20 + "testing"
     21 + "unicode"
     22 + 
     23 + "github.com/kylelemons/godebug/pretty"
     24 + "github.com/mum4k/termdash/cell"
     25 + "github.com/mum4k/termdash/internal/canvas/buffer"
     26 +)
     27 + 
     28 +func TestValidTextAndCells(t *testing.T) {
     29 + tests := []struct {
     30 + desc string
     31 + text string // All runes checked individually.
     32 + wantErr bool
     33 + }{
     34 + {
     35 + desc: "empty text is not valid",
     36 + wantErr: true,
     37 + },
     38 + {
     39 + desc: "digits are allowed",
     40 + text: "0123456789",
     41 + },
     42 + {
     43 + desc: "all printable ASCII characters are allowed",
     44 + text: func() string {
     45 + var b bytes.Buffer
     46 + for i := 0; i < unicode.MaxASCII; i++ {
     47 + r := rune(i)
     48 + if unicode.IsPrint(r) {
     49 + b.WriteRune(r)
     50 + }
     51 + }
     52 + return b.String()
     53 + }(),
     54 + },
     55 + {
     56 + desc: "all printable Unicode characters in the Latin-1 space are allowed",
     57 + text: func() string {
     58 + var b bytes.Buffer
     59 + for i := 0; i < unicode.MaxLatin1; i++ {
     60 + r := rune(i)
     61 + if unicode.IsPrint(r) {
     62 + b.WriteRune(r)
     63 + }
     64 + }
     65 + return b.String()
     66 + }(),
     67 + },
     68 + {
     69 + desc: "sample of half-width unicode runes that are allowed",
     70 + text: "セカイ☆",
     71 + },
     72 + {
     73 + desc: "sample of full-width unicode runes that are allowed",
     74 + text: "世界",
     75 + },
     76 + {
     77 + desc: "spaces are allowed",
     78 + text: " ",
     79 + },
     80 + {
     81 + desc: "no other space characters",
     82 + text: fmt.Sprintf("\t\v\f\r%c%c", 0x85, 0xA0),
     83 + wantErr: true,
     84 + },
     85 + {
     86 + desc: "no control characters",
     87 + text: fmt.Sprintf("%c%c", 0x0000, 0x007f),
     88 + wantErr: true,
     89 + },
     90 + 
     91 + {
     92 + desc: "newlines are allowed",
     93 + text: " ",
     94 + },
     95 + }
     96 + 
     97 + for _, tc := range tests {
     98 + t.Run(tc.desc, func(t *testing.T) {
     99 + {
     100 + err := ValidText(tc.text)
     101 + if (err != nil) != tc.wantErr {
     102 + t.Errorf("ValidText => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     103 + }
     104 + }
     105 + 
     106 + // All individual runes must give the same result.
     107 + for _, r := range tc.text {
     108 + err := ValidText(string(r))
     109 + if (err != nil) != tc.wantErr {
     110 + t.Errorf("ValidText => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     111 + }
     112 + }
     113 + 
     114 + var cells []*buffer.Cell
     115 + for _, r := range tc.text {
     116 + cells = append(cells, buffer.NewCell(r))
     117 + }
     118 + {
     119 + err := ValidCells(cells)
     120 + if (err != nil) != tc.wantErr {
     121 + t.Errorf("ValidCells => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     122 + }
     123 + }
     124 + })
     125 + }
     126 +}
     127 + 
     128 +func TestCells(t *testing.T) {
     129 + tests := []struct {
     130 + desc string
     131 + cells []*buffer.Cell
     132 + // width is the width of the canvas.
     133 + width int
     134 + mode Mode
     135 + want [][]*buffer.Cell
     136 + wantErr bool
     137 + }{
     138 + {
     139 + desc: "fails with zero text",
     140 + width: 1,
     141 + wantErr: true,
     142 + },
     143 + {
     144 + desc: "fails with invalid runes (tabs)",
     145 + cells: buffer.NewCells("hello\t"),
     146 + width: 1,
     147 + wantErr: true,
     148 + },
     149 + {
     150 + desc: "fails with unsupported wrap mode",
     151 + cells: buffer.NewCells("hello"),
     152 + width: 1,
     153 + mode: Mode(-1),
     154 + wantErr: true,
     155 + },
     156 + {
     157 + desc: "zero canvas width",
     158 + cells: buffer.NewCells("hello"),
     159 + width: 0,
     160 + want: nil,
     161 + },
     162 + {
     163 + desc: "wrapping disabled, no newlines, fits in canvas width",
     164 + cells: buffer.NewCells("hello"),
     165 + width: 5,
     166 + want: [][]*buffer.Cell{
     167 + buffer.NewCells("hello"),
     168 + },
     169 + },
     170 + {
     171 + desc: "wrapping disabled, no newlines, doesn't fits in canvas width",
     172 + cells: buffer.NewCells("hello"),
     173 + width: 4,
     174 + want: [][]*buffer.Cell{
     175 + buffer.NewCells("hello"),
     176 + },
     177 + },
     178 + {
     179 + desc: "wrapping disabled, newlines, fits in canvas width",
     180 + cells: buffer.NewCells("hello\nworld"),
     181 + width: 5,
     182 + want: [][]*buffer.Cell{
     183 + buffer.NewCells("hello"),
     184 + buffer.NewCells("world"),
     185 + },
     186 + },
     187 + {
     188 + desc: "wrapping disabled, newlines, doesn't fit in canvas width",
     189 + cells: buffer.NewCells("hello\nworld"),
     190 + width: 4,
     191 + want: [][]*buffer.Cell{
     192 + buffer.NewCells("hello"),
     193 + buffer.NewCells("world"),
     194 + },
     195 + },
     196 + {
     197 + desc: "wrapping enabled, no newlines, fits in canvas width",
     198 + cells: buffer.NewCells("hello"),
     199 + width: 5,
     200 + mode: AtRunes,
     201 + want: [][]*buffer.Cell{
     202 + buffer.NewCells("hello"),
     203 + },
     204 + },
     205 + {
     206 + desc: "wrapping enabled, no newlines, doesn't fit in canvas width",
     207 + cells: buffer.NewCells("hello"),
     208 + width: 4,
     209 + mode: AtRunes,
     210 + want: [][]*buffer.Cell{
     211 + buffer.NewCells("hell"),
     212 + buffer.NewCells("o"),
     213 + },
     214 + },
     215 + {
     216 + desc: "wrapping enabled, newlines, fits in canvas width",
     217 + cells: buffer.NewCells("hello\nworld"),
     218 + width: 5,
     219 + mode: AtRunes,
     220 + want: [][]*buffer.Cell{
     221 + buffer.NewCells("hello"),
     222 + buffer.NewCells("world"),
     223 + },
     224 + },
     225 + {
     226 + desc: "wrapping enabled, newlines, doesn't fit in canvas width",
     227 + cells: buffer.NewCells("hello\nworld"),
     228 + width: 4,
     229 + mode: AtRunes,
     230 + want: [][]*buffer.Cell{
     231 + buffer.NewCells("hell"),
     232 + buffer.NewCells("o"),
     233 + buffer.NewCells("worl"),
     234 + buffer.NewCells("d"),
     235 + },
     236 + },
     237 + {
     238 + desc: "wrapping enabled, newlines, doesn't fit in canvas width, unicode characters",
     239 + cells: buffer.NewCells("⇧\n…\n⇩"),
     240 + width: 1,
     241 + mode: AtRunes,
     242 + want: [][]*buffer.Cell{
     243 + buffer.NewCells("⇧"),
     244 + buffer.NewCells("…"),
     245 + buffer.NewCells("⇩"),
     246 + },
     247 + },
     248 + {
     249 + desc: "wrapping enabled, newlines, doesn't fit in width, full-width unicode characters",
     250 + cells: buffer.NewCells("你好\n世界"),
     251 + width: 2,
     252 + mode: AtRunes,
     253 + want: [][]*buffer.Cell{
     254 + buffer.NewCells("你"),
     255 + buffer.NewCells("好"),
     256 + buffer.NewCells("世"),
     257 + buffer.NewCells("界"),
     258 + },
     259 + },
     260 + {
     261 + desc: "wraps before a full-width character that starts in and falls out",
     262 + cells: buffer.NewCells("a你b"),
     263 + width: 2,
     264 + mode: AtRunes,
     265 + want: [][]*buffer.Cell{
     266 + buffer.NewCells("a"),
     267 + buffer.NewCells("你"),
     268 + buffer.NewCells("b"),
     269 + },
     270 + },
     271 + {
     272 + desc: "wraps before a full-width character that falls out",
     273 + cells: buffer.NewCells("ab你b"),
     274 + width: 2,
     275 + mode: AtRunes,
     276 + want: [][]*buffer.Cell{
     277 + buffer.NewCells("ab"),
     278 + buffer.NewCells("你"),
     279 + buffer.NewCells("b"),
     280 + },
     281 + },
     282 + {
     283 + desc: "handles leading and trailing newlines",
     284 + cells: buffer.NewCells("\n\n\nhello\n\n\n"),
     285 + width: 4,
     286 + mode: AtRunes,
     287 + want: [][]*buffer.Cell{
     288 + buffer.NewCells(""),
     289 + buffer.NewCells(""),
     290 + buffer.NewCells(""),
     291 + buffer.NewCells("hell"),
     292 + buffer.NewCells("o"),
     293 + buffer.NewCells(""),
     294 + buffer.NewCells(""),
     295 + buffer.NewCells(""),
     296 + },
     297 + },
     298 + {
     299 + desc: "handles multiple newlines in the middle",
     300 + cells: buffer.NewCells("hello\n\n\nworld"),
     301 + width: 5,
     302 + mode: AtRunes,
     303 + want: [][]*buffer.Cell{
     304 + buffer.NewCells("hello"),
     305 + buffer.NewCells(""),
     306 + buffer.NewCells(""),
     307 + buffer.NewCells("world"),
     308 + },
     309 + },
     310 + {
     311 + desc: "handles multiple newlines in the middle and wraps",
     312 + cells: buffer.NewCells("hello\n\n\nworld"),
     313 + width: 2,
     314 + mode: AtRunes,
     315 + want: [][]*buffer.Cell{
     316 + buffer.NewCells("he"),
     317 + buffer.NewCells("ll"),
     318 + buffer.NewCells("o"),
     319 + buffer.NewCells(""),
     320 + buffer.NewCells(""),
     321 + buffer.NewCells("wo"),
     322 + buffer.NewCells("rl"),
     323 + buffer.NewCells("d"),
     324 + },
     325 + },
     326 + {
     327 + desc: "contains only newlines",
     328 + cells: buffer.NewCells("\n\n\n"),
     329 + width: 4,
     330 + mode: AtRunes,
     331 + want: [][]*buffer.Cell{
     332 + buffer.NewCells(""),
     333 + buffer.NewCells(""),
     334 + buffer.NewCells(""),
     335 + buffer.NewCells(""),
     336 + },
     337 + },
     338 + {
     339 + desc: "wraps at words, no need to wrap",
     340 + cells: buffer.NewCells("aaa bb cc"),
     341 + width: 9,
     342 + mode: AtWords,
     343 + want: [][]*buffer.Cell{
     344 + buffer.NewCells("aaa bb cc"),
     345 + },
     346 + },
     347 + {
     348 + desc: "wraps at words, all fit individually, wrap falls on space",
     349 + cells: buffer.NewCells("aaa bb cc"),
     350 + width: 6,
     351 + mode: AtWords,
     352 + want: [][]*buffer.Cell{
     353 + buffer.NewCells("aaa bb"),
     354 + buffer.NewCells("cc"),
     355 + },
     356 + },
     357 + {
     358 + desc: "wraps at words, all fit individually, each word on its own line",
     359 + cells: buffer.NewCells("aaa bb cc"),
     360 + width: 3,
     361 + mode: AtWords,
     362 + want: [][]*buffer.Cell{
     363 + buffer.NewCells("aaa"),
     364 + buffer.NewCells("bb"),
     365 + buffer.NewCells("cc"),
     366 + },
     367 + },
     368 + {
     369 + desc: "wraps at words, respects newline characters with spaces between words",
     370 + cells: buffer.NewCells("aaa \n bb cc"),
     371 + width: 3,
     372 + mode: AtWords,
     373 + want: [][]*buffer.Cell{
     374 + buffer.NewCells("aaa"),
     375 + buffer.NewCells(" "),
     376 + buffer.NewCells(" bb"),
     377 + buffer.NewCells("cc"),
     378 + },
     379 + },
     380 + {
     381 + desc: "wraps at words, respects newline characters between words",
     382 + cells: buffer.NewCells("aaa\nbb cc"),
     383 + width: 3,
     384 + mode: AtWords,
     385 + want: [][]*buffer.Cell{
     386 + buffer.NewCells("aaa"),
     387 + buffer.NewCells("bb"),
     388 + buffer.NewCells("cc"),
     389 + },
     390 + },
     391 + {
     392 + desc: "wraps at words, respects multiple spaces between words",
     393 + cells: buffer.NewCells("aa bb cc"),
     394 + width: 3,
     395 + mode: AtWords,
     396 + want: [][]*buffer.Cell{
     397 + buffer.NewCells("aa "),
     398 + buffer.NewCells(" "),
     399 + buffer.NewCells("bb"),
     400 + buffer.NewCells("cc"),
     401 + },
     402 + },
     403 + {
     404 + desc: "wraps at words, handles leading spaces",
     405 + cells: buffer.NewCells(" aa bb cc"),
     406 + width: 3,
     407 + mode: AtWords,
     408 + want: [][]*buffer.Cell{
     409 + buffer.NewCells(" "),
     410 + buffer.NewCells("aa"),
     411 + buffer.NewCells("bb"),
     412 + buffer.NewCells("cc"),
     413 + },
     414 + },
     415 + {
     416 + desc: "wraps at words, handles trailing spaces",
     417 + cells: buffer.NewCells("aa bb cc "),
     418 + width: 3,
     419 + mode: AtWords,
     420 + want: [][]*buffer.Cell{
     421 + buffer.NewCells("aa"),
     422 + buffer.NewCells("bb"),
     423 + buffer.NewCells("cc "),
     424 + buffer.NewCells(" "),
     425 + },
     426 + },
     427 + {
     428 + desc: "wraps at words, handles leading newlines",
     429 + cells: buffer.NewCells("\n\n\naa bb cc"),
     430 + width: 3,
     431 + mode: AtWords,
     432 + want: [][]*buffer.Cell{
     433 + buffer.NewCells(""),
     434 + buffer.NewCells(""),
     435 + buffer.NewCells(""),
     436 + buffer.NewCells("aa"),
     437 + buffer.NewCells("bb"),
     438 + buffer.NewCells("cc"),
     439 + },
     440 + },
     441 + {
     442 + desc: "wraps at words, handles trailing newlines",
     443 + cells: buffer.NewCells("aa bb cc\n\n\n"),
     444 + width: 3,
     445 + mode: AtWords,
     446 + want: [][]*buffer.Cell{
     447 + buffer.NewCells("aa"),
     448 + buffer.NewCells("bb"),
     449 + buffer.NewCells("cc"),
     450 + buffer.NewCells(""),
     451 + buffer.NewCells(""),
     452 + buffer.NewCells(""),
     453 + },
     454 + },
     455 + {
     456 + desc: "wraps at words, handles continuous newlines",
     457 + cells: buffer.NewCells("aa\n\n\ncc"),
     458 + width: 3,
     459 + mode: AtWords,
     460 + want: [][]*buffer.Cell{
     461 + buffer.NewCells("aa"),
     462 + buffer.NewCells(""),
     463 + buffer.NewCells(""),
     464 + buffer.NewCells("cc"),
     465 + },
     466 + },
     467 + {
     468 + desc: "wraps at words, punctuation is wrapped with words",
     469 + cells: buffer.NewCells("aa. bb! cc?"),
     470 + width: 3,
     471 + mode: AtWords,
     472 + want: [][]*buffer.Cell{
     473 + buffer.NewCells("aa."),
     474 + buffer.NewCells("bb!"),
     475 + buffer.NewCells("cc?"),
     476 + },
     477 + },
     478 + {
     479 + desc: "wraps at words, quotes are wrapped with words",
     480 + cells: buffer.NewCells("'aa' 'bb' 'cc'"),
     481 + width: 4,
     482 + mode: AtWords,
     483 + want: [][]*buffer.Cell{
     484 + buffer.NewCells("'aa'"),
     485 + buffer.NewCells("'bb'"),
     486 + buffer.NewCells("'cc'"),
     487 + },
     488 + },
     489 + {
     490 + desc: "wraps at words, begins with a word too long for one line",
     491 + cells: buffer.NewCells("aabbcc"),
     492 + width: 3,
     493 + mode: AtWords,
     494 + want: [][]*buffer.Cell{
     495 + buffer.NewCells("aa-"),
     496 + buffer.NewCells("bbc"),
     497 + buffer.NewCells("c"),
     498 + },
     499 + },
     500 + {
     501 + desc: "wraps at words, begins with a word too long for one line, width is one",
     502 + cells: buffer.NewCells("abcd"),
     503 + width: 1,
     504 + mode: AtWords,
     505 + want: [][]*buffer.Cell{
     506 + buffer.NewCells("a"),
     507 + buffer.NewCells("b"),
     508 + buffer.NewCells("c"),
     509 + buffer.NewCells("d"),
     510 + },
     511 + },
     512 + {
     513 + desc: "wraps at words, begins with a word too long for multiple lines",
     514 + cells: buffer.NewCells("aabbccaabbcc"),
     515 + width: 3,
     516 + mode: AtWords,
     517 + want: [][]*buffer.Cell{
     518 + buffer.NewCells("aa-"),
     519 + buffer.NewCells("bbc"),
     520 + buffer.NewCells("caa"),
     521 + buffer.NewCells("bbc"),
     522 + buffer.NewCells("c"),
     523 + },
     524 + },
     525 + {
     526 + desc: "wraps at words, a word doesn't fit on one line",
     527 + cells: buffer.NewCells("aa bbbb cc"),
     528 + width: 3,
     529 + mode: AtWords,
     530 + want: [][]*buffer.Cell{
     531 + buffer.NewCells("aa"),
     532 + buffer.NewCells("bb-"),
     533 + buffer.NewCells("bb"),
     534 + buffer.NewCells("cc"),
     535 + },
     536 + },
     537 + {
     538 + desc: "wraps at words, a word doesn't fit on multiple line",
     539 + cells: buffer.NewCells("aa bbbbbb cc"),
     540 + width: 3,
     541 + mode: AtWords,
     542 + want: [][]*buffer.Cell{
     543 + buffer.NewCells("aa"),
     544 + buffer.NewCells("bb-"),
     545 + buffer.NewCells("bbb"),
     546 + buffer.NewCells("b"),
     547 + buffer.NewCells("cc"),
     548 + },
     549 + },
     550 + {
     551 + desc: "wraps at words, a word doesn't fit on multiple line, width is one so no dash",
     552 + cells: buffer.NewCells("a bbb"),
     553 + width: 1,
     554 + mode: AtWords,
     555 + want: [][]*buffer.Cell{
     556 + buffer.NewCells("a"),
     557 + buffer.NewCells("b"),
     558 + buffer.NewCells("b"),
     559 + buffer.NewCells("b"),
     560 + },
     561 + },
     562 + {
     563 + desc: "wraps at words, starts with half-width runes word, fits exactly",
     564 + cells: buffer.NewCells("aaa"),
     565 + width: 3,
     566 + mode: AtWords,
     567 + want: [][]*buffer.Cell{
     568 + buffer.NewCells("aaa"),
     569 + },
     570 + },
     571 + {
     572 + desc: "wraps at words, starts with half-width runes word, wraps",
     573 + cells: buffer.NewCells("abc"),
     574 + width: 2,
     575 + mode: AtWords,
     576 + want: [][]*buffer.Cell{
     577 + buffer.NewCells("a-"),
     578 + buffer.NewCells("bc"),
     579 + },
     580 + },
     581 + {
     582 + desc: "wraps at words, starts with full-width runes word, fits exactly",
     583 + cells: buffer.NewCells("世世"),
     584 + width: 4,
     585 + mode: AtWords,
     586 + want: [][]*buffer.Cell{
     587 + buffer.NewCells("世世"),
     588 + },
     589 + },
     590 + {
     591 + desc: "wraps at words, starts with full-width runes word, wraps",
     592 + cells: buffer.NewCells("世世"),
     593 + width: 3,
     594 + mode: AtWords,
     595 + want: [][]*buffer.Cell{
     596 + buffer.NewCells("世"),
     597 + buffer.NewCells("世"),
     598 + },
     599 + },
     600 + {
     601 + desc: "wraps at words, a full-width rune word in the middle, fits exactly",
     602 + cells: buffer.NewCells("aaaa 世世"),
     603 + width: 4,
     604 + mode: AtWords,
     605 + want: [][]*buffer.Cell{
     606 + buffer.NewCells("aaaa"),
     607 + buffer.NewCells("世世"),
     608 + },
     609 + },
     610 + {
     611 + desc: "wraps at words, a full-width rune word in the middle, one cell left, wraps",
     612 + cells: buffer.NewCells("aaa 世世"),
     613 + width: 3,
     614 + mode: AtWords,
     615 + want: [][]*buffer.Cell{
     616 + buffer.NewCells("aaa"),
     617 + buffer.NewCells("世"),
     618 + buffer.NewCells("世"),
     619 + },
     620 + },
     621 + {
     622 + desc: "wraps at words, a full-width rune word in the middle, no cell left, wraps",
     623 + cells: buffer.NewCells("aa 世世"),
     624 + width: 2,
     625 + mode: AtWords,
     626 + want: [][]*buffer.Cell{
     627 + buffer.NewCells("aa"),
     628 + buffer.NewCells("世"),
     629 + buffer.NewCells("世"),
     630 + },
     631 + },
     632 + {
     633 + desc: "wraps of words with half-width runes preserves cell options",
     634 + cells: buffer.NewCells("a bc", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     635 + width: 2,
     636 + mode: AtWords,
     637 + want: [][]*buffer.Cell{
     638 + buffer.NewCells("a", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     639 + buffer.NewCells("bc", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     640 + },
     641 + },
     642 + {
     643 + desc: "wraps of words with full-width runes preserves cell options",
     644 + cells: buffer.NewCells("aa 世世", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     645 + width: 2,
     646 + mode: AtWords,
     647 + want: [][]*buffer.Cell{
     648 + buffer.NewCells("aa", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     649 + buffer.NewCells("世", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     650 + buffer.NewCells("世", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     651 + },
     652 + },
     653 + {
     654 + desc: "inserted dash inherits cell options",
     655 + cells: buffer.NewCells("abc", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     656 + width: 2,
     657 + mode: AtWords,
     658 + want: [][]*buffer.Cell{
     659 + buffer.NewCells("a-", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     660 + buffer.NewCells("bc", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)),
     661 + },
     662 + },
     663 + }
     664 + 
     665 + for _, tc := range tests {
     666 + t.Run(tc.desc, func(t *testing.T) {
     667 + t.Logf(fmt.Sprintf("Mode: %v", tc.mode))
     668 + got, err := Cells(tc.cells, tc.width, tc.mode)
     669 + if (err != nil) != tc.wantErr {
     670 + t.Errorf("Cells => unexpected error %v, wantErr %v", err, tc.wantErr)
     671 + }
     672 + if err != nil {
     673 + return
     674 + }
     675 + if diff := pretty.Compare(tc.want, got); diff != "" {
     676 + t.Errorf("Cells =>\n got:%v\nwant:%v\nunexpected diff (-want, +got):\n%s", got, tc.want, diff)
     677 + }
     678 + })
     679 + }
     680 + 
     681 +}
     682 + 
     683 +func TestRuneWrapNeeded(t *testing.T) {
     684 + tests := []struct {
     685 + desc string
     686 + r rune
     687 + posX int
     688 + width int
     689 + want bool
     690 + }{
     691 + {
     692 + desc: "half-width rune, falls within canvas",
     693 + r: 'a',
     694 + posX: 2,
     695 + width: 3,
     696 + want: false,
     697 + },
     698 + {
     699 + desc: "full-width rune, falls within canvas",
     700 + r: '世',
     701 + posX: 1,
     702 + width: 3,
     703 + want: false,
     704 + },
     705 + {
     706 + desc: "half-width rune, falls outside of canvas, wrapping configured",
     707 + r: 'a',
     708 + posX: 3,
     709 + width: 3,
     710 + want: true,
     711 + },
     712 + {
     713 + desc: "full-width rune, starts in and falls outside of canvas, wrapping configured",
     714 + r: '世',
     715 + posX: 3,
     716 + width: 3,
     717 + want: true,
     718 + },
     719 + {
     720 + desc: "full-width rune, starts outside of canvas, wrapping configured",
     721 + r: '世',
     722 + posX: 3,
     723 + width: 3,
     724 + want: true,
     725 + },
     726 + {
     727 + desc: "doesn't wrap for newline characters",
     728 + r: '\n',
     729 + posX: 3,
     730 + width: 3,
     731 + want: false,
     732 + },
     733 + }
     734 + 
     735 + for _, tc := range tests {
     736 + t.Run(tc.desc, func(t *testing.T) {
     737 + got := runeWrapNeeded(tc.r, tc.posX, tc.width)
     738 + if got != tc.want {
     739 + t.Errorf("runeWrapNeeded => got %v, want %v", got, tc.want)
     740 + }
     741 + })
     742 + }
     743 +}
     744 + 
Please wait...
Page is in error, reload to recover