Projects STRLCPY termdash Commits 65328eb4
🤬
  • ■ ■ ■ ■ ■
    .travis.yml
    skipped 9 lines
    10 10   - go test -race ./...
    11 11   - go vet ./...
    12 12   - diff -u <(echo -n) <(gofmt -d -s .)
     13 + - diff -u <(echo -n) <(./scripts/autogen_licences.sh .)
    13 14  after_success:
    14 15   - ./scripts/coverage.sh
    15 16   
  • ■ ■ ■ ■ ■ ■
    README.md
    skipped 80 lines
    81 81  Displays text content, supports trimming and scrolling of content. Run the
    82 82  [textdemo](widgets/text/demo/textdemo.go).
    83 83   
    84  -[<img src="./images/textdemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/demo/gaugedemo.go)
     84 +[<img src="./images/textdemo.gif" alt="textdemo" type="image/gif">](widgets/gauge/demo/gaugedemo.go)
     85 + 
     86 +### The SparkLine
     87 + 
     88 +Draws a graph showing a series of values as vertical bars. The bars can have
     89 +sub-cell height. Run the
     90 +[sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go).
     91 + 
     92 +[<img src="./images/sparklinedemo.gif" alt="sparklinedemo" type="image/gif">](widgets/sparkline/sparklinedemo/sparklinedemo.go)
     93 + 
     94 +### The BarChart
     95 + 
     96 +Displays multiple bars showing relative ratios of values. Run the
     97 +[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go).
     98 + 
     99 +[<img src="./images/barchartdemo.gif" alt="barchartdemo" type="image/gif">](widgets/barchart/barchartdemo/barchartdemo.go)
     100 + 
    85 101   
    86 102  ## Disclaimer
    87 103   
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    eventqueue/eventqueue.go
    skipped 74 lines
    75 75   }
    76 76  }
    77 77   
     78 +// Empty determines if the queue is empty.
     79 +func (u *Unbound) Empty() bool {
     80 + u.mu.Lock()
     81 + defer u.mu.Unlock()
     82 + return u.empty()
     83 +}
     84 + 
    78 85  // empty determines if the queue is empty.
    79 86  func (u *Unbound) empty() bool {
    80 87   return u.first == nil
    skipped 66 lines
  • ■ ■ ■ ■ ■
    eventqueue/eventqueue_test.go
    skipped 24 lines
    25 25   
    26 26  func TestQueue(t *testing.T) {
    27 27   tests := []struct {
    28  - desc string
    29  - pushes []terminalapi.Event
    30  - wantPops []terminalapi.Event
     28 + desc string
     29 + pushes []terminalapi.Event
     30 + wantEmpty bool // Checked after pushes and before pops.
     31 + wantPops []terminalapi.Event
    31 32   }{
    32 33   {
    33  - desc: "empty queue returns nil",
     34 + desc: "empty queue returns nil",
     35 + wantEmpty: true,
    34 36   wantPops: []terminalapi.Event{
    35 37   nil,
    36 38   },
    skipped 5 lines
    42 44   terminalapi.NewError("error2"),
    43 45   terminalapi.NewError("error3"),
    44 46   },
     47 + wantEmpty: false,
    45 48   wantPops: []terminalapi.Event{
    46 49   terminalapi.NewError("error1"),
    47 50   terminalapi.NewError("error2"),
    skipped 9 lines
    57 60   defer q.Close()
    58 61   for _, ev := range tc.pushes {
    59 62   q.Push(ev)
     63 + }
     64 + 
     65 + gotEmpty := q.Empty()
     66 + if gotEmpty != tc.wantEmpty {
     67 + t.Errorf("Empty => got %v, want %v", gotEmpty, tc.wantEmpty)
    60 68   }
    61 69   
    62 70   for i, want := range tc.wantPops {
    skipped 55 lines
  • images/barchartdemo.gif
  • images/sparklinedemo.gif
  • ■ ■ ■ ■ ■
    scripts/autogen_licences.sh
    skipped 27 lines
    28 28  fi
    29 29   
    30 30  DIRECTORY="$1"
    31  -WRITE="$2"
     31 + 
     32 +WRITE=""
     33 +if [ "$#" -ge 2 ]; then
     34 + WRITE="$2"
     35 +fi
    32 36   
    33 37  if [ ! -d "${BIN_DIR}" ]; then
    34 38   echo "Directory ${BIN_DIR} doesn't exist."
    skipped 3 lines
    38 42   
    39 43   
    40 44  if [ ! -d "${INSTALL_DIR}" ]; then
    41  - git clone git@github.com:mbrukman/autogen.git "${BIN_DIR}/autogen"
     45 + git clone https://github.com/mbrukman/autogen.git "${BIN_DIR}/autogen"
    42 46   if [ $? -ne 0 ]; then
    43 47   echo "Failed to run git clone."
    44 48   exit 1
    skipped 4 lines
    49 53   DRY_RUN=""
    50 54  else
    51 55   DRY_RUN="echo "
    52  - echo "The WRITE argument not specified, dry run mode."
    53  - echo "Would have executed:"
    54 56  fi
    55 57   
    56 58  ADD_LICENCE="${DRY_RUN}${AUTOGEN} -i --no-top-level-comment"
    57 59  FIND_FILES="find ${DIRECTORY} -type f -name \*.go"
    58 60  LICENCE="Licensed under the Apache License"
    59 61   
     62 +MISSING=0
     63 + 
    60 64  for FILE in `eval ${FIND_FILES}`; do
    61 65   if ! grep -q "${LICENCE}" "${FILE}"; then
     66 + MISSING=1
    62 67   eval "${ADD_LICENCE} ${FILE}"
    63 68   fi
    64 69  done
    65 70   
     71 +if [[ ! -z "$DRY_RUN" ]] && [ $MISSING -eq 1 ]; then
     72 + echo -e "\nFound files with missing licences. To fix, run the commands above."
     73 + echo "Or just execute:"
     74 + echo "$0 . WRITE"
     75 +fi
     76 + 
  • ■ ■ ■ ■ ■ ■
    termdash.go
    skipped 24 lines
    25 25   
    26 26  import (
    27 27   "context"
     28 + "errors"
    28 29   "fmt"
    29 30   "sync"
    30 31   "time"
    skipped 20 lines
    51 52  }
    52 53   
    53 54  // RedrawInterval sets how often termdash redraws the container and all the widgets.
    54  -// Defaults to DefaultRedrawInterval.
     55 +// Defaults to DefaultRedrawInterval. Use the controller to disable the
     56 +// periodic redraw.
    55 57  func RedrawInterval(t time.Duration) Option {
    56 58   return option(func(td *termdash) {
    57 59   td.redrawInterval = t
    skipped 30 lines
    88 90  }
    89 91   
    90 92  // Run runs the terminal dashboard with the provided container on the terminal.
     93 +// Redraws the terminal periodically. If you prefer a manual redraw, use the
     94 +// Controller instead.
    91 95  // Blocks until the context expires.
    92 96  func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, opts ...Option) error {
    93 97   td := newTermdash(t, c, opts...)
    94  - defer td.stop()
     98 + 
     99 + err := td.start(ctx)
     100 + // Only return the status (error or nil) after the termdash event
     101 + // processing goroutine actually exits.
     102 + td.stop()
     103 + return err
     104 +}
     105 + 
     106 +// Controller controls a termdash instance.
     107 +// The controller instance is only valid until Close() is called.
     108 +// The controller is not thread-safe.
     109 +type Controller struct {
     110 + td *termdash
     111 + cancel context.CancelFunc
     112 +}
    95 113   
    96  - return td.start(ctx)
     114 +// NewController initializes termdash and returns an instance of the controller.
     115 +// Periodic redrawing is disabled when using the controller, the RedrawInterval
     116 +// option is ignored.
     117 +// Close the controller when it isn't needed anymore.
     118 +func NewController(t terminalapi.Terminal, c *container.Container, opts ...Option) (*Controller, error) {
     119 + ctx, cancel := context.WithCancel(context.Background())
     120 + ctrl := &Controller{
     121 + td: newTermdash(t, c, opts...),
     122 + cancel: cancel,
     123 + }
     124 + 
     125 + // stops when Close() is called.
     126 + go ctrl.td.processEvents(ctx)
     127 + if err := ctrl.td.periodicRedraw(); err != nil {
     128 + return nil, err
     129 + }
     130 + return ctrl, nil
     131 +}
     132 + 
     133 +// Redraw triggers redraw of the terminal.
     134 +func (c *Controller) Redraw() error {
     135 + if c.td == nil {
     136 + return errors.New("the termdash instance is no longer running, this controller is now invalid")
     137 + }
     138 + 
     139 + c.td.mu.Lock()
     140 + defer c.td.mu.Unlock()
     141 + return c.td.redraw()
     142 +}
     143 + 
     144 +// Close closes the Controller and its termdash instance.
     145 +func (c *Controller) Close() {
     146 + c.cancel()
     147 + c.td.stop()
     148 + c.td = nil
    97 149  }
    98 150   
    99 151  // termdash is a terminal based dashboard.
    skipped 193 lines
  • ■ ■ ■ ■ ■ ■
    termdash_test.go
    skipped 33 lines
    34 34   "github.com/mum4k/termdash/widgets/fakewidget"
    35 35  )
    36 36   
    37  -// Example shows how to setup and run termdash.
     37 +// Example shows how to setup and run termdash with periodic redraw.
    38 38  func Example() {
    39 39   // Create the terminal.
    40 40   t, err := termbox.New()
    skipped 25 lines
    66 66   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    67 67   defer cancel()
    68 68   if err := Run(ctx, t, c); err != nil {
     69 + panic(err)
     70 + }
     71 +}
     72 + 
     73 +// Example shows how to setup and run termdash with manually triggered redraw.
     74 +func Example_triggered() {
     75 + // Create the terminal.
     76 + t, err := termbox.New()
     77 + if err != nil {
     78 + panic(err)
     79 + }
     80 + defer t.Close()
     81 + 
     82 + wOpts := widgetapi.Options{
     83 + MinimumSize: fakewidget.MinimumSize,
     84 + WantKeyboard: true,
     85 + WantMouse: true,
     86 + }
     87 + 
     88 + // Create the container with a widget.
     89 + c := container.New(
     90 + t,
     91 + container.PlaceWidget(fakewidget.New(wOpts)),
     92 + )
     93 + 
     94 + // Create the controller and disable periodic redraw.
     95 + ctrl, err := NewController(t, c)
     96 + if err != nil {
     97 + panic(err)
     98 + }
     99 + // Close the controller and termdash once it isn't required anymore.
     100 + defer ctrl.Close()
     101 + 
     102 + // Redraw the terminal manually.
     103 + if err := ctrl.Redraw(); err != nil {
    69 104   panic(err)
    70 105   }
    71 106  }
    skipped 70 lines
    142 177   return ft
    143 178   },
    144 179   wantErr: true,
    145  - },
    146  - {
    147  - desc: "resizes the terminal",
    148  - size: image.Point{60, 10},
    149  - opts: []Option{
    150  - RedrawInterval(1),
    151  - },
    152  - events: []terminalapi.Event{
    153  - &terminalapi.Resize{Size: image.Point{70, 10}},
    154  - },
    155  - want: func(size image.Point) *faketerm.Terminal {
    156  - ft := faketerm.MustNew(image.Point{70, 10})
    157  - 
    158  - fakewidget.MustDraw(
    159  - ft,
    160  - testcanvas.MustNew(ft.Area()),
    161  - widgetapi.Options{},
    162  - )
    163  - return ft
    164  - },
    165 180   },
    166 181   {
    167 182   desc: "forwards mouse events to container",
    skipped 167 lines
    335 350   return
    336 351   }
    337 352   
     353 + if err := untilEmpty(5*time.Second, eq); err != nil {
     354 + t.Fatalf("untilEmpty => %v", err)
     355 + }
     356 + 
    338 357   if tc.after != nil {
    339 358   if err := tc.after(); err != nil {
    340 359   t.Errorf("after => unexpected error: %v", err)
    341 360   }
    342 361   }
     362 + 
    343 363   if diff := faketerm.Diff(tc.want(got.Size()), got); diff != "" {
    344 364   t.Errorf("Run => %v", diff)
    345 365   }
    skipped 1 lines
    347 367   }
    348 368  }
    349 369   
     370 +func TestController(t *testing.T) {
     371 + tests := []struct {
     372 + desc string
     373 + size image.Point
     374 + opts []Option
     375 + events []terminalapi.Event
     376 + apiEvents func(*fakewidget.Mirror) // Calls to the API of the widget.
     377 + controls func(*Controller) error
     378 + want func(size image.Point) *faketerm.Terminal
     379 + wantErr bool
     380 + }{
     381 + {
     382 + desc: "event triggers a redraw",
     383 + size: image.Point{60, 10},
     384 + events: []terminalapi.Event{
     385 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     386 + },
     387 + want: func(size image.Point) *faketerm.Terminal {
     388 + ft := faketerm.MustNew(size)
     389 + 
     390 + fakewidget.MustDraw(
     391 + ft,
     392 + testcanvas.MustNew(ft.Area()),
     393 + widgetapi.Options{
     394 + WantKeyboard: true,
     395 + WantMouse: true,
     396 + },
     397 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     398 + )
     399 + return ft
     400 + 
     401 + },
     402 + },
     403 + {
     404 + desc: "controller triggers redraw",
     405 + size: image.Point{60, 10},
     406 + apiEvents: func(mi *fakewidget.Mirror) {
     407 + mi.Text("hello")
     408 + },
     409 + controls: func(ctrl *Controller) error {
     410 + return ctrl.Redraw()
     411 + },
     412 + want: func(size image.Point) *faketerm.Terminal {
     413 + ft := faketerm.MustNew(size)
     414 + 
     415 + mirror := fakewidget.New(widgetapi.Options{})
     416 + mirror.Text("hello")
     417 + fakewidget.MustDrawWithMirror(
     418 + mirror,
     419 + ft,
     420 + testcanvas.MustNew(ft.Area()),
     421 + )
     422 + return ft
     423 + },
     424 + },
     425 + {
     426 + desc: "ignores periodic redraw via the controller",
     427 + size: image.Point{60, 10},
     428 + opts: []Option{
     429 + RedrawInterval(1),
     430 + },
     431 + apiEvents: func(mi *fakewidget.Mirror) {
     432 + mi.Text("hello")
     433 + },
     434 + controls: func(ctrl *Controller) error {
     435 + return nil
     436 + },
     437 + want: func(size image.Point) *faketerm.Terminal {
     438 + ft := faketerm.MustNew(size)
     439 + 
     440 + fakewidget.MustDraw(
     441 + ft,
     442 + testcanvas.MustNew(ft.Area()),
     443 + widgetapi.Options{},
     444 + )
     445 + return ft
     446 + },
     447 + },
     448 + {
     449 + desc: "does not redraw unless triggered when periodic disabled",
     450 + size: image.Point{60, 10},
     451 + apiEvents: func(mi *fakewidget.Mirror) {
     452 + mi.Text("hello")
     453 + },
     454 + controls: func(ctrl *Controller) error {
     455 + return nil
     456 + },
     457 + want: func(size image.Point) *faketerm.Terminal {
     458 + ft := faketerm.MustNew(size)
     459 + 
     460 + fakewidget.MustDraw(
     461 + ft,
     462 + testcanvas.MustNew(ft.Area()),
     463 + widgetapi.Options{},
     464 + )
     465 + return ft
     466 + },
     467 + },
     468 + {
     469 + desc: "fails when redraw fails",
     470 + size: image.Point{1, 1},
     471 + want: func(size image.Point) *faketerm.Terminal {
     472 + return faketerm.MustNew(size)
     473 + },
     474 + wantErr: true,
     475 + },
     476 + {
     477 + desc: "resizes the terminal",
     478 + size: image.Point{60, 10},
     479 + events: []terminalapi.Event{
     480 + &terminalapi.Resize{Size: image.Point{70, 10}},
     481 + },
     482 + controls: func(ctrl *Controller) error {
     483 + return ctrl.Redraw()
     484 + },
     485 + want: func(size image.Point) *faketerm.Terminal {
     486 + ft := faketerm.MustNew(image.Point{70, 10})
     487 + 
     488 + fakewidget.MustDraw(
     489 + ft,
     490 + testcanvas.MustNew(ft.Area()),
     491 + widgetapi.Options{},
     492 + )
     493 + return ft
     494 + },
     495 + },
     496 + }
     497 + 
     498 + for _, tc := range tests {
     499 + t.Run(tc.desc, func(t *testing.T) {
     500 + eq := eventqueue.New()
     501 + for _, ev := range tc.events {
     502 + eq.Push(ev)
     503 + }
     504 + 
     505 + got, err := faketerm.New(tc.size, faketerm.WithEventQueue(eq))
     506 + if err != nil {
     507 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     508 + }
     509 + 
     510 + mi := fakewidget.New(widgetapi.Options{
     511 + WantKeyboard: true,
     512 + WantMouse: true,
     513 + })
     514 + cont := container.New(
     515 + got,
     516 + container.PlaceWidget(mi),
     517 + )
     518 + 
     519 + ctrl, err := NewController(got, cont, tc.opts...)
     520 + if (err != nil) != tc.wantErr {
     521 + t.Errorf("NewController => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     522 + }
     523 + if err != nil {
     524 + return
     525 + }
     526 + 
     527 + if tc.apiEvents != nil {
     528 + tc.apiEvents(mi)
     529 + }
     530 + 
     531 + if err := untilEmpty(5*time.Second, eq); err != nil {
     532 + t.Fatalf("untilEmpty => %v", err)
     533 + }
     534 + if tc.controls != nil {
     535 + if err := tc.controls(ctrl); err != nil {
     536 + t.Errorf("controls => unexpected error: %v", err)
     537 + }
     538 + }
     539 + ctrl.Close()
     540 + 
     541 + if diff := faketerm.Diff(tc.want(got.Size()), got); diff != "" {
     542 + t.Errorf("Run => %v", diff)
     543 + }
     544 + })
     545 + }
     546 +}
     547 + 
     548 +// untilEmpty waits until the queue empties.
     549 +// Waits at most the specified duration.
     550 +func untilEmpty(timeout time.Duration, q *eventqueue.Unbound) error {
     551 + ctx, cancel := context.WithTimeout(context.Background(), timeout)
     552 + defer cancel()
     553 + 
     554 + tick := time.NewTimer(5 * time.Millisecond)
     555 + defer tick.Stop()
     556 + for {
     557 + select {
     558 + case <-tick.C:
     559 + if q.Empty() {
     560 + return nil
     561 + }
     562 + 
     563 + case <-ctx.Done():
     564 + return fmt.Errorf("while waiting for the event queue to empty: %v", ctx.Err())
     565 + }
     566 + }
     567 +}
     568 + 
  • ■ ■ ■ ■ ■ ■
    widgets/barchart/barchart.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 barchart implements a widget that draws multiple bars displaying
     16 +// values and their relative ratios.
     17 +package barchart
     18 + 
     19 +import (
     20 + "errors"
     21 + "fmt"
     22 + "image"
     23 + "sync"
     24 + 
     25 + "github.com/mum4k/termdash/align"
     26 + "github.com/mum4k/termdash/canvas"
     27 + "github.com/mum4k/termdash/cell"
     28 + "github.com/mum4k/termdash/draw"
     29 + "github.com/mum4k/termdash/terminalapi"
     30 + "github.com/mum4k/termdash/widgetapi"
     31 +)
     32 + 
     33 +// BarChart displays multiple bars showing relative ratios of values.
     34 +//
     35 +// Each bar can have a text label under it explaining the meaning of the value
     36 +// and can display the value itself inside the bar.
     37 +//
     38 +// Implements widgetapi.Widget. This object is thread-safe.
     39 +type BarChart struct {
     40 + // values are the values provided on a call to Values(). These are the
     41 + // individual bars that will be drawn.
     42 + values []int
     43 + // max is the maximum value of a bar. A bar having this value takes all the
     44 + // vertical space.
     45 + max int
     46 + 
     47 + // mu protects the BarChart.
     48 + mu sync.Mutex
     49 + 
     50 + // opts are the provided options.
     51 + opts *options
     52 +}
     53 + 
     54 +// New returns a new BarChart.
     55 +func New(opts ...Option) *BarChart {
     56 + opt := newOptions()
     57 + for _, o := range opts {
     58 + o.set(opt)
     59 + }
     60 + return &BarChart{
     61 + opts: opt,
     62 + }
     63 +}
     64 + 
     65 +// Draw draws the BarChart widget onto the canvas.
     66 +// Implements widgetapi.Widget.Draw.
     67 +func (bc *BarChart) Draw(cvs *canvas.Canvas) error {
     68 + bc.mu.Lock()
     69 + defer bc.mu.Unlock()
     70 + 
     71 + for i, v := range bc.values {
     72 + r, err := bc.barRect(cvs, i, v)
     73 + if err != nil {
     74 + return err
     75 + }
     76 + 
     77 + if r.Dy() > 0 { // Value might be so small so that the rectangle is zero.
     78 + if err := draw.Rectangle(cvs, r,
     79 + draw.RectCellOpts(cell.BgColor(bc.barColor(i))),
     80 + draw.RectChar(bc.opts.barChar),
     81 + ); err != nil {
     82 + return err
     83 + }
     84 + }
     85 + 
     86 + if bc.opts.showValues {
     87 + if err := bc.drawText(cvs, i, fmt.Sprint(bc.values[i]), bc.valColor(i), insideBar); err != nil {
     88 + return err
     89 + }
     90 + }
     91 + 
     92 + l, c := bc.label(i)
     93 + if l != "" {
     94 + if err := bc.drawText(cvs, i, l, c, underBar); err != nil {
     95 + return err
     96 + }
     97 + }
     98 + }
     99 + return nil
     100 +}
     101 + 
     102 +// textLoc represents the location of the drawn text.
     103 +type textLoc int
     104 + 
     105 +const (
     106 + insideBar textLoc = iota
     107 + underBar
     108 +)
     109 + 
     110 +// drawText draws the provided text inside or under the i-th bar.
     111 +func (bc *BarChart) drawText(cvs *canvas.Canvas, i int, text string, color cell.Color, loc textLoc) error {
     112 + // Rectangle representing area in which the text will be aligned.
     113 + var barCol image.Rectangle
     114 + 
     115 + r, err := bc.barRect(cvs, i, bc.max)
     116 + if err != nil {
     117 + return err
     118 + }
     119 + 
     120 + switch loc {
     121 + case insideBar:
     122 + // Align the text within the bar itself.
     123 + barCol = r
     124 + case underBar:
     125 + // Align the text within the entire column where the bar is, this
     126 + // includes the space for any label under the bar.
     127 + barCol = image.Rect(r.Min.X, cvs.Area().Min.Y, r.Max.X, cvs.Area().Max.Y)
     128 + }
     129 + 
     130 + start, err := align.Text(barCol, text, align.HorizontalCenter, align.VerticalBottom)
     131 + if err != nil {
     132 + return err
     133 + }
     134 + 
     135 + return draw.Text(cvs, text, start,
     136 + draw.TextCellOpts(cell.FgColor(color)),
     137 + draw.TextMaxX(barCol.Max.X),
     138 + draw.TextOverrunMode(draw.OverrunModeThreeDot),
     139 + )
     140 +}
     141 + 
     142 +// barWidth determines the width of a single bar based on options and the canvas.
     143 +func (bc *BarChart) barWidth(cvs *canvas.Canvas) int {
     144 + if len(bc.values) == 0 {
     145 + return 0 // No width when we have no values.
     146 + }
     147 + 
     148 + if bc.opts.barWidth >= 1 {
     149 + // Prefer width set via the options if it is positive.
     150 + return bc.opts.barWidth
     151 + }
     152 + 
     153 + gaps := len(bc.values) - 1
     154 + gapW := gaps * bc.opts.barGap
     155 + rem := cvs.Area().Dx() - gapW
     156 + return rem / len(bc.values)
     157 +}
     158 + 
     159 +// barHeight determines the height of the i-th bar based on the value it is displaying.
     160 +func (bc *BarChart) barHeight(cvs *canvas.Canvas, i, value int) int {
     161 + available := cvs.Area().Dy()
     162 + if len(bc.opts.labels) > 0 {
     163 + // One line for the bar labels.
     164 + available--
     165 + }
     166 + 
     167 + ratio := float32(value) / float32(bc.max)
     168 + return int(float32(available) * ratio)
     169 +}
     170 + 
     171 +// barRect returns a rectangle that represents the i-th bar on the canvas that
     172 +// displays the specified value.
     173 +func (bc *BarChart) barRect(cvs *canvas.Canvas, i, value int) (image.Rectangle, error) {
     174 + bw := bc.barWidth(cvs)
     175 + minX := bw * i
     176 + if i > 0 {
     177 + minX += bc.opts.barGap * i
     178 + }
     179 + maxX := minX + bw
     180 + 
     181 + bh := bc.barHeight(cvs, i, value)
     182 + maxY := cvs.Area().Max.Y
     183 + if len(bc.opts.labels) > 0 {
     184 + // One line for the bar labels.
     185 + maxY--
     186 + }
     187 + minY := maxY - bh
     188 + return image.Rect(minX, minY, maxX, maxY), nil
     189 +}
     190 + 
     191 +// barColor safely determines the color for the i-th bar.
     192 +// Colors are optional and don't have to be specified for all the bars.
     193 +func (bc *BarChart) barColor(i int) cell.Color {
     194 + if len(bc.opts.barColors) > i {
     195 + return bc.opts.barColors[i]
     196 + }
     197 + return DefaultBarColor
     198 +}
     199 + 
     200 +// valColor safely determines the color for the i-th value.
     201 +// Colors are optional and don't have to be specified for all the values.
     202 +func (bc *BarChart) valColor(i int) cell.Color {
     203 + if len(bc.opts.valueColors) > i {
     204 + return bc.opts.valueColors[i]
     205 + }
     206 + return DefaultValueColor
     207 +}
     208 + 
     209 +// label safely determines the label and its color for the i-th bar.
     210 +// Labels are optional and don't have to be specified for all the bars.
     211 +func (bc *BarChart) label(i int) (string, cell.Color) {
     212 + var label string
     213 + if len(bc.opts.labels) > i {
     214 + label = bc.opts.labels[i]
     215 + }
     216 + 
     217 + if len(bc.opts.labelColors) > i {
     218 + return label, bc.opts.labelColors[i]
     219 + }
     220 + return label, DefaultLabelColor
     221 +}
     222 + 
     223 +// Values sets the values to be displayed by the BarChart.
     224 +// Each value ends up in its own bar. The values must not be negative and must
     225 +// be less or equal the maximum value. A bar displaying the maximum value is a
     226 +// full bar, taking all available vertical space.
     227 +// Provided options override values set when New() was called.
     228 +func (bc *BarChart) Values(values []int, max int, opts ...Option) error {
     229 + bc.mu.Lock()
     230 + defer bc.mu.Unlock()
     231 + 
     232 + if err := validateValues(values, max); err != nil {
     233 + return err
     234 + }
     235 + 
     236 + for _, opt := range opts {
     237 + opt.set(bc.opts)
     238 + }
     239 + bc.values = values
     240 + bc.max = max
     241 + return nil
     242 +}
     243 + 
     244 +// Keyboard input isn't supported on the BarChart widget.
     245 +func (*BarChart) Keyboard(k *terminalapi.Keyboard) error {
     246 + return errors.New("the BarChart widget doesn't support keyboard events")
     247 +}
     248 + 
     249 +// Mouse input isn't supported on the BarChart widget.
     250 +func (*BarChart) Mouse(m *terminalapi.Mouse) error {
     251 + return errors.New("the BarChart widget doesn't support mouse events")
     252 +}
     253 + 
     254 +// Options implements widgetapi.Widget.Options.
     255 +func (bc *BarChart) Options() widgetapi.Options {
     256 + bc.mu.Lock()
     257 + defer bc.mu.Unlock()
     258 + return widgetapi.Options{
     259 + MinimumSize: bc.minSize(),
     260 + WantKeyboard: false,
     261 + WantMouse: false,
     262 + }
     263 +}
     264 + 
     265 +// minSize determines the minimum required size of the canvas.
     266 +func (bc *BarChart) minSize() image.Point {
     267 + bars := len(bc.values)
     268 + if bars == 0 {
     269 + return image.Point{1, 1}
     270 + }
     271 + 
     272 + minHeight := 1 // At least one character vertically to display the bar.
     273 + if len(bc.opts.labels) > 0 {
     274 + minHeight++ // One line for the labels.
     275 + }
     276 + 
     277 + var minBarWidth int
     278 + if bc.opts.barWidth < 1 {
     279 + minBarWidth = 1 // At least one char for the bar itself.
     280 + } else {
     281 + minBarWidth = bc.opts.barWidth
     282 + }
     283 + minWidth := bars*minBarWidth + (bars-1)*bc.opts.barGap
     284 + return image.Point{minWidth, minHeight}
     285 +}
     286 + 
     287 +// validateValues validates the provided values and maximum.
     288 +func validateValues(values []int, max int) error {
     289 + if max < 1 {
     290 + return fmt.Errorf("invalid maximum value %d, must be at least 1", max)
     291 + }
     292 + 
     293 + for i, v := range values {
     294 + if v < 0 || v > max {
     295 + return fmt.Errorf("invalid values[%d]: %d, each value must be 0 <= value <= max", i, v)
     296 + }
     297 + }
     298 + return nil
     299 +}
     300 + 
  • ■ ■ ■ ■ ■ ■
    widgets/barchart/barchart_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 barchart
     16 + 
     17 +import (
     18 + "image"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 + "github.com/mum4k/termdash/canvas"
     23 + "github.com/mum4k/termdash/canvas/testcanvas"
     24 + "github.com/mum4k/termdash/cell"
     25 + "github.com/mum4k/termdash/draw"
     26 + "github.com/mum4k/termdash/draw/testdraw"
     27 + "github.com/mum4k/termdash/terminal/faketerm"
     28 + "github.com/mum4k/termdash/widgetapi"
     29 +)
     30 + 
     31 +func TestGauge(t *testing.T) {
     32 + tests := []struct {
     33 + desc string
     34 + bc *BarChart
     35 + update func(*BarChart) error // update gets called before drawing of the widget.
     36 + canvas image.Rectangle
     37 + want func(size image.Point) *faketerm.Terminal
     38 + wantUpdateErr bool // whether to expect an error on a call to the update function
     39 + wantDrawErr bool
     40 + }{
     41 + {
     42 + desc: "draws empty for no values",
     43 + bc: New(
     44 + Char('o'),
     45 + ),
     46 + update: func(bc *BarChart) error {
     47 + return nil
     48 + },
     49 + canvas: image.Rect(0, 0, 3, 10),
     50 + want: func(size image.Point) *faketerm.Terminal {
     51 + return faketerm.MustNew(size)
     52 + },
     53 + },
     54 + {
     55 + desc: "fails for zero max",
     56 + bc: New(
     57 + Char('o'),
     58 + ),
     59 + update: func(bc *BarChart) error {
     60 + return bc.Values([]int{0, 2, 5, 10}, 0)
     61 + },
     62 + canvas: image.Rect(0, 0, 3, 10),
     63 + want: func(size image.Point) *faketerm.Terminal {
     64 + return faketerm.MustNew(size)
     65 + },
     66 + wantUpdateErr: true,
     67 + },
     68 + {
     69 + desc: "fails for negative max",
     70 + bc: New(
     71 + Char('o'),
     72 + ),
     73 + update: func(bc *BarChart) error {
     74 + return bc.Values([]int{0, 2, 5, 10}, -1)
     75 + },
     76 + canvas: image.Rect(0, 0, 3, 10),
     77 + want: func(size image.Point) *faketerm.Terminal {
     78 + return faketerm.MustNew(size)
     79 + },
     80 + wantUpdateErr: true,
     81 + },
     82 + {
     83 + desc: "fails when negative value",
     84 + bc: New(
     85 + Char('o'),
     86 + ),
     87 + update: func(bc *BarChart) error {
     88 + return bc.Values([]int{0, -2, 5, 10}, 10)
     89 + },
     90 + canvas: image.Rect(0, 0, 3, 10),
     91 + want: func(size image.Point) *faketerm.Terminal {
     92 + return faketerm.MustNew(size)
     93 + },
     94 + wantUpdateErr: true,
     95 + },
     96 + {
     97 + desc: "fails for value larger than max",
     98 + bc: New(
     99 + Char('o'),
     100 + ),
     101 + update: func(bc *BarChart) error {
     102 + return bc.Values([]int{0, 2, 5, 11}, 10)
     103 + },
     104 + canvas: image.Rect(0, 0, 3, 10),
     105 + want: func(size image.Point) *faketerm.Terminal {
     106 + return faketerm.MustNew(size)
     107 + },
     108 + wantUpdateErr: true,
     109 + },
     110 + {
     111 + desc: "displays bars",
     112 + bc: New(
     113 + Char('o'),
     114 + ),
     115 + update: func(bc *BarChart) error {
     116 + return bc.Values([]int{0, 2, 5, 10}, 10)
     117 + },
     118 + canvas: image.Rect(0, 0, 7, 10),
     119 + want: func(size image.Point) *faketerm.Terminal {
     120 + ft := faketerm.MustNew(size)
     121 + c := testcanvas.MustNew(ft.Area())
     122 + 
     123 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     124 + draw.RectChar('o'),
     125 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     126 + )
     127 + testdraw.MustRectangle(c, image.Rect(4, 5, 5, 10),
     128 + draw.RectChar('o'),
     129 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     130 + )
     131 + testdraw.MustRectangle(c, image.Rect(6, 0, 7, 10),
     132 + draw.RectChar('o'),
     133 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     134 + )
     135 + testcanvas.MustApply(c, ft)
     136 + return ft
     137 + },
     138 + },
     139 + {
     140 + desc: "displays bars with labels",
     141 + bc: New(
     142 + Char('o'),
     143 + Labels([]string{
     144 + "1",
     145 + "2",
     146 + "3",
     147 + }),
     148 + ),
     149 + update: func(bc *BarChart) error {
     150 + return bc.Values([]int{1, 2, 5, 10}, 10)
     151 + },
     152 + canvas: image.Rect(0, 0, 7, 11),
     153 + want: func(size image.Point) *faketerm.Terminal {
     154 + ft := faketerm.MustNew(size)
     155 + c := testcanvas.MustNew(ft.Area())
     156 + 
     157 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     158 + draw.RectChar('o'),
     159 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     160 + )
     161 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     162 + draw.RectChar('o'),
     163 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     164 + )
     165 + testdraw.MustRectangle(c, image.Rect(4, 5, 5, 10),
     166 + draw.RectChar('o'),
     167 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     168 + )
     169 + testdraw.MustRectangle(c, image.Rect(6, 0, 7, 10),
     170 + draw.RectChar('o'),
     171 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     172 + )
     173 + 
     174 + // Labels.
     175 + testdraw.MustText(c, "1", image.Point{0, 10}, draw.TextCellOpts(
     176 + cell.FgColor(DefaultLabelColor),
     177 + ))
     178 + testdraw.MustText(c, "2", image.Point{2, 10}, draw.TextCellOpts(
     179 + cell.FgColor(DefaultLabelColor),
     180 + ))
     181 + testdraw.MustText(c, "3", image.Point{4, 10}, draw.TextCellOpts(
     182 + cell.FgColor(DefaultLabelColor),
     183 + ))
     184 + testcanvas.MustApply(c, ft)
     185 + return ft
     186 + },
     187 + },
     188 + {
     189 + desc: "trims too long labels",
     190 + bc: New(
     191 + Char('o'),
     192 + Labels([]string{
     193 + "1",
     194 + "22",
     195 + "3",
     196 + }),
     197 + ),
     198 + update: func(bc *BarChart) error {
     199 + return bc.Values([]int{1, 2, 5, 10}, 10)
     200 + },
     201 + canvas: image.Rect(0, 0, 7, 11),
     202 + want: func(size image.Point) *faketerm.Terminal {
     203 + ft := faketerm.MustNew(size)
     204 + c := testcanvas.MustNew(ft.Area())
     205 + 
     206 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     207 + draw.RectChar('o'),
     208 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     209 + )
     210 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     211 + draw.RectChar('o'),
     212 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     213 + )
     214 + testdraw.MustRectangle(c, image.Rect(4, 5, 5, 10),
     215 + draw.RectChar('o'),
     216 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     217 + )
     218 + testdraw.MustRectangle(c, image.Rect(6, 0, 7, 10),
     219 + draw.RectChar('o'),
     220 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     221 + )
     222 + 
     223 + // Labels.
     224 + testdraw.MustText(c, "1", image.Point{0, 10}, draw.TextCellOpts(
     225 + cell.FgColor(DefaultLabelColor),
     226 + ))
     227 + testdraw.MustText(c, "…", image.Point{2, 10}, draw.TextCellOpts(
     228 + cell.FgColor(DefaultLabelColor),
     229 + ))
     230 + testdraw.MustText(c, "3", image.Point{4, 10}, draw.TextCellOpts(
     231 + cell.FgColor(DefaultLabelColor),
     232 + ))
     233 + testcanvas.MustApply(c, ft)
     234 + return ft
     235 + },
     236 + },
     237 + {
     238 + desc: "displays bars with labels and values",
     239 + bc: New(
     240 + Char('o'),
     241 + Labels([]string{
     242 + "1",
     243 + "2",
     244 + "3",
     245 + }),
     246 + ShowValues(),
     247 + ),
     248 + update: func(bc *BarChart) error {
     249 + return bc.Values([]int{1, 2, 5, 10}, 10)
     250 + },
     251 + canvas: image.Rect(0, 0, 7, 11),
     252 + want: func(size image.Point) *faketerm.Terminal {
     253 + ft := faketerm.MustNew(size)
     254 + c := testcanvas.MustNew(ft.Area())
     255 + 
     256 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     257 + draw.RectChar('o'),
     258 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     259 + )
     260 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     261 + draw.RectChar('o'),
     262 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     263 + )
     264 + testdraw.MustRectangle(c, image.Rect(4, 5, 5, 10),
     265 + draw.RectChar('o'),
     266 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     267 + )
     268 + testdraw.MustRectangle(c, image.Rect(6, 0, 7, 10),
     269 + draw.RectChar('o'),
     270 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     271 + )
     272 + // Labels.
     273 + testdraw.MustText(c, "1", image.Point{0, 10}, draw.TextCellOpts(
     274 + cell.FgColor(DefaultLabelColor),
     275 + ))
     276 + testdraw.MustText(c, "2", image.Point{2, 10}, draw.TextCellOpts(
     277 + cell.FgColor(DefaultLabelColor),
     278 + ))
     279 + testdraw.MustText(c, "3", image.Point{4, 10}, draw.TextCellOpts(
     280 + cell.FgColor(DefaultLabelColor),
     281 + ))
     282 + // Values.
     283 + testdraw.MustText(c, "1", image.Point{0, 9}, draw.TextCellOpts(
     284 + cell.FgColor(DefaultValueColor),
     285 + cell.BgColor(DefaultBarColor),
     286 + ))
     287 + testdraw.MustText(c, "2", image.Point{2, 9}, draw.TextCellOpts(
     288 + cell.FgColor(DefaultValueColor),
     289 + cell.BgColor(DefaultBarColor),
     290 + ))
     291 + testdraw.MustText(c, "5", image.Point{4, 9}, draw.TextCellOpts(
     292 + cell.FgColor(DefaultValueColor),
     293 + cell.BgColor(DefaultBarColor),
     294 + ))
     295 + testdraw.MustText(c, "…", image.Point{6, 9}, draw.TextCellOpts(
     296 + cell.FgColor(DefaultValueColor),
     297 + cell.BgColor(DefaultBarColor),
     298 + ))
     299 + testcanvas.MustApply(c, ft)
     300 + return ft
     301 + },
     302 + },
     303 + {
     304 + desc: "bars take as much width as available",
     305 + bc: New(
     306 + Char('o'),
     307 + ),
     308 + update: func(bc *BarChart) error {
     309 + return bc.Values([]int{1, 2}, 10)
     310 + },
     311 + canvas: image.Rect(0, 0, 5, 10),
     312 + want: func(size image.Point) *faketerm.Terminal {
     313 + ft := faketerm.MustNew(size)
     314 + c := testcanvas.MustNew(ft.Area())
     315 + 
     316 + testdraw.MustRectangle(c, image.Rect(0, 9, 2, 10),
     317 + draw.RectChar('o'),
     318 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     319 + )
     320 + testdraw.MustRectangle(c, image.Rect(3, 8, 5, 10),
     321 + draw.RectChar('o'),
     322 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     323 + )
     324 + testcanvas.MustApply(c, ft)
     325 + return ft
     326 + },
     327 + },
     328 + {
     329 + desc: "respects set bar width",
     330 + bc: New(
     331 + Char('o'),
     332 + BarWidth(1),
     333 + ),
     334 + update: func(bc *BarChart) error {
     335 + return bc.Values([]int{1, 2}, 10)
     336 + },
     337 + canvas: image.Rect(0, 0, 5, 10),
     338 + want: func(size image.Point) *faketerm.Terminal {
     339 + ft := faketerm.MustNew(size)
     340 + c := testcanvas.MustNew(ft.Area())
     341 + 
     342 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     343 + draw.RectChar('o'),
     344 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     345 + )
     346 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     347 + draw.RectChar('o'),
     348 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     349 + )
     350 + testcanvas.MustApply(c, ft)
     351 + return ft
     352 + },
     353 + },
     354 + {
     355 + desc: "options can be set on a call to Values",
     356 + bc: New(),
     357 + update: func(bc *BarChart) error {
     358 + return bc.Values([]int{1, 2}, 10, Char('o'), BarWidth(1))
     359 + },
     360 + canvas: image.Rect(0, 0, 5, 10),
     361 + want: func(size image.Point) *faketerm.Terminal {
     362 + ft := faketerm.MustNew(size)
     363 + c := testcanvas.MustNew(ft.Area())
     364 + 
     365 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     366 + draw.RectChar('o'),
     367 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     368 + )
     369 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     370 + draw.RectChar('o'),
     371 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     372 + )
     373 + testcanvas.MustApply(c, ft)
     374 + return ft
     375 + },
     376 + },
     377 + {
     378 + desc: "respects set bar gap",
     379 + bc: New(
     380 + Char('o'),
     381 + BarGap(2),
     382 + ),
     383 + update: func(bc *BarChart) error {
     384 + return bc.Values([]int{1, 2}, 10)
     385 + },
     386 + canvas: image.Rect(0, 0, 5, 10),
     387 + want: func(size image.Point) *faketerm.Terminal {
     388 + ft := faketerm.MustNew(size)
     389 + c := testcanvas.MustNew(ft.Area())
     390 + 
     391 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     392 + draw.RectChar('o'),
     393 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     394 + )
     395 + testdraw.MustRectangle(c, image.Rect(3, 8, 4, 10),
     396 + draw.RectChar('o'),
     397 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     398 + )
     399 + testcanvas.MustApply(c, ft)
     400 + return ft
     401 + },
     402 + },
     403 + {
     404 + desc: "respects both width and gap",
     405 + bc: New(
     406 + Char('o'),
     407 + BarGap(2),
     408 + BarWidth(2),
     409 + ),
     410 + update: func(bc *BarChart) error {
     411 + return bc.Values([]int{5, 3}, 10)
     412 + },
     413 + canvas: image.Rect(0, 0, 6, 10),
     414 + want: func(size image.Point) *faketerm.Terminal {
     415 + ft := faketerm.MustNew(size)
     416 + c := testcanvas.MustNew(ft.Area())
     417 + 
     418 + testdraw.MustRectangle(c, image.Rect(0, 5, 2, 10),
     419 + draw.RectChar('o'),
     420 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     421 + )
     422 + testdraw.MustRectangle(c, image.Rect(4, 7, 6, 10),
     423 + draw.RectChar('o'),
     424 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     425 + )
     426 + testcanvas.MustApply(c, ft)
     427 + return ft
     428 + },
     429 + },
     430 + {
     431 + desc: "respects bar and label colors",
     432 + bc: New(
     433 + Char('o'),
     434 + BarColors([]cell.Color{
     435 + cell.ColorBlue,
     436 + cell.ColorYellow,
     437 + }),
     438 + LabelColors([]cell.Color{
     439 + cell.ColorCyan,
     440 + cell.ColorMagenta,
     441 + }),
     442 + Labels([]string{
     443 + "1",
     444 + "2",
     445 + }),
     446 + ),
     447 + update: func(bc *BarChart) error {
     448 + return bc.Values([]int{1, 2, 3}, 10)
     449 + },
     450 + canvas: image.Rect(0, 0, 5, 11),
     451 + want: func(size image.Point) *faketerm.Terminal {
     452 + ft := faketerm.MustNew(size)
     453 + c := testcanvas.MustNew(ft.Area())
     454 + 
     455 + testdraw.MustRectangle(c, image.Rect(0, 9, 1, 10),
     456 + draw.RectChar('o'),
     457 + draw.RectCellOpts(cell.BgColor(cell.ColorBlue)),
     458 + )
     459 + testdraw.MustText(c, "1", image.Point{0, 10}, draw.TextCellOpts(
     460 + cell.FgColor(cell.ColorCyan),
     461 + ))
     462 + 
     463 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     464 + draw.RectChar('o'),
     465 + draw.RectCellOpts(cell.BgColor(cell.ColorYellow)),
     466 + )
     467 + testdraw.MustText(c, "2", image.Point{2, 10}, draw.TextCellOpts(
     468 + cell.FgColor(cell.ColorMagenta),
     469 + ))
     470 + 
     471 + testdraw.MustRectangle(c, image.Rect(4, 7, 5, 10),
     472 + draw.RectChar('o'),
     473 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     474 + )
     475 + testcanvas.MustApply(c, ft)
     476 + return ft
     477 + },
     478 + },
     479 + {
     480 + desc: "respects value colors",
     481 + bc: New(
     482 + Char('o'),
     483 + ValueColors([]cell.Color{
     484 + cell.ColorBlue,
     485 + cell.ColorBlack,
     486 + }),
     487 + ShowValues(),
     488 + ),
     489 + update: func(bc *BarChart) error {
     490 + return bc.Values([]int{0, 2, 3}, 10)
     491 + },
     492 + canvas: image.Rect(0, 0, 5, 10),
     493 + want: func(size image.Point) *faketerm.Terminal {
     494 + ft := faketerm.MustNew(size)
     495 + c := testcanvas.MustNew(ft.Area())
     496 + 
     497 + testdraw.MustText(c, "0", image.Point{0, 9}, draw.TextCellOpts(
     498 + cell.FgColor(cell.ColorBlue),
     499 + ))
     500 + 
     501 + testdraw.MustRectangle(c, image.Rect(2, 8, 3, 10),
     502 + draw.RectChar('o'),
     503 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     504 + )
     505 + testdraw.MustText(c, "2", image.Point{2, 9}, draw.TextCellOpts(
     506 + cell.FgColor(cell.ColorBlack),
     507 + cell.BgColor(DefaultBarColor),
     508 + ))
     509 + 
     510 + testdraw.MustRectangle(c, image.Rect(4, 7, 5, 10),
     511 + draw.RectChar('o'),
     512 + draw.RectCellOpts(cell.BgColor(DefaultBarColor)),
     513 + )
     514 + testdraw.MustText(c, "3", image.Point{4, 9}, draw.TextCellOpts(
     515 + cell.FgColor(DefaultValueColor),
     516 + cell.BgColor(DefaultBarColor),
     517 + ))
     518 + testcanvas.MustApply(c, ft)
     519 + return ft
     520 + },
     521 + },
     522 + }
     523 + 
     524 + for _, tc := range tests {
     525 + t.Run(tc.desc, func(t *testing.T) {
     526 + c, err := canvas.New(tc.canvas)
     527 + if err != nil {
     528 + t.Fatalf("canvas.New => unexpected error: %v", err)
     529 + }
     530 + 
     531 + err = tc.update(tc.bc)
     532 + if (err != nil) != tc.wantUpdateErr {
     533 + t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr)
     534 + 
     535 + }
     536 + if err != nil {
     537 + return
     538 + }
     539 + 
     540 + err = tc.bc.Draw(c)
     541 + if (err != nil) != tc.wantDrawErr {
     542 + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
     543 + }
     544 + if err != nil {
     545 + return
     546 + }
     547 + 
     548 + got, err := faketerm.New(c.Size())
     549 + if err != nil {
     550 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     551 + }
     552 + 
     553 + if err := c.Apply(got); err != nil {
     554 + t.Fatalf("Apply => unexpected error: %v", err)
     555 + }
     556 + 
     557 + if diff := faketerm.Diff(tc.want(c.Size()), got); diff != "" {
     558 + t.Errorf("Draw => %v", diff)
     559 + }
     560 + })
     561 + }
     562 +}
     563 + 
     564 +func TestOptions(t *testing.T) {
     565 + tests := []struct {
     566 + desc string
     567 + create func() (*BarChart, error)
     568 + want widgetapi.Options
     569 + }{
     570 + {
     571 + desc: "minimum size for no bars",
     572 + create: func() (*BarChart, error) {
     573 + return New(), nil
     574 + },
     575 + want: widgetapi.Options{
     576 + MinimumSize: image.Point{1, 1},
     577 + WantKeyboard: false,
     578 + WantMouse: false,
     579 + },
     580 + },
     581 + {
     582 + desc: "minimum size for no bars, but have labels",
     583 + create: func() (*BarChart, error) {
     584 + return New(
     585 + Labels([]string{"foo"}),
     586 + ), nil
     587 + },
     588 + want: widgetapi.Options{
     589 + MinimumSize: image.Point{1, 1},
     590 + WantKeyboard: false,
     591 + WantMouse: false,
     592 + },
     593 + },
     594 + {
     595 + desc: "minimum size for one bar, default width, gap and no labels",
     596 + create: func() (*BarChart, error) {
     597 + bc := New()
     598 + if err := bc.Values([]int{1}, 3); err != nil {
     599 + return nil, err
     600 + }
     601 + return bc, nil
     602 + },
     603 + want: widgetapi.Options{
     604 + MinimumSize: image.Point{1, 1},
     605 + WantKeyboard: false,
     606 + WantMouse: false,
     607 + },
     608 + },
     609 + {
     610 + desc: "minimum size for two bars, default width, gap and no labels",
     611 + create: func() (*BarChart, error) {
     612 + bc := New()
     613 + if err := bc.Values([]int{1, 2}, 3); err != nil {
     614 + return nil, err
     615 + }
     616 + return bc, nil
     617 + },
     618 + want: widgetapi.Options{
     619 + MinimumSize: image.Point{3, 1},
     620 + WantKeyboard: false,
     621 + WantMouse: false,
     622 + },
     623 + },
     624 + {
     625 + desc: "minimum size for two bars, custom width, gap and no labels",
     626 + create: func() (*BarChart, error) {
     627 + bc := New(
     628 + BarWidth(3),
     629 + BarGap(2),
     630 + )
     631 + if err := bc.Values([]int{1, 2}, 3); err != nil {
     632 + return nil, err
     633 + }
     634 + return bc, nil
     635 + },
     636 + want: widgetapi.Options{
     637 + MinimumSize: image.Point{8, 1},
     638 + WantKeyboard: false,
     639 + WantMouse: false,
     640 + },
     641 + },
     642 + {
     643 + desc: "minimum size for two bars, custom width, gap and labels",
     644 + create: func() (*BarChart, error) {
     645 + bc := New(
     646 + BarWidth(3),
     647 + BarGap(2),
     648 + )
     649 + if err := bc.Values([]int{1, 2}, 3, Labels([]string{"foo", "bar"})); err != nil {
     650 + return nil, err
     651 + }
     652 + return bc, nil
     653 + },
     654 + want: widgetapi.Options{
     655 + MinimumSize: image.Point{8, 2},
     656 + WantKeyboard: false,
     657 + WantMouse: false,
     658 + },
     659 + },
     660 + }
     661 + 
     662 + for _, tc := range tests {
     663 + t.Run(tc.desc, func(t *testing.T) {
     664 + bc, err := tc.create()
     665 + if err != nil {
     666 + t.Fatalf("create => unexpected error: %v", err)
     667 + }
     668 + 
     669 + got := bc.Options()
     670 + if diff := pretty.Compare(tc.want, got); diff != "" {
     671 + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)
     672 + }
     673 + 
     674 + })
     675 + }
     676 +}
     677 + 
  • ■ ■ ■ ■ ■ ■
    widgets/barchart/barchartdemo/barchartdemo.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 +// Binary barchartdemo displays a couple of BarChart widgets.
     16 +// Exist when 'q' is pressed.
     17 +package main
     18 + 
     19 +import (
     20 + "context"
     21 + "math/rand"
     22 + "time"
     23 + 
     24 + "github.com/mum4k/termdash"
     25 + "github.com/mum4k/termdash/cell"
     26 + "github.com/mum4k/termdash/container"
     27 + "github.com/mum4k/termdash/draw"
     28 + "github.com/mum4k/termdash/terminal/termbox"
     29 + "github.com/mum4k/termdash/terminalapi"
     30 + "github.com/mum4k/termdash/widgets/barchart"
     31 +)
     32 + 
     33 +// playBarChart continuously changes the displayed values on the bar chart once every delay.
     34 +// Exits when the context expires.
     35 +func playBarChart(ctx context.Context, bc *barchart.BarChart, delay time.Duration) {
     36 + const (
     37 + bars = 6
     38 + max = 100
     39 + )
     40 + 
     41 + values := make([]int, 6)
     42 + 
     43 + ticker := time.NewTicker(delay)
     44 + defer ticker.Stop()
     45 + for {
     46 + select {
     47 + case <-ticker.C:
     48 + for i := range values {
     49 + values[i] = int(rand.Int31n(max + 1))
     50 + }
     51 + 
     52 + if err := bc.Values(values, max); err != nil {
     53 + panic(err)
     54 + }
     55 + 
     56 + case <-ctx.Done():
     57 + return
     58 + }
     59 + }
     60 +}
     61 + 
     62 +func main() {
     63 + t, err := termbox.New()
     64 + if err != nil {
     65 + panic(err)
     66 + }
     67 + defer t.Close()
     68 + 
     69 + ctx, cancel := context.WithCancel(context.Background())
     70 + bc := barchart.New(
     71 + barchart.BarColors([]cell.Color{
     72 + cell.ColorBlue,
     73 + cell.ColorRed,
     74 + cell.ColorYellow,
     75 + cell.ColorBlue,
     76 + cell.ColorGreen,
     77 + cell.ColorRed,
     78 + }),
     79 + barchart.ValueColors([]cell.Color{
     80 + cell.ColorRed,
     81 + cell.ColorYellow,
     82 + cell.ColorBlue,
     83 + cell.ColorGreen,
     84 + cell.ColorRed,
     85 + cell.ColorBlue,
     86 + }),
     87 + barchart.ShowValues(),
     88 + barchart.Labels([]string{
     89 + "CPU1",
     90 + "",
     91 + "CPU3",
     92 + }),
     93 + )
     94 + go playBarChart(ctx, bc, 1*time.Second)
     95 + 
     96 + c := container.New(
     97 + t,
     98 + container.Border(draw.LineStyleLight),
     99 + container.BorderTitle("PRESS Q TO QUIT"),
     100 + container.PlaceWidget(bc),
     101 + )
     102 + 
     103 + quitter := func(k *terminalapi.Keyboard) {
     104 + if k.Key == 'q' || k.Key == 'Q' {
     105 + cancel()
     106 + }
     107 + }
     108 + 
     109 + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter)); err != nil {
     110 + panic(err)
     111 + }
     112 +}
     113 + 
  • ■ ■ ■ ■ ■ ■
    widgets/barchart/options.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 barchart
     16 + 
     17 +// options.go contains configurable options for BarChart.
     18 + 
     19 +import (
     20 + "github.com/mum4k/termdash/cell"
     21 + "github.com/mum4k/termdash/draw"
     22 +)
     23 + 
     24 +// Option is used to provide options.
     25 +type Option interface {
     26 + // set sets the provided option.
     27 + set(*options)
     28 +}
     29 + 
     30 +// option implements Option.
     31 +type option func(*options)
     32 + 
     33 +// set implements Option.set.
     34 +func (o option) set(opts *options) {
     35 + o(opts)
     36 +}
     37 + 
     38 +// options holds the provided options.
     39 +type options struct {
     40 + barChar rune
     41 + barWidth int
     42 + barGap int
     43 + showValues bool
     44 + barColors []cell.Color
     45 + labelColors []cell.Color
     46 + valueColors []cell.Color
     47 + labels []string
     48 +}
     49 + 
     50 +// newOptions returns options with the default values set.
     51 +func newOptions() *options {
     52 + return &options{
     53 + barChar: DefaultChar,
     54 + barGap: DefaultBarGap,
     55 + }
     56 +}
     57 + 
     58 +// DefaultChar is the default value for the Char option.
     59 +const DefaultChar = draw.DefaultRectChar
     60 + 
     61 +// Char sets the rune that is used when drawing the rectangle representing the
     62 +// bars.
     63 +func Char(ch rune) Option {
     64 + return option(func(opts *options) {
     65 + opts.barChar = ch
     66 + })
     67 +}
     68 + 
     69 +// BarWidth sets the width of the bars. If not set, the bars use all the space
     70 +// available to the widget.
     71 +func BarWidth(width int) Option {
     72 + return option(func(opts *options) {
     73 + opts.barWidth = width
     74 + })
     75 +}
     76 + 
     77 +// DefaultBarGap is the default value for the BarGap option.
     78 +const DefaultBarGap = 1
     79 + 
     80 +// BarGap sets the width of the space between the bars.
     81 +// Defaults to DefaultBarGap.
     82 +func BarGap(width int) Option {
     83 + return option(func(opts *options) {
     84 + opts.barGap = width
     85 + })
     86 +}
     87 + 
     88 +// ShowValues tells the bar chart to display the actual values inside each of the bars.
     89 +func ShowValues() Option {
     90 + return option(func(opts *options) {
     91 + opts.showValues = true
     92 + })
     93 +}
     94 + 
     95 +// DefaultBarColor is the default color of a bar, unless specified otherwise
     96 +// via the BarColors option.
     97 +const DefaultBarColor = cell.ColorRed
     98 + 
     99 +// BarColors sets the colors of each of the bars.
     100 +// Bars are created on a call to Values(), each value ends up in its own Bar.
     101 +// The first supplied color applies to the bar displaying the first value.
     102 +// Any bars that don't have a color specified use the DefaultBarColor.
     103 +func BarColors(colors []cell.Color) Option {
     104 + return option(func(opts *options) {
     105 + opts.barColors = colors
     106 + })
     107 +}
     108 + 
     109 +// DefaultLabelColor is the default color of a bar label, unless specified
     110 +// otherwise via the LabelColors option.
     111 +const DefaultLabelColor = cell.ColorGreen
     112 + 
     113 +// LabelColors sets the colors of each of the labels under the bars.
     114 +// Bars are created on a call to Values(), each value ends up in its own Bar.
     115 +// The first supplied color applies to the label of the bar displaying the
     116 +// first value. Any labels that don't have a color specified use the
     117 +// DefaultLabelColor.
     118 +func LabelColors(colors []cell.Color) Option {
     119 + return option(func(opts *options) {
     120 + opts.labelColors = colors
     121 + })
     122 +}
     123 + 
     124 +// Labels sets the labels displayed under each bar,
     125 +// Bars are created on a call to Values(), each value ends up in its own Bar.
     126 +// The first supplied label applies to the bar displaying the first value.
     127 +// If not specified, the corresponding bar (or all the bars) don't have a
     128 +// label.
     129 +func Labels(labels []string) Option {
     130 + return option(func(opts *options) {
     131 + opts.labels = labels
     132 + })
     133 +}
     134 + 
     135 +// DefaultValueColor is the default color of a bar value, unless specified
     136 +// otherwise via the ValueColors option.
     137 +const DefaultValueColor = cell.ColorYellow
     138 + 
     139 +// ValueColors sets the colors of each of the values in the bars. Bars are
     140 +// created on a call to Values(), each value ends up in its own Bar. The first
     141 +// supplied color applies to the bar displaying the first value. Any values
     142 +// that don't have a color specified use the DefaultValueColor.
     143 +func ValueColors(colors []cell.Color) Option {
     144 + return option(func(opts *options) {
     145 + opts.valueColors = colors
     146 + })
     147 +}
     148 + 
  • ■ ■ ■ ■ ■
    widgets/fakewidget/fakewidget.go
    skipped 44 lines
    45 45  // Mirror is a fake widget. The fake widget draws a border around its assigned
    46 46  // canvas and writes the size of its assigned canvas on the first line of the
    47 47  // canvas. It writes the last received keyboard event onto the second line. It
    48  -// writes the last received mouse event onto the third line.
     48 +// writes the last received mouse event onto the third line. If a non-empty
     49 +// string is provided via the Text() method, that text will be written right
     50 +// after the canvas size on the first line.
    49 51  //
    50 52  // The widget requests the same options that are provided to the constructor.
    51 53  // If the options or canvas size don't allow for the three lines mentioned
    skipped 5 lines
    57 59   // lines are the three lines that will be drawn on the canvas.
    58 60   lines []string
    59 61   
     62 + // text is the text provided by the last call to Text().
     63 + text string
     64 + 
    60 65   // mu protects lines.
    61 66   mu sync.RWMutex
    62 67   
    skipped 25 lines
    88 93   return err
    89 94   }
    90 95   
    91  - mi.lines[sizeLine] = cvs.Size().String()
     96 + mi.lines[sizeLine] = fmt.Sprintf("%s%s", cvs.Size().String(), mi.text)
    92 97   usable := area.ExcludeBorder(cvs.Area())
    93 98   start := cvs.Area().Intersect(usable).Min
    94 99   for i := 0; i < outputLines; i++ {
    skipped 10 lines
    105 110   return nil
    106 111  }
    107 112   
     113 +// Text stores a text that should be displayed right after the canvas size on
     114 +// the first line of the output.
     115 +func (mi *Mirror) Text(txt string) {
     116 + mi.text = txt
     117 +}
     118 + 
    108 119  // Keyboard draws the received key on the canvas.
    109 120  // Sending the keyboard.KeyEsc causes this widget to forget the last keyboard
    110 121  // event and return an error instead.
    skipped 36 lines
    147 158  // widget onto the provided canvas and forwarding the given events.
    148 159  func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, events ...terminalapi.Event) error {
    149 160   mirror := New(opts)
     161 + return DrawWithMirror(mirror, t, cvs, events...)
     162 +}
     163 + 
     164 +// MustDraw is like Draw, but panics on all errors.
     165 +func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, events ...terminalapi.Event) {
     166 + if err := Draw(t, cvs, opts, events...); err != nil {
     167 + panic(fmt.Sprintf("Draw => %v", err))
     168 + }
     169 +}
     170 + 
     171 +// DrawWithMirror is like Draw, but uses the provided Mirror instead of creating one.
     172 +func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, events ...terminalapi.Event) error {
    150 173   for _, ev := range events {
    151 174   switch e := ev.(type) {
    152 175   case *terminalapi.Mouse:
    153  - if !opts.WantMouse {
     176 + if !mirror.opts.WantMouse {
    154 177   continue
    155 178   }
    156 179   if err := mirror.Mouse(e); err != nil {
    157 180   return err
    158 181   }
    159 182   case *terminalapi.Keyboard:
    160  - if !opts.WantKeyboard {
     183 + if !mirror.opts.WantKeyboard {
    161 184   continue
    162 185   }
    163 186   if err := mirror.Keyboard(e); err != nil {
    skipped 10 lines
    174 197   return cvs.Apply(t)
    175 198  }
    176 199   
    177  -// MustDraw is like Draw, but panics on all errors.
    178  -func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, events ...terminalapi.Event) {
    179  - if err := Draw(t, cvs, opts, events...); err != nil {
    180  - panic(fmt.Sprintf("Draw => %v", err))
     200 +// MustDrawWithMirror is like DrawWithMirror, but panics on all errors.
     201 +func MustDrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, events ...terminalapi.Event) {
     202 + if err := DrawWithMirror(mirror, t, cvs, events...); err != nil {
     203 + panic(fmt.Sprintf("DrawWithMirror => %v", err))
    181 204   }
    182 205  }
    183 206   
  • ■ ■ ■ ■ ■ ■
    widgets/fakewidget/fakewidget_test.go
    skipped 45 lines
    46 46   desc string
    47 47   keyEvents []keyEvents // Keyboard events to send before calling Draw().
    48 48   mouseEvents []mouseEvents // Mouse events to send before calling Draw().
     49 + apiEvents func(*Mirror) // External events via the widget's API.
    49 50   cvs *canvas.Canvas
    50 51   want func(size image.Point) *faketerm.Terminal
    51 52   wantErr bool
    skipped 22 lines
    74 75   cvs := testcanvas.MustNew(ft.Area())
    75 76   testdraw.MustBorder(cvs, cvs.Area())
    76 77   testdraw.MustText(cvs, "(7,3)", image.Point{1, 1})
     78 + testcanvas.MustApply(cvs, ft)
     79 + return ft
     80 + },
     81 + },
     82 + {
     83 + desc: "draws the box, canvas size and custom text",
     84 + apiEvents: func(mi *Mirror) {
     85 + mi.Text("hi")
     86 + },
     87 + cvs: testcanvas.MustNew(image.Rect(0, 0, 9, 3)),
     88 + want: func(size image.Point) *faketerm.Terminal {
     89 + ft := faketerm.MustNew(size)
     90 + cvs := testcanvas.MustNew(ft.Area())
     91 + testdraw.MustBorder(cvs, cvs.Area())
     92 + testdraw.MustText(cvs, "(9,3)hi", image.Point{1, 1})
    77 93   testcanvas.MustApply(cvs, ft)
    78 94   return ft
    79 95   },
    skipped 147 lines
    227 243   t.Run(tc.desc, func(t *testing.T) {
    228 244   w := New(widgetapi.Options{})
    229 245   
     246 + if tc.apiEvents != nil {
     247 + tc.apiEvents(w)
     248 + }
     249 + 
    230 250   for _, keyEv := range tc.keyEvents {
    231 251   err := w.Keyboard(keyEv.k)
    232 252   if (err != nil) != keyEv.wantErr {
    skipped 38 lines
    271 291   }
    272 292  }
    273 293   
     294 +func TestDraw(t *testing.T) {
     295 + tests := []struct {
     296 + desc string
     297 + opts widgetapi.Options
     298 + cvs *canvas.Canvas
     299 + events []terminalapi.Event
     300 + want func(size image.Point) *faketerm.Terminal
     301 + wantErr bool
     302 + }{
     303 + {
     304 + desc: "canvas too small to draw a box",
     305 + cvs: testcanvas.MustNew(image.Rect(0, 0, 1, 1)),
     306 + want: func(size image.Point) *faketerm.Terminal {
     307 + return faketerm.MustNew(size)
     308 + },
     309 + wantErr: true,
     310 + },
     311 + {
     312 + desc: "draws the box and canvas size",
     313 + cvs: testcanvas.MustNew(image.Rect(0, 0, 9, 3)),
     314 + want: func(size image.Point) *faketerm.Terminal {
     315 + ft := faketerm.MustNew(size)
     316 + cvs := testcanvas.MustNew(ft.Area())
     317 + testdraw.MustBorder(cvs, cvs.Area())
     318 + testdraw.MustText(cvs, "(9,3)", image.Point{1, 1})
     319 + testcanvas.MustApply(cvs, ft)
     320 + return ft
     321 + },
     322 + },
     323 + {
     324 + desc: "draws both keyboard and mouse events",
     325 + opts: widgetapi.Options{
     326 + WantKeyboard: true,
     327 + WantMouse: true,
     328 + },
     329 + cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
     330 + events: []terminalapi.Event{
     331 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     332 + &terminalapi.Mouse{Button: mouse.ButtonLeft},
     333 + },
     334 + want: func(size image.Point) *faketerm.Terminal {
     335 + ft := faketerm.MustNew(size)
     336 + cvs := testcanvas.MustNew(ft.Area())
     337 + testdraw.MustBorder(cvs, cvs.Area())
     338 + testdraw.MustText(cvs, "(17,5)", image.Point{1, 1})
     339 + testdraw.MustText(cvs, "KeyEnter", image.Point{1, 2})
     340 + testdraw.MustText(cvs, "(0,0)ButtonLeft", image.Point{1, 3})
     341 + testcanvas.MustApply(cvs, ft)
     342 + return ft
     343 + },
     344 + },
     345 + }
     346 + 
     347 + for _, tc := range tests {
     348 + t.Run(tc.desc, func(t *testing.T) {
     349 + got := faketerm.MustNew(tc.cvs.Size())
     350 + err := Draw(got, tc.cvs, tc.opts, tc.events...)
     351 + if (err != nil) != tc.wantErr {
     352 + t.Errorf("Draw => got error:%v, wantErr: %v", err, tc.wantErr)
     353 + }
     354 + 
     355 + if diff := faketerm.Diff(tc.want(tc.cvs.Size()), got); diff != "" {
     356 + t.Errorf("Draw => %v", diff)
     357 + }
     358 + })
     359 + }
     360 +}
     361 + 
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/options.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 sparkline
     16 + 
     17 +// options.go contains configurable options for SparkLine.
     18 + 
     19 +import "github.com/mum4k/termdash/cell"
     20 + 
     21 +// Option is used to provide options.
     22 +type Option interface {
     23 + // set sets the provided option.
     24 + set(*options)
     25 +}
     26 + 
     27 +// option implements Option.
     28 +type option func(*options)
     29 + 
     30 +// set implements Option.set.
     31 +func (o option) set(opts *options) {
     32 + o(opts)
     33 +}
     34 + 
     35 +// options holds the provided options.
     36 +type options struct {
     37 + label string
     38 + labelCellOpts []cell.Option
     39 + height int
     40 + color cell.Color
     41 +}
     42 + 
     43 +// newOptions returns options with the default values set.
     44 +func newOptions() *options {
     45 + return &options{
     46 + color: DefaultColor,
     47 + }
     48 +}
     49 + 
     50 +// Label adds a label above the SparkLine.
     51 +func Label(text string, cOpts ...cell.Option) Option {
     52 + return option(func(opts *options) {
     53 + opts.label = text
     54 + opts.labelCellOpts = cOpts
     55 + })
     56 +}
     57 + 
     58 +// Height sets a fixed height for the SparkLine.
     59 +// If not provided, the SparkLine takes all the available vertical space in the
     60 +// container.
     61 +func Height(h int) Option {
     62 + return option(func(opts *options) {
     63 + opts.height = h
     64 + })
     65 +}
     66 + 
     67 +// DefaultColor is the default value for the Color option.
     68 +const DefaultColor = cell.ColorGreen
     69 + 
     70 +// Color sets the color of the SparkLine.
     71 +// Defaults to DefaultColor if not set.
     72 +func Color(c cell.Color) Option {
     73 + return option(func(opts *options) {
     74 + opts.color = c
     75 + })
     76 +}
     77 + 
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/sparkline.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 sparkline is a widget that draws a graph showing a series of values as vertical bars.
     16 +package sparkline
     17 + 
     18 +import (
     19 + "errors"
     20 + "fmt"
     21 + "image"
     22 + "sync"
     23 + 
     24 + "github.com/mum4k/termdash/canvas"
     25 + "github.com/mum4k/termdash/cell"
     26 + "github.com/mum4k/termdash/draw"
     27 + "github.com/mum4k/termdash/terminalapi"
     28 + "github.com/mum4k/termdash/widgetapi"
     29 +)
     30 + 
     31 +// SparkLine draws a graph showing a series of values as vertical bars.
     32 +//
     33 +// Bars can have sub-cell height. The graphs scale adjusts dynamically based on
     34 +// the largest visible value.
     35 +//
     36 +// Implements widgetapi.Widget. This object is thread-safe.
     37 +type SparkLine struct {
     38 + // data are the data points the SparkLine displays.
     39 + data []int
     40 + 
     41 + // mu protects the SparkLine.
     42 + mu sync.Mutex
     43 + 
     44 + // opts are the provided options.
     45 + opts *options
     46 +}
     47 + 
     48 +// New returns a new SparkLine.
     49 +func New(opts ...Option) *SparkLine {
     50 + opt := newOptions()
     51 + for _, o := range opts {
     52 + o.set(opt)
     53 + }
     54 + return &SparkLine{
     55 + opts: opt,
     56 + }
     57 +}
     58 + 
     59 +// Draw draws the SparkLine widget onto the canvas.
     60 +// Implements widgetapi.Widget.Draw.
     61 +func (sl *SparkLine) Draw(cvs *canvas.Canvas) error {
     62 + sl.mu.Lock()
     63 + defer sl.mu.Unlock()
     64 + 
     65 + ar := sl.area(cvs)
     66 + visible, max := visibleMax(sl.data, ar.Dx())
     67 + var curX int
     68 + if len(visible) < ar.Dx() {
     69 + curX = ar.Max.X - len(visible)
     70 + } else {
     71 + curX = ar.Min.X
     72 + }
     73 + 
     74 + for _, v := range visible {
     75 + blocks := toBlocks(v, max, ar.Dy())
     76 + curY := ar.Max.Y - 1
     77 + for i := 0; i < blocks.full; i++ {
     78 + if _, err := cvs.SetCell(
     79 + image.Point{curX, curY},
     80 + sparks[len(sparks)-1], // Last spark represents full cell.
     81 + cell.FgColor(sl.opts.color),
     82 + ); err != nil {
     83 + return err
     84 + }
     85 + 
     86 + curY--
     87 + }
     88 + 
     89 + if blocks.partSpark != 0 {
     90 + if _, err := cvs.SetCell(
     91 + image.Point{curX, curY},
     92 + blocks.partSpark,
     93 + cell.FgColor(sl.opts.color),
     94 + ); err != nil {
     95 + return err
     96 + }
     97 + }
     98 + 
     99 + curX++
     100 + }
     101 + 
     102 + if sl.opts.label != "" {
     103 + // Label is placed immediately above the SparkLine.
     104 + lStart := image.Point{ar.Min.X, ar.Min.Y - 1}
     105 + if err := draw.Text(cvs, sl.opts.label, lStart,
     106 + draw.TextCellOpts(sl.opts.labelCellOpts...),
     107 + draw.TextOverrunMode(draw.OverrunModeThreeDot),
     108 + ); err != nil {
     109 + return err
     110 + }
     111 + }
     112 + return nil
     113 +}
     114 + 
     115 +// Add adds data points to the SparkLine.
     116 +// Each data point is represented by one bar on the SparkLine. Zero value data
     117 +// points are valid and are represented by an empty space on the SparkLine
     118 +// (i.e. a missing bar).
     119 +//
     120 +// At least one data point must be provided. All data points must be positive
     121 +// integers.
     122 +//
     123 +// The last added data point will be the one displayed all the way on the right
     124 +// of the SparkLine. If there are more data points than we can fit bars to the
     125 +// width of the SparkLine, only the last n data points that fit will be
     126 +// visible.
     127 +//
     128 +// Provided options override values set when New() was called.
     129 +func (sl *SparkLine) Add(data []int, opts ...Option) error {
     130 + sl.mu.Lock()
     131 + defer sl.mu.Unlock()
     132 + 
     133 + for _, opt := range opts {
     134 + opt.set(sl.opts)
     135 + }
     136 + 
     137 + for i, d := range data {
     138 + if d < 0 {
     139 + return fmt.Errorf("data point[%d]: %v must be a positive integer", i, d)
     140 + }
     141 + }
     142 + sl.data = append(sl.data, data...)
     143 + return nil
     144 +}
     145 + 
     146 +// Clear removes all the data points in the SparkLine, effectively returning to
     147 +// an empty graph.
     148 +func (sl *SparkLine) Clear() {
     149 + sl.mu.Lock()
     150 + defer sl.mu.Unlock()
     151 + 
     152 + sl.data = nil
     153 +}
     154 + 
     155 +// Keyboard input isn't supported on the SparkLine widget.
     156 +func (*SparkLine) Keyboard(k *terminalapi.Keyboard) error {
     157 + return errors.New("the SparkLine widget doesn't support keyboard events")
     158 +}
     159 + 
     160 +// Mouse input isn't supported on the SparkLine widget.
     161 +func (*SparkLine) Mouse(m *terminalapi.Mouse) error {
     162 + return errors.New("the SparkLine widget doesn't support mouse events")
     163 +}
     164 + 
     165 +// area returns the area of the canvas available to the SparkLine.
     166 +func (sl *SparkLine) area(cvs *canvas.Canvas) image.Rectangle {
     167 + cvsAr := cvs.Area()
     168 + maxY := cvsAr.Max.Y
     169 + 
     170 + // Height is determined based on options (fixed height / label).
     171 + var minY int
     172 + if sl.opts.height > 0 {
     173 + minY = maxY - sl.opts.height
     174 + } else {
     175 + minY = cvsAr.Min.Y
     176 + 
     177 + if sl.opts.label != "" {
     178 + minY++ // Reserve one line for the label.
     179 + }
     180 + }
     181 + return image.Rect(
     182 + cvsAr.Min.X,
     183 + minY,
     184 + cvsAr.Max.X,
     185 + maxY,
     186 + )
     187 +}
     188 + 
     189 +// minSize returns the minimum canvas size for the SparkLine based on the options.
     190 +func (sl *SparkLine) minSize() image.Point {
     191 + const minWidth = 1 // At least one data point.
     192 + 
     193 + var minHeight int
     194 + if sl.opts.height > 0 {
     195 + minHeight = sl.opts.height
     196 + } else {
     197 + minHeight = 1 // At least one line of characters.
     198 + }
     199 + 
     200 + if sl.opts.label != "" {
     201 + minHeight++ // One line for the text label.
     202 + }
     203 + return image.Point{minWidth, minHeight}
     204 +}
     205 + 
     206 +// Options implements widgetapi.Widget.Options.
     207 +func (sl *SparkLine) Options() widgetapi.Options {
     208 + sl.mu.Lock()
     209 + defer sl.mu.Unlock()
     210 + 
     211 + min := sl.minSize()
     212 + var max image.Point
     213 + if sl.opts.height > 0 {
     214 + max = min // Fix the height to the one specified.
     215 + }
     216 + 
     217 + return widgetapi.Options{
     218 + MinimumSize: min,
     219 + MaximumSize: max,
     220 + WantKeyboard: false,
     221 + WantMouse: false,
     222 + }
     223 +}
     224 + 
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/sparkline_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 sparkline
     16 + 
     17 +import (
     18 + "image"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 + "github.com/mum4k/termdash/canvas"
     23 + "github.com/mum4k/termdash/canvas/testcanvas"
     24 + "github.com/mum4k/termdash/cell"
     25 + "github.com/mum4k/termdash/draw"
     26 + "github.com/mum4k/termdash/draw/testdraw"
     27 + "github.com/mum4k/termdash/terminal/faketerm"
     28 + "github.com/mum4k/termdash/widgetapi"
     29 +)
     30 + 
     31 +func TestSparkLine(t *testing.T) {
     32 + tests := []struct {
     33 + desc string
     34 + sparkLine *SparkLine
     35 + update func(*SparkLine) error // update gets called before drawing of the widget.
     36 + canvas image.Rectangle
     37 + want func(size image.Point) *faketerm.Terminal
     38 + wantUpdateErr bool // whether to expect an error on a call to the update function
     39 + wantDrawErr bool
     40 + }{
     41 + {
     42 + desc: "draws empty for no data points",
     43 + sparkLine: New(),
     44 + update: func(sl *SparkLine) error {
     45 + return nil
     46 + },
     47 + canvas: image.Rect(0, 0, 1, 1),
     48 + want: func(size image.Point) *faketerm.Terminal {
     49 + return faketerm.MustNew(size)
     50 + },
     51 + },
     52 + {
     53 + desc: "fails on negative data points",
     54 + sparkLine: New(),
     55 + update: func(sl *SparkLine) error {
     56 + return sl.Add([]int{0, 3, -1, 2})
     57 + },
     58 + canvas: image.Rect(0, 0, 1, 1),
     59 + want: func(size image.Point) *faketerm.Terminal {
     60 + return faketerm.MustNew(size)
     61 + },
     62 + wantUpdateErr: true,
     63 + },
     64 + {
     65 + desc: "single height sparkline",
     66 + sparkLine: New(),
     67 + update: func(sl *SparkLine) error {
     68 + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8})
     69 + },
     70 + canvas: image.Rect(0, 0, 9, 1),
     71 + want: func(size image.Point) *faketerm.Terminal {
     72 + ft := faketerm.MustNew(size)
     73 + c := testcanvas.MustNew(ft.Area())
     74 + 
     75 + testdraw.MustText(c, "▁▂▃▄▅▆▇█", image.Point{1, 0}, draw.TextCellOpts(
     76 + cell.FgColor(DefaultColor),
     77 + ))
     78 + testcanvas.MustApply(c, ft)
     79 + return ft
     80 + },
     81 + },
     82 + {
     83 + desc: "sparkline can be cleared",
     84 + sparkLine: New(),
     85 + update: func(sl *SparkLine) error {
     86 + if err := sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}); err != nil {
     87 + return err
     88 + }
     89 + sl.Clear()
     90 + return nil
     91 + },
     92 + canvas: image.Rect(0, 0, 9, 1),
     93 + want: func(size image.Point) *faketerm.Terminal {
     94 + return faketerm.MustNew(size)
     95 + },
     96 + },
     97 + {
     98 + desc: "sets sparkline color",
     99 + sparkLine: New(
     100 + Color(cell.ColorMagenta),
     101 + ),
     102 + update: func(sl *SparkLine) error {
     103 + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8})
     104 + },
     105 + canvas: image.Rect(0, 0, 9, 1),
     106 + want: func(size image.Point) *faketerm.Terminal {
     107 + ft := faketerm.MustNew(size)
     108 + c := testcanvas.MustNew(ft.Area())
     109 + 
     110 + testdraw.MustText(c, "▁▂▃▄▅▆▇█", image.Point{1, 0}, draw.TextCellOpts(
     111 + cell.FgColor(cell.ColorMagenta),
     112 + ))
     113 + testcanvas.MustApply(c, ft)
     114 + return ft
     115 + },
     116 + },
     117 + {
     118 + desc: "sets sparkline color on a call to Add",
     119 + sparkLine: New(),
     120 + update: func(sl *SparkLine) error {
     121 + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}, Color(cell.ColorMagenta))
     122 + },
     123 + canvas: image.Rect(0, 0, 9, 1),
     124 + want: func(size image.Point) *faketerm.Terminal {
     125 + ft := faketerm.MustNew(size)
     126 + c := testcanvas.MustNew(ft.Area())
     127 + 
     128 + testdraw.MustText(c, "▁▂▃▄▅▆▇█", image.Point{1, 0}, draw.TextCellOpts(
     129 + cell.FgColor(cell.ColorMagenta),
     130 + ))
     131 + testcanvas.MustApply(c, ft)
     132 + return ft
     133 + },
     134 + },
     135 + 
     136 + {
     137 + desc: "draws data points from the right",
     138 + sparkLine: New(),
     139 + update: func(sl *SparkLine) error {
     140 + return sl.Add([]int{7, 8})
     141 + },
     142 + canvas: image.Rect(0, 0, 9, 1),
     143 + want: func(size image.Point) *faketerm.Terminal {
     144 + ft := faketerm.MustNew(size)
     145 + c := testcanvas.MustNew(ft.Area())
     146 + 
     147 + testdraw.MustText(c, "▇█", image.Point{7, 0}, draw.TextCellOpts(
     148 + cell.FgColor(DefaultColor),
     149 + ))
     150 + 
     151 + testcanvas.MustApply(c, ft)
     152 + return ft
     153 + },
     154 + },
     155 + {
     156 + desc: "single height sparkline with label",
     157 + sparkLine: New(
     158 + Label("Hello"),
     159 + ),
     160 + update: func(sl *SparkLine) error {
     161 + return sl.Add([]int{0, 1, 2, 3, 8, 3, 2, 1, 1})
     162 + },
     163 + canvas: image.Rect(0, 0, 9, 2),
     164 + want: func(size image.Point) *faketerm.Terminal {
     165 + ft := faketerm.MustNew(size)
     166 + c := testcanvas.MustNew(ft.Area())
     167 + 
     168 + testdraw.MustText(c, "Hello", image.Point{0, 0})
     169 + testdraw.MustText(c, "▁▂▃█▃▂▁▁", image.Point{1, 1}, draw.TextCellOpts(
     170 + cell.FgColor(DefaultColor),
     171 + ))
     172 + 
     173 + testcanvas.MustApply(c, ft)
     174 + return ft
     175 + },
     176 + },
     177 + {
     178 + desc: "too long label is trimmed",
     179 + sparkLine: New(
     180 + Label("Hello world"),
     181 + ),
     182 + update: func(sl *SparkLine) error {
     183 + return sl.Add([]int{8})
     184 + },
     185 + canvas: image.Rect(0, 0, 9, 2),
     186 + want: func(size image.Point) *faketerm.Terminal {
     187 + ft := faketerm.MustNew(size)
     188 + c := testcanvas.MustNew(ft.Area())
     189 + 
     190 + testdraw.MustText(c, "Hello wo…", image.Point{0, 0})
     191 + testdraw.MustText(c, "█", image.Point{8, 1}, draw.TextCellOpts(
     192 + cell.FgColor(DefaultColor),
     193 + ))
     194 + 
     195 + testcanvas.MustApply(c, ft)
     196 + return ft
     197 + },
     198 + },
     199 + {
     200 + desc: "stretches up to the height of the container",
     201 + sparkLine: New(),
     202 + update: func(sl *SparkLine) error {
     203 + return sl.Add([]int{0, 100, 50, 85})
     204 + },
     205 + canvas: image.Rect(0, 0, 4, 4),
     206 + want: func(size image.Point) *faketerm.Terminal {
     207 + ft := faketerm.MustNew(size)
     208 + c := testcanvas.MustNew(ft.Area())
     209 + 
     210 + testdraw.MustText(c, "█", image.Point{1, 0}, draw.TextCellOpts(
     211 + cell.FgColor(DefaultColor),
     212 + ))
     213 + testdraw.MustText(c, "▃", image.Point{3, 0}, draw.TextCellOpts(
     214 + cell.FgColor(DefaultColor),
     215 + ))
     216 + testdraw.MustText(c, "█", image.Point{1, 1}, draw.TextCellOpts(
     217 + cell.FgColor(DefaultColor),
     218 + ))
     219 + testdraw.MustText(c, "█", image.Point{3, 1}, draw.TextCellOpts(
     220 + cell.FgColor(DefaultColor),
     221 + ))
     222 + testdraw.MustText(c, "███", image.Point{1, 2}, draw.TextCellOpts(
     223 + cell.FgColor(DefaultColor),
     224 + ))
     225 + testdraw.MustText(c, "███", image.Point{1, 3}, draw.TextCellOpts(
     226 + cell.FgColor(DefaultColor),
     227 + ))
     228 + 
     229 + testcanvas.MustApply(c, ft)
     230 + return ft
     231 + },
     232 + },
     233 + {
     234 + desc: "stretches up to the height of the container with label",
     235 + sparkLine: New(
     236 + Label("zoo"),
     237 + ),
     238 + update: func(sl *SparkLine) error {
     239 + return sl.Add([]int{0, 90, 30, 85})
     240 + },
     241 + canvas: image.Rect(0, 0, 4, 4),
     242 + want: func(size image.Point) *faketerm.Terminal {
     243 + ft := faketerm.MustNew(size)
     244 + c := testcanvas.MustNew(ft.Area())
     245 + 
     246 + testdraw.MustText(c, "zoo", image.Point{0, 0})
     247 + testdraw.MustText(c, "█", image.Point{1, 1}, draw.TextCellOpts(
     248 + cell.FgColor(DefaultColor),
     249 + ))
     250 + testdraw.MustText(c, "▇", image.Point{3, 1}, draw.TextCellOpts(
     251 + cell.FgColor(DefaultColor),
     252 + ))
     253 + testdraw.MustText(c, "█", image.Point{1, 2}, draw.TextCellOpts(
     254 + cell.FgColor(DefaultColor),
     255 + ))
     256 + testdraw.MustText(c, "█", image.Point{3, 2}, draw.TextCellOpts(
     257 + cell.FgColor(DefaultColor),
     258 + ))
     259 + testdraw.MustText(c, "███", image.Point{1, 3}, draw.TextCellOpts(
     260 + cell.FgColor(DefaultColor),
     261 + ))
     262 + 
     263 + testcanvas.MustApply(c, ft)
     264 + return ft
     265 + },
     266 + },
     267 + {
     268 + desc: "respects fixed height",
     269 + sparkLine: New(
     270 + Height(2),
     271 + ),
     272 + update: func(sl *SparkLine) error {
     273 + return sl.Add([]int{0, 100, 50, 85})
     274 + },
     275 + canvas: image.Rect(0, 0, 4, 4),
     276 + want: func(size image.Point) *faketerm.Terminal {
     277 + ft := faketerm.MustNew(size)
     278 + c := testcanvas.MustNew(ft.Area())
     279 + 
     280 + testdraw.MustText(c, "█", image.Point{1, 2}, draw.TextCellOpts(
     281 + cell.FgColor(DefaultColor),
     282 + ))
     283 + testdraw.MustText(c, "▆", image.Point{3, 2}, draw.TextCellOpts(
     284 + cell.FgColor(DefaultColor),
     285 + ))
     286 + testdraw.MustText(c, "███", image.Point{1, 3}, draw.TextCellOpts(
     287 + cell.FgColor(DefaultColor),
     288 + ))
     289 + 
     290 + testcanvas.MustApply(c, ft)
     291 + return ft
     292 + },
     293 + },
     294 + {
     295 + desc: "respects fixed height with label",
     296 + sparkLine: New(
     297 + Label("zoo"),
     298 + Height(2),
     299 + ),
     300 + update: func(sl *SparkLine) error {
     301 + return sl.Add([]int{0, 100, 50, 0})
     302 + },
     303 + canvas: image.Rect(0, 0, 4, 4),
     304 + want: func(size image.Point) *faketerm.Terminal {
     305 + ft := faketerm.MustNew(size)
     306 + c := testcanvas.MustNew(ft.Area())
     307 + 
     308 + testdraw.MustText(c, "zoo", image.Point{0, 1}, draw.TextCellOpts(
     309 + cell.FgColor(cell.ColorDefault),
     310 + ))
     311 + testdraw.MustText(c, "█", image.Point{1, 2}, draw.TextCellOpts(
     312 + cell.FgColor(DefaultColor),
     313 + ))
     314 + testdraw.MustText(c, "██", image.Point{1, 3}, draw.TextCellOpts(
     315 + cell.FgColor(DefaultColor),
     316 + ))
     317 + 
     318 + testcanvas.MustApply(c, ft)
     319 + return ft
     320 + },
     321 + },
     322 + {
     323 + desc: "sets label color",
     324 + sparkLine: New(
     325 + Label(
     326 + "Hello",
     327 + cell.FgColor(cell.ColorBlue),
     328 + cell.BgColor(cell.ColorYellow),
     329 + ),
     330 + ),
     331 + update: func(sl *SparkLine) error {
     332 + return sl.Add([]int{0, 1})
     333 + },
     334 + canvas: image.Rect(0, 0, 9, 2),
     335 + want: func(size image.Point) *faketerm.Terminal {
     336 + ft := faketerm.MustNew(size)
     337 + c := testcanvas.MustNew(ft.Area())
     338 + 
     339 + testdraw.MustText(c, "Hello", image.Point{0, 0}, draw.TextCellOpts(
     340 + cell.FgColor(cell.ColorBlue),
     341 + cell.BgColor(cell.ColorYellow),
     342 + ))
     343 + testdraw.MustText(c, "█", image.Point{8, 1}, draw.TextCellOpts(
     344 + cell.FgColor(DefaultColor),
     345 + ))
     346 + 
     347 + testcanvas.MustApply(c, ft)
     348 + return ft
     349 + },
     350 + },
     351 + {
     352 + desc: "displays only data points that fit the width",
     353 + sparkLine: New(),
     354 + update: func(sl *SparkLine) error {
     355 + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8})
     356 + },
     357 + canvas: image.Rect(0, 0, 3, 1),
     358 + want: func(size image.Point) *faketerm.Terminal {
     359 + ft := faketerm.MustNew(size)
     360 + c := testcanvas.MustNew(ft.Area())
     361 + 
     362 + testdraw.MustText(c, "▆▇█", image.Point{0, 0}, draw.TextCellOpts(
     363 + cell.FgColor(DefaultColor),
     364 + ))
     365 + 
     366 + testcanvas.MustApply(c, ft)
     367 + return ft
     368 + },
     369 + },
     370 + {
     371 + desc: "data points not visible don't affect the determined max data point",
     372 + sparkLine: New(),
     373 + update: func(sl *SparkLine) error {
     374 + return sl.Add([]int{10, 4, 8})
     375 + },
     376 + canvas: image.Rect(0, 0, 2, 1),
     377 + want: func(size image.Point) *faketerm.Terminal {
     378 + ft := faketerm.MustNew(size)
     379 + c := testcanvas.MustNew(ft.Area())
     380 + 
     381 + testdraw.MustText(c, "▄█", image.Point{0, 0}, draw.TextCellOpts(
     382 + cell.FgColor(DefaultColor),
     383 + ))
     384 + 
     385 + testcanvas.MustApply(c, ft)
     386 + return ft
     387 + },
     388 + },
     389 + }
     390 + 
     391 + for _, tc := range tests {
     392 + t.Run(tc.desc, func(t *testing.T) {
     393 + c, err := canvas.New(tc.canvas)
     394 + if err != nil {
     395 + t.Fatalf("canvas.New => unexpected error: %v", err)
     396 + }
     397 + 
     398 + err = tc.update(tc.sparkLine)
     399 + if (err != nil) != tc.wantUpdateErr {
     400 + t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr)
     401 + 
     402 + }
     403 + if err != nil {
     404 + return
     405 + }
     406 + 
     407 + err = tc.sparkLine.Draw(c)
     408 + if (err != nil) != tc.wantDrawErr {
     409 + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
     410 + }
     411 + if err != nil {
     412 + return
     413 + }
     414 + 
     415 + got, err := faketerm.New(c.Size())
     416 + if err != nil {
     417 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     418 + }
     419 + 
     420 + if err := c.Apply(got); err != nil {
     421 + t.Fatalf("Apply => unexpected error: %v", err)
     422 + }
     423 + 
     424 + if diff := faketerm.Diff(tc.want(c.Size()), got); diff != "" {
     425 + t.Errorf("Draw => %v", diff)
     426 + }
     427 + })
     428 + }
     429 +}
     430 + 
     431 +func TestOptions(t *testing.T) {
     432 + tests := []struct {
     433 + desc string
     434 + sparkLine *SparkLine
     435 + want widgetapi.Options
     436 + }{
     437 + {
     438 + desc: "no label and no fixed height",
     439 + sparkLine: New(),
     440 + want: widgetapi.Options{
     441 + MinimumSize: image.Point{1, 1},
     442 + WantKeyboard: false,
     443 + WantMouse: false,
     444 + },
     445 + },
     446 + {
     447 + desc: "label and no fixed height",
     448 + sparkLine: New(
     449 + Label("foo"),
     450 + ),
     451 + want: widgetapi.Options{
     452 + MinimumSize: image.Point{1, 2},
     453 + WantKeyboard: false,
     454 + WantMouse: false,
     455 + },
     456 + },
     457 + {
     458 + desc: "no label and fixed height",
     459 + sparkLine: New(
     460 + Height(3),
     461 + ),
     462 + want: widgetapi.Options{
     463 + MinimumSize: image.Point{1, 3},
     464 + MaximumSize: image.Point{1, 3},
     465 + WantKeyboard: false,
     466 + WantMouse: false,
     467 + },
     468 + },
     469 + {
     470 + desc: "label and fixed height",
     471 + sparkLine: New(
     472 + Label("foo"),
     473 + Height(3),
     474 + ),
     475 + want: widgetapi.Options{
     476 + MinimumSize: image.Point{1, 4},
     477 + MaximumSize: image.Point{1, 4},
     478 + WantKeyboard: false,
     479 + WantMouse: false,
     480 + },
     481 + },
     482 + }
     483 + 
     484 + for _, tc := range tests {
     485 + t.Run(tc.desc, func(t *testing.T) {
     486 + got := tc.sparkLine.Options()
     487 + if diff := pretty.Compare(tc.want, got); diff != "" {
     488 + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)
     489 + }
     490 + 
     491 + })
     492 + }
     493 +}
     494 + 
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/sparklinedemo/sparklinedemo.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 +// Binary sparklinedemo displays a couple of SparkLine widgets.
     16 +// Exist when 'q' is pressed.
     17 +package main
     18 + 
     19 +import (
     20 + "context"
     21 + "math/rand"
     22 + "time"
     23 + 
     24 + "github.com/mum4k/termdash"
     25 + "github.com/mum4k/termdash/cell"
     26 + "github.com/mum4k/termdash/container"
     27 + "github.com/mum4k/termdash/draw"
     28 + "github.com/mum4k/termdash/terminal/termbox"
     29 + "github.com/mum4k/termdash/terminalapi"
     30 + "github.com/mum4k/termdash/widgets/sparkline"
     31 +)
     32 + 
     33 +// playSparkLine continuously adds values to the SparkLine, once every delay.
     34 +// Exits when the context expires.
     35 +func playSparkLine(ctx context.Context, sl *sparkline.SparkLine, delay time.Duration) {
     36 + const max = 100
     37 + 
     38 + ticker := time.NewTicker(delay)
     39 + defer ticker.Stop()
     40 + for {
     41 + select {
     42 + case <-ticker.C:
     43 + v := int(rand.Int31n(max + 1))
     44 + if err := sl.Add([]int{v}); err != nil {
     45 + panic(err)
     46 + }
     47 + 
     48 + case <-ctx.Done():
     49 + return
     50 + }
     51 + }
     52 +}
     53 + 
     54 +func main() {
     55 + t, err := termbox.New()
     56 + if err != nil {
     57 + panic(err)
     58 + }
     59 + defer t.Close()
     60 + 
     61 + ctx, cancel := context.WithCancel(context.Background())
     62 + green := sparkline.New(
     63 + sparkline.Label("Green SparkLine", cell.FgColor(cell.ColorBlue)),
     64 + sparkline.Color(cell.ColorGreen),
     65 + )
     66 + go playSparkLine(ctx, green, 250*time.Millisecond)
     67 + red := sparkline.New(
     68 + sparkline.Label("Red SparkLine", cell.FgColor(cell.ColorBlue)),
     69 + sparkline.Color(cell.ColorRed),
     70 + )
     71 + go playSparkLine(ctx, red, 500*time.Millisecond)
     72 + yellow := sparkline.New(
     73 + sparkline.Label("Yellow SparkLine", cell.FgColor(cell.ColorGreen)),
     74 + sparkline.Color(cell.ColorYellow),
     75 + )
     76 + go playSparkLine(ctx, yellow, 1*time.Second)
     77 + 
     78 + c := container.New(
     79 + t,
     80 + container.Border(draw.LineStyleLight),
     81 + container.BorderTitle("PRESS Q TO QUIT"),
     82 + container.SplitVertical(
     83 + container.Left(
     84 + container.SplitHorizontal(
     85 + container.Top(),
     86 + container.Bottom(
     87 + container.Border(draw.LineStyleLight),
     88 + container.BorderTitle("SparkLine group"),
     89 + container.SplitHorizontal(
     90 + container.Top(
     91 + container.PlaceWidget(green),
     92 + ),
     93 + container.Bottom(
     94 + container.PlaceWidget(red),
     95 + ),
     96 + ),
     97 + ),
     98 + ),
     99 + ),
     100 + container.Right(
     101 + container.Border(draw.LineStyleLight),
     102 + container.PlaceWidget(yellow),
     103 + ),
     104 + ),
     105 + )
     106 + 
     107 + quitter := func(k *terminalapi.Keyboard) {
     108 + if k.Key == 'q' || k.Key == 'Q' {
     109 + cancel()
     110 + }
     111 + }
     112 + 
     113 + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter)); err != nil {
     114 + panic(err)
     115 + }
     116 +}
     117 + 
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/sparks.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 sparkline
     16 + 
     17 +// sparks.go contains code that determines which characters should be used to
     18 +// represent a value on the SparkLine.
     19 + 
     20 +import (
     21 + "fmt"
     22 + "math"
     23 + 
     24 + runewidth "github.com/mattn/go-runewidth"
     25 +)
     26 + 
     27 +// sparks are the characters used to draw the SparkLine.
     28 +var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
     29 + 
     30 +// visibleMax determines the maximum visible data point given the canvas width.
     31 +// Returns a slice that contains only visible data points and the maximum value
     32 +// among them.
     33 +func visibleMax(data []int, width int) ([]int, int) {
     34 + if width <= 0 || len(data) == 0 {
     35 + return nil, 0
     36 + }
     37 + 
     38 + if width < len(data) {
     39 + data = data[len(data)-width:]
     40 + }
     41 + 
     42 + var max int
     43 + for _, v := range data {
     44 + if v > max {
     45 + max = v
     46 + }
     47 + }
     48 + return data, max
     49 +}
     50 + 
     51 +// blocks represents the building blocks that display one value on a SparkLine.
     52 +// I.e. one vertical bar.
     53 +type blocks struct {
     54 + // full is the number of fully populated blocks.
     55 + full int
     56 + 
     57 + // partSpark is the spark character from sparks that should be used in the
     58 + // topmost block. Equals to zero if no partial block should be displayed.
     59 + partSpark rune
     60 +}
     61 + 
     62 +// toBlocks determines the number of full and partial vertical blocks required
     63 +// to represent the provided value given the specified max visible value and
     64 +// number of vertical cells available to the SparkLine.
     65 +func toBlocks(value, max, vertCells int) blocks {
     66 + if value <= 0 || max <= 0 || vertCells <= 0 {
     67 + return blocks{}
     68 + }
     69 + 
     70 + // How many of the smallest spark elements fit into a cell.
     71 + cellSparks := len(sparks)
     72 + 
     73 + // Scale is how much of the max does one smallest spark element represent,
     74 + // given the vertical cells that will be used to represent the value.
     75 + scale := float64(cellSparks) * float64(vertCells) / float64(max)
     76 + 
     77 + // How many smallest spark elements are needed to represent the value.
     78 + elements := int(round(float64(value) * scale))
     79 + 
     80 + b := blocks{
     81 + full: elements / cellSparks,
     82 + }
     83 + 
     84 + part := elements % cellSparks
     85 + if part > 0 {
     86 + b.partSpark = sparks[part-1]
     87 + }
     88 + return b
     89 +}
     90 + 
     91 +// round returns the nearest integer, rounding half away from zero.
     92 +// Copied from the math package of Go 1.10 for backwards compatibility with Go
     93 +// 1.8 where the math.Round function doesn't exist yet.
     94 +func round(x float64) float64 {
     95 + t := math.Trunc(x)
     96 + if math.Abs(x-t) >= 0.5 {
     97 + return t + math.Copysign(1, x)
     98 + }
     99 + return t
     100 +}
     101 + 
     102 +// init ensures that all spark characters are half-width runes.
     103 +// The SparkLine widget assumes that each value can be represented in a column
     104 +// that has a width of one cell.
     105 +func init() {
     106 + for i, s := range sparks {
     107 + if got := runewidth.RuneWidth(s); got > 1 {
     108 + panic(fmt.Sprintf("all sparks must be half-width runes (width of one), spark[%d] has width %d", i, got))
     109 + }
     110 + }
     111 +}
     112 + 
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/sparks_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 sparkline
     16 + 
     17 +import (
     18 + "testing"
     19 + 
     20 + "github.com/kylelemons/godebug/pretty"
     21 +)
     22 + 
     23 +func TestVisibleMax(t *testing.T) {
     24 + tests := []struct {
     25 + desc string
     26 + data []int
     27 + width int
     28 + wantData []int
     29 + wantMax int
     30 + }{
     31 + {
     32 + desc: "zero for no data",
     33 + width: 3,
     34 + wantData: nil,
     35 + wantMax: 0,
     36 + },
     37 + {
     38 + desc: "zero for zero width",
     39 + data: []int{0, 1},
     40 + width: 0,
     41 + wantData: nil,
     42 + wantMax: 0,
     43 + },
     44 + {
     45 + desc: "zero for negative width",
     46 + data: []int{0, 1},
     47 + width: -1,
     48 + wantData: nil,
     49 + wantMax: 0,
     50 + },
     51 + {
     52 + desc: "all values are zero",
     53 + data: []int{0, 0, 0},
     54 + width: 3,
     55 + wantData: []int{0, 0, 0},
     56 + wantMax: 0,
     57 + },
     58 + {
     59 + desc: "all values are visible",
     60 + data: []int{8, 0, 1},
     61 + width: 3,
     62 + wantData: []int{8, 0, 1},
     63 + wantMax: 8,
     64 + },
     65 + {
     66 + desc: "width greater than number of values",
     67 + data: []int{8, 0, 1},
     68 + width: 10,
     69 + wantData: []int{8, 0, 1},
     70 + wantMax: 8,
     71 + },
     72 + {
     73 + desc: "only some values are visible",
     74 + data: []int{8, 2, 1},
     75 + width: 2,
     76 + wantData: []int{2, 1},
     77 + wantMax: 2,
     78 + },
     79 + {
     80 + desc: "only one value is visible",
     81 + data: []int{8, 2, 1},
     82 + width: 1,
     83 + wantData: []int{1},
     84 + wantMax: 1,
     85 + },
     86 + }
     87 + 
     88 + for _, tc := range tests {
     89 + t.Run(tc.desc, func(t *testing.T) {
     90 + gotData, gotMax := visibleMax(tc.data, tc.width)
     91 + if diff := pretty.Compare(tc.wantData, gotData); diff != "" {
     92 + t.Errorf("visibleMax => unexpected visible data, diff (-want, +got):\n%s", diff)
     93 + }
     94 + if gotMax != tc.wantMax {
     95 + t.Errorf("visibleMax => gotMax %v, wantMax %v", gotMax, tc.wantMax)
     96 + }
     97 + })
     98 + }
     99 +}
     100 + 
     101 +func TestToBlocks(t *testing.T) {
     102 + tests := []struct {
     103 + desc string
     104 + value int
     105 + max int
     106 + vertCells int
     107 + want blocks
     108 + }{
     109 + {
     110 + desc: "zero value has no blocks",
     111 + value: 0,
     112 + max: 10,
     113 + vertCells: 2,
     114 + want: blocks{},
     115 + },
     116 + {
     117 + desc: "negative value has no blocks",
     118 + value: -1,
     119 + max: 10,
     120 + vertCells: 2,
     121 + want: blocks{},
     122 + },
     123 + {
     124 + desc: "zero max has no blocks",
     125 + value: 10,
     126 + max: 0,
     127 + vertCells: 2,
     128 + want: blocks{},
     129 + },
     130 + {
     131 + desc: "negative max has no blocks",
     132 + value: 10,
     133 + max: -1,
     134 + vertCells: 2,
     135 + want: blocks{},
     136 + },
     137 + {
     138 + desc: "zero vertCells has no blocks",
     139 + value: 10,
     140 + max: 10,
     141 + vertCells: 0,
     142 + want: blocks{},
     143 + },
     144 + {
     145 + desc: "negative vertCells has no blocks",
     146 + value: 10,
     147 + max: 10,
     148 + vertCells: -1,
     149 + want: blocks{},
     150 + },
     151 + {
     152 + desc: "single line, zero value",
     153 + value: 0,
     154 + max: 8,
     155 + vertCells: 1,
     156 + want: blocks{},
     157 + },
     158 + {
     159 + desc: "single line, value is 1/8",
     160 + value: 1,
     161 + max: 8,
     162 + vertCells: 1,
     163 + want: blocks{full: 0, partSpark: sparks[0]},
     164 + },
     165 + {
     166 + desc: "single line, value is 2/8",
     167 + value: 2,
     168 + max: 8,
     169 + vertCells: 1,
     170 + want: blocks{full: 0, partSpark: sparks[1]},
     171 + },
     172 + {
     173 + desc: "single line, value is 3/8",
     174 + value: 3,
     175 + max: 8,
     176 + vertCells: 1,
     177 + want: blocks{full: 0, partSpark: sparks[2]},
     178 + },
     179 + {
     180 + desc: "single line, value is 4/8",
     181 + value: 4,
     182 + max: 8,
     183 + vertCells: 1,
     184 + want: blocks{full: 0, partSpark: sparks[3]},
     185 + },
     186 + {
     187 + desc: "single line, value is 5/8",
     188 + value: 5,
     189 + max: 8,
     190 + vertCells: 1,
     191 + want: blocks{full: 0, partSpark: sparks[4]},
     192 + },
     193 + {
     194 + desc: "single line, value is 6/8",
     195 + value: 6,
     196 + max: 8,
     197 + vertCells: 1,
     198 + want: blocks{full: 0, partSpark: sparks[5]},
     199 + },
     200 + {
     201 + desc: "single line, value is 7/8",
     202 + value: 7,
     203 + max: 8,
     204 + vertCells: 1,
     205 + want: blocks{full: 0, partSpark: sparks[6]},
     206 + },
     207 + {
     208 + desc: "single line, value is 8/8",
     209 + value: 8,
     210 + max: 8,
     211 + vertCells: 1,
     212 + want: blocks{full: 1, partSpark: 0},
     213 + },
     214 + {
     215 + desc: "multi line, zero value",
     216 + value: 0,
     217 + max: 24,
     218 + vertCells: 3,
     219 + want: blocks{},
     220 + },
     221 + {
     222 + desc: "multi line, lowest block is partial",
     223 + value: 2,
     224 + max: 24,
     225 + vertCells: 3,
     226 + want: blocks{full: 0, partSpark: sparks[1]},
     227 + },
     228 + {
     229 + desc: "multi line, two full blocks, no partial block",
     230 + value: 16,
     231 + max: 24,
     232 + vertCells: 3,
     233 + want: blocks{full: 2, partSpark: 0},
     234 + },
     235 + {
     236 + desc: "multi line, topmost block is partial",
     237 + value: 20,
     238 + max: 24,
     239 + vertCells: 3,
     240 + want: blocks{full: 2, partSpark: sparks[3]},
     241 + },
     242 + }
     243 + 
     244 + for _, tc := range tests {
     245 + t.Run(tc.desc, func(t *testing.T) {
     246 + got := toBlocks(tc.value, tc.max, tc.vertCells)
     247 + if diff := pretty.Compare(tc.want, got); diff != "" {
     248 + t.Errorf("toBlocks => unexpected diff (-want, +got):\n%s", diff)
     249 + if got.full != tc.want.full {
     250 + t.Errorf("toBlocks => unexpected diff, blocks.full got %d, want %d", got.full, tc.want.full)
     251 + }
     252 + if got.partSpark != tc.want.partSpark {
     253 + t.Errorf("toBlocks => unexpected diff, blocks.partSpark got '%c' (sparks[%d])), want '%c' (sparks[%d])",
     254 + got.partSpark, findRune(got.partSpark, sparks), tc.want.partSpark, findRune(tc.want.partSpark, sparks))
     255 + }
     256 + }
     257 + })
     258 + }
     259 +}
     260 + 
     261 +// findRune finds the rune in the slice and returns its index.
     262 +// Returns -1 if the rune isn't in the slice.
     263 +func findRune(target rune, runes []rune) int {
     264 + for i, r := range runes {
     265 + if r == target {
     266 + return i
     267 + }
     268 + }
     269 + return -1
     270 +}
     271 + 
  • ■ ■ ■ ■ ■ ■
    widgets/text/line_trim.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 + 
    1 15  package text
    2 16   
    3 17  import (
    skipped 100 lines
  • ■ ■ ■ ■ ■ ■
    widgets/text/line_trim_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 + 
    1 15  package text
    2 16   
    3 17  import (
    skipped 266 lines
Please wait...
Page is in error, reload to recover