Projects STRLCPY termdash Commits d2b202a0
🤬
Showing first 43 files as there are too many
  • ■ ■ ■ ■ ■ ■
    .gitignore
    1 1  # Exclude MacOS attribute files.
    2 2  .DS_Store
    3 3   
     4 +# Exclude IDE files.
     5 +.idea/
  • ■ ■ ■ ■ ■ ■
    CHANGELOG.md
    skipped 6 lines
    7 7   
    8 8  ## [Unreleased]
    9 9   
     10 +## [0.14.0] - 30-Dec-2020
     11 + 
     12 +### Breaking API changes
     13 + 
     14 +- The `widgetapi.Widget.Keyboard` and `widgetapi.Widget.Mouse` methods now
     15 + accepts a second argument which provides widgets with additional metadata.
     16 + All widgets implemented outside of the `termdash` repository will need to be
     17 + updated similarly to the `Barchart` example below. Change the original method
     18 + signatures:
     19 + ```go
     20 + func (*BarChart) Keyboard(k *terminalapi.Keyboard) error { ... }
     21 + 
     22 + func (*BarChart) Mouse(m *terminalapi.Mouse) error { ... }
     23 + 
     24 + ```
     25 + 
     26 + By adding the new `*widgetapi.EventMeta` argument as follows:
     27 + ```go
     28 + func (*BarChart) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error { ... }
     29 + 
     30 + func (*BarChart) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error { ... }
     31 + ```
     32 + 
     33 +### Fixed
     34 + 
     35 +- `termdash` no longer crashes when `tcell` is used and the terminal window
     36 + downsizes while content is being drawn.
     37 + 
     38 +### Added
     39 + 
     40 +#### Text input form functionality with keyboard navigation
     41 + 
     42 +- added a new `formdemo` that demonstrates a text input form with keyboard
     43 + navigation.
     44 + 
     45 +#### Infrastructure changes
     46 + 
     47 +- `container` now allows users to configure keyboard keys that move focus to
     48 + the next or the previous container.
     49 +- containers can request to be skipped when focus is moved using keyboard keys.
     50 +- containers can register into separate focus groups and specific keyboard keys
     51 + can be configured to move the focus within each focus group.
     52 +- widgets can now request keyboard events exclusively when focused.
     53 +- users can now set a `container` as focused using the new `container.Focused`
     54 + option.
     55 + 
     56 +#### Updates to the `button` widget
     57 + 
     58 +- the `button` widget allows users to specify multiple trigger keys.
     59 +- the `button` widget now supports different keys for the global and focused
     60 + scope.
     61 +- the `button` widget can now be drawn without the shadow or the press
     62 + animation.
     63 +- the `button` widget can now be drawn without horizontal padding around its
     64 + text.
     65 +- the `button` widget now allows specifying cell options for each cell of the
     66 + displayed text. Separate cell options can be specified for each of button's
     67 + main states (up, focused and up, down).
     68 +- the `button` widget allows specifying separate fill color values for each of
     69 + its main states (up, focused and up, down).
     70 +- the `button` widget now has a method `SetCallback` that allows updating the
     71 + callback function on an existing `button` instance.
     72 + 
     73 +#### Updates to the `textinput` widget
     74 + 
     75 +- the `textinput` widget can now be configured to request keyboard events
     76 + exclusively when focused.
     77 +- the `textinput` widget can now be initialized with a default text in the
     78 + input box.
     79 + 
    10 80  ## [0.13.0] - 17-Nov-2020
    11 81   
    12 82  ### Added
    skipped 356 lines
    369 439  - The Gauge widget.
    370 440  - The Text widget.
    371 441   
    372  -[unreleased]: https://github.com/mum4k/termdash/compare/v0.13.0...devel
     442 +[unreleased]: https://github.com/mum4k/termdash/compare/v0.14.0...devel
     443 +[0.14.0]: https://github.com/mum4k/termdash/compare/v0.13.0...v0.14.0
    373 444  [0.13.0]: https://github.com/mum4k/termdash/compare/v0.12.2...v0.13.0
    374 445  [0.12.2]: https://github.com/mum4k/termdash/compare/v0.12.1...v0.12.2
    375 446  [0.12.1]: https://github.com/mum4k/termdash/compare/v0.12.0...v0.12.1
    skipped 16 lines
  • ■ ■ ■ ■ ■ ■
    README.md
    skipped 54 lines
    55 55   
    56 56  ```go
    57 57  go get -u github.com/mum4k/termdash
     58 +cd github.com/mum4k/termdash
    58 59  ```
    59 60   
    60 61  # Usage
    skipped 2 lines
    63 64  [termdashdemo.go](termdashdemo/termdashdemo.go). To execute the demo:
    64 65   
    65 66  ```go
    66  -go run github.com/mum4k/termdash/termdashdemo/termdashdemo.go
     67 +go run termdashdemo/termdashdemo.go
    67 68  ```
    68 69   
    69 70  # Documentation
    skipped 10 lines
    80 81  [buttondemo](widgets/button/buttondemo/buttondemo.go).
    81 82   
    82 83  ```go
    83  -go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go
     84 +go run widgets/button/buttondemo/buttondemo.go
    84 85  ```
    85 86   
    86 87  [<img src="./doc/images/buttondemo.gif" alt="buttondemo" type="image/gif" width="50%">](widgets/button/buttondemo/buttondemo.go)
    skipped 5 lines
    92 93  [textinputdemo](widgets/textinput/textinputdemo/textinputdemo.go).
    93 94   
    94 95  ```go
    95  -go run github.com/mum4k/termdash/widgets/textinput/textinputdemo/textinputdemo.go
     96 +go run widgets/textinput/textinputdemo/textinputdemo.go
    96 97  ```
    97 98   
    98 99  [<img src="./doc/images/textinputdemo.gif" alt="textinputdemo" type="image/gif" width="80%">](widgets/textinput/textinputdemo/textinputdemo.go)
     100 + 
     101 +Can be used to create text input forms that support keyboard navigation:
     102 + 
     103 +```go
     104 +go run widgets/textinput/formdemo/formdemo.go
     105 +```
     106 + 
     107 +[<img src="./doc/images/formdemo.gif" alt="formdemo" type="image/gif" width="50%">](widgets/textinput/formdemo/formdemo.go)
    99 108   
    100 109  ## The Gauge
    101 110   
    skipped 1 lines
    103 112  [gaugedemo](widgets/gauge/gaugedemo/gaugedemo.go).
    104 113   
    105 114  ```go
    106  -go run github.com/mum4k/termdash/widgets/gauge/gaugedemo/gaugedemo.go
     115 +go run widgets/gauge/gaugedemo/gaugedemo.go
    107 116  ```
    108 117   
    109 118  [<img src="./doc/images/gaugedemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/gaugedemo/gaugedemo.go)
    skipped 4 lines
    114 123  [donutdemo](widgets/donut/donutdemo/donutdemo.go).
    115 124   
    116 125  ```go
    117  -go run github.com/mum4k/termdash/widgets/donut/donutdemo/donutdemo.go
     126 +go run widgets/donut/donutdemo/donutdemo.go
    118 127  ```
    119 128   
    120 129  [<img src="./doc/images/donutdemo.gif" alt="donutdemo" type="image/gif">](widgets/donut/donutdemo/donutdemo.go)
    skipped 4 lines
    125 134  [textdemo](widgets/text/textdemo/textdemo.go).
    126 135   
    127 136  ```go
    128  -go run github.com/mum4k/termdash/widgets/text/textdemo/textdemo.go
     137 +go run widgets/text/textdemo/textdemo.go
    129 138  ```
    130 139   
    131 140  [<img src="./doc/images/textdemo.gif" alt="textdemo" type="image/gif">](widgets/text/textdemo/textdemo.go)
    skipped 5 lines
    137 146  [sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go).
    138 147   
    139 148  ```go
    140  -go run github.com/mum4k/termdash/widgets/sparkline/sparklinedemo/sparklinedemo.go
     149 +go run widgets/sparkline/sparklinedemo/sparklinedemo.go
    141 150  ```
    142 151   
    143 152  [<img src="./doc/images/sparklinedemo.gif" alt="sparklinedemo" type="image/gif" width="50%">](widgets/sparkline/sparklinedemo/sparklinedemo.go)
    skipped 4 lines
    148 157  [barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go).
    149 158   
    150 159  ```go
    151  -go run github.com/mum4k/termdash/widgets/barchart/barchartdemo/barchartdemo.go
     160 +go run widgets/barchart/barchartdemo/barchartdemo.go
    152 161  ```
    153 162   
    154 163  [<img src="./doc/images/barchartdemo.gif" alt="barchartdemo" type="image/gif" width="50%">](widgets/barchart/barchartdemo/barchartdemo.go)
    skipped 5 lines
    160 169  [linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go).
    161 170   
    162 171  ```go
    163  -go run github.com/mum4k/termdash/widgets/linechart/linechartdemo/linechartdemo.go
     172 +go run widgets/linechart/linechartdemo/linechartdemo.go
    164 173  ```
    165 174   
    166 175  [<img src="./doc/images/linechartdemo.gif" alt="linechartdemo" type="image/gif" width="70%">](widgets/linechart/linechartdemo/linechartdemo.go)
    skipped 4 lines
    171 180  [segmentdisplaydemo](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go).
    172 181   
    173 182  ```go
    174  -go run github.com/mum4k/termdash/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go
     183 +go run widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go
    175 184  ```
    176 185   
    177 186  [<img src="./doc/images/segmentdisplaydemo.gif" alt="segmentdisplaydemo" type="image/gif">](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go)
    skipped 40 lines
  • ■ ■ ■ ■ ■
    container/container.go
    skipped 121 lines
    122 122   return c.opts.widget != nil
    123 123  }
    124 124   
     125 +// isLeaf determines if this container is a leaf container in the binary tree of containers.
     126 +// Only leaf containers are guaranteed to be "visible" on the screen, because
     127 +// they are on the top of other non-leaf containers.
     128 +func (c *Container) isLeaf() bool {
     129 + return c.first == nil && c.second == nil
     130 +}
     131 + 
    125 132  // usable returns the usable area in this container.
    126 133  // This depends on whether the container has a border, etc.
    127 134  func (c *Container) usable() image.Rectangle {
    skipped 129 lines
    257 264   return nil
    258 265  }
    259 266   
    260  -// updateFocus processes the mouse event and determines if it changes the
    261  -// focused container.
     267 +// updateFocusFromMouse processes the mouse event and determines if it changes
     268 +// the focused container.
    262 269  // Caller must hold c.mu.
    263  -func (c *Container) updateFocus(m *terminalapi.Mouse) {
     270 +func (c *Container) updateFocusFromMouse(m *terminalapi.Mouse) {
    264 271   target := pointCont(c, m.Position)
    265 272   if target == nil { // Ignore mouse clicks where no containers are.
    266 273   return
    skipped 1 lines
    268 275   c.focusTracker.mouse(target, m)
    269 276  }
    270 277   
     278 +// inFocusGroup returns true if this container is in the specified focus group.
     279 +func (c *Container) inFocusGroup(fg FocusGroup) bool {
     280 + for _, cg := range c.opts.keyFocusGroups {
     281 + if cg == fg {
     282 + return true
     283 + }
     284 + }
     285 + return false
     286 +}
     287 + 
     288 +// updateFocusFromKeyboard processes the keyboard event and determines if it
     289 +// changes the focused container.
     290 +// Caller must hold c.mu.
     291 +func (c *Container) updateFocusFromKeyboard(k *terminalapi.Keyboard) {
     292 + active := c.focusTracker.active()
     293 + nextGroupsForKey, isGroupKeyForNext := active.opts.global.keyFocusGroupsNext[k.Key]
     294 + prevGroupsForKey, isGroupKeyForPrev := active.opts.global.keyFocusGroupsPrevious[k.Key]
     295 + 
     296 + nextMatchesContGroup, nextG := nextGroupsForKey.firstMatching(active.opts.keyFocusGroups)
     297 + prevMatchesContGroup, prevG := prevGroupsForKey.firstMatching(active.opts.keyFocusGroups)
     298 + 
     299 + switch {
     300 + case active.opts.global.keyFocusNext != nil && *active.opts.global.keyFocusNext == k.Key:
     301 + c.focusTracker.next( /* group = */ nil)
     302 + case active.opts.global.keyFocusPrevious != nil && *active.opts.global.keyFocusPrevious == k.Key:
     303 + c.focusTracker.previous( /* group = */ nil)
     304 + case isGroupKeyForNext && nextMatchesContGroup:
     305 + c.focusTracker.next(&nextG)
     306 + case isGroupKeyForPrev && prevMatchesContGroup:
     307 + c.focusTracker.previous(&prevG)
     308 + }
     309 +}
     310 + 
    271 311  // processEvent processes events delivered to the container.
    272 312  func (c *Container) processEvent(ev terminalapi.Event) error {
    273 313   // This is done in two stages.
    skipped 19 lines
    293 333  func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error) {
    294 334   switch e := ev.(type) {
    295 335   case *terminalapi.Mouse:
    296  - c.updateFocus(ev.(*terminalapi.Mouse))
     336 + c.updateFocusFromMouse(ev.(*terminalapi.Mouse))
    297 337   
    298 338   targets, err := c.mouseEvTargets(e)
    299 339   if err != nil {
    skipped 1 lines
    301 341   }
    302 342   return func() error {
    303 343   for _, mt := range targets {
    304  - if err := mt.widget.Mouse(mt.ev); err != nil {
     344 + if err := mt.widget.Mouse(mt.ev, mt.meta); err != nil {
    305 345   return err
    306 346   }
    307 347   }
    skipped 1 lines
    309 349   }, nil
    310 350   
    311 351   case *terminalapi.Keyboard:
     352 + c.updateFocusFromKeyboard(ev.(*terminalapi.Keyboard))
     353 + 
    312 354   targets := c.keyEvTargets()
    313 355   return func() error {
    314  - for _, w := range targets {
    315  - if err := w.Keyboard(e); err != nil {
     356 + for _, kt := range targets {
     357 + if err := kt.widget.Keyboard(e, kt.meta); err != nil {
    316 358   return err
    317 359   }
    318 360   }
    skipped 5 lines
    324 366   }
    325 367  }
    326 368   
     369 +// keyEvTarget contains a widget that should receive an event and the metadata
     370 +// for the event.
     371 +type keyEvTarget struct {
     372 + // widget is the widget that should receive the keyboard event.
     373 + widget widgetapi.Widget
     374 + // meta is the metadata about the event.
     375 + meta *widgetapi.EventMeta
     376 +}
     377 + 
     378 +// newKeyEvTarget returns a new keyEvTarget.
     379 +func newKeyEvTarget(w widgetapi.Widget, meta *widgetapi.EventMeta) *keyEvTarget {
     380 + return &keyEvTarget{
     381 + widget: w,
     382 + meta: meta,
     383 + }
     384 +}
     385 + 
    327 386  // keyEvTargets returns those widgets found in the container that should
    328 387  // receive this keyboard event.
    329 388  // Caller must hold c.mu.
    330  -func (c *Container) keyEvTargets() []widgetapi.Widget {
     389 +func (c *Container) keyEvTargets() []*keyEvTarget {
    331 390   var (
    332 391   errStr string
    333  - widgets []widgetapi.Widget
     392 + targets []*keyEvTarget
     393 + // If the currently focused widget set the ExclusiveKeyboardOnFocus
     394 + // option, this pointer is set to that widget.
     395 + exclusiveWidget widgetapi.Widget
    334 396   )
    335 397   
    336  - // All the widgets that should receive this event.
     398 + // All the targets that should receive this event.
    337 399   // For now stable ordering (preOrder).
    338 400   preOrder(c, &errStr, visitFunc(func(cur *Container) error {
    339 401   if !cur.hasWidget() {
    340 402   return nil
    341 403   }
    342 404   
     405 + focused := cur.focusTracker.isActive(cur)
     406 + meta := &widgetapi.EventMeta{
     407 + Focused: focused,
     408 + }
    343 409   wOpt := cur.opts.widget.Options()
     410 + if focused && wOpt.ExclusiveKeyboardOnFocus {
     411 + exclusiveWidget = cur.opts.widget
     412 + }
     413 + 
    344 414   switch wOpt.WantKeyboard {
    345 415   case widgetapi.KeyScopeNone:
    346 416   // Widget doesn't want any keyboard events.
    347 417   return nil
    348 418   
    349 419   case widgetapi.KeyScopeFocused:
    350  - if cur.focusTracker.isActive(cur) {
    351  - widgets = append(widgets, cur.opts.widget)
     420 + if focused {
     421 + targets = append(targets, newKeyEvTarget(cur.opts.widget, meta))
    352 422   }
    353 423   
    354 424   case widgetapi.KeyScopeGlobal:
    355  - widgets = append(widgets, cur.opts.widget)
     425 + targets = append(targets, newKeyEvTarget(cur.opts.widget, meta))
    356 426   }
    357 427   return nil
    358 428   }))
    359  - return widgets
     429 + 
     430 + if exclusiveWidget != nil {
     431 + targets = []*keyEvTarget{
     432 + newKeyEvTarget(exclusiveWidget, &widgetapi.EventMeta{Focused: true}),
     433 + }
     434 + }
     435 + return targets
    360 436  }
    361 437   
    362  -// mouseEvTarget contains a mouse event adjusted relative to the widget's area
    363  -// and the widget that should receive it.
     438 +// mouseEvTarget contains a mouse event adjusted relative to the widget's area,
     439 +// the widget that should receive it and metadata about the event.
    364 440  type mouseEvTarget struct {
    365 441   // widget is the widget that should receive the mouse event.
    366 442   widget widgetapi.Widget
    367 443   // ev is the adjusted mouse event.
    368 444   ev *terminalapi.Mouse
     445 + // meta is the metadata about the event.
     446 + meta *widgetapi.EventMeta
    369 447  }
    370 448   
    371  -// newMouseEvTarget returns a new newMouseEvTarget.
    372  -func newMouseEvTarget(w widgetapi.Widget, wArea image.Rectangle, ev *terminalapi.Mouse) *mouseEvTarget {
     449 +// newMouseEvTarget returns a new mouseEvTarget.
     450 +func newMouseEvTarget(w widgetapi.Widget, wArea image.Rectangle, ev *terminalapi.Mouse, meta *widgetapi.EventMeta) *mouseEvTarget {
    373 451   return &mouseEvTarget{
    374 452   widget: w,
    375 453   ev: adjustMouseEv(ev, wArea),
     454 + meta: meta,
    376 455   }
    377 456  }
    378 457   
    skipped 19 lines
    398 477   return err
    399 478   }
    400 479   
     480 + meta := &widgetapi.EventMeta{
     481 + Focused: cur.focusTracker.isActive(cur),
     482 + }
    401 483   switch wOpts.WantMouse {
    402 484   case widgetapi.MouseScopeNone:
    403 485   // Widget doesn't want any mouse events.
    skipped 2 lines
    406 488   case widgetapi.MouseScopeWidget:
    407 489   // Only if the event falls inside of the widget's canvas.
    408 490   if m.Position.In(wa) {
    409  - widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
     491 + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m, meta))
    410 492   }
    411 493   
    412 494   case widgetapi.MouseScopeContainer:
    413 495   // Only if the event falls inside the widget's parent container.
    414 496   if m.Position.In(cur.area) {
    415  - widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
     497 + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m, meta))
    416 498   }
    417 499   
    418 500   case widgetapi.MouseScopeGlobal:
    419 501   // Widget wants all mouse events.
    420  - widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
     502 + widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m, meta))
    421 503   }
    422 504   return nil
    423 505   }))
    skipped 49 lines
  • ■ ■ ■ ■ ■ ■
    container/container_test.go
    skipped 529 lines
    530 530   wantContainerErr: true,
    531 531   },
    532 532   {
     533 + desc: "fails on KeyFocusGroups with a negative group",
     534 + termSize: image.Point{10, 20},
     535 + container: func(ft *faketerm.Terminal) (*Container, error) {
     536 + return New(
     537 + ft,
     538 + KeyFocusGroups(0, -1),
     539 + )
     540 + },
     541 + wantContainerErr: true,
     542 + },
     543 + {
     544 + desc: "fails on KeyFocusGroupsNext with a negative group",
     545 + termSize: image.Point{10, 20},
     546 + container: func(ft *faketerm.Terminal) (*Container, error) {
     547 + return New(
     548 + ft,
     549 + KeyFocusGroupsNext('n', 0, -1),
     550 + )
     551 + },
     552 + wantContainerErr: true,
     553 + },
     554 + {
     555 + desc: "fails on KeyFocusGroupsPrevious with a negative group",
     556 + termSize: image.Point{10, 20},
     557 + container: func(ft *faketerm.Terminal) (*Container, error) {
     558 + return New(
     559 + ft,
     560 + KeyFocusGroupsPrevious('p', 0, -1),
     561 + )
     562 + },
     563 + wantContainerErr: true,
     564 + },
     565 + {
     566 + desc: "fails on KeyFocusGroupsNext with a key already assigned as KeyFocusGroupsPrevious",
     567 + termSize: image.Point{10, 20},
     568 + container: func(ft *faketerm.Terminal) (*Container, error) {
     569 + return New(
     570 + ft,
     571 + KeyFocusGroupsPrevious('n', 0),
     572 + KeyFocusGroupsNext('n', 0),
     573 + )
     574 + },
     575 + wantContainerErr: true,
     576 + },
     577 + {
     578 + desc: "fails on KeyFocusGroupsPrevious with a key already assigned as KeyFocusGroupsNext",
     579 + termSize: image.Point{10, 20},
     580 + container: func(ft *faketerm.Terminal) (*Container, error) {
     581 + return New(
     582 + ft,
     583 + KeyFocusGroupsNext('n', 0),
     584 + KeyFocusGroupsPrevious('n', 0),
     585 + )
     586 + },
     587 + wantContainerErr: true,
     588 + },
     589 + {
    533 590   desc: "empty container",
    534 591   termSize: image.Point{10, 10},
    535 592   container: func(ft *faketerm.Terminal) (*Container, error) {
    skipped 607 lines
    1143 1200   testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
    1144 1201   &widgetapi.Meta{Focused: true},
    1145 1202   widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
    1146  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1203 + &fakewidget.Event{
     1204 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1205 + Meta: &widgetapi.EventMeta{Focused: true},
     1206 + },
    1147 1207   )
    1148 1208   return ft
    1149 1209   },
    skipped 38 lines
    1188 1248   testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
    1189 1249   &widgetapi.Meta{},
    1190 1250   widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
    1191  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1251 + &fakewidget.Event{
     1252 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1253 + Meta: &widgetapi.EventMeta{},
     1254 + },
     1255 + )
     1256 + 
     1257 + // Widget that isn't focused and only wants focused events.
     1258 + fakewidget.MustDraw(
     1259 + ft,
     1260 + testcanvas.MustNew(image.Rect(20, 0, 40, 10)),
     1261 + &widgetapi.Meta{},
     1262 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1263 + )
     1264 + 
     1265 + // The focused widget receives the key.
     1266 + fakewidget.MustDraw(
     1267 + ft,
     1268 + testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
     1269 + &widgetapi.Meta{Focused: true},
     1270 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1271 + &fakewidget.Event{
     1272 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1273 + Meta: &widgetapi.EventMeta{Focused: true},
     1274 + },
     1275 + )
     1276 + return ft
     1277 + },
     1278 + },
     1279 + {
     1280 + desc: "keyboard event forwarded to exclusive widget only when focused",
     1281 + termSize: image.Point{40, 20},
     1282 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1283 + return New(
     1284 + ft,
     1285 + SplitVertical(
     1286 + Left(
     1287 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
     1288 + ),
     1289 + Right(
     1290 + SplitHorizontal(
     1291 + Top(
     1292 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1293 + ),
     1294 + Bottom(
     1295 + PlaceWidget(fakewidget.New(
     1296 + widgetapi.Options{
     1297 + WantKeyboard: widgetapi.KeyScopeFocused,
     1298 + ExclusiveKeyboardOnFocus: true,
     1299 + },
     1300 + )),
     1301 + ),
     1302 + ),
     1303 + ),
     1304 + ),
     1305 + )
     1306 + },
     1307 + events: []terminalapi.Event{
     1308 + // Move focus to the target container.
     1309 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
     1310 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
     1311 + // Send the keyboard event.
     1312 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1313 + },
     1314 + want: func(size image.Point) *faketerm.Terminal {
     1315 + ft := faketerm.MustNew(size)
     1316 + 
     1317 + // Widget that isn't focused, but registered for global
     1318 + // keyboard events.
     1319 + fakewidget.MustDraw(
     1320 + ft,
     1321 + testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
     1322 + &widgetapi.Meta{},
     1323 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
     1324 + )
     1325 + 
     1326 + // Widget that isn't focused and only wants focused events.
     1327 + fakewidget.MustDraw(
     1328 + ft,
     1329 + testcanvas.MustNew(image.Rect(20, 0, 40, 10)),
     1330 + &widgetapi.Meta{},
     1331 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1332 + )
     1333 + 
     1334 + // The focused widget receives the key.
     1335 + fakewidget.MustDraw(
     1336 + ft,
     1337 + testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
     1338 + &widgetapi.Meta{Focused: true},
     1339 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1340 + &fakewidget.Event{
     1341 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1342 + Meta: &widgetapi.EventMeta{Focused: true},
     1343 + },
     1344 + )
     1345 + return ft
     1346 + },
     1347 + },
     1348 + {
     1349 + desc: "the ExclusiveKeyboardOnFocus option has no effect when widget not focused",
     1350 + termSize: image.Point{40, 20},
     1351 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1352 + return New(
     1353 + ft,
     1354 + SplitVertical(
     1355 + Left(
     1356 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
     1357 + ),
     1358 + Right(
     1359 + SplitHorizontal(
     1360 + Top(
     1361 + PlaceWidget(fakewidget.New(
     1362 + widgetapi.Options{
     1363 + WantKeyboard: widgetapi.KeyScopeFocused,
     1364 + ExclusiveKeyboardOnFocus: true,
     1365 + },
     1366 + )),
     1367 + ),
     1368 + Bottom(
     1369 + PlaceWidget(fakewidget.New(
     1370 + widgetapi.Options{
     1371 + WantKeyboard: widgetapi.KeyScopeFocused,
     1372 + },
     1373 + )),
     1374 + ),
     1375 + ),
     1376 + ),
     1377 + ),
     1378 + )
     1379 + },
     1380 + events: []terminalapi.Event{
     1381 + // Move focus to the target container.
     1382 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
     1383 + &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
     1384 + // Send the keyboard event.
     1385 + &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1386 + },
     1387 + want: func(size image.Point) *faketerm.Terminal {
     1388 + ft := faketerm.MustNew(size)
     1389 + 
     1390 + // Widget that isn't focused, but registered for global
     1391 + // keyboard events.
     1392 + fakewidget.MustDraw(
     1393 + ft,
     1394 + testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
     1395 + &widgetapi.Meta{},
     1396 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
     1397 + &fakewidget.Event{
     1398 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1399 + Meta: &widgetapi.EventMeta{Focused: false},
     1400 + },
    1192 1401   )
    1193 1402   
    1194 1403   // Widget that isn't focused and only wants focused events.
    skipped 10 lines
    1205 1414   testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
    1206 1415   &widgetapi.Meta{Focused: true},
    1207 1416   widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
    1208  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1417 + &fakewidget.Event{
     1418 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1419 + Meta: &widgetapi.EventMeta{Focused: true},
     1420 + },
    1209 1421   )
    1210 1422   return ft
    1211 1423   },
    skipped 200 lines
    1412 1624   testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
    1413 1625   &widgetapi.Meta{},
    1414 1626   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1415  - &terminalapi.Keyboard{},
    1416 1627   )
    1417 1628   
    1418 1629   // The target widget receives the mouse event.
    skipped 2 lines
    1421 1632   testcanvas.MustNew(image.Rect(25, 0, 50, 10)),
    1422 1633   &widgetapi.Meta{Focused: true},
    1423 1634   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1424  - &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonLeft},
    1425  - &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonRelease},
     1635 + &fakewidget.Event{
     1636 + Ev: &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonLeft},
     1637 + Meta: &widgetapi.EventMeta{},
     1638 + },
     1639 + &fakewidget.Event{
     1640 + Ev: &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonRelease},
     1641 + Meta: &widgetapi.EventMeta{Focused: true},
     1642 + },
    1426 1643   )
    1427 1644   return ft
    1428 1645   },
    skipped 66 lines
    1495 1712   testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
    1496 1713   &widgetapi.Meta{},
    1497 1714   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1498  - &terminalapi.Keyboard{},
    1499 1715   )
    1500 1716   
    1501 1717   // The target widget receives the mouse event.
    skipped 2 lines
    1504 1720   testcanvas.MustNew(image.Rect(26, 1, 49, 9)),
    1505 1721   &widgetapi.Meta{Focused: true},
    1506 1722   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1507  - &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonLeft},
    1508  - &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonRelease},
     1723 + &fakewidget.Event{
     1724 + Ev: &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonLeft},
     1725 + Meta: &widgetapi.EventMeta{},
     1726 + },
     1727 + &fakewidget.Event{
     1728 + Ev: &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonRelease},
     1729 + Meta: &widgetapi.EventMeta{Focused: true},
     1730 + },
     1731 + )
     1732 + return ft
     1733 + },
     1734 + },
     1735 + {
     1736 + desc: "key event focuses the next container, widget with KeyScopeFocused gets the key as it is now focused",
     1737 + termSize: image.Point{50, 20},
     1738 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1739 + c, err := New(
     1740 + ft,
     1741 + SplitVertical(
     1742 + Left(
     1743 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1744 + ),
     1745 + Right(
     1746 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1747 + ),
     1748 + ),
     1749 + KeyFocusNext(keyboard.KeyTab),
     1750 + )
     1751 + if err != nil {
     1752 + return nil, err
     1753 + }
     1754 + return c, nil
     1755 + },
     1756 + events: []terminalapi.Event{
     1757 + &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1758 + },
     1759 + want: func(size image.Point) *faketerm.Terminal {
     1760 + ft := faketerm.MustNew(size)
     1761 + 
     1762 + fakewidget.MustDraw(
     1763 + ft,
     1764 + testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
     1765 + &widgetapi.Meta{Focused: true},
     1766 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1767 + &fakewidget.Event{
     1768 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1769 + Meta: &widgetapi.EventMeta{Focused: true},
     1770 + },
     1771 + )
     1772 + fakewidget.MustDraw(
     1773 + ft,
     1774 + testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
     1775 + &widgetapi.Meta{},
     1776 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1777 + )
     1778 + return ft
     1779 + },
     1780 + },
     1781 + {
     1782 + desc: "key event focuses the previous container, option set on both parent and child, the last option is used since focus keys are global options",
     1783 + termSize: image.Point{50, 20},
     1784 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1785 + c, err := New(
     1786 + ft,
     1787 + KeyFocusPrevious(keyboard.KeyEnter),
     1788 + SplitVertical(
     1789 + Left(
     1790 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1791 + ),
     1792 + Right(
     1793 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1794 + KeyFocusPrevious(keyboard.KeyTab),
     1795 + ),
     1796 + ),
     1797 + )
     1798 + if err != nil {
     1799 + return nil, err
     1800 + }
     1801 + return c, nil
     1802 + },
     1803 + events: []terminalapi.Event{
     1804 + &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1805 + },
     1806 + want: func(size image.Point) *faketerm.Terminal {
     1807 + ft := faketerm.MustNew(size)
     1808 + 
     1809 + fakewidget.MustDraw(
     1810 + ft,
     1811 + testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
     1812 + &widgetapi.Meta{},
     1813 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1814 + )
     1815 + fakewidget.MustDraw(
     1816 + ft,
     1817 + testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
     1818 + &widgetapi.Meta{Focused: true},
     1819 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1820 + &fakewidget.Event{
     1821 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1822 + Meta: &widgetapi.EventMeta{Focused: true},
     1823 + },
     1824 + )
     1825 + return ft
     1826 + },
     1827 + },
     1828 + {
     1829 + desc: "key event focuses the next container, widget with KeyScopeGlobal also gets the key",
     1830 + termSize: image.Point{50, 20},
     1831 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1832 + c, err := New(
     1833 + ft,
     1834 + SplitVertical(
     1835 + Left(
     1836 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1837 + ),
     1838 + Right(
     1839 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
     1840 + ),
     1841 + ),
     1842 + KeyFocusNext(keyboard.KeyTab),
     1843 + )
     1844 + if err != nil {
     1845 + return nil, err
     1846 + }
     1847 + return c, nil
     1848 + },
     1849 + events: []terminalapi.Event{
     1850 + &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1851 + },
     1852 + want: func(size image.Point) *faketerm.Terminal {
     1853 + ft := faketerm.MustNew(size)
     1854 + 
     1855 + fakewidget.MustDraw(
     1856 + ft,
     1857 + testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
     1858 + &widgetapi.Meta{Focused: true},
     1859 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1860 + &fakewidget.Event{
     1861 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1862 + Meta: &widgetapi.EventMeta{Focused: true},
     1863 + },
     1864 + )
     1865 + fakewidget.MustDraw(
     1866 + ft,
     1867 + testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
     1868 + &widgetapi.Meta{},
     1869 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
     1870 + &fakewidget.Event{
     1871 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1872 + Meta: &widgetapi.EventMeta{},
     1873 + },
     1874 + )
     1875 + return ft
     1876 + },
     1877 + },
     1878 + {
     1879 + desc: "key event moves focus from a widget with KeyScopeFocused, the newly focused widget gets the key",
     1880 + termSize: image.Point{50, 20},
     1881 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1882 + c, err := New(
     1883 + ft,
     1884 + SplitVertical(
     1885 + Left(
     1886 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1887 + ),
     1888 + Right(
     1889 + PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
     1890 + ),
     1891 + ),
     1892 + KeyFocusNext(keyboard.KeyTab),
     1893 + )
     1894 + if err != nil {
     1895 + return nil, err
     1896 + }
     1897 + return c, nil
     1898 + },
     1899 + events: []terminalapi.Event{
     1900 + // Focus the left container.
     1901 + &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1902 + // Move focus from left to right.
     1903 + &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1904 + },
     1905 + want: func(size image.Point) *faketerm.Terminal {
     1906 + ft := faketerm.MustNew(size)
     1907 + 
     1908 + fakewidget.MustDraw(
     1909 + ft,
     1910 + testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
     1911 + &widgetapi.Meta{},
     1912 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1913 + // Also gets the key, since we are sending two events above.
     1914 + &fakewidget.Event{
     1915 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1916 + // Also is focused at the time of the first event.
     1917 + Meta: &widgetapi.EventMeta{Focused: true},
     1918 + },
     1919 + )
     1920 + fakewidget.MustDraw(
     1921 + ft,
     1922 + testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
     1923 + &widgetapi.Meta{Focused: true},
     1924 + widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
     1925 + &fakewidget.Event{
     1926 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     1927 + Meta: &widgetapi.EventMeta{Focused: true},
     1928 + },
    1509 1929   )
    1510 1930   return ft
    1511 1931   },
    skipped 59 lines
    1571 1991   },
    1572 1992   {
    1573 1993   desc: "MouseScopeContainer, event forwarded if it falls on the container's border",
    1574  - termSize: image.Point{21, 20},
     1994 + termSize: image.Point{23, 20},
    1575 1995   container: func(ft *faketerm.Terminal) (*Container, error) {
    1576 1996   return New(
    1577 1997   ft,
    skipped 19 lines
    1597 2017   
    1598 2018   fakewidget.MustDraw(
    1599 2019   ft,
    1600  - testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
     2020 + testcanvas.MustNew(image.Rect(1, 1, 22, 19)),
    1601 2021   &widgetapi.Meta{Focused: true},
    1602 2022   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1603  - &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2023 + &fakewidget.Event{
     2024 + Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2025 + Meta: &widgetapi.EventMeta{Focused: true},
     2026 + },
    1604 2027   )
    1605 2028   return ft
    1606 2029   },
    1607 2030   },
    1608 2031   {
    1609 2032   desc: "MouseScopeGlobal, event forwarded if it falls on the container's border",
    1610  - termSize: image.Point{21, 20},
     2033 + termSize: image.Point{23, 20},
    1611 2034   container: func(ft *faketerm.Terminal) (*Container, error) {
    1612 2035   return New(
    1613 2036   ft,
    skipped 19 lines
    1633 2056   
    1634 2057   fakewidget.MustDraw(
    1635 2058   ft,
    1636  - testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
     2059 + testcanvas.MustNew(image.Rect(1, 1, 22, 19)),
    1637 2060   &widgetapi.Meta{Focused: true},
    1638 2061   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1639  - &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2062 + &fakewidget.Event{
     2063 + Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2064 + Meta: &widgetapi.EventMeta{Focused: true},
     2065 + },
    1640 2066   )
    1641 2067   return ft
    1642 2068   },
    skipped 31 lines
    1674 2100   },
    1675 2101   {
    1676 2102   desc: "MouseScopeContainer event forwarded if it falls outside of widget's canvas",
    1677  - termSize: image.Point{20, 20},
     2103 + termSize: image.Point{22, 20},
    1678 2104   container: func(ft *faketerm.Terminal) (*Container, error) {
    1679 2105   return New(
    1680 2106   ft,
    skipped 15 lines
    1696 2122   
    1697 2123   fakewidget.MustDraw(
    1698 2124   ft,
    1699  - testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
     2125 + testcanvas.MustNew(image.Rect(0, 4, 22, 15)),
    1700 2126   &widgetapi.Meta{Focused: true},
    1701 2127   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1702  - &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2128 + &fakewidget.Event{
     2129 + Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2130 + Meta: &widgetapi.EventMeta{Focused: true},
     2131 + },
    1703 2132   )
    1704 2133   return ft
    1705 2134   },
    1706 2135   },
    1707 2136   {
    1708 2137   desc: "MouseScopeGlobal event forwarded if it falls outside of widget's canvas",
    1709  - termSize: image.Point{20, 20},
     2138 + termSize: image.Point{22, 20},
    1710 2139   container: func(ft *faketerm.Terminal) (*Container, error) {
    1711 2140   return New(
    1712 2141   ft,
    skipped 15 lines
    1728 2157   
    1729 2158   fakewidget.MustDraw(
    1730 2159   ft,
    1731  - testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
     2160 + testcanvas.MustNew(image.Rect(0, 4, 22, 15)),
    1732 2161   &widgetapi.Meta{Focused: true},
    1733 2162   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1734  - &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2163 + &fakewidget.Event{
     2164 + Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2165 + Meta: &widgetapi.EventMeta{Focused: true},
     2166 + },
    1735 2167   )
    1736 2168   return ft
    1737 2169   },
    skipped 102 lines
    1840 2272   testcanvas.MustNew(image.Rect(0, 10, 20, 20)),
    1841 2273   &widgetapi.Meta{},
    1842 2274   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1843  - &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2275 + &fakewidget.Event{
     2276 + Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
     2277 + Meta: &widgetapi.EventMeta{},
     2278 + },
    1844 2279   )
    1845 2280   return ft
    1846 2281   },
    skipped 25 lines
    1872 2307   testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
    1873 2308   &widgetapi.Meta{Focused: true},
    1874 2309   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1875  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     2310 + &fakewidget.Event{
     2311 + Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     2312 + Meta: &widgetapi.EventMeta{Focused: true},
     2313 + },
    1876 2314   )
    1877 2315   return ft
    1878 2316   },
    1879 2317   },
    1880 2318   {
    1881 2319   desc: "mouse position adjusted relative to widget's canvas, horizontal offset",
    1882  - termSize: image.Point{30, 20},
     2320 + termSize: image.Point{40, 30},
    1883 2321   container: func(ft *faketerm.Terminal) (*Container, error) {
    1884 2322   return New(
    1885 2323   ft,
    skipped 15 lines
    1901 2339   
    1902 2340   fakewidget.MustDraw(
    1903 2341   ft,
    1904  - testcanvas.MustNew(image.Rect(6, 0, 24, 20)),
     2342 + testcanvas.MustNew(image.Rect(6, 0, 33, 30)),
    1905 2343   &widgetapi.Meta{Focused: true},
    1906 2344   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    1907  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     2345 + &fakewidget.Event{
     2346 + Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     2347 + Meta: &widgetapi.EventMeta{Focused: true},
     2348 + },
    1908 2349   )
    1909 2350   return ft
    1910 2351   },
    skipped 396 lines
    2307 2748   },
    2308 2749   {
    2309 2750   desc: "newly placed widget gets keyboard events",
    2310  - termSize: image.Point{10, 10},
     2751 + termSize: image.Point{12, 10},
    2311 2752   container: func(ft *faketerm.Terminal) (*Container, error) {
    2312 2753   return New(
    2313 2754   ft,
    skipped 22 lines
    2336 2777   cvs,
    2337 2778   &widgetapi.Meta{Focused: true},
    2338 2779   widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
    2339  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     2780 + &fakewidget.Event{
     2781 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     2782 + Meta: &widgetapi.EventMeta{Focused: true},
     2783 + },
    2340 2784   )
    2341 2785   testcanvas.MustApply(cvs, ft)
    2342 2786   return ft
    skipped 1 lines
    2344 2788   },
    2345 2789   {
    2346 2790   desc: "newly placed widget gets mouse events",
    2347  - termSize: image.Point{20, 10},
     2791 + termSize: image.Point{22, 10},
    2348 2792   container: func(ft *faketerm.Terminal) (*Container, error) {
    2349 2793   return New(
    2350 2794   ft,
    skipped 17 lines
    2368 2812   cvs,
    2369 2813   &widgetapi.Meta{Focused: true},
    2370 2814   widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
    2371  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     2815 + &fakewidget.Event{
     2816 + Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     2817 + Meta: &widgetapi.EventMeta{Focused: true},
     2818 + },
    2372 2819   )
    2373 2820   testcanvas.MustApply(cvs, ft)
    2374 2821   return ft
    skipped 87 lines
  • ■ ■ ■ ■ ■ ■
    container/focus.go
    skipped 67 lines
    68 68   }
    69 69  }
    70 70   
     71 +// active returns container that is currently active.
     72 +func (ft *focusTracker) active() *Container {
     73 + return ft.container
     74 +}
     75 + 
    71 76  // isActive determines if the provided container is the currently active container.
    72 77  func (ft *focusTracker) isActive(c *Container) bool {
    73 78   return ft.container == c
    skipped 2 lines
    76 81  // setActive sets the currently active container to the one provided.
    77 82  func (ft *focusTracker) setActive(c *Container) {
    78 83   ft.container = c
     84 +}
     85 + 
     86 +// next moves focus to the next container.
     87 +// If group is not nil, focus will only move between containers with a matching
     88 +// focus group number.
     89 +func (ft *focusTracker) next(group *FocusGroup) {
     90 + var (
     91 + errStr string
     92 + firstCont *Container
     93 + nextCont *Container
     94 + focusNext bool
     95 + )
     96 + preOrder(rootCont(ft.container), &errStr, visitFunc(func(c *Container) error {
     97 + if nextCont != nil {
     98 + // Already found the next container, nothing to do.
     99 + return nil
     100 + }
     101 + 
     102 + if firstCont == nil && c.isLeaf() {
     103 + // Remember the first eligible container in case we "wrap" over,
     104 + // i.e. finish the iteration before finding the next container.
     105 + switch {
     106 + case group == nil && !c.opts.keyFocusSkip:
     107 + fallthrough
     108 + case group != nil && c.inFocusGroup(*group):
     109 + firstCont = c
     110 + }
     111 + }
     112 + 
     113 + if ft.container == c {
     114 + // Visiting the currently focused container, going to focus the
     115 + // next one.
     116 + focusNext = true
     117 + return nil
     118 + }
     119 + 
     120 + if focusNext && c.isLeaf() {
     121 + switch {
     122 + case group == nil && !c.opts.keyFocusSkip:
     123 + fallthrough
     124 + case group != nil && c.inFocusGroup(*group):
     125 + nextCont = c
     126 + }
     127 + }
     128 + return nil
     129 + }))
     130 + 
     131 + if nextCont == nil && firstCont != nil {
     132 + // If the traversal finishes without finding the next container, move
     133 + // focus back to the first container.
     134 + ft.setActive(firstCont)
     135 + } else if nextCont != nil {
     136 + ft.setActive(nextCont)
     137 + }
     138 +}
     139 + 
     140 +// previous moves focus to the previous container.
     141 +// If group is not nil, focus will only move between containers with a matching
     142 +// focus group number.
     143 +func (ft *focusTracker) previous(group *FocusGroup) {
     144 + var (
     145 + errStr string
     146 + prevCont *Container
     147 + lastCont *Container
     148 + visitedCurr bool
     149 + )
     150 + preOrder(rootCont(ft.container), &errStr, visitFunc(func(c *Container) error {
     151 + if ft.container == c {
     152 + visitedCurr = true
     153 + }
     154 + 
     155 + if c.isLeaf() {
     156 + switch {
     157 + case group == nil && !c.opts.keyFocusSkip:
     158 + fallthrough
     159 + case group != nil && c.inFocusGroup(*group):
     160 + if !visitedCurr {
     161 + // Remember the last eligible container closest to the one
     162 + // currently focused.
     163 + prevCont = c
     164 + }
     165 + lastCont = c
     166 + }
     167 + }
     168 + return nil
     169 + }))
     170 + 
     171 + if prevCont != nil {
     172 + ft.setActive(prevCont)
     173 + } else if lastCont != nil {
     174 + ft.setActive(lastCont)
     175 + }
    79 176  }
    80 177   
    81 178  // mouse identifies mouse events that change the focused container and track
    skipped 36 lines
  • ■ ■ ■ ■ ■ ■
    container/focus_test.go
    skipped 16 lines
    17 17  import (
    18 18   "fmt"
    19 19   "image"
     20 + "strings"
    20 21   "testing"
    21 22   "time"
    22 23   
    23 24   "github.com/mum4k/termdash/cell"
     25 + "github.com/mum4k/termdash/keyboard"
    24 26   "github.com/mum4k/termdash/linestyle"
    25 27   "github.com/mum4k/termdash/mouse"
    26 28   "github.com/mum4k/termdash/private/event"
    skipped 232 lines
    259 261   }
    260 262  }
    261 263   
     264 +// contLocIntro3 prints out an introduction explaining the used container
     265 +// locations on test failures.
     266 +func contLocIntro3() string {
     267 + var s strings.Builder
     268 + s.WriteString("Container locations refer to containers in the following tree, i.e. contLocA is the root container:\n")
     269 + s.WriteString(`
     270 + A
     271 + / \
     272 + B C
     273 +`)
     274 + return s.String()
     275 +}
     276 + 
     277 +// contLocIntro5 prints out an introduction explaining the used container
     278 +// locations on test failures.
     279 +func contLocIntro5() string {
     280 + var s strings.Builder
     281 + s.WriteString("Container locations refer to containers in the following tree, i.e. contLocA is the root container:\n")
     282 + s.WriteString(`
     283 + A
     284 + / \
     285 + B C
     286 + / \
     287 +D E
     288 +`)
     289 + return s.String()
     290 +}
     291 + 
    262 292  // contLoc is used in tests to indicate the desired location of a container.
    263 293  type contLoc int
    264 294   
    skipped 7 lines
    272 302   
    273 303  // contLocNames maps contLoc values to human readable names.
    274 304  var contLocNames = map[contLoc]string{
    275  - contLocRoot: "Root",
    276  - contLocLeft: "Left",
    277  - contLocRight: "Right",
     305 + contLocA: "contLocA",
     306 + contLocB: "contLocB",
     307 + contLocC: "contLocC",
     308 + contLocD: "contLocD",
     309 + contLocE: "contLocE",
    278 310  }
    279 311   
    280 312  const (
    281 313   contLocUnknown contLoc = iota
    282  - contLocRoot
    283  - contLocLeft
    284  - contLocRight
     314 + contLocA
     315 + contLocB
     316 + contLocC
     317 + contLocD
     318 + contLocE
    285 319  )
    286 320   
    287 321  func TestFocusTrackerMouse(t *testing.T) {
     322 + t.Log(contLocIntro3())
     323 + 
    288 324   ft, err := faketerm.New(image.Point{10, 10})
    289 325   if err != nil {
    290 326   t.Fatalf("faketerm.New => unexpected error: %v", err)
    291 327   }
    292 328   
    293 329   var (
    294  - insideLeft = image.Point{1, 1}
    295  - insideRight = image.Point{6, 6}
     330 + insideB = image.Point{1, 1}
     331 + insideC = image.Point{6, 6}
    296 332   )
    297 333   
    298 334   tests := []struct {
    skipped 5 lines
    304 340   }{
    305 341   {
    306 342   desc: "initially the root is focused",
    307  - wantFocused: contLocRoot,
     343 + wantFocused: contLocA,
    308 344   },
    309 345   {
    310 346   desc: "click and release moves focus to the left",
    skipped 1 lines
    312 348   {Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
    313 349   {Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
    314 350   },
    315  - wantFocused: contLocLeft,
     351 + wantFocused: contLocB,
    316 352   wantProcessed: 2,
    317 353   },
    318 354   {
    skipped 2 lines
    321 357   {Position: image.Point{5, 5}, Button: mouse.ButtonLeft},
    322 358   {Position: image.Point{6, 6}, Button: mouse.ButtonRelease},
    323 359   },
    324  - wantFocused: contLocRight,
     360 + wantFocused: contLocC,
    325 361   wantProcessed: 2,
    326 362   },
    327 363   {
    328 364   desc: "click in the same container is a no-op",
    329 365   events: []*terminalapi.Mouse{
    330  - {Position: insideRight, Button: mouse.ButtonLeft},
    331  - {Position: insideRight, Button: mouse.ButtonRelease},
    332  - {Position: insideRight, Button: mouse.ButtonLeft},
    333  - {Position: insideRight, Button: mouse.ButtonRelease},
     366 + {Position: insideC, Button: mouse.ButtonLeft},
     367 + {Position: insideC, Button: mouse.ButtonRelease},
     368 + {Position: insideC, Button: mouse.ButtonLeft},
     369 + {Position: insideC, Button: mouse.ButtonRelease},
    334 370   },
    335  - wantFocused: contLocRight,
     371 + wantFocused: contLocC,
    336 372   wantProcessed: 4,
    337 373   },
    338 374   {
    339 375   desc: "click in the same container and release never happens",
    340 376   events: []*terminalapi.Mouse{
    341  - {Position: insideRight, Button: mouse.ButtonLeft},
    342  - {Position: insideLeft, Button: mouse.ButtonLeft},
    343  - {Position: insideLeft, Button: mouse.ButtonRelease},
     377 + {Position: insideC, Button: mouse.ButtonLeft},
     378 + {Position: insideB, Button: mouse.ButtonLeft},
     379 + {Position: insideB, Button: mouse.ButtonRelease},
    344 380   },
    345  - wantFocused: contLocLeft,
     381 + wantFocused: contLocB,
    346 382   wantProcessed: 3,
    347 383   },
    348 384   {
    349 385   desc: "click in the same container, release elsewhere",
    350 386   events: []*terminalapi.Mouse{
    351  - {Position: insideRight, Button: mouse.ButtonLeft},
    352  - {Position: insideLeft, Button: mouse.ButtonRelease},
     387 + {Position: insideC, Button: mouse.ButtonLeft},
     388 + {Position: insideB, Button: mouse.ButtonRelease},
    353 389   },
    354  - wantFocused: contLocRoot,
     390 + wantFocused: contLocA,
    355 391   wantProcessed: 2,
    356 392   },
    357 393   {
    358 394   desc: "other buttons are ignored",
    359 395   events: []*terminalapi.Mouse{
    360  - {Position: insideLeft, Button: mouse.ButtonMiddle},
    361  - {Position: insideLeft, Button: mouse.ButtonRelease},
    362  - {Position: insideLeft, Button: mouse.ButtonRight},
    363  - {Position: insideLeft, Button: mouse.ButtonRelease},
    364  - {Position: insideLeft, Button: mouse.ButtonWheelUp},
    365  - {Position: insideLeft, Button: mouse.ButtonWheelDown},
     396 + {Position: insideB, Button: mouse.ButtonMiddle},
     397 + {Position: insideB, Button: mouse.ButtonRelease},
     398 + {Position: insideB, Button: mouse.ButtonRight},
     399 + {Position: insideB, Button: mouse.ButtonRelease},
     400 + {Position: insideB, Button: mouse.ButtonWheelUp},
     401 + {Position: insideB, Button: mouse.ButtonWheelDown},
    366 402   },
    367  - wantFocused: contLocRoot,
     403 + wantFocused: contLocA,
    368 404   wantProcessed: 6,
    369 405   },
    370 406   {
    skipped 3 lines
    374 410   {Position: image.Point{1, 1}, Button: mouse.ButtonLeft},
    375 411   {Position: image.Point{2, 2}, Button: mouse.ButtonRelease},
    376 412   },
    377  - wantFocused: contLocLeft,
     413 + wantFocused: contLocB,
    378 414   wantProcessed: 3,
    379 415   },
    380 416   {
    381 417   desc: "click ignored if followed by another click of the same button elsewhere",
    382 418   events: []*terminalapi.Mouse{
    383  - {Position: insideRight, Button: mouse.ButtonLeft},
    384  - {Position: insideLeft, Button: mouse.ButtonLeft},
    385  - {Position: insideRight, Button: mouse.ButtonRelease},
     419 + {Position: insideC, Button: mouse.ButtonLeft},
     420 + {Position: insideB, Button: mouse.ButtonLeft},
     421 + {Position: insideC, Button: mouse.ButtonRelease},
    386 422   },
    387  - wantFocused: contLocRoot,
     423 + wantFocused: contLocA,
    388 424   wantProcessed: 3,
    389 425   },
    390 426   {
    391 427   desc: "click ignored if followed by another click of a different button",
    392 428   events: []*terminalapi.Mouse{
    393  - {Position: insideRight, Button: mouse.ButtonLeft},
    394  - {Position: insideRight, Button: mouse.ButtonMiddle},
    395  - {Position: insideRight, Button: mouse.ButtonRelease},
     429 + {Position: insideC, Button: mouse.ButtonLeft},
     430 + {Position: insideC, Button: mouse.ButtonMiddle},
     431 + {Position: insideC, Button: mouse.ButtonRelease},
    396 432   },
    397  - wantFocused: contLocRoot,
     433 + wantFocused: contLocA,
    398 434   wantProcessed: 3,
    399 435   },
    400 436   }
    skipped 31 lines
    432 468   
    433 469   var wantFocused *Container
    434 470   switch wf := tc.wantFocused; wf {
    435  - case contLocRoot:
     471 + case contLocA:
    436 472   wantFocused = root
    437  - case contLocLeft:
     473 + case contLocB:
    438 474   wantFocused = root.first
    439  - case contLocRight:
     475 + case contLocC:
    440 476   wantFocused = root.second
    441 477   default:
    442 478   t.Fatalf("unsupported wantFocused value => %v", wf)
    443 479   }
    444 480   
    445 481   if !root.focusTracker.isActive(wantFocused) {
    446  - t.Errorf("isActive(%v) => false, want true, status: root(%v):%v, left(%v):%v, right(%v):%v",
     482 + t.Errorf("isActive(%v) => false, want true, status: contLocA(%v):%v, contLocB(%v):%v, contLocC(%v):%v",
    447 483   tc.wantFocused,
    448  - contLocRoot, root.focusTracker.isActive(root),
    449  - contLocLeft, root.focusTracker.isActive(root.first),
    450  - contLocRight, root.focusTracker.isActive(root.second),
     484 + contLocA, root.focusTracker.isActive(root),
     485 + contLocB, root.focusTracker.isActive(root.first),
     486 + contLocC, root.focusTracker.isActive(root.second),
     487 + )
     488 + }
     489 + })
     490 + }
     491 +}
     492 + 
     493 +// contDir represents a direction in which we want to change container focus.
     494 +type contDir int
     495 + 
     496 +// String implements fmt.Stringer()
     497 +func (cd contDir) String() string {
     498 + if n, ok := contDirNames[cd]; ok {
     499 + return n
     500 + }
     501 + return "contDirUnknown"
     502 +}
     503 + 
     504 +// contDirNames maps contDir values to human readable names.
     505 +var contDirNames = map[contDir]string{
     506 + contDirNext: "contDirNext",
     507 + contDirPrevious: "contDirPrevious",
     508 +}
     509 + 
     510 +const (
     511 + contDirUnknown contDir = iota
     512 + contDirNext
     513 + contDirPrevious
     514 +)
     515 + 
     516 +// contSize determines the size of the container used in the test.
     517 +type contSize int
     518 + 
     519 +const (
     520 + contSize3 contSize = iota
     521 + contSize5
     522 +)
     523 + 
     524 +func TestFocusTrackerNextAndPrevious(t *testing.T) {
     525 + ft, err := faketerm.New(image.Point{10, 10})
     526 + if err != nil {
     527 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     528 + }
     529 + 
     530 + const (
     531 + keyNext keyboard.Key = keyboard.KeyTab
     532 + keyPrevious keyboard.Key = '~'
     533 + )
     534 + 
     535 + tests := []struct {
     536 + desc string
     537 + contSize contSize
     538 + container func(ft *faketerm.Terminal) (*Container, error)
     539 + events []*terminalapi.Keyboard
     540 + wantFocused contLoc
     541 + wantProcessed int
     542 + }{
     543 + {
     544 + desc: "initially the root is focused by default",
     545 + container: func(ft *faketerm.Terminal) (*Container, error) {
     546 + return New(
     547 + ft,
     548 + SplitVertical(
     549 + Left(),
     550 + Right(),
     551 + ),
     552 + KeyFocusNext(keyNext),
     553 + )
     554 + },
     555 + wantFocused: contLocA,
     556 + },
     557 + {
     558 + desc: "focus root explicitly",
     559 + container: func(ft *faketerm.Terminal) (*Container, error) {
     560 + return New(
     561 + ft,
     562 + Focused(),
     563 + SplitVertical(
     564 + Left(),
     565 + Right(),
     566 + ),
     567 + KeyFocusNext(keyNext),
     568 + )
     569 + },
     570 + wantFocused: contLocA,
     571 + },
     572 + {
     573 + desc: "focus can be set to a container other than root",
     574 + container: func(ft *faketerm.Terminal) (*Container, error) {
     575 + return New(
     576 + ft,
     577 + SplitVertical(
     578 + Left(Focused()),
     579 + Right(),
     580 + ),
     581 + KeyFocusNext(keyNext),
     582 + )
     583 + },
     584 + wantFocused: contLocB,
     585 + },
     586 + {
     587 + desc: "option Focused used on multiple containers, the last one takes effect",
     588 + container: func(ft *faketerm.Terminal) (*Container, error) {
     589 + return New(
     590 + ft,
     591 + SplitVertical(
     592 + Left(Focused()),
     593 + Right(Focused()),
     594 + ),
     595 + KeyFocusNext(keyNext),
     596 + )
     597 + },
     598 + wantFocused: contLocC,
     599 + },
     600 + {
     601 + desc: "keyNext does nothing when only root exists",
     602 + container: func(ft *faketerm.Terminal) (*Container, error) {
     603 + return New(
     604 + ft,
     605 + KeyFocusNext(keyNext),
     606 + )
     607 + },
     608 + events: []*terminalapi.Keyboard{
     609 + {Key: keyNext},
     610 + },
     611 + wantFocused: contLocA,
     612 + wantProcessed: 1,
     613 + },
     614 + {
     615 + desc: "keyNext focuses the first container",
     616 + container: func(ft *faketerm.Terminal) (*Container, error) {
     617 + return New(
     618 + ft,
     619 + SplitVertical(
     620 + Left(),
     621 + Right(),
     622 + ),
     623 + KeyFocusNext(keyNext),
     624 + )
     625 + },
     626 + events: []*terminalapi.Keyboard{
     627 + {Key: keyNext},
     628 + },
     629 + wantFocused: contLocB,
     630 + wantProcessed: 1,
     631 + },
     632 + {
     633 + desc: "two keyNext presses focuses the second container",
     634 + container: func(ft *faketerm.Terminal) (*Container, error) {
     635 + return New(
     636 + ft,
     637 + SplitVertical(
     638 + Left(),
     639 + Right(),
     640 + ),
     641 + KeyFocusNext(keyNext),
     642 + )
     643 + },
     644 + events: []*terminalapi.Keyboard{
     645 + {Key: keyNext},
     646 + {Key: keyNext},
     647 + },
     648 + wantFocused: contLocC,
     649 + wantProcessed: 2,
     650 + },
     651 + {
     652 + desc: "three keyNext presses focuses the first container again",
     653 + container: func(ft *faketerm.Terminal) (*Container, error) {
     654 + return New(
     655 + ft,
     656 + SplitVertical(
     657 + Left(),
     658 + Right(),
     659 + ),
     660 + KeyFocusNext(keyNext),
     661 + )
     662 + },
     663 + events: []*terminalapi.Keyboard{
     664 + {Key: keyNext},
     665 + {Key: keyNext},
     666 + {Key: keyNext},
     667 + },
     668 + wantFocused: contLocB,
     669 + wantProcessed: 3,
     670 + },
     671 + {
     672 + desc: "four keyNext presses focuses the second container again",
     673 + container: func(ft *faketerm.Terminal) (*Container, error) {
     674 + return New(
     675 + ft,
     676 + SplitVertical(
     677 + Left(),
     678 + Right(),
     679 + ),
     680 + KeyFocusNext(keyNext),
     681 + )
     682 + },
     683 + events: []*terminalapi.Keyboard{
     684 + {Key: keyNext},
     685 + {Key: keyNext},
     686 + {Key: keyNext},
     687 + {Key: keyNext},
     688 + },
     689 + wantFocused: contLocC,
     690 + wantProcessed: 4,
     691 + },
     692 + {
     693 + desc: "five keyNext presses focuses the first container again",
     694 + container: func(ft *faketerm.Terminal) (*Container, error) {
     695 + return New(
     696 + ft,
     697 + SplitVertical(
     698 + Left(),
     699 + Right(),
     700 + ),
     701 + KeyFocusNext(keyNext),
     702 + )
     703 + },
     704 + events: []*terminalapi.Keyboard{
     705 + {Key: keyNext},
     706 + {Key: keyNext},
     707 + {Key: keyNext},
     708 + {Key: keyNext},
     709 + {Key: keyNext},
     710 + },
     711 + wantFocused: contLocB,
     712 + wantProcessed: 5,
     713 + },
     714 + {
     715 + desc: "keyPrevious does nothing when only root exists",
     716 + container: func(ft *faketerm.Terminal) (*Container, error) {
     717 + return New(
     718 + ft,
     719 + KeyFocusPrevious(keyPrevious),
     720 + )
     721 + },
     722 + events: []*terminalapi.Keyboard{
     723 + {Key: keyPrevious},
     724 + },
     725 + wantFocused: contLocA,
     726 + wantProcessed: 1,
     727 + },
     728 + {
     729 + desc: "keyPrevious focuses the last container",
     730 + container: func(ft *faketerm.Terminal) (*Container, error) {
     731 + return New(
     732 + ft,
     733 + SplitVertical(
     734 + Left(),
     735 + Right(),
     736 + ),
     737 + KeyFocusPrevious(keyPrevious),
     738 + )
     739 + },
     740 + events: []*terminalapi.Keyboard{
     741 + {Key: keyPrevious},
     742 + },
     743 + wantFocused: contLocC,
     744 + wantProcessed: 1,
     745 + },
     746 + {
     747 + desc: "two keyPrevious presses focuses the first container",
     748 + container: func(ft *faketerm.Terminal) (*Container, error) {
     749 + return New(
     750 + ft,
     751 + SplitVertical(
     752 + Left(),
     753 + Right(),
     754 + ),
     755 + KeyFocusPrevious(keyPrevious),
     756 + )
     757 + },
     758 + events: []*terminalapi.Keyboard{
     759 + {Key: keyPrevious},
     760 + {Key: keyPrevious},
     761 + },
     762 + wantFocused: contLocB,
     763 + wantProcessed: 2,
     764 + },
     765 + {
     766 + desc: "three keyPrevious presses focuses the second container again",
     767 + container: func(ft *faketerm.Terminal) (*Container, error) {
     768 + return New(
     769 + ft,
     770 + SplitVertical(
     771 + Left(),
     772 + Right(),
     773 + ),
     774 + KeyFocusPrevious(keyPrevious),
     775 + )
     776 + },
     777 + events: []*terminalapi.Keyboard{
     778 + {Key: keyPrevious},
     779 + {Key: keyPrevious},
     780 + {Key: keyPrevious},
     781 + },
     782 + wantFocused: contLocC,
     783 + wantProcessed: 3,
     784 + },
     785 + {
     786 + desc: "four keyPrevious presses focuses the first container again",
     787 + container: func(ft *faketerm.Terminal) (*Container, error) {
     788 + return New(
     789 + ft,
     790 + SplitVertical(
     791 + Left(),
     792 + Right(),
     793 + ),
     794 + KeyFocusPrevious(keyPrevious),
     795 + )
     796 + },
     797 + events: []*terminalapi.Keyboard{
     798 + {Key: keyPrevious},
     799 + {Key: keyPrevious},
     800 + {Key: keyPrevious},
     801 + {Key: keyPrevious},
     802 + },
     803 + wantFocused: contLocB,
     804 + wantProcessed: 4,
     805 + },
     806 + {
     807 + desc: "five keyPrevious presses focuses the second container again",
     808 + container: func(ft *faketerm.Terminal) (*Container, error) {
     809 + return New(
     810 + ft,
     811 + SplitVertical(
     812 + Left(),
     813 + Right(),
     814 + ),
     815 + KeyFocusPrevious(keyPrevious),
     816 + )
     817 + },
     818 + events: []*terminalapi.Keyboard{
     819 + {Key: keyPrevious},
     820 + {Key: keyPrevious},
     821 + {Key: keyPrevious},
     822 + {Key: keyPrevious},
     823 + {Key: keyPrevious},
     824 + },
     825 + wantFocused: contLocC,
     826 + wantProcessed: 5,
     827 + },
     828 + {
     829 + desc: "first container requests to be skipped on key based focus changes, using next",
     830 + container: func(ft *faketerm.Terminal) (*Container, error) {
     831 + return New(
     832 + ft,
     833 + SplitVertical(
     834 + Left(
     835 + KeyFocusSkip(),
     836 + ),
     837 + Right(),
     838 + ),
     839 + KeyFocusNext(keyNext),
     840 + )
     841 + },
     842 + events: []*terminalapi.Keyboard{
     843 + {Key: keyNext},
     844 + },
     845 + wantFocused: contLocC,
     846 + wantProcessed: 1,
     847 + },
     848 + {
     849 + desc: "last container requests to be skipped on key based focus changes, using next",
     850 + container: func(ft *faketerm.Terminal) (*Container, error) {
     851 + return New(
     852 + ft,
     853 + SplitVertical(
     854 + Left(),
     855 + Right(
     856 + KeyFocusSkip(),
     857 + ),
     858 + ),
     859 + KeyFocusNext(keyNext),
     860 + )
     861 + },
     862 + events: []*terminalapi.Keyboard{
     863 + {Key: keyNext},
     864 + {Key: keyNext},
     865 + },
     866 + wantFocused: contLocB,
     867 + wantProcessed: 2,
     868 + },
     869 + {
     870 + desc: "all containers request to be skipped on key based focus changes, using next",
     871 + container: func(ft *faketerm.Terminal) (*Container, error) {
     872 + return New(
     873 + ft,
     874 + SplitVertical(
     875 + Left(
     876 + KeyFocusSkip(),
     877 + ),
     878 + Right(
     879 + KeyFocusSkip(),
     880 + ),
     881 + ),
     882 + KeyFocusNext(keyNext),
     883 + )
     884 + },
     885 + events: []*terminalapi.Keyboard{
     886 + {Key: keyNext},
     887 + },
     888 + wantFocused: contLocA,
     889 + wantProcessed: 1,
     890 + },
     891 + {
     892 + desc: "first container requests to be skipped on key based focus changes, using previous",
     893 + container: func(ft *faketerm.Terminal) (*Container, error) {
     894 + return New(
     895 + ft,
     896 + SplitVertical(
     897 + Left(
     898 + KeyFocusSkip(),
     899 + ),
     900 + Right(),
     901 + ),
     902 + KeyFocusPrevious(keyPrevious),
     903 + )
     904 + },
     905 + events: []*terminalapi.Keyboard{
     906 + {Key: keyPrevious},
     907 + {Key: keyPrevious},
     908 + },
     909 + wantFocused: contLocC,
     910 + wantProcessed: 2,
     911 + },
     912 + {
     913 + desc: "last container requests to be skipped on key based focus changes, using previous",
     914 + container: func(ft *faketerm.Terminal) (*Container, error) {
     915 + return New(
     916 + ft,
     917 + SplitVertical(
     918 + Left(),
     919 + Right(
     920 + KeyFocusSkip(),
     921 + ),
     922 + ),
     923 + KeyFocusPrevious(keyPrevious),
     924 + )
     925 + },
     926 + events: []*terminalapi.Keyboard{
     927 + {Key: keyPrevious},
     928 + },
     929 + wantFocused: contLocB,
     930 + wantProcessed: 1,
     931 + },
     932 + {
     933 + desc: "all containers request to be skipped on key based focus changes, using previous",
     934 + container: func(ft *faketerm.Terminal) (*Container, error) {
     935 + return New(
     936 + ft,
     937 + SplitVertical(
     938 + Left(
     939 + KeyFocusSkip(),
     940 + ),
     941 + Right(
     942 + KeyFocusSkip(),
     943 + ),
     944 + ),
     945 + KeyFocusPrevious(keyPrevious),
     946 + )
     947 + },
     948 + events: []*terminalapi.Keyboard{
     949 + {Key: keyPrevious},
     950 + },
     951 + wantFocused: contLocA,
     952 + wantProcessed: 1,
     953 + },
     954 + {
     955 + desc: "containers don't belong to focus group by default",
     956 + container: func(ft *faketerm.Terminal) (*Container, error) {
     957 + return New(
     958 + ft,
     959 + SplitVertical(
     960 + Left(),
     961 + Right(),
     962 + ),
     963 + KeyFocusGroupsNext('n', 0),
     964 + )
     965 + },
     966 + events: []*terminalapi.Keyboard{
     967 + {Key: 'n'},
     968 + },
     969 + wantFocused: contLocA,
     970 + wantProcessed: 1,
     971 + },
     972 + {
     973 + desc: "moves to the next container in focus group, pressing KeysFocusGroupNext once focuses the first container",
     974 + container: func(ft *faketerm.Terminal) (*Container, error) {
     975 + return New(
     976 + ft,
     977 + KeyFocusGroups(1),
     978 + SplitVertical(
     979 + Left(
     980 + KeyFocusGroups(1),
     981 + ),
     982 + Right(
     983 + KeyFocusGroups(1),
     984 + ),
     985 + ),
     986 + KeyFocusGroupsNext('n', 1),
     987 + )
     988 + },
     989 + events: []*terminalapi.Keyboard{
     990 + {Key: 'n'},
     991 + },
     992 + wantFocused: contLocB,
     993 + wantProcessed: 1,
     994 + },
     995 + {
     996 + desc: "moves to the next container in focus group, pressing KeysFocusGroupNext twice focuses the second container",
     997 + container: func(ft *faketerm.Terminal) (*Container, error) {
     998 + return New(
     999 + ft,
     1000 + KeyFocusGroups(1),
     1001 + SplitVertical(
     1002 + Left(
     1003 + KeyFocusGroups(1),
     1004 + ),
     1005 + Right(
     1006 + KeyFocusGroups(1),
     1007 + ),
     1008 + ),
     1009 + KeyFocusGroupsNext('n', 1),
     1010 + )
     1011 + },
     1012 + events: []*terminalapi.Keyboard{
     1013 + {Key: 'n'},
     1014 + {Key: 'n'},
     1015 + },
     1016 + wantFocused: contLocC,
     1017 + wantProcessed: 2,
     1018 + },
     1019 + {
     1020 + desc: "moves to the next container in focus group, pressing KeysFocusGroupNext three times focuses the first container again",
     1021 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1022 + return New(
     1023 + ft,
     1024 + KeyFocusGroups(2),
     1025 + SplitVertical(
     1026 + Left(
     1027 + KeyFocusGroups(2),
     1028 + ),
     1029 + Right(
     1030 + KeyFocusGroups(2),
     1031 + ),
     1032 + ),
     1033 + KeyFocusGroupsNext('n', 2),
     1034 + )
     1035 + },
     1036 + events: []*terminalapi.Keyboard{
     1037 + {Key: 'n'},
     1038 + {Key: 'n'},
     1039 + {Key: 'n'},
     1040 + },
     1041 + wantFocused: contLocB,
     1042 + wantProcessed: 3,
     1043 + },
     1044 + {
     1045 + desc: "moves to the previous container in focus group, pressing KeysFocusGroupPrevious once focuses the second container",
     1046 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1047 + return New(
     1048 + ft,
     1049 + KeyFocusGroups(1),
     1050 + SplitVertical(
     1051 + Left(
     1052 + KeyFocusGroups(1),
     1053 + ),
     1054 + Right(
     1055 + KeyFocusGroups(1),
     1056 + ),
     1057 + ),
     1058 + KeyFocusGroupsPrevious('p', 1),
     1059 + )
     1060 + },
     1061 + events: []*terminalapi.Keyboard{
     1062 + {Key: 'p'},
     1063 + },
     1064 + wantFocused: contLocC,
     1065 + wantProcessed: 1,
     1066 + },
     1067 + {
     1068 + desc: "moves to the previous container in focus group, pressing KeysFocusGroupPrevious twice focuses the first container",
     1069 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1070 + return New(
     1071 + ft,
     1072 + KeyFocusGroups(1),
     1073 + SplitVertical(
     1074 + Left(
     1075 + KeyFocusGroups(1),
     1076 + ),
     1077 + Right(
     1078 + KeyFocusGroups(1),
     1079 + ),
     1080 + ),
     1081 + KeyFocusGroupsPrevious('p', 1),
     1082 + )
     1083 + },
     1084 + events: []*terminalapi.Keyboard{
     1085 + {Key: 'p'},
     1086 + {Key: 'p'},
     1087 + },
     1088 + wantFocused: contLocB,
     1089 + wantProcessed: 2,
     1090 + },
     1091 + {
     1092 + desc: "moves to the previous container in focus group, pressing KeysFocusGroupPrevious three times focuses the second container again",
     1093 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1094 + return New(
     1095 + ft,
     1096 + KeyFocusGroups(1),
     1097 + SplitVertical(
     1098 + Left(
     1099 + KeyFocusGroups(1),
     1100 + ),
     1101 + Right(
     1102 + KeyFocusGroups(1),
     1103 + ),
     1104 + ),
     1105 + KeyFocusGroupsPrevious('p', 1),
     1106 + )
     1107 + },
     1108 + events: []*terminalapi.Keyboard{
     1109 + {Key: 'p'},
     1110 + {Key: 'p'},
     1111 + {Key: 'p'},
     1112 + },
     1113 + wantFocused: contLocC,
     1114 + wantProcessed: 3,
     1115 + },
     1116 + {
     1117 + desc: "configuring container with KeyFocusSkip has no effect on a closed focus group",
     1118 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1119 + return New(
     1120 + ft,
     1121 + KeyFocusGroups(1),
     1122 + SplitVertical(
     1123 + Left(
     1124 + KeyFocusSkip(),
     1125 + KeyFocusGroups(1),
     1126 + ),
     1127 + Right(
     1128 + KeyFocusSkip(),
     1129 + KeyFocusGroups(1),
     1130 + ),
     1131 + ),
     1132 + KeyFocusGroupsNext('n', 1),
     1133 + )
     1134 + },
     1135 + events: []*terminalapi.Keyboard{
     1136 + {Key: 'n'},
     1137 + },
     1138 + wantFocused: contLocB,
     1139 + wantProcessed: 1,
     1140 + },
     1141 + {
     1142 + desc: "a focus group can have multiple keys configured for next",
     1143 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1144 + return New(
     1145 + ft,
     1146 + KeyFocusGroups(1),
     1147 + SplitVertical(
     1148 + Left(
     1149 + KeyFocusSkip(),
     1150 + KeyFocusGroups(1),
     1151 + ),
     1152 + Right(
     1153 + KeyFocusSkip(),
     1154 + KeyFocusGroups(1),
     1155 + ),
     1156 + ),
     1157 + KeyFocusGroupsNext('n', 1),
     1158 + KeyFocusGroupsNext(keyboard.KeyArrowRight, 1),
     1159 + )
     1160 + },
     1161 + events: []*terminalapi.Keyboard{
     1162 + {Key: 'n'},
     1163 + {Key: keyboard.KeyArrowRight},
     1164 + },
     1165 + wantFocused: contLocC,
     1166 + wantProcessed: 2,
     1167 + },
     1168 + {
     1169 + desc: "a focus group can have multiple keys configured for previous",
     1170 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1171 + return New(
     1172 + ft,
     1173 + KeyFocusGroups(1),
     1174 + SplitVertical(
     1175 + Left(
     1176 + KeyFocusGroups(1),
     1177 + ),
     1178 + Right(
     1179 + KeyFocusGroups(1),
     1180 + ),
     1181 + ),
     1182 + KeyFocusGroupsPrevious('n', 1),
     1183 + KeyFocusGroupsPrevious(keyboard.KeyArrowRight, 1),
     1184 + )
     1185 + },
     1186 + events: []*terminalapi.Keyboard{
     1187 + {Key: 'n'},
     1188 + {Key: keyboard.KeyArrowRight},
     1189 + },
     1190 + wantFocused: contLocB,
     1191 + wantProcessed: 2,
     1192 + },
     1193 + {
     1194 + desc: "a container can be in multiple focus groups, rotates within group while on next",
     1195 + contSize: contSize5,
     1196 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1197 + return New( // contLocA
     1198 + ft,
     1199 + KeyFocusGroups(1),
     1200 + SplitVertical(
     1201 + Left( // contLocB
     1202 + KeyFocusGroups(1),
     1203 + SplitVertical(
     1204 + Left( // contLocD
     1205 + KeyFocusGroups(1),
     1206 + ),
     1207 + Right( // contLocE
     1208 + KeyFocusGroups(1, 2),
     1209 + ),
     1210 + ),
     1211 + ),
     1212 + Right( // contLocC
     1213 + KeyFocusGroups(1, 2),
     1214 + ),
     1215 + ),
     1216 + KeyFocusGroupsNext('n', 1),
     1217 + KeyFocusGroupsNext(keyboard.KeyArrowRight, 2),
     1218 + )
     1219 + },
     1220 + events: []*terminalapi.Keyboard{
     1221 + {Key: 'n'}, // focuses contLocD
     1222 + {Key: 'n'}, // focuses contLocE
     1223 + {Key: keyboard.KeyArrowRight}, // focuses contLocC
     1224 + {Key: keyboard.KeyArrowRight}, // rotates focus to contLocE
     1225 + },
     1226 + wantFocused: contLocE,
     1227 + wantProcessed: 4,
     1228 + },
     1229 + {
     1230 + desc: "a container can be in multiple focus groups, rotates within group while on previous",
     1231 + contSize: contSize5,
     1232 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1233 + return New( // contLocA
     1234 + ft,
     1235 + KeyFocusGroups(1),
     1236 + SplitVertical(
     1237 + Left( // contLocB
     1238 + KeyFocusGroups(1),
     1239 + SplitVertical(
     1240 + Left( // contLocD
     1241 + KeyFocusGroups(1),
     1242 + ),
     1243 + Right( // contLocE
     1244 + KeyFocusGroups(1, 2),
     1245 + ),
     1246 + ),
     1247 + ),
     1248 + Right( // contLocC
     1249 + KeyFocusGroups(1, 2),
     1250 + ),
     1251 + ),
     1252 + KeyFocusGroupsPrevious('n', 1),
     1253 + KeyFocusGroupsPrevious(keyboard.KeyArrowLeft, 2),
    451 1254   )
     1255 + },
     1256 + events: []*terminalapi.Keyboard{
     1257 + {Key: 'n'}, // focuses contLocC
     1258 + {Key: keyboard.KeyArrowLeft}, // focuses contLocE
     1259 + {Key: keyboard.KeyArrowLeft}, // rotates focus back to contLocC
     1260 + },
     1261 + wantFocused: contLocC,
     1262 + wantProcessed: 3,
     1263 + },
     1264 + {
     1265 + desc: "same key and group, first group takes priority, group 1 is first",
     1266 + contSize: contSize5,
     1267 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1268 + return New( // contLocA
     1269 + ft,
     1270 + KeyFocusGroups(1),
     1271 + SplitVertical(
     1272 + Left( // contLocB
     1273 + KeyFocusGroups(1),
     1274 + SplitVertical(
     1275 + Left( // contLocD
     1276 + KeyFocusGroups(1, 2),
     1277 + ),
     1278 + Right( // contLocE
     1279 + KeyFocusGroups(1),
     1280 + ),
     1281 + ),
     1282 + ),
     1283 + Right( // contLocC
     1284 + KeyFocusGroups(1, 2),
     1285 + ),
     1286 + ),
     1287 + KeyFocusGroupsNext('n', 1),
     1288 + KeyFocusGroupsNext('n', 2),
     1289 + )
     1290 + },
     1291 + events: []*terminalapi.Keyboard{
     1292 + {Key: 'n'}, // focuses contLocD
     1293 + {Key: 'n'}, // focuses contLocE
     1294 + },
     1295 + wantFocused: contLocE,
     1296 + wantProcessed: 2,
     1297 + },
     1298 + {
     1299 + desc: "same key and group, first group takes priority, group 2 is first",
     1300 + contSize: contSize5,
     1301 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1302 + return New( // contLocA
     1303 + ft,
     1304 + KeyFocusGroups(1),
     1305 + SplitVertical(
     1306 + Left( // contLocB
     1307 + KeyFocusGroups(1),
     1308 + SplitVertical(
     1309 + Left( // contLocD
     1310 + KeyFocusGroups(2, 1),
     1311 + ),
     1312 + Right( // contLocE
     1313 + KeyFocusGroups(1),
     1314 + ),
     1315 + ),
     1316 + ),
     1317 + Right( // contLocC
     1318 + KeyFocusGroups(1, 2),
     1319 + ),
     1320 + ),
     1321 + KeyFocusGroupsNext('n', 1),
     1322 + KeyFocusGroupsNext('n', 2),
     1323 + )
     1324 + },
     1325 + events: []*terminalapi.Keyboard{
     1326 + {Key: 'n'}, // focuses contLocD
     1327 + {Key: 'n'}, // focuses contLocC
     1328 + },
     1329 + wantFocused: contLocC,
     1330 + wantProcessed: 2,
     1331 + },
     1332 + {
     1333 + desc: "KeyFocusGroups called multiple times, same key and group, first group takes priority, group 2 is first",
     1334 + contSize: contSize5,
     1335 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1336 + return New( // contLocA
     1337 + ft,
     1338 + KeyFocusGroups(1),
     1339 + SplitVertical(
     1340 + Left( // contLocB
     1341 + KeyFocusGroups(1),
     1342 + SplitVertical(
     1343 + Left( // contLocD
     1344 + KeyFocusGroups(2),
     1345 + KeyFocusGroups(1),
     1346 + ),
     1347 + Right( // contLocE
     1348 + KeyFocusGroups(1),
     1349 + ),
     1350 + ),
     1351 + ),
     1352 + Right( // contLocC
     1353 + KeyFocusGroups(1, 2),
     1354 + ),
     1355 + ),
     1356 + KeyFocusGroupsNext('n', 1),
     1357 + KeyFocusGroupsNext('n', 2),
     1358 + )
     1359 + },
     1360 + events: []*terminalapi.Keyboard{
     1361 + {Key: 'n'}, // focuses contLocD
     1362 + {Key: 'n'}, // focuses contLocC
     1363 + },
     1364 + wantFocused: contLocC,
     1365 + wantProcessed: 2,
     1366 + },
     1367 + {
     1368 + desc: "global KeyFocusNext moves focus out of a focus group",
     1369 + contSize: contSize3,
     1370 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1371 + return New( // contLocA
     1372 + ft,
     1373 + SplitVertical(
     1374 + Left( // contLocB
     1375 + KeyFocusGroups(1),
     1376 + ),
     1377 + Right( // contLocC
     1378 + ),
     1379 + ),
     1380 + KeyFocusNext('n'),
     1381 + KeyFocusGroupsNext(keyboard.KeyArrowRight, 1),
     1382 + )
     1383 + },
     1384 + events: []*terminalapi.Keyboard{
     1385 + {Key: 'n'}, // focuses contLocB in focus group 1
     1386 + {Key: 'n'}, // focuses contLocC
     1387 + },
     1388 + wantFocused: contLocC,
     1389 + wantProcessed: 2,
     1390 + },
     1391 + {
     1392 + desc: "global KeyFocusPrevious moves focus out of a focus group",
     1393 + contSize: contSize3,
     1394 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1395 + return New( // contLocA
     1396 + ft,
     1397 + SplitVertical(
     1398 + Left( // contLocB
     1399 + ),
     1400 + Right( // contLocC
     1401 + KeyFocusGroups(1),
     1402 + ),
     1403 + ),
     1404 + KeyFocusPrevious('p'),
     1405 + KeyFocusGroupsPrevious(keyboard.KeyArrowLeft, 1),
     1406 + )
     1407 + },
     1408 + events: []*terminalapi.Keyboard{
     1409 + {Key: 'p'}, // focuses contLocC in focus group 1
     1410 + {Key: 'p'}, // focuses contLocB
     1411 + },
     1412 + wantFocused: contLocB,
     1413 + wantProcessed: 2,
     1414 + },
     1415 + {
     1416 + desc: "KeyFocusGroups with no arguments removes all groups",
     1417 + contSize: contSize5,
     1418 + container: func(ft *faketerm.Terminal) (*Container, error) {
     1419 + return New( // contLocA
     1420 + ft,
     1421 + KeyFocusGroups(1),
     1422 + SplitVertical(
     1423 + Left( // contLocB
     1424 + KeyFocusGroups(1),
     1425 + SplitVertical(
     1426 + Left( // contLocD
     1427 + KeyFocusGroups(1),
     1428 + ),
     1429 + Right( // contLocE
     1430 + KeyFocusGroups(1),
     1431 + KeyFocusGroups(),
     1432 + ),
     1433 + ),
     1434 + ),
     1435 + Right( // contLocC
     1436 + KeyFocusGroups(1),
     1437 + ),
     1438 + ),
     1439 + KeyFocusGroupsNext('n', 1),
     1440 + )
     1441 + },
     1442 + events: []*terminalapi.Keyboard{
     1443 + {Key: 'n'}, // focuses contLocD
     1444 + {Key: 'n'}, // focuses contLocC
     1445 + },
     1446 + wantFocused: contLocC,
     1447 + wantProcessed: 2,
     1448 + },
     1449 + }
     1450 + 
     1451 + for _, tc := range tests {
     1452 + t.Run(tc.desc, func(t *testing.T) {
     1453 + root, err := tc.container(ft)
     1454 + if err != nil {
     1455 + t.Fatalf("tc.container => unexpected error: %v", err)
    452 1456   }
     1457 + 
     1458 + eds := event.NewDistributionSystem()
     1459 + root.Subscribe(eds)
     1460 + for _, ev := range tc.events {
     1461 + eds.Event(ev)
     1462 + }
     1463 + if err := testevent.WaitFor(5*time.Second, func() error {
     1464 + if got, want := eds.Processed(), tc.wantProcessed; got != want {
     1465 + return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
     1466 + }
     1467 + return nil
     1468 + }); err != nil {
     1469 + t.Fatalf("testevent.WaitFor => %v", err)
     1470 + }
     1471 + 
     1472 + var wantFocused *Container
     1473 + switch wf := tc.wantFocused; wf {
     1474 + case contLocA:
     1475 + wantFocused = root
     1476 + case contLocB:
     1477 + wantFocused = root.first
     1478 + case contLocC:
     1479 + wantFocused = root.second
     1480 + case contLocD:
     1481 + wantFocused = root.first.first
     1482 + case contLocE:
     1483 + wantFocused = root.first.second
     1484 + default:
     1485 + t.Fatalf("unsupported wantFocused value => %v", wf)
     1486 + }
     1487 + 
     1488 + switch tc.contSize {
     1489 + case contSize3:
     1490 + t.Log(contLocIntro3())
     1491 + if !root.focusTracker.isActive(wantFocused) {
     1492 + t.Errorf("isActive(%v) => false, want true, status: %v:%v, %v:%v, %v:%v",
     1493 + tc.wantFocused,
     1494 + contLocA, root.focusTracker.isActive(root),
     1495 + contLocB, root.focusTracker.isActive(root.first),
     1496 + contLocC, root.focusTracker.isActive(root.second),
     1497 + )
     1498 + }
     1499 + 
     1500 + case contSize5:
     1501 + t.Log(contLocIntro5())
     1502 + if !root.focusTracker.isActive(wantFocused) {
     1503 + t.Errorf("isActive(%v) => false, want true, status: %v:%v, %v:%v, %v:%v, %v:%v, %v:%v",
     1504 + tc.wantFocused,
     1505 + contLocA, root.focusTracker.isActive(root),
     1506 + contLocB, root.focusTracker.isActive(root.first),
     1507 + contLocC, root.focusTracker.isActive(root.second),
     1508 + contLocD, root.focusTracker.isActive(root.first.first),
     1509 + contLocE, root.focusTracker.isActive(root.first.second),
     1510 + )
     1511 + }
     1512 + default:
     1513 + t.Errorf("unknown contSize: %v", tc.contSize)
     1514 + }
     1515 + 
    453 1516   })
    454 1517   }
    455 1518  }
    skipped 1 lines
  • ■ ■ ■ ■ ■ ■
    container/options.go
    skipped 22 lines
    23 23   
    24 24   "github.com/mum4k/termdash/align"
    25 25   "github.com/mum4k/termdash/cell"
     26 + "github.com/mum4k/termdash/keyboard"
    26 27   "github.com/mum4k/termdash/linestyle"
    27 28   "github.com/mum4k/termdash/private/area"
    28 29   "github.com/mum4k/termdash/widgetapi"
    skipped 66 lines
    95 96   // id is the identifier provided by the user.
    96 97   id string
    97 98   
     99 + // global are options that apply globally to all containers in the tree.
     100 + // There is only one instance of these options in the entire tree, if any
     101 + // of the child containers change their values, the new values apply to the
     102 + // entire container tree.
     103 + global *globalOptions
     104 + 
    98 105   // inherited are options that are inherited by child containers.
     106 + // After inheriting these options, the child container can set them to
     107 + // different values.
    99 108   inherited inherited
    100 109   
    101 110   // split identifies how is this container split.
    skipped 21 lines
    123 132   
    124 133   // margin is a space reserved on the outside of the container.
    125 134   margin margin
     135 + 
     136 + // keyFocusSkip asserts whether this container should be skipped when focus
     137 + // is being moved using either of KeyFocusNext or KeyFocusPrevious.
     138 + keyFocusSkip bool
     139 + // keyFocusGroups are the focus groups this container belongs to.
     140 + keyFocusGroups []FocusGroup
    126 141  }
    127 142   
    128 143  // margin stores the configured margin for the container.
    skipped 52 lines
    181 196   focusedColor cell.Color
    182 197  }
    183 198   
     199 +// focusGroups maps focus group numbers that have the same key assigned.
     200 +// The value is always true for all the keys.
     201 +type focusGroups map[FocusGroup]bool
     202 + 
     203 +// firstMatching examines the focus groups the container is assigned to and
     204 +// returns the first matching focus group that is also present in this
     205 +// instance. The bool return value indicates if match was found.
     206 +func (fg focusGroups) firstMatching(contGroups []FocusGroup) (bool, FocusGroup) {
     207 + for _, cg := range contGroups {
     208 + if fg[cg] {
     209 + return true, cg
     210 + }
     211 + }
     212 + return false, 0
     213 +}
     214 + 
     215 +// globalOptions are options that can only have a single value across the
     216 +// entire tree of containers.
     217 +// Regardless of which container they get set on, the new value will take
     218 +// effect on all the containers in the tree.
     219 +type globalOptions struct {
     220 + // keyFocusNext when set is the key that moves the focus to the next container.
     221 + keyFocusNext *keyboard.Key
     222 + // keyFocusPrevious when set is the key that moves the focus to the previous container.
     223 + keyFocusPrevious *keyboard.Key
     224 + // keysFocusGroupNext maps keyboard keys that move to the next container
     225 + // within a focus group to the focus groups they should work on in the
     226 + // order they were configured.
     227 + keyFocusGroupsNext map[keyboard.Key]focusGroups
     228 + // keysFocusGroupPrevious maps keyboard keys that move to the previous
     229 + // container within a focus group to the focus groups they should work on
     230 + // in the order they were configured.
     231 + keyFocusGroupsPrevious map[keyboard.Key]focusGroups
     232 +}
     233 + 
    184 234  // newOptions returns a new options instance with the default values.
    185 235  // Parent are the inherited options from the parent container or nil if these
    186 236  // options are for a container with no parent (the root).
    187 237  func newOptions(parent *options) *options {
    188 238   opts := &options{
     239 + global: &globalOptions{
     240 + keyFocusGroupsNext: map[keyboard.Key]focusGroups{},
     241 + keyFocusGroupsPrevious: map[keyboard.Key]focusGroups{},
     242 + },
    189 243   inherited: inherited{
    190 244   focusedColor: cell.ColorYellow,
    191 245   },
    skipped 3 lines
    195 249   splitFixed: DefaultSplitFixed,
    196 250   }
    197 251   if parent != nil {
     252 + opts.global = parent.global
    198 253   opts.inherited = parent.inherited
    199 254   }
    200 255   return opts
    skipped 615 lines
    816 871   })
    817 872  }
    818 873   
     874 +// KeyFocusNext configures a key that moves the keyboard focus to the next
     875 +// container when pressed.
     876 +//
     877 +// Containers are organized in a binary tree, when the focus moves to the next
     878 +// container, it targets the next leaf container in a DFS (Depth-first search) traversal.
     879 +// Non-leaf containers are skipped. If the currently focused container is the
     880 +// last container, the focus moves back to the first container.
     881 +//
     882 +// This option is global and applies to all created containers.
     883 +// If neither of (KeyFocusNext, KeyFocusPrevious) is specified, the keyboard
     884 +// focus can only be changed by using the mouse.
     885 +func KeyFocusNext(key keyboard.Key) Option {
     886 + return option(func(c *Container) error {
     887 + c.opts.global.keyFocusNext = &key
     888 + return nil
     889 + })
     890 +}
     891 + 
     892 +// KeyFocusPrevious configures a key that moves the keyboard focus to the
     893 +// previous container when pressed.
     894 +//
     895 +// Containers are organized in a binary tree, when the focus moves to the previous
     896 +// container, it targets the previous leaf container in a DFS (Depth-first search) traversal.
     897 +// Non-leaf containers are skipped. If the currently focused container is the
     898 +// first container, the focus moves back to the last container.
     899 +//
     900 +// This option is global and applies to all created containers.
     901 +// If neither of (KeyFocusNext, KeyFocusPrevious) is specified, the keyboard
     902 +// focus can only be changed by using the mouse.
     903 +func KeyFocusPrevious(key keyboard.Key) Option {
     904 + return option(func(c *Container) error {
     905 + c.opts.global.keyFocusPrevious = &key
     906 + return nil
     907 + })
     908 +}
     909 + 
     910 +// KeyFocusSkip indicates that this container should never receive the keyboard
     911 +// focus when KeyFocusNext or KeyFocusPrevious is pressed.
     912 +//
     913 +// A container configured like this would still receive the keyboard focus when
     914 +// directly clicked on with a mouse or when via KeysFocusGroupNext or
     915 +// KeysFocusGroupPrevious.
     916 +func KeyFocusSkip() Option {
     917 + return option(func(c *Container) error {
     918 + c.opts.keyFocusSkip = true
     919 + return nil
     920 + })
     921 +}
     922 + 
     923 +// FocusGroup represents a group of containers that can have the keyboard focus
     924 +// moved between them sharing the same keyboard key.
     925 +type FocusGroup int
     926 + 
     927 +// KeyFocusGroups assigns this container to focus groups with the specified
     928 +// numbers.
     929 +//
     930 +// See either of (KeysFocusGroupNext, KeysFocusGroupPrevious) for a description
     931 +// of focus groups.
     932 +//
     933 +// If both the pressed key and the currently focused container are configured
     934 +// to be in multiple matching focus groups, focus will follow the first
     935 +// focus group defined on the container, i.e. the order of the supplied groups
     936 +// matters.
     937 +//
     938 +// If not specified, the container doesn't belong to any focus groups.
     939 +// If called with zero groups, the container will be removed from all focus
     940 +// groups.
     941 +func KeyFocusGroups(groups ...FocusGroup) Option {
     942 + return option(func(c *Container) error {
     943 + if len(groups) == 0 {
     944 + c.opts.keyFocusGroups = nil
     945 + }
     946 + for _, g := range groups {
     947 + if min := FocusGroup(0); g < min {
     948 + return fmt.Errorf("invalid KeyFocusGroups %d, must be 0 <= group", g)
     949 + }
     950 + c.opts.keyFocusGroups = append(c.opts.keyFocusGroups, g)
     951 + }
     952 + return nil
     953 + })
     954 +}
     955 + 
     956 +// KeyFocusGroupsNext configures a key that moves the keyboard focus to the
     957 +// next container within the specified focus groups.
     958 +//
     959 +// Containers are assigned to focus groups using the KeyFocusGroup option.
     960 +// The group parameter indicates which groups is the key attached to. This
     961 +// option can be specified multiple times to define multiple keys for the same
     962 +// focus groups.
     963 +//
     964 +// A key configured using KeyFocusGroupsNext only moves focus if the container
     965 +// that is currently focused is part of the same focus group as one of the
     966 +// group specified in this option. The keyboard focus only gets moved to the
     967 +// next container in the same focus group, other containers are ignored.
     968 +//
     969 +// The order in which the containers in the group are visited is the same as
     970 +// with the KeyFocusNext option.
     971 +//
     972 +// This option is global and applies to all created containers.
     973 +// Pressing either of (KeyFocusNext, KeyFocusPrevious) still moves the focus to
     974 +// any container regardless of its focus group.
     975 +func KeyFocusGroupsNext(key keyboard.Key, groups ...FocusGroup) Option {
     976 + return option(func(c *Container) error {
     977 + for _, g := range groups {
     978 + if min := FocusGroup(0); g < min {
     979 + return fmt.Errorf("invalid group %d in KeyFocusGroupsNext for key %q, must be 0 <= group", g, key)
     980 + }
     981 + if g, ok := c.opts.global.keyFocusGroupsPrevious[key]; ok {
     982 + return fmt.Errorf("key %q is already assigned as a KeyFocusGroupsPrevious for focus groups %v", key, g)
     983 + }
     984 + 
     985 + fg, ok := c.opts.global.keyFocusGroupsNext[key]
     986 + if !ok {
     987 + fg = focusGroups{}
     988 + c.opts.global.keyFocusGroupsNext[key] = fg
     989 + }
     990 + fg[g] = true
     991 + }
     992 + return nil
     993 + })
     994 +}
     995 + 
     996 +// KeyFocusGroupsPrevious configures a key that moves the keyboard focus to the
     997 +// previous container within the specified focus groups.
     998 +//
     999 +// Containers are assigned to focus groups using the KeyFocusGroup option.
     1000 +// The group parameter indicates which groups is the key attached to. This
     1001 +// option can be specified multiple times to define multiple keys for the same
     1002 +// focus groups.
     1003 +//
     1004 +// A key configured using KeyFocusGroupsPrevious only moves focus if the
     1005 +// container that is currently focused is part of the same focus group as one
     1006 +// of the group specified in this option. The keyboard focus only gets moved to
     1007 +// the previous container in the same focus group, other containers are
     1008 +// ignored.
     1009 +//
     1010 +// The order in which the containers in the group are visited is the same as
     1011 +// with the KeyFocusPrevious option.
     1012 +//
     1013 +// This option is global and applies to all created containers.
     1014 +// Pressing either of (KeyFocusNext, KeyFocusPrevious) still moves the focus to
     1015 +// any container regardless of its focus group.
     1016 +func KeyFocusGroupsPrevious(key keyboard.Key, groups ...FocusGroup) Option {
     1017 + return option(func(c *Container) error {
     1018 + for _, g := range groups {
     1019 + if min := FocusGroup(0); g < min {
     1020 + return fmt.Errorf("invalid group %d in KeyFocusGroupsNext for key %q, must be 0 <= group", g, key)
     1021 + }
     1022 + if g, ok := c.opts.global.keyFocusGroupsNext[key]; ok {
     1023 + return fmt.Errorf("key %q is already assigned as a KeyFocusGroupsNext for focus groups %v", key, g)
     1024 + }
     1025 + 
     1026 + fg, ok := c.opts.global.keyFocusGroupsPrevious[key]
     1027 + if !ok {
     1028 + fg = focusGroups{}
     1029 + c.opts.global.keyFocusGroupsPrevious[key] = fg
     1030 + }
     1031 + fg[g] = true
     1032 + }
     1033 + return nil
     1034 + })
     1035 +}
     1036 + 
     1037 +// Focused moves the keyboard focus to this container.
     1038 +// If not specified, termdash will start with the root container focused.
     1039 +// If specified on multiple containers, the last container with this option
     1040 +// will be focused.
     1041 +func Focused() Option {
     1042 + return option(func(c *Container) error {
     1043 + c.focusTracker.setActive(c)
     1044 + return nil
     1045 + })
     1046 +}
     1047 + 
  • doc/images/formdemo.gif
  • ■ ■ ■ ■ ■ ■
    private/canvas/canvas.go
    skipped 194 lines
    195 195  }
    196 196   
    197 197  // Apply applies the canvas to the corresponding area of the terminal.
    198  -// Guarantees to stay within limits of the area the canvas was created with.
    199 198  func (c *Canvas) Apply(t terminalapi.Terminal) error {
    200  - termArea, err := area.FromSize(t.Size())
    201  - if err != nil {
    202  - return err
    203  - }
    204  - 
    205  - bufArea, err := area.FromSize(c.buffer.Size())
    206  - if err != nil {
    207  - return err
    208  - }
    209  - 
    210  - if !bufArea.In(termArea) {
    211  - return fmt.Errorf("the canvas area %+v doesn't fit onto the terminal %+v", bufArea, termArea)
    212  - }
     199 + // Note - the size of the terminal might have changed since we started
     200 + // drawing, since terminal windows are inherently racy (the user can resize
     201 + // them at any time).
     202 + //
     203 + // This is ok, since the underlying terminal layer will just ignore cells
     204 + // that are out of bounds and termdash will redraw again once it receives
     205 + // the resize event. Regression for #281.
    213 206   
    214 207   // The image.Point{0, 0} of this canvas isn't always exactly at
    215 208   // image.Point{0, 0} on the terminal.
    skipped 33 lines
  • ■ ■ ■ ■ ■ ■
    private/faketerm/diff.go
    skipped 99 lines
    100 100   for col := 0; col < size.X; col++ {
    101 101   got := got.BackBuffer()[col][row].Rune
    102 102   want := want.BackBuffer()[col][row].Rune
     103 + if got == want {
     104 + continue
     105 + }
    103 106   b.WriteString(fmt.Sprintf(" cell(%v, %v) => got '%c' (rune %d), want '%c' (rune %d)\n", col, row, got, got, want, want))
    104 107   }
    105 108   }
    skipped 4 lines
  • ■ ■ ■ ■ ■ ■
    private/fakewidget/fakewidget.go
    skipped 42 lines
    43 43  // MinimumSize is the minimum size required to draw this widget.
    44 44  var MinimumSize = image.Point{24, 5}
    45 45   
     46 +// Event is an event that should be delivered to the fake widget.
     47 +type Event struct {
     48 + // Ev is the event to deliver.
     49 + Ev terminalapi.Event
     50 + // Meta is metadata about the event.
     51 + Meta *widgetapi.EventMeta
     52 +}
     53 + 
    46 54  // Mirror is a fake widget. The fake widget draws a border around its assigned
    47 55  // canvas and writes the size of its assigned canvas on the first line of the
    48  -// canvas. It writes the last received keyboard event onto the second line. It
    49  -// writes the last received mouse event onto the third line. If a non-empty
    50  -// string is provided via the Text() method, that text will be written right
    51  -// after the canvas size on the first line. If the widget's container is
    52  -// focused it writes "focus" onto the fourth line.
     56 +// canvas.
     57 +//
     58 +// It writes the last received keyboard event onto the second line. It
     59 +// writes the last received mouse event onto the third line. If the widget was
     60 +// focused at the time of the event, the event will be prepended with a "F:".
     61 +//
     62 +// If a non-empty string is provided via the Text() method, that text will be
     63 +// written right after the canvas size on the first line. If the widget's
     64 +// container is focused it writes "focus" onto the fourth line.
    53 65  //
    54 66  // The widget requests the same options that are provided to the constructor.
    55 67  // If the options or canvas size don't allow for the lines mentioned above, the
    skipped 70 lines
    126 138  // Sending the keyboard.KeyEsc causes this widget to forget the last keyboard
    127 139  // event and return an error instead.
    128 140  // Keyboard implements widgetapi.Widget.Keyboard.
    129  -func (mi *Mirror) Keyboard(k *terminalapi.Keyboard) error {
     141 +func (mi *Mirror) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    130 142   mi.mu.Lock()
    131 143   defer mi.mu.Unlock()
    132 144   
    skipped 1 lines
    134 146   mi.lines[keyboardLine] = ""
    135 147   return fmt.Errorf("fakewidget received keyboard event: %v", k)
    136 148   }
    137  - mi.lines[keyboardLine] = k.Key.String()
     149 + if meta.Focused {
     150 + mi.lines[keyboardLine] = fmt.Sprintf("F:%s", k.Key.String())
     151 + } else {
     152 + mi.lines[keyboardLine] = k.Key.String()
     153 + }
    138 154   return nil
    139 155  }
    140 156   
    skipped 2 lines
    143 159  // Sending the mouse.ButtonRight causes this widget to forget the last mouse
    144 160  // event and return an error instead.
    145 161  // Mouse implements widgetapi.Widget.Mouse.
    146  -func (mi *Mirror) Mouse(m *terminalapi.Mouse) error {
     162 +func (mi *Mirror) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    147 163   mi.mu.Lock()
    148 164   defer mi.mu.Unlock()
    149 165   
    skipped 1 lines
    151 167   mi.lines[mouseLine] = ""
    152 168   return fmt.Errorf("fakewidget received mouse event: %v", m)
    153 169   }
    154  - mi.lines[mouseLine] = fmt.Sprintf("%v%v", m.Position, m.Button)
     170 + if meta.Focused {
     171 + mi.lines[mouseLine] = fmt.Sprintf("F:%v%v", m.Position, m.Button)
     172 + } else {
     173 + mi.lines[mouseLine] = fmt.Sprintf("%v%v", m.Position, m.Button)
     174 + }
    155 175   return nil
    156 176  }
    157 177   
    skipped 4 lines
    162 182   
    163 183  // Draw draws the content that would be expected after placing the Mirror
    164 184  // widget onto the provided canvas and forwarding the given events.
    165  -func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...terminalapi.Event) error {
     185 +func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...*Event) error {
    166 186   mirror := New(opts)
    167 187   return DrawWithMirror(mirror, t, cvs, meta, events...)
    168 188  }
    169 189   
    170 190  // MustDraw is like Draw, but panics on all errors.
    171  -func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...terminalapi.Event) {
     191 +func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...*Event) {
    172 192   if err := Draw(t, cvs, meta, opts, events...); err != nil {
    173 193   panic(fmt.Sprintf("Draw => %v", err))
    174 194   }
    175 195  }
    176 196   
    177 197  // DrawWithMirror is like Draw, but uses the provided Mirror instead of creating one.
    178  -func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...terminalapi.Event) error {
     198 +func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...*Event) error {
    179 199   for _, ev := range events {
    180  - switch e := ev.(type) {
     200 + switch e := ev.Ev.(type) {
    181 201   case *terminalapi.Mouse:
    182 202   if mirror.opts.WantMouse == widgetapi.MouseScopeNone {
    183 203   continue
    184 204   }
    185  - if err := mirror.Mouse(e); err != nil {
     205 + if err := mirror.Mouse(e, ev.Meta); err != nil {
    186 206   return err
    187 207   }
    188 208   case *terminalapi.Keyboard:
    189 209   if mirror.opts.WantKeyboard == widgetapi.KeyScopeNone {
    190 210   continue
    191 211   }
    192  - if err := mirror.Keyboard(e); err != nil {
     212 + if err := mirror.Keyboard(e, ev.Meta); err != nil {
    193 213   return err
    194 214   }
    195 215   default:
    skipped 8 lines
    204 224  }
    205 225   
    206 226  // MustDrawWithMirror is like DrawWithMirror, but panics on all errors.
    207  -func MustDrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...terminalapi.Event) {
     227 +func MustDrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...*Event) {
    208 228   if err := DrawWithMirror(mirror, t, cvs, meta, events...); err != nil {
    209 229   panic(fmt.Sprintf("DrawWithMirror => %v", err))
    210 230   }
    skipped 2 lines
  • ■ ■ ■ ■ ■
    private/fakewidget/fakewidget_test.go
    skipped 31 lines
    32 32  // keyEvents are keyboard events to send to the widget.
    33 33  type keyEvents struct {
    34 34   k *terminalapi.Keyboard
     35 + meta *widgetapi.EventMeta
    35 36   wantErr bool
    36 37  }
    37 38   
    38 39  // mouseEvents are mouse events to send to the widget.
    39 40  type mouseEvents struct {
    40 41   m *terminalapi.Mouse
     42 + meta *widgetapi.EventMeta
    41 43   wantErr bool
    42 44  }
    43 45   
    skipped 87 lines
    131 133   desc: "draws the last keyboard event",
    132 134   keyEvents: []keyEvents{
    133 135   {
    134  - k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     136 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     137 + meta: &widgetapi.EventMeta{},
    135 138   },
    136 139   {
    137  - k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
     140 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
     141 + meta: &widgetapi.EventMeta{},
    138 142   },
    139 143   },
    140 144   cvs: testcanvas.MustNew(image.Rect(0, 0, 8, 4)),
    skipped 9 lines
    150 154   },
    151 155   },
    152 156   {
     157 + desc: "draws the last keyboard event when focused",
     158 + keyEvents: []keyEvents{
     159 + {
     160 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     161 + meta: &widgetapi.EventMeta{Focused: true},
     162 + },
     163 + {
     164 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
     165 + meta: &widgetapi.EventMeta{Focused: true},
     166 + },
     167 + },
     168 + cvs: testcanvas.MustNew(image.Rect(0, 0, 10, 4)),
     169 + meta: &widgetapi.Meta{},
     170 + want: func(size image.Point) *faketerm.Terminal {
     171 + ft := faketerm.MustNew(size)
     172 + cvs := testcanvas.MustNew(ft.Area())
     173 + testdraw.MustBorder(cvs, cvs.Area())
     174 + testdraw.MustText(cvs, "(10,4)", image.Point{1, 1})
     175 + testdraw.MustText(cvs, "F:KeyEnd", image.Point{1, 2})
     176 + testcanvas.MustApply(cvs, ft)
     177 + return ft
     178 + },
     179 + },
     180 + {
    153 181   desc: "skips the keyboard event if there isn't a line for it",
    154 182   keyEvents: []keyEvents{
    155 183   {
    156  - k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
     184 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
     185 + meta: &widgetapi.EventMeta{},
    157 186   },
    158 187   },
    159 188   cvs: testcanvas.MustNew(image.Rect(0, 0, 8, 3)),
    skipped 11 lines
    171 200   desc: "draws the last mouse event",
    172 201   mouseEvents: []mouseEvents{
    173 202   {
    174  - m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     203 + m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     204 + meta: &widgetapi.EventMeta{},
    175 205   },
    176 206   {
    177 207   m: &terminalapi.Mouse{
    178 208   Position: image.Point{1, 2},
    179  - Button: mouse.ButtonMiddle},
     209 + Button: mouse.ButtonMiddle,
     210 + },
     211 + meta: &widgetapi.EventMeta{},
    180 212   },
    181 213   },
    182 214   cvs: testcanvas.MustNew(image.Rect(0, 0, 19, 5)),
    skipped 9 lines
    192 224   },
    193 225   },
    194 226   {
     227 + desc: "draws the last mouse event when focused",
     228 + mouseEvents: []mouseEvents{
     229 + {
     230 + m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     231 + meta: &widgetapi.EventMeta{},
     232 + },
     233 + {
     234 + m: &terminalapi.Mouse{
     235 + Position: image.Point{1, 2},
     236 + Button: mouse.ButtonMiddle,
     237 + },
     238 + meta: &widgetapi.EventMeta{Focused: true},
     239 + },
     240 + },
     241 + cvs: testcanvas.MustNew(image.Rect(0, 0, 21, 5)),
     242 + meta: &widgetapi.Meta{},
     243 + want: func(size image.Point) *faketerm.Terminal {
     244 + ft := faketerm.MustNew(size)
     245 + cvs := testcanvas.MustNew(ft.Area())
     246 + testdraw.MustBorder(cvs, cvs.Area())
     247 + testdraw.MustText(cvs, "(21,5)", image.Point{1, 1})
     248 + testdraw.MustText(cvs, "F:(1,2)ButtonMiddle", image.Point{1, 3})
     249 + testcanvas.MustApply(cvs, ft)
     250 + return ft
     251 + },
     252 + },
     253 + {
    195 254   desc: "skips the mouse event if there isn't a line for it",
    196 255   mouseEvents: []mouseEvents{
    197 256   {
    198  - m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     257 + m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     258 + meta: &widgetapi.EventMeta{},
    199 259   },
    200 260   },
    201 261   cvs: testcanvas.MustNew(image.Rect(0, 0, 13, 4)),
    skipped 11 lines
    213 273   desc: "draws both keyboard and mouse events",
    214 274   keyEvents: []keyEvents{
    215 275   {
    216  - k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     276 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     277 + meta: &widgetapi.EventMeta{},
    217 278   },
    218 279   },
    219 280   mouseEvents: []mouseEvents{
    220 281   {
    221  - m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     282 + m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     283 + meta: &widgetapi.EventMeta{},
    222 284   },
    223 285   },
    224 286   cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
    skipped 13 lines
    238 300   desc: "KeyEsc and ButtonRight reset the last event and return error",
    239 301   keyEvents: []keyEvents{
    240 302   {
    241  - k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     303 + k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     304 + meta: &widgetapi.EventMeta{},
    242 305   },
    243 306   {
    244 307   k: &terminalapi.Keyboard{Key: keyboard.KeyEsc},
     308 + meta: &widgetapi.EventMeta{},
    245 309   wantErr: true,
    246 310   },
    247 311   },
    248 312   mouseEvents: []mouseEvents{
    249 313   {
    250  - m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     314 + m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     315 + meta: &widgetapi.EventMeta{},
    251 316   },
    252 317   {
    253 318   m: &terminalapi.Mouse{Button: mouse.ButtonRight},
     319 + meta: &widgetapi.EventMeta{},
    254 320   wantErr: true,
    255 321   },
    256 322   },
    skipped 19 lines
    276 342   }
    277 343   
    278 344   for _, keyEv := range tc.keyEvents {
    279  - err := w.Keyboard(keyEv.k)
     345 + err := w.Keyboard(keyEv.k, keyEv.meta)
    280 346   if (err != nil) != keyEv.wantErr {
    281 347   t.Errorf("Keyboard => got error:%v, wantErr: %v", err, keyEv.wantErr)
    282 348   }
    283 349   }
    284 350   
    285 351   for _, mouseEv := range tc.mouseEvents {
    286  - err := w.Mouse(mouseEv.m)
     352 + err := w.Mouse(mouseEv.m, mouseEv.meta)
    287 353   if (err != nil) != mouseEv.wantErr {
    288 354   t.Errorf("Mouse => got error:%v, wantErr: %v", err, mouseEv.wantErr)
    289 355   }
    skipped 35 lines
    325 391   opts widgetapi.Options
    326 392   cvs *canvas.Canvas
    327 393   meta *widgetapi.Meta
    328  - events []terminalapi.Event
     394 + events []*Event
    329 395   want func(size image.Point) *faketerm.Terminal
    330 396   wantErr bool
    331 397   }{
    skipped 27 lines
    359 425   },
    360 426   cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
    361 427   meta: &widgetapi.Meta{},
    362  - events: []terminalapi.Event{
    363  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    364  - &terminalapi.Mouse{Button: mouse.ButtonLeft},
     428 + events: []*Event{
     429 + {
     430 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     431 + Meta: &widgetapi.EventMeta{},
     432 + },
     433 + {
     434 + Ev: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     435 + Meta: &widgetapi.EventMeta{},
     436 + },
    365 437   },
    366 438   want: func(size image.Point) *faketerm.Terminal {
    367 439   ft := faketerm.MustNew(size)
    skipped 2 lines
    370 442   testdraw.MustText(cvs, "(17,5)", image.Point{1, 1})
    371 443   testdraw.MustText(cvs, "KeyEnter", image.Point{1, 2})
    372 444   testdraw.MustText(cvs, "(0,0)ButtonLeft", image.Point{1, 3})
     445 + testcanvas.MustApply(cvs, ft)
     446 + return ft
     447 + },
     448 + },
     449 + {
     450 + desc: "draws both keyboard and mouse events while focused",
     451 + opts: widgetapi.Options{
     452 + WantKeyboard: widgetapi.KeyScopeFocused,
     453 + WantMouse: widgetapi.MouseScopeWidget,
     454 + },
     455 + cvs: testcanvas.MustNew(image.Rect(0, 0, 19, 5)),
     456 + meta: &widgetapi.Meta{},
     457 + events: []*Event{
     458 + {
     459 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     460 + Meta: &widgetapi.EventMeta{Focused: true},
     461 + },
     462 + {
     463 + Ev: &terminalapi.Mouse{Button: mouse.ButtonLeft},
     464 + Meta: &widgetapi.EventMeta{Focused: true},
     465 + },
     466 + },
     467 + want: func(size image.Point) *faketerm.Terminal {
     468 + ft := faketerm.MustNew(size)
     469 + cvs := testcanvas.MustNew(ft.Area())
     470 + testdraw.MustBorder(cvs, cvs.Area())
     471 + testdraw.MustText(cvs, "(19,5)", image.Point{1, 1})
     472 + testdraw.MustText(cvs, "F:KeyEnter", image.Point{1, 2})
     473 + testdraw.MustText(cvs, "F:(0,0)ButtonLeft", image.Point{1, 3})
    373 474   testcanvas.MustApply(cvs, ft)
    374 475   return ft
    375 476   },
    skipped 18 lines
  • ■ ■ ■ ■ ■
    termdash_test.go
    skipped 252 lines
    253 253   widgetapi.Options{
    254 254   WantMouse: widgetapi.MouseScopeWidget,
    255 255   },
    256  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     256 + &fakewidget.Event{
     257 + Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     258 + Meta: &widgetapi.EventMeta{Focused: true},
     259 + },
    257 260   )
    258 261   return ft
    259 262   },
    skipped 21 lines
    281 284   WantKeyboard: widgetapi.KeyScopeFocused,
    282 285   WantMouse: widgetapi.MouseScopeWidget,
    283 286   },
    284  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     287 + &fakewidget.Event{
     288 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     289 + Meta: &widgetapi.EventMeta{Focused: true},
     290 + },
    285 291   )
    286 292   return ft
    287 293   },
    skipped 59 lines
    347 353   widgetapi.Options{
    348 354   WantKeyboard: widgetapi.KeyScopeFocused,
    349 355   },
    350  - &terminalapi.Keyboard{Key: keyboard.KeyF1},
     356 + &fakewidget.Event{
     357 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyF1},
     358 + Meta: &widgetapi.EventMeta{Focused: true},
     359 + },
    351 360   )
    352 361   return ft
    353 362   },
    skipped 28 lines
    382 391   widgetapi.Options{
    383 392   WantMouse: widgetapi.MouseScopeWidget,
    384 393   },
    385  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
     394 + &fakewidget.Event{
     395 + Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
     396 + Meta: &widgetapi.EventMeta{Focused: true},
     397 + },
    386 398   )
    387 399   return ft
    388 400   },
    skipped 102 lines
    491 503   WantKeyboard: widgetapi.KeyScopeFocused,
    492 504   WantMouse: widgetapi.MouseScopeWidget,
    493 505   },
    494  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     506 + &fakewidget.Event{
     507 + Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     508 + Meta: &widgetapi.EventMeta{Focused: true},
     509 + },
    495 510   )
    496 511   return ft
    497 512   
    skipped 167 lines
  • ■ ■ ■ ■ ■
    widgetapi/widgetapi.go
    skipped 129 lines
    130 130   // forwarded to the widget.
    131 131   WantKeyboard KeyScope
    132 132   
     133 + // ExclusiveKeyboardOnFocus allows a widget to request exclusive access to
     134 + // keyboard events when its container is focused. When set to true, no
     135 + // other widgets will receive any keyboard events that happen while the
     136 + // container of this widget is focused even if they registered for
     137 + // KeyScopeGlobal.
     138 + ExclusiveKeyboardOnFocus bool
     139 + 
    133 140   // WantMouse allows a widget to request mouse events and specify their
    134 141   // desired scope. If set to MouseScopeNone, no mouse events are forwarded
    135 142   // to the widget.
    skipped 9 lines
    145 152   Focused bool
    146 153  }
    147 154   
     155 +// EventMeta provides additional metadata about events to widgets.
     156 +type EventMeta struct {
     157 + // Focused asserts whether the widget's container is focused at the time of the event.
     158 + // If the event itself changes focus, the value here reflects the state of
     159 + // the focus after the change.
     160 + Focused bool
     161 +}
     162 + 
    148 163  // Widget is a single widget on the dashboard.
    149 164  // Implementations must be thread safe.
    150 165  type Widget interface {
    skipped 8 lines
    159 174   // The argument meta is guaranteed to be valid (i.e. non-nil).
    160 175   Draw(cvs *canvas.Canvas, meta *Meta) error
    161 176   
    162  - // Keyboard is called when the widget is focused on the dashboard and a key
    163  - // shortcut the widget registered for was pressed. Only called if the widget
    164  - // registered for keyboard events.
    165  - Keyboard(k *terminalapi.Keyboard) error
     177 + // Keyboard is called with every keyboard event whose scope the widget
     178 + // registered for.
     179 + //
     180 + // The argument meta is guaranteed to be valid (i.e. non-nil).
     181 + Keyboard(k *terminalapi.Keyboard, meta *EventMeta) error
    166 182   
    167  - // Mouse is called when the widget is focused on the dashboard and a mouse
    168  - // event happens on its canvas. Only called if the widget registered for mouse
    169  - // events.
    170  - Mouse(m *terminalapi.Mouse) error
     183 + // Mouse is called with every mouse event whose scope the widget registered
     184 + // for.
     185 + //
     186 + // The argument meta is guaranteed to be valid (i.e. non-nil).
     187 + Mouse(m *terminalapi.Mouse, meta *EventMeta) error
    171 188   
    172 189   // Options returns registration options for the widget.
    173 190   // This is how the widget indicates to the infrastructure whether it is
    skipped 13 lines
  • ■ ■ ■ ■ ■ ■
    widgets/barchart/barchart.go
    skipped 279 lines
    280 280  }
    281 281   
    282 282  // Keyboard input isn't supported on the BarChart widget.
    283  -func (*BarChart) Keyboard(k *terminalapi.Keyboard) error {
     283 +func (*BarChart) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    284 284   return errors.New("the BarChart widget doesn't support keyboard events")
    285 285  }
    286 286   
    287 287  // Mouse input isn't supported on the BarChart widget.
    288  -func (*BarChart) Mouse(m *terminalapi.Mouse) error {
     288 +func (*BarChart) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    289 289   return errors.New("the BarChart widget doesn't support mouse events")
    290 290  }
    291 291   
    skipped 76 lines
  • ■ ■ ■ ■ ■
    widgets/button/button.go
    skipped 17 lines
    18 18   
    19 19  import (
    20 20   "errors"
     21 + "fmt"
    21 22   "image"
     23 + "strings"
    22 24   "sync"
    23 25   "time"
    24 26   
    skipped 1 lines
    26 28   "github.com/mum4k/termdash/cell"
    27 29   "github.com/mum4k/termdash/mouse"
    28 30   "github.com/mum4k/termdash/private/alignfor"
     31 + "github.com/mum4k/termdash/private/attrrange"
    29 32   "github.com/mum4k/termdash/private/button"
    30 33   "github.com/mum4k/termdash/private/canvas"
    31 34   "github.com/mum4k/termdash/private/draw"
    skipped 13 lines
    45 48  // termdash.ErrorHandler.
    46 49  type CallbackFn func() error
    47 50   
     51 +// TextChunk is a part of or the full text displayed in the button.
     52 +type TextChunk struct {
     53 + text string
     54 + tOpts *textOptions
     55 +}
     56 + 
     57 +// NewChunk creates a new text chunk. Each chunk of text can have its own cell options.
     58 +func NewChunk(text string, tOpts ...TextOption) *TextChunk {
     59 + return &TextChunk{
     60 + text: text,
     61 + tOpts: newTextOptions(tOpts...),
     62 + }
     63 +}
     64 + 
    48 65  // Button can be pressed using a mouse click or a configured keyboard key.
    49 66  //
    50 67  // Upon each press, the button invokes a callback provided by the user.
    skipped 1 lines
    52 69  // Implements widgetapi.Widget. This object is thread-safe.
    53 70  type Button struct {
    54 71   // text in the text label displayed in the button.
    55  - text string
     72 + text strings.Builder
     73 + 
     74 + // givenTOpts are text options given for the button's of text.
     75 + givenTOpts []*textOptions
     76 + // tOptsTracker tracks the positions in a text to which the givenTOpts apply.
     77 + tOptsTracker *attrrange.Tracker
    56 78   
    57 79   // mouseFSM tracks left mouse clicks.
    58 80   mouseFSM *button.FSM
    skipped 18 lines
    77 99   
    78 100  // New returns a new Button that will display the provided text.
    79 101  // Each press of the button will invoke the callback function.
     102 +// The callback function can be nil in which case pressing the button is a
     103 +// no-op.
    80 104  func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
    81  - if cFn == nil {
    82  - return nil, errors.New("the CallbackFn argument cannot be nil")
     105 + return NewFromChunks([]*TextChunk{NewChunk(text)}, cFn, opts...)
     106 +}
     107 + 
     108 +// NewFromChunks is like New, but allows specifying write options for
     109 +// individual chunks of text displayed in the button.
     110 +func NewFromChunks(chunks []*TextChunk, cFn CallbackFn, opts ...Option) (*Button, error) {
     111 + if len(chunks) == 0 {
     112 + return nil, errors.New("at least one text chunk must be specified")
    83 113   }
    84 114   
    85  - opt := newOptions(text)
     115 + var (
     116 + text strings.Builder
     117 + givenTOpts []*textOptions
     118 + )
     119 + tOptsTracker := attrrange.NewTracker()
     120 + for i, tc := range chunks {
     121 + if tc.text == "" {
     122 + return nil, fmt.Errorf("text chunk[%d] is empty, all chunks must contains some text", i)
     123 + }
     124 + 
     125 + pos := text.Len()
     126 + givenTOpts = append(givenTOpts, tc.tOpts)
     127 + tOptsIdx := len(givenTOpts) - 1
     128 + if err := tOptsTracker.Add(pos, pos+len(tc.text), tOptsIdx); err != nil {
     129 + return nil, err
     130 + }
     131 + text.WriteString(tc.text)
     132 + }
     133 + 
     134 + opt := newOptions(text.String())
    86 135   for _, o := range opts {
    87 136   o.set(opt)
    88 137   }
    89 138   if err := opt.validate(); err != nil {
    90 139   return nil, err
    91 140   }
     141 + 
     142 + for _, tOpts := range givenTOpts {
     143 + tOpts.setDefaultFgColor(opt.textColor)
     144 + }
    92 145   return &Button{
    93  - text: text,
    94  - mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
    95  - callback: cFn,
    96  - opts: opt,
     146 + text: text,
     147 + givenTOpts: givenTOpts,
     148 + tOptsTracker: tOptsTracker,
     149 + mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
     150 + callback: cFn,
     151 + opts: opt,
    97 152   }, nil
     153 +}
     154 + 
     155 +// SetCallback replaces the callback function of the button with the one provided.
     156 +func (b *Button) SetCallback(cFn CallbackFn) {
     157 + b.mu.Lock()
     158 + defer b.mu.Unlock()
     159 + b.callback = cFn
    98 160  }
    99 161   
    100 162  // Vars to be replaced from tests.
    skipped 25 lines
    126 188   cvsAr := cvs.Area()
    127 189   b.mouseFSM.UpdateArea(cvsAr)
    128 190   
    129  - shadowAr := image.Rect(shadowWidth, shadowWidth, cvsAr.Dx(), cvsAr.Dy())
    130  - if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil {
     191 + sw := b.shadowWidth()
     192 + shadowAr := image.Rect(sw, sw, cvsAr.Dx(), cvsAr.Dy())
     193 + if !b.opts.disableShadow {
     194 + if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil {
     195 + return err
     196 + }
     197 + }
     198 + 
     199 + buttonAr := image.Rect(0, 0, cvsAr.Dx()-sw, cvsAr.Dy()-sw)
     200 + if b.state == button.Down && !b.opts.disableShadow {
     201 + buttonAr = shadowAr
     202 + }
     203 + 
     204 + var fillColor cell.Color
     205 + switch {
     206 + case b.state == button.Down && b.opts.pressedFillColor != nil:
     207 + fillColor = *b.opts.pressedFillColor
     208 + case meta.Focused && b.opts.focusedFillColor != nil:
     209 + fillColor = *b.opts.focusedFillColor
     210 + default:
     211 + fillColor = b.opts.fillColor
     212 + }
     213 + 
     214 + if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(fillColor)); err != nil {
    131 215   return err
    132 216   }
     217 + return b.drawText(cvs, meta, buttonAr)
     218 +}
    133 219   
    134  - var buttonAr image.Rectangle
    135  - if b.state == button.Up {
    136  - buttonAr = image.Rect(0, 0, cvsAr.Dx()-shadowWidth, cvsAr.Dy()-shadowWidth)
    137  - } else {
    138  - buttonAr = shadowAr
     220 +// drawText draws the text inside the button.
     221 +func (b *Button) drawText(cvs *canvas.Canvas, meta *widgetapi.Meta, buttonAr image.Rectangle) error {
     222 + pad := b.opts.textHorizontalPadding
     223 + textAr := image.Rect(buttonAr.Min.X+pad, buttonAr.Min.Y, buttonAr.Dx()-pad, buttonAr.Max.Y)
     224 + start, err := alignfor.Text(textAr, b.text.String(), align.HorizontalCenter, align.VerticalMiddle)
     225 + if err != nil {
     226 + return err
    139 227   }
    140 228   
    141  - if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil {
     229 + maxCells := buttonAr.Max.X - start.X
     230 + trimmed, err := draw.TrimText(b.text.String(), maxCells, draw.OverrunModeThreeDot)
     231 + if err != nil {
    142 232   return err
    143 233   }
    144 234   
    145  - textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y)
    146  - start, err := alignfor.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle)
     235 + optRange, err := b.tOptsTracker.ForPosition(0) // Text options for the current byte.
    147 236   if err != nil {
    148 237   return err
    149 238   }
    150  - return draw.Text(cvs, b.text, start,
    151  - draw.TextOverrunMode(draw.OverrunModeThreeDot),
    152  - draw.TextMaxX(buttonAr.Max.X),
    153  - draw.TextCellOpts(cell.FgColor(b.opts.textColor)),
    154  - )
     239 + 
     240 + cur := start
     241 + for i, r := range trimmed {
     242 + if i >= optRange.High { // Get the next write options.
     243 + or, err := b.tOptsTracker.ForPosition(i)
     244 + if err != nil {
     245 + return err
     246 + }
     247 + optRange = or
     248 + }
     249 + 
     250 + tOpts := b.givenTOpts[optRange.AttrIdx]
     251 + var cellOpts []cell.Option
     252 + switch {
     253 + case b.state == button.Down && len(tOpts.pressedCellOpts) > 0:
     254 + cellOpts = tOpts.pressedCellOpts
     255 + case meta.Focused && len(tOpts.focusedCellOpts) > 0:
     256 + cellOpts = tOpts.focusedCellOpts
     257 + default:
     258 + cellOpts = tOpts.cellOpts
     259 + }
     260 + cells, err := cvs.SetCell(cur, r, cellOpts...)
     261 + if err != nil {
     262 + return err
     263 + }
     264 + cur = image.Point{cur.X + cells, cur.Y}
     265 + }
     266 + return nil
    155 267  }
    156 268   
    157 269  // activated asserts whether the keyboard event activated the button.
    158  -func (b *Button) keyActivated(k *terminalapi.Keyboard) bool {
     270 +func (b *Button) keyActivated(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) bool {
    159 271   b.mu.Lock()
    160 272   defer b.mu.Unlock()
    161 273   
    162  - if k.Key == b.opts.key {
     274 + if b.opts.globalKeys[k.Key] || (b.opts.focusedKeys[k.Key] && meta.Focused) {
    163 275   b.state = button.Down
    164 276   now := time.Now().UTC()
    165 277   b.keyTriggerTime = &now
    skipped 6 lines
    172 284  // Key.
    173 285  //
    174 286  // Implements widgetapi.Widget.Keyboard.
    175  -func (b *Button) Keyboard(k *terminalapi.Keyboard) error {
    176  - if b.keyActivated(k) {
    177  - // Mutex must be released when calling the callback.
    178  - // Users might call container methods from the callback like the
    179  - // Container.Update, see #205.
    180  - return b.callback()
     287 +func (b *Button) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
     288 + if b.keyActivated(k, meta) {
     289 + if b.callback != nil {
     290 + // Mutex must be released when calling the callback.
     291 + // Users might call container methods from the callback like the
     292 + // Container.Update, see #205.
     293 + return b.callback()
     294 + }
    181 295   }
    182 296   return nil
    183 297  }
    skipped 14 lines
    198 312  // the release happen inside the button.
    199 313  //
    200 314  // Implements widgetapi.Widget.Mouse.
    201  -func (b *Button) Mouse(m *terminalapi.Mouse) error {
     315 +func (b *Button) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    202 316   if b.mouseActivated(m) {
    203  - // Mutex must be released when calling the callback.
    204  - // Users might call container methods from the callback like the
    205  - // Container.Update, see #205.
    206  - return b.callback()
     317 + if b.callback != nil {
     318 + // Mutex must be released when calling the callback.
     319 + // Users might call container methods from the callback like the
     320 + // Container.Update, see #205.
     321 + return b.callback()
     322 + }
    207 323   }
    208 324   return nil
    209 325  }
    210 326   
    211  -// shadowWidth is the width of the shadow under the button in cell.
    212  -const shadowWidth = 1
     327 +// shadowWidth returns the width of the shadow under the button or zero if the
     328 +// button shouldn't have any shadow.
     329 +func (b *Button) shadowWidth() int {
     330 + if b.opts.disableShadow {
     331 + return 0
     332 + }
     333 + return 1
     334 +}
    213 335   
    214 336  // Options implements widgetapi.Widget.Options.
    215 337  func (b *Button) Options() widgetapi.Options {
    216 338   // No need to lock, as the height and width get fixed when New is called.
    217 339   
    218  - width := b.opts.width + shadowWidth
    219  - height := b.opts.height + shadowWidth
     340 + width := b.opts.width + b.shadowWidth() + 2*b.opts.textHorizontalPadding
     341 + height := b.opts.height + b.shadowWidth()
     342 + 
     343 + var keyScope widgetapi.KeyScope
     344 + if len(b.opts.focusedKeys) > 0 || len(b.opts.globalKeys) > 0 {
     345 + keyScope = widgetapi.KeyScopeGlobal
     346 + } else {
     347 + keyScope = widgetapi.KeyScopeNone
     348 + }
    220 349   return widgetapi.Options{
    221 350   MinimumSize: image.Point{width, height},
    222 351   MaximumSize: image.Point{width, height},
    223  - WantKeyboard: b.opts.keyScope,
     352 + WantKeyboard: keyScope,
    224 353   WantMouse: widgetapi.MouseScopeGlobal,
    225 354   }
    226 355  }
    skipped 1 lines
  • ■ ■ ■ ■ ■ ■
    widgets/button/button_test.go
    skipped 44 lines
    45 45   // count is the number of times the callback was called.
    46 46   count int
    47 47   
     48 + // useSetCallback when set to true instructs the test to set the callback
     49 + // via button.SetCallback instead of button.New or button.NewFromChunks.
     50 + useSetCallback bool
     51 + 
    48 52   // mu protects the tracker.
    49 53   mu sync.Mutex
    50 54  }
    skipped 12 lines
    63 67   return nil
    64 68  }
    65 69   
     70 +// event represents a terminal event for tests.
     71 +type event struct {
     72 + ev terminalapi.Event
     73 + meta *widgetapi.EventMeta
     74 +}
     75 + 
    66 76  func TestButton(t *testing.T) {
    67 77   tests := []struct {
    68  - desc string
    69  - text string
     78 + desc string
     79 + 
     80 + // Only one of these must be specified.
     81 + text string // Calls New() as the constructor.
     82 + textChunks []*TextChunk // Calls NewFromChunks() as the constructor.
     83 + 
    70 84   callback *callbackTracker
    71 85   opts []Option
    72  - events []terminalapi.Event
     86 + events []*event
    73 87   canvas image.Rectangle
    74 88   meta *widgetapi.Meta
    75 89   
    skipped 8 lines
    84 98   wantCallbackErr bool
    85 99   }{
    86 100   {
    87  - desc: "New fails with nil callback",
     101 + desc: "New fails with negative keyUpDelay",
     102 + callback: &callbackTracker{},
     103 + opts: []Option{
     104 + KeyUpDelay(-1 * time.Second),
     105 + },
     106 + canvas: image.Rect(0, 0, 1, 1),
     107 + text: "hello",
     108 + meta: &widgetapi.Meta{Focused: false},
     109 + wantNewErr: true,
     110 + },
     111 + {
     112 + desc: "New fails with zero Height",
     113 + callback: &callbackTracker{},
     114 + opts: []Option{
     115 + Height(0),
     116 + },
     117 + canvas: image.Rect(0, 0, 1, 1),
     118 + text: "hello",
     119 + meta: &widgetapi.Meta{Focused: false},
     120 + wantNewErr: true,
     121 + },
     122 + {
     123 + desc: "New fails with zero Width",
     124 + callback: &callbackTracker{},
     125 + opts: []Option{
     126 + Width(0),
     127 + },
     128 + canvas: image.Rect(0, 0, 1, 1),
     129 + text: "hello",
     130 + meta: &widgetapi.Meta{Focused: false},
     131 + wantNewErr: true,
     132 + },
     133 + {
     134 + desc: "New fails with negative textHorizontalPadding",
     135 + callback: &callbackTracker{},
     136 + opts: []Option{
     137 + TextHorizontalPadding(-1),
     138 + },
     139 + canvas: image.Rect(0, 0, 1, 1),
     140 + text: "hello",
     141 + meta: &widgetapi.Meta{Focused: false},
     142 + wantNewErr: true,
     143 + },
     144 + {
     145 + desc: "New fails when duplicate Key and GlobalKey are specified",
     146 + callback: &callbackTracker{},
     147 + opts: []Option{
     148 + Key('a'),
     149 + GlobalKey('a'),
     150 + },
    88 151   canvas: image.Rect(0, 0, 1, 1),
     152 + text: "hello",
     153 + meta: &widgetapi.Meta{Focused: false},
    89 154   wantNewErr: true,
    90 155   },
    91 156   {
    92  - desc: "New fails with negative keyUpDelay",
     157 + desc: "New fails when duplicate Keys and GlobalKeys are specified",
     158 + callback: &callbackTracker{},
     159 + opts: []Option{
     160 + Keys('a'),
     161 + GlobalKeys('a'),
     162 + },
     163 + canvas: image.Rect(0, 0, 1, 1),
     164 + text: "hello",
     165 + meta: &widgetapi.Meta{Focused: false},
     166 + wantNewErr: true,
     167 + },
     168 + {
     169 + desc: "NewFromChunks fails with negative keyUpDelay",
     170 + textChunks: []*TextChunk{
     171 + NewChunk("text"),
     172 + },
    93 173   callback: &callbackTracker{},
    94 174   opts: []Option{
    95 175   KeyUpDelay(-1 * time.Second),
    96 176   },
    97 177   canvas: image.Rect(0, 0, 1, 1),
     178 + meta: &widgetapi.Meta{Focused: false},
    98 179   wantNewErr: true,
    99 180   },
    100 181   {
    101  - desc: "New fails with zero Height",
     182 + desc: "NewFromChunks fails with zero Height",
     183 + textChunks: []*TextChunk{
     184 + NewChunk("text"),
     185 + },
    102 186   callback: &callbackTracker{},
    103 187   opts: []Option{
    104 188   Height(0),
    105 189   },
    106 190   canvas: image.Rect(0, 0, 1, 1),
     191 + meta: &widgetapi.Meta{Focused: false},
    107 192   wantNewErr: true,
    108 193   },
    109 194   {
    110  - desc: "New fails with zero Width",
     195 + desc: "NewFromChunks fails with zero Width",
     196 + textChunks: []*TextChunk{
     197 + NewChunk("text"),
     198 + },
    111 199   callback: &callbackTracker{},
    112 200   opts: []Option{
    113 201   Width(0),
    114 202   },
    115 203   canvas: image.Rect(0, 0, 1, 1),
     204 + meta: &widgetapi.Meta{Focused: false},
     205 + wantNewErr: true,
     206 + },
     207 + {
     208 + desc: "NewFromChunks fails with negative textHorizontalPadding",
     209 + textChunks: []*TextChunk{
     210 + NewChunk("text"),
     211 + },
     212 + callback: &callbackTracker{},
     213 + opts: []Option{
     214 + TextHorizontalPadding(-1),
     215 + },
     216 + canvas: image.Rect(0, 0, 1, 1),
     217 + meta: &widgetapi.Meta{Focused: false},
     218 + wantNewErr: true,
     219 + },
     220 + {
     221 + desc: "NewFromChunks fails when duplicate Key and GlobalKey are specified",
     222 + callback: &callbackTracker{},
     223 + opts: []Option{
     224 + Key('a'),
     225 + GlobalKey('a'),
     226 + },
     227 + canvas: image.Rect(0, 0, 1, 1),
     228 + textChunks: []*TextChunk{
     229 + NewChunk("text"),
     230 + },
     231 + meta: &widgetapi.Meta{Focused: false},
     232 + wantNewErr: true,
     233 + },
     234 + {
     235 + desc: "NewFromChunks fails when duplicate Keys and GlobalKeys are specified",
     236 + callback: &callbackTracker{},
     237 + opts: []Option{
     238 + Keys('a'),
     239 + GlobalKeys('a'),
     240 + },
     241 + canvas: image.Rect(0, 0, 1, 1),
     242 + textChunks: []*TextChunk{
     243 + NewChunk("text"),
     244 + },
     245 + meta: &widgetapi.Meta{Focused: false},
     246 + wantNewErr: true,
     247 + },
     248 + {
     249 + desc: "NewFromChunks fails with zero chunks",
     250 + textChunks: []*TextChunk{},
     251 + callback: &callbackTracker{},
     252 + opts: []Option{
     253 + TextHorizontalPadding(-1),
     254 + },
     255 + canvas: image.Rect(0, 0, 1, 1),
     256 + meta: &widgetapi.Meta{Focused: false},
     257 + wantNewErr: true,
     258 + },
     259 + {
     260 + desc: "NewFromChunks fails with an empty chunk",
     261 + textChunks: []*TextChunk{
     262 + NewChunk(""),
     263 + },
     264 + callback: &callbackTracker{},
     265 + opts: []Option{
     266 + TextHorizontalPadding(-1),
     267 + },
     268 + canvas: image.Rect(0, 0, 1, 1),
     269 + meta: &widgetapi.Meta{Focused: false},
    116 270   wantNewErr: true,
    117 271   },
    118 272   {
    skipped 1 lines
    120 274   callback: &callbackTracker{},
    121 275   text: "hello",
    122 276   canvas: image.Rect(0, 0, 1, 1),
     277 + meta: &widgetapi.Meta{Focused: false},
    123 278   wantDrawErr: true,
    124 279   },
    125 280   {
    skipped 1 lines
    127 282   callback: &callbackTracker{},
    128 283   text: "hello",
    129 284   canvas: image.Rect(0, 0, 8, 4),
     285 + meta: &widgetapi.Meta{Focused: false},
    130 286   want: func(size image.Point) *faketerm.Terminal {
    131 287   ft := faketerm.MustNew(size)
    132 288   cvs := testcanvas.MustNew(ft.Area())
    skipped 17 lines
    150 306   wantCallback: &callbackTracker{},
    151 307   },
    152 308   {
     309 + desc: "draws button without a shadow in up state",
     310 + callback: &callbackTracker{},
     311 + opts: []Option{
     312 + DisableShadow(),
     313 + },
     314 + text: "hello",
     315 + canvas: image.Rect(0, 0, 8, 4),
     316 + meta: &widgetapi.Meta{Focused: false},
     317 + want: func(size image.Point) *faketerm.Terminal {
     318 + ft := faketerm.MustNew(size)
     319 + cvs := testcanvas.MustNew(ft.Area())
     320 + 
     321 + // Button.
     322 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
     323 + 
     324 + // Text.
     325 + testdraw.MustText(cvs, "hello", image.Point{1, 1},
     326 + draw.TextCellOpts(
     327 + cell.FgColor(cell.ColorBlack),
     328 + cell.BgColor(cell.ColorNumber(117))),
     329 + )
     330 + 
     331 + testcanvas.MustApply(cvs, ft)
     332 + return ft
     333 + },
     334 + wantCallback: &callbackTracker{},
     335 + },
     336 + {
    153 337   desc: "draws button in down state due to a mouse event",
    154 338   callback: &callbackTracker{},
    155 339   text: "hello",
    156 340   canvas: image.Rect(0, 0, 8, 4),
    157  - events: []terminalapi.Event{
    158  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     341 + meta: &widgetapi.Meta{Focused: false},
     342 + events: []*event{
     343 + {
     344 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     345 + meta: &widgetapi.EventMeta{},
     346 + },
    159 347   },
    160 348   want: func(size image.Point) *faketerm.Terminal {
    161 349   ft := faketerm.MustNew(size)
    skipped 15 lines
    177 365   wantCallback: &callbackTracker{},
    178 366   },
    179 367   {
    180  - desc: "mouse triggered the callback",
     368 + desc: "draws button in down state without a shadow",
     369 + callback: &callbackTracker{},
     370 + opts: []Option{
     371 + DisableShadow(),
     372 + },
     373 + text: "hello",
     374 + canvas: image.Rect(0, 0, 8, 4),
     375 + meta: &widgetapi.Meta{Focused: false},
     376 + events: []*event{
     377 + {
     378 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     379 + meta: &widgetapi.EventMeta{},
     380 + },
     381 + },
     382 + want: func(size image.Point) *faketerm.Terminal {
     383 + ft := faketerm.MustNew(size)
     384 + cvs := testcanvas.MustNew(ft.Area())
     385 + 
     386 + // Button.
     387 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
     388 + 
     389 + // Text.
     390 + testdraw.MustText(cvs, "hello", image.Point{1, 1},
     391 + draw.TextCellOpts(
     392 + cell.FgColor(cell.ColorBlack),
     393 + cell.BgColor(cell.ColorNumber(117))),
     394 + )
     395 + 
     396 + testcanvas.MustApply(cvs, ft)
     397 + return ft
     398 + },
     399 + wantCallback: &callbackTracker{},
     400 + },
     401 + {
     402 + desc: "mouse triggered a button with nil callback",
     403 + callback: nil,
     404 + text: "hello",
     405 + canvas: image.Rect(0, 0, 8, 4),
     406 + meta: &widgetapi.Meta{Focused: false},
     407 + events: []*event{
     408 + {
     409 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     410 + meta: &widgetapi.EventMeta{},
     411 + },
     412 + {
     413 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     414 + meta: &widgetapi.EventMeta{},
     415 + },
     416 + },
     417 + want: func(size image.Point) *faketerm.Terminal {
     418 + ft := faketerm.MustNew(size)
     419 + cvs := testcanvas.MustNew(ft.Area())
     420 + 
     421 + // Shadow.
     422 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240)))
     423 + 
     424 + // Button.
     425 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117)))
     426 + 
     427 + // Text.
     428 + testdraw.MustText(cvs, "hello", image.Point{1, 1},
     429 + draw.TextCellOpts(
     430 + cell.FgColor(cell.ColorBlack),
     431 + cell.BgColor(cell.ColorNumber(117))),
     432 + )
     433 + 
     434 + testcanvas.MustApply(cvs, ft)
     435 + return ft
     436 + },
     437 + },
     438 + {
     439 + desc: "mouse triggered a callback set via the constructor",
    181 440   callback: &callbackTracker{},
    182 441   text: "hello",
    183 442   canvas: image.Rect(0, 0, 8, 4),
    184  - events: []terminalapi.Event{
    185  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
    186  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     443 + meta: &widgetapi.Meta{Focused: false},
     444 + events: []*event{
     445 + {
     446 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     447 + meta: &widgetapi.EventMeta{},
     448 + },
     449 + {
     450 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     451 + meta: &widgetapi.EventMeta{},
     452 + },
    187 453   },
    188 454   want: func(size image.Point) *faketerm.Terminal {
    189 455   ft := faketerm.MustNew(size)
    skipped 21 lines
    211 477   },
    212 478   },
    213 479   {
     480 + desc: "mouse triggered a callback set via SetCallback",
     481 + callback: &callbackTracker{
     482 + useSetCallback: true,
     483 + },
     484 + text: "hello",
     485 + canvas: image.Rect(0, 0, 8, 4),
     486 + meta: &widgetapi.Meta{Focused: false},
     487 + events: []*event{
     488 + {
     489 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     490 + meta: &widgetapi.EventMeta{},
     491 + },
     492 + {
     493 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     494 + meta: &widgetapi.EventMeta{},
     495 + },
     496 + },
     497 + want: func(size image.Point) *faketerm.Terminal {
     498 + ft := faketerm.MustNew(size)
     499 + cvs := testcanvas.MustNew(ft.Area())
     500 + 
     501 + // Shadow.
     502 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240)))
     503 + 
     504 + // Button.
     505 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117)))
     506 + 
     507 + // Text.
     508 + testdraw.MustText(cvs, "hello", image.Point{1, 1},
     509 + draw.TextCellOpts(
     510 + cell.FgColor(cell.ColorBlack),
     511 + cell.BgColor(cell.ColorNumber(117))),
     512 + )
     513 + 
     514 + testcanvas.MustApply(cvs, ft)
     515 + return ft
     516 + },
     517 + wantCallback: &callbackTracker{
     518 + called: true,
     519 + count: 1,
     520 + useSetCallback: true,
     521 + },
     522 + },
     523 + {
    214 524   desc: "draws button in down state due to a keyboard event, callback triggered",
    215 525   callback: &callbackTracker{},
    216 526   text: "hello",
    skipped 1 lines
    218 528   Key(keyboard.KeyEnter),
    219 529   },
    220 530   canvas: image.Rect(0, 0, 8, 4),
    221  - events: []terminalapi.Event{
    222  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     531 + meta: &widgetapi.Meta{Focused: false},
     532 + events: []*event{
     533 + {
     534 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     535 + meta: &widgetapi.EventMeta{Focused: true},
     536 + },
     537 + },
     538 + want: func(size image.Point) *faketerm.Terminal {
     539 + ft := faketerm.MustNew(size)
     540 + cvs := testcanvas.MustNew(ft.Area())
     541 + 
     542 + // Button.
     543 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
     544 + 
     545 + // Text.
     546 + testdraw.MustText(cvs, "hello", image.Point{2, 2},
     547 + draw.TextCellOpts(
     548 + cell.FgColor(cell.ColorBlack),
     549 + cell.BgColor(cell.ColorNumber(117))),
     550 + )
     551 + 
     552 + testcanvas.MustApply(cvs, ft)
     553 + return ft
     554 + },
     555 + wantCallback: &callbackTracker{
     556 + called: true,
     557 + count: 1,
     558 + },
     559 + },
     560 + {
     561 + desc: "ignores keyboard event configured with Key when not focused",
     562 + callback: &callbackTracker{},
     563 + text: "hello",
     564 + opts: []Option{
     565 + Key(keyboard.KeyEnter),
     566 + },
     567 + canvas: image.Rect(0, 0, 8, 4),
     568 + meta: &widgetapi.Meta{Focused: false},
     569 + events: []*event{
     570 + {
     571 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     572 + meta: &widgetapi.EventMeta{Focused: false},
     573 + },
     574 + },
     575 + want: func(size image.Point) *faketerm.Terminal {
     576 + ft := faketerm.MustNew(size)
     577 + cvs := testcanvas.MustNew(ft.Area())
     578 + 
     579 + // Shadow.
     580 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240)))
     581 + 
     582 + // Button.
     583 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117)))
     584 + 
     585 + // Text.
     586 + testdraw.MustText(cvs, "hello", image.Point{1, 1},
     587 + draw.TextCellOpts(
     588 + cell.FgColor(cell.ColorBlack),
     589 + cell.BgColor(cell.ColorNumber(117))),
     590 + )
     591 + 
     592 + testcanvas.MustApply(cvs, ft)
     593 + return ft
     594 + },
     595 + wantCallback: &callbackTracker{
     596 + called: false,
     597 + count: 0,
     598 + },
     599 + },
     600 + 
     601 + {
     602 + desc: "draws button in down state due to a keyboard event when multiple keys are specified",
     603 + callback: &callbackTracker{},
     604 + text: "hello",
     605 + opts: []Option{
     606 + Keys(keyboard.KeyEnter, keyboard.KeyTab),
     607 + },
     608 + canvas: image.Rect(0, 0, 8, 4),
     609 + meta: &widgetapi.Meta{Focused: false},
     610 + events: []*event{
     611 + {
     612 + ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     613 + meta: &widgetapi.EventMeta{Focused: true},
     614 + },
     615 + },
     616 + want: func(size image.Point) *faketerm.Terminal {
     617 + ft := faketerm.MustNew(size)
     618 + cvs := testcanvas.MustNew(ft.Area())
     619 + 
     620 + // Button.
     621 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
     622 + 
     623 + // Text.
     624 + testdraw.MustText(cvs, "hello", image.Point{2, 2},
     625 + draw.TextCellOpts(
     626 + cell.FgColor(cell.ColorBlack),
     627 + cell.BgColor(cell.ColorNumber(117))),
     628 + )
     629 + 
     630 + testcanvas.MustApply(cvs, ft)
     631 + return ft
     632 + },
     633 + wantCallback: &callbackTracker{
     634 + called: true,
     635 + count: 1,
     636 + },
     637 + },
     638 + {
     639 + desc: "draws button in down state due to a keyboard event when single global key is specified",
     640 + callback: &callbackTracker{},
     641 + text: "hello",
     642 + opts: []Option{
     643 + GlobalKey(keyboard.KeyTab),
     644 + },
     645 + canvas: image.Rect(0, 0, 8, 4),
     646 + meta: &widgetapi.Meta{Focused: false},
     647 + events: []*event{
     648 + {
     649 + ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     650 + meta: &widgetapi.EventMeta{},
     651 + },
     652 + },
     653 + want: func(size image.Point) *faketerm.Terminal {
     654 + ft := faketerm.MustNew(size)
     655 + cvs := testcanvas.MustNew(ft.Area())
     656 + 
     657 + // Button.
     658 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
     659 + 
     660 + // Text.
     661 + testdraw.MustText(cvs, "hello", image.Point{2, 2},
     662 + draw.TextCellOpts(
     663 + cell.FgColor(cell.ColorBlack),
     664 + cell.BgColor(cell.ColorNumber(117))),
     665 + )
     666 + 
     667 + testcanvas.MustApply(cvs, ft)
     668 + return ft
     669 + },
     670 + wantCallback: &callbackTracker{
     671 + called: true,
     672 + count: 1,
     673 + },
     674 + },
     675 + {
     676 + desc: "draws button in down state due to a keyboard event when multiple global keys are specified",
     677 + callback: &callbackTracker{},
     678 + text: "hello",
     679 + opts: []Option{
     680 + GlobalKeys(keyboard.KeyEnter, keyboard.KeyTab),
     681 + },
     682 + canvas: image.Rect(0, 0, 8, 4),
     683 + meta: &widgetapi.Meta{Focused: false},
     684 + events: []*event{
     685 + {
     686 + ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
     687 + meta: &widgetapi.EventMeta{},
     688 + },
    223 689   },
    224 690   want: func(size image.Point) *faketerm.Terminal {
    225 691   ft := faketerm.MustNew(size)
    skipped 22 lines
    248 714   callback: &callbackTracker{},
    249 715   text: "hello",
    250 716   canvas: image.Rect(0, 0, 8, 4),
    251  - events: []terminalapi.Event{
    252  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     717 + meta: &widgetapi.Meta{Focused: false},
     718 + events: []*event{
     719 + {
     720 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     721 + meta: &widgetapi.EventMeta{},
     722 + },
    253 723   },
    254 724   want: func(size image.Point) *faketerm.Terminal {
    255 725   ft := faketerm.MustNew(size)
    skipped 18 lines
    274 744   wantCallback: &callbackTracker{},
    275 745   },
    276 746   {
     747 + desc: "keyboard event triggers a button with nil callback",
     748 + callback: nil,
     749 + text: "hello",
     750 + opts: []Option{
     751 + Key(keyboard.KeyEnter),
     752 + },
     753 + timeSince: func(time.Time) time.Duration {
     754 + return 200 * time.Millisecond
     755 + },
     756 + canvas: image.Rect(0, 0, 8, 4),
     757 + meta: &widgetapi.Meta{Focused: false},
     758 + events: []*event{
     759 + {
     760 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     761 + meta: &widgetapi.EventMeta{Focused: true},
     762 + },
     763 + },
     764 + want: func(size image.Point) *faketerm.Terminal {
     765 + ft := faketerm.MustNew(size)
     766 + cvs := testcanvas.MustNew(ft.Area())
     767 + 
     768 + // Button.
     769 + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
     770 + 
     771 + // Text.
     772 + testdraw.MustText(cvs, "hello", image.Point{2, 2},
     773 + draw.TextCellOpts(
     774 + cell.FgColor(cell.ColorBlack),
     775 + cell.BgColor(cell.ColorNumber(117))),
     776 + )
     777 + 
     778 + testcanvas.MustApply(cvs, ft)
     779 + return ft
     780 + },
     781 + },
     782 + {
    277 783   desc: "keyboard event triggers the button, trigger time didn't expire so button is down",
    278 784   callback: &callbackTracker{},
    279 785   text: "hello",
    skipped 4 lines
    284 790   return 200 * time.Millisecond
    285 791   },
    286 792   canvas: image.Rect(0, 0, 8, 4),
    287  - events: []terminalapi.Event{
    288  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     793 + meta: &widgetapi.Meta{Focused: false},
     794 + events: []*event{
     795 + {
     796 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     797 + meta: &widgetapi.EventMeta{Focused: true},
     798 + },
    289 799   },
    290 800   want: func(size image.Point) *faketerm.Terminal {
    291 801   ft := faketerm.MustNew(size)
    skipped 29 lines
    321 831   return 200 * time.Millisecond
    322 832   },
    323 833   canvas: image.Rect(0, 0, 8, 4),
    324  - events: []terminalapi.Event{
    325  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     834 + meta: &widgetapi.Meta{Focused: false},
     835 + events: []*event{
     836 + {
     837 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     838 + meta: &widgetapi.EventMeta{Focused: true},
     839 + },
    326 840   },
    327 841   want: func(size image.Point) *faketerm.Terminal {
    328 842   ft := faketerm.MustNew(size)
    skipped 32 lines
    361 875   return 200 * time.Millisecond
    362 876   },
    363 877   canvas: image.Rect(0, 0, 8, 4),
    364  - events: []terminalapi.Event{
    365  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    366  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
    367  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     878 + meta: &widgetapi.Meta{Focused: false},
     879 + events: []*event{
     880 + {
     881 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     882 + meta: &widgetapi.EventMeta{Focused: true},
     883 + },
     884 + {
     885 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     886 + meta: &widgetapi.EventMeta{Focused: true},
     887 + },
     888 + {
     889 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     890 + meta: &widgetapi.EventMeta{Focused: true},
     891 + },
    368 892   },
    369 893   want: func(size image.Point) *faketerm.Terminal {
    370 894   ft := faketerm.MustNew(size)
    skipped 25 lines
    396 920   callback: &callbackTracker{},
    397 921   text: "hello",
    398 922   canvas: image.Rect(0, 0, 8, 4),
    399  - events: []terminalapi.Event{
    400  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
    401  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
    402  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
    403  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     923 + meta: &widgetapi.Meta{Focused: false},
     924 + events: []*event{
     925 + {
     926 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     927 + meta: &widgetapi.EventMeta{},
     928 + },
     929 + {
     930 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     931 + meta: &widgetapi.EventMeta{},
     932 + },
     933 + {
     934 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     935 + meta: &widgetapi.EventMeta{},
     936 + },
     937 + {
     938 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     939 + meta: &widgetapi.EventMeta{},
     940 + },
    404 941   },
    405 942   want: func(size image.Point) *faketerm.Terminal {
    406 943   ft := faketerm.MustNew(size)
    skipped 27 lines
    434 971   },
    435 972   text: "hello",
    436 973   canvas: image.Rect(0, 0, 8, 4),
    437  - events: []terminalapi.Event{
    438  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
    439  - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     974 + meta: &widgetapi.Meta{Focused: false},
     975 + events: []*event{
     976 + {
     977 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     978 + meta: &widgetapi.EventMeta{},
     979 + },
     980 + {
     981 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
     982 + meta: &widgetapi.EventMeta{},
     983 + },
    440 984   },
    441 985   wantCallbackErr: true,
    442 986   },
    skipped 11 lines
    454 998   return 200 * time.Millisecond
    455 999   },
    456 1000   canvas: image.Rect(0, 0, 8, 4),
    457  - events: []terminalapi.Event{
    458  - &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1001 + meta: &widgetapi.Meta{Focused: false},
     1002 + events: []*event{
     1003 + {
     1004 + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
     1005 + meta: &widgetapi.EventMeta{Focused: true},
     1006 + },
    459 1007   },
    460 1008   wantCallbackErr: true,
    461 1009   },
    skipped 2 lines
    464 1012   callback: &callbackTracker{},
    465 1013   text: "hello",
    466 1014   canvas: image.Rect(0, 0, 8, 2),
     1015 + meta: &widgetapi.Meta{Focused: false},
    467 1016   want: func(size image.Point) *faketerm.Terminal {
    468 1017   ft := faketerm.MustNew(size)
    469 1018   cvs := testcanvas.MustNew(ft.Area())
    skipped 21 lines
    491 1040   callback: &callbackTracker{},
    492 1041   text: "h",
    493 1042   canvas: image.Rect(0, 0, 4, 2),
     1043 + meta: &widgetapi.Meta{Focused: false},
    494 1044   want: func(size image.Point) *faketerm.Terminal {
    495 1045   ft := faketerm.MustNew(size)
    496 1046   cvs := testcanvas.MustNew(ft.Area())
    skipped 24 lines
    521 1071   TextColor(cell.ColorRed),
    522 1072   },
    523 1073   canvas: image.Rect(0, 0, 8, 4),
     1074 + meta: &widgetapi.Meta{Focused: false},
    524 1075   want: func(size image.Point) *faketerm.Terminal {
    525 1076   ft := faketerm.MustNew(size)
    526 1077   cvs := testcanvas.MustNew(ft.Area())
    skipped 24 lines
    551 1102   FillColor(cell.ColorRed),
    552 1103   },
    553 1104   canvas: image.Rect(0, 0, 8, 4),
     1105 + meta: &widgetapi.Meta{Focused: false},
    554 1106   want: func(size image.Point) *faketerm.Terminal {
    555 1107   ft := faketerm.MustNew(size)
    556 1108   cvs := testcanvas.MustNew(ft.Area())
    skipped 24 lines
    581 1133   ShadowColor(cell.ColorRed),
    582 1134   },
    583 1135   canvas: image.Rect(0, 0, 8, 4),
     1136 + meta: &widgetapi.Meta{Focused: false},
    584 1137   want: func(size image.Point) *faketerm.Terminal {
    585 1138   ft := faketerm.MustNew(size)
    586 1139   cvs := testcanvas.MustNew(ft.Area())
    skipped 16 lines
    603 1156   },
    604 1157   wantCallback: &callbackTracker{},
    605 1158   },
     1159 + {
     1160 + desc: "draws button with text chunks and custom fill color in up state",
     1161 + callback: &callbackTracker{},
     1162 + opts: []Option{
     1163 + FillColor(cell.ColorBlue),
     1164 + FocusedFillColor(cell.ColorYellow),
     1165 + PressedFillColor(cell.ColorRed),
     1166 + DisableShadow(),
     1167 + },
     1168 + textChunks: []*TextChunk{
     1169 + NewChunk(
     1170 + "h",
     1171 + TextCellOpts(cell.FgColor(cell.ColorBlack)),
     1172 + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
     1173 + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
     1174 + ),
     1175 + NewChunk(
     1176 + "ello",
     1177 + TextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1178 + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1179 + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1180 + ),
     1181 + },
     1182 + canvas: image.Rect(0, 0, 8, 4),
     1183 + meta: &widgetapi.Meta{Focused: false},
     1184 + want: func(size image.Point) *faketerm.Terminal {
     1185 + ft := faketerm.MustNew(size)
     1186 + cvs := testcanvas.MustNew(ft.Area())
     1187 + 
     1188 + // Button.
     1189 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue))
     1190 + 
     1191 + // Text.
     1192 + testdraw.MustText(cvs, "h", image.Point{1, 1},
     1193 + draw.TextCellOpts(
     1194 + cell.FgColor(cell.ColorBlack),
     1195 + cell.BgColor(cell.ColorBlue)),
     1196 + )
     1197 + testdraw.MustText(cvs, "ello", image.Point{2, 1},
     1198 + draw.TextCellOpts(
     1199 + cell.FgColor(cell.ColorMagenta),
     1200 + cell.BgColor(cell.ColorBlue)),
     1201 + )
     1202 + 
     1203 + testcanvas.MustApply(cvs, ft)
     1204 + return ft
     1205 + },
     1206 + wantCallback: &callbackTracker{},
     1207 + },
     1208 + {
     1209 + desc: "draws button with text chunks and custom fill color in focused up state",
     1210 + callback: &callbackTracker{},
     1211 + opts: []Option{
     1212 + FillColor(cell.ColorBlue),
     1213 + FocusedFillColor(cell.ColorYellow),
     1214 + PressedFillColor(cell.ColorRed),
     1215 + DisableShadow(),
     1216 + },
     1217 + textChunks: []*TextChunk{
     1218 + NewChunk(
     1219 + "h",
     1220 + TextCellOpts(cell.FgColor(cell.ColorBlack)),
     1221 + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
     1222 + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
     1223 + ),
     1224 + NewChunk(
     1225 + "ello",
     1226 + TextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1227 + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1228 + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1229 + ),
     1230 + },
     1231 + canvas: image.Rect(0, 0, 8, 4),
     1232 + meta: &widgetapi.Meta{Focused: true},
     1233 + want: func(size image.Point) *faketerm.Terminal {
     1234 + ft := faketerm.MustNew(size)
     1235 + cvs := testcanvas.MustNew(ft.Area())
     1236 + 
     1237 + // Button.
     1238 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorYellow))
     1239 + 
     1240 + // Text.
     1241 + testdraw.MustText(cvs, "h", image.Point{1, 1},
     1242 + draw.TextCellOpts(
     1243 + cell.FgColor(cell.ColorWhite),
     1244 + cell.BgColor(cell.ColorYellow)),
     1245 + )
     1246 + testdraw.MustText(cvs, "ello", image.Point{2, 1},
     1247 + draw.TextCellOpts(
     1248 + cell.FgColor(cell.ColorMagenta),
     1249 + cell.BgColor(cell.ColorYellow)),
     1250 + )
     1251 + 
     1252 + testcanvas.MustApply(cvs, ft)
     1253 + return ft
     1254 + },
     1255 + wantCallback: &callbackTracker{},
     1256 + },
     1257 + {
     1258 + desc: "draws button with text chunks in up state, focused colors default to regular colors",
     1259 + callback: &callbackTracker{},
     1260 + opts: []Option{
     1261 + FillColor(cell.ColorBlue),
     1262 + PressedFillColor(cell.ColorRed),
     1263 + DisableShadow(),
     1264 + },
     1265 + textChunks: []*TextChunk{
     1266 + NewChunk(
     1267 + "h",
     1268 + TextCellOpts(cell.FgColor(cell.ColorBlack)),
     1269 + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
     1270 + ),
     1271 + NewChunk(
     1272 + "ello",
     1273 + TextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1274 + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1275 + ),
     1276 + },
     1277 + canvas: image.Rect(0, 0, 8, 4),
     1278 + meta: &widgetapi.Meta{Focused: true},
     1279 + want: func(size image.Point) *faketerm.Terminal {
     1280 + ft := faketerm.MustNew(size)
     1281 + cvs := testcanvas.MustNew(ft.Area())
     1282 + 
     1283 + // Button.
     1284 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue))
     1285 + 
     1286 + // Text.
     1287 + testdraw.MustText(cvs, "h", image.Point{1, 1},
     1288 + draw.TextCellOpts(
     1289 + cell.FgColor(cell.ColorBlack),
     1290 + cell.BgColor(cell.ColorBlue)),
     1291 + )
     1292 + testdraw.MustText(cvs, "ello", image.Point{2, 1},
     1293 + draw.TextCellOpts(
     1294 + cell.FgColor(cell.ColorMagenta),
     1295 + cell.BgColor(cell.ColorBlue)),
     1296 + )
     1297 + 
     1298 + testcanvas.MustApply(cvs, ft)
     1299 + return ft
     1300 + },
     1301 + wantCallback: &callbackTracker{},
     1302 + },
     1303 + {
     1304 + desc: "draws button with text chunks and custom fill color in down state",
     1305 + events: []*event{
     1306 + {
     1307 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     1308 + meta: &widgetapi.EventMeta{},
     1309 + },
     1310 + },
     1311 + callback: &callbackTracker{},
     1312 + opts: []Option{
     1313 + FillColor(cell.ColorBlue),
     1314 + FocusedFillColor(cell.ColorYellow),
     1315 + PressedFillColor(cell.ColorRed),
     1316 + DisableShadow(),
     1317 + },
     1318 + textChunks: []*TextChunk{
     1319 + NewChunk(
     1320 + "h",
     1321 + TextCellOpts(cell.FgColor(cell.ColorBlack)),
     1322 + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
     1323 + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
     1324 + ),
     1325 + NewChunk(
     1326 + "ello",
     1327 + TextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1328 + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1329 + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1330 + ),
     1331 + },
     1332 + canvas: image.Rect(0, 0, 8, 4),
     1333 + meta: &widgetapi.Meta{Focused: false},
     1334 + want: func(size image.Point) *faketerm.Terminal {
     1335 + ft := faketerm.MustNew(size)
     1336 + cvs := testcanvas.MustNew(ft.Area())
     1337 + 
     1338 + // Button.
     1339 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorRed))
     1340 + 
     1341 + // Text.
     1342 + testdraw.MustText(cvs, "h", image.Point{1, 1},
     1343 + draw.TextCellOpts(
     1344 + cell.FgColor(cell.ColorGreen),
     1345 + cell.BgColor(cell.ColorRed)),
     1346 + )
     1347 + testdraw.MustText(cvs, "ello", image.Point{2, 1},
     1348 + draw.TextCellOpts(
     1349 + cell.FgColor(cell.ColorMagenta),
     1350 + cell.BgColor(cell.ColorRed)),
     1351 + )
     1352 + 
     1353 + testcanvas.MustApply(cvs, ft)
     1354 + return ft
     1355 + },
     1356 + wantCallback: &callbackTracker{},
     1357 + },
     1358 + {
     1359 + desc: "draws button with text chunks and custom fill color in down focused state (focus has no impact)",
     1360 + events: []*event{
     1361 + {
     1362 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     1363 + meta: &widgetapi.EventMeta{},
     1364 + },
     1365 + },
     1366 + callback: &callbackTracker{},
     1367 + opts: []Option{
     1368 + FillColor(cell.ColorBlue),
     1369 + FocusedFillColor(cell.ColorYellow),
     1370 + PressedFillColor(cell.ColorRed),
     1371 + DisableShadow(),
     1372 + },
     1373 + textChunks: []*TextChunk{
     1374 + NewChunk(
     1375 + "h",
     1376 + TextCellOpts(cell.FgColor(cell.ColorBlack)),
     1377 + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
     1378 + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
     1379 + ),
     1380 + NewChunk(
     1381 + "ello",
     1382 + TextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1383 + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1384 + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1385 + ),
     1386 + },
     1387 + canvas: image.Rect(0, 0, 8, 4),
     1388 + meta: &widgetapi.Meta{Focused: true},
     1389 + want: func(size image.Point) *faketerm.Terminal {
     1390 + ft := faketerm.MustNew(size)
     1391 + cvs := testcanvas.MustNew(ft.Area())
     1392 + 
     1393 + // Button.
     1394 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorRed))
     1395 + 
     1396 + // Text.
     1397 + testdraw.MustText(cvs, "h", image.Point{1, 1},
     1398 + draw.TextCellOpts(
     1399 + cell.FgColor(cell.ColorGreen),
     1400 + cell.BgColor(cell.ColorRed)),
     1401 + )
     1402 + testdraw.MustText(cvs, "ello", image.Point{2, 1},
     1403 + draw.TextCellOpts(
     1404 + cell.FgColor(cell.ColorMagenta),
     1405 + cell.BgColor(cell.ColorRed)),
     1406 + )
     1407 + 
     1408 + testcanvas.MustApply(cvs, ft)
     1409 + return ft
     1410 + },
     1411 + wantCallback: &callbackTracker{},
     1412 + },
     1413 + {
     1414 + desc: "draws button with text chunks in down satte, pressed colors default to regular colors",
     1415 + events: []*event{
     1416 + {
     1417 + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
     1418 + meta: &widgetapi.EventMeta{},
     1419 + },
     1420 + },
     1421 + callback: &callbackTracker{},
     1422 + opts: []Option{
     1423 + FillColor(cell.ColorBlue),
     1424 + FocusedFillColor(cell.ColorYellow),
     1425 + DisableShadow(),
     1426 + },
     1427 + textChunks: []*TextChunk{
     1428 + NewChunk(
     1429 + "h",
     1430 + TextCellOpts(cell.FgColor(cell.ColorBlack)),
     1431 + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
     1432 + ),
     1433 + NewChunk(
     1434 + "ello",
     1435 + TextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1436 + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
     1437 + ),
     1438 + },
     1439 + canvas: image.Rect(0, 0, 8, 4),
     1440 + meta: &widgetapi.Meta{Focused: false},
     1441 + want: func(size image.Point) *faketerm.Terminal {
     1442 + ft := faketerm.MustNew(size)
     1443 + cvs := testcanvas.MustNew(ft.Area())
     1444 + 
     1445 + // Button.
     1446 + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue))
     1447 + 
     1448 + // Text.
     1449 + testdraw.MustText(cvs, "h", image.Point{1, 1},
     1450 + draw.TextCellOpts(
     1451 + cell.FgColor(cell.ColorBlack),
     1452 + cell.BgColor(cell.ColorBlue)),
     1453 + )
     1454 + testdraw.MustText(cvs, "ello", image.Point{2, 1},
     1455 + draw.TextCellOpts(
     1456 + cell.FgColor(cell.ColorMagenta),
     1457 + cell.BgColor(cell.ColorBlue)),
     1458 + )
     1459 + 
     1460 + testcanvas.MustApply(cvs, ft)
     1461 + return ft
     1462 + },
     1463 + wantCallback: &callbackTracker{},
     1464 + },
    606 1465   }
    607 1466   
    608 1467   buttonRune = 'x'
    skipped 10 lines
    619 1478   var cFn CallbackFn
    620 1479   if gotCallback == nil {
    621 1480   cFn = nil
     1481 + } else if gotCallback.useSetCallback {
     1482 + // Set an no-op callback via the constructor.
     1483 + // It will be updated to the real one via SetCallback.
     1484 + cFn = func() error { return nil }
    622 1485   } else {
    623 1486   cFn = gotCallback.callback
    624 1487   }
    625  - b, err := New(tc.text, cFn, tc.opts...)
    626  - if (err != nil) != tc.wantNewErr {
    627  - t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr)
     1488 + 
     1489 + if tc.text != "" && tc.textChunks != nil {
     1490 + t.Fatalf("cannot specify both text and textChunks in the testdata")
     1491 + }
     1492 + 
     1493 + var btn *Button
     1494 + if tc.textChunks != nil {
     1495 + b, err := NewFromChunks(tc.textChunks, cFn, tc.opts...)
     1496 + if (err != nil) != tc.wantNewErr {
     1497 + t.Errorf("NewFromChunks => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr)
     1498 + }
     1499 + if err != nil {
     1500 + return
     1501 + }
     1502 + btn = b
     1503 + } else {
     1504 + b, err := New(tc.text, cFn, tc.opts...)
     1505 + if (err != nil) != tc.wantNewErr {
     1506 + t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr)
     1507 + }
     1508 + if err != nil {
     1509 + return
     1510 + }
     1511 + btn = b
    628 1512   }
    629  - if err != nil {
    630  - return
     1513 + 
     1514 + if gotCallback != nil && gotCallback.useSetCallback {
     1515 + btn.SetCallback(gotCallback.callback)
    631 1516   }
    632 1517   
    633 1518   {
    skipped 2 lines
    636 1521   if err != nil {
    637 1522   t.Fatalf("canvas.New => unexpected error: %v", err)
    638 1523   }
    639  - err = b.Draw(c, tc.meta)
     1524 + err = btn.Draw(c, tc.meta)
    640 1525   if (err != nil) != tc.wantDrawErr {
    641 1526   t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
    642 1527   }
    skipped 2 lines
    645 1530   }
    646 1531   }
    647 1532   
    648  - for i, ev := range tc.events {
    649  - switch e := ev.(type) {
     1533 + for i, event := range tc.events {
     1534 + switch e := event.ev.(type) {
    650 1535   case *terminalapi.Mouse:
    651  - err := b.Mouse(e)
     1536 + err := btn.Mouse(e, event.meta)
    652 1537   // Only the last event in test cases is the one that triggers the callback.
    653 1538   if i == len(tc.events)-1 {
    654 1539   if (err != nil) != tc.wantCallbackErr {
    skipped 9 lines
    664 1549   }
    665 1550   
    666 1551   case *terminalapi.Keyboard:
    667  - err := b.Keyboard(e)
     1552 + err := btn.Keyboard(e, event.meta)
    668 1553   // Only the last event in test cases is the one that triggers the callback.
    669 1554   if i == len(tc.events)-1 {
    670 1555   if (err != nil) != tc.wantCallbackErr {
    skipped 9 lines
    680 1565   }
    681 1566   
    682 1567   default:
    683  - t.Fatalf("unsupported event type: %T", ev)
     1568 + t.Fatalf("unsupported event type: %T", event.ev)
    684 1569   }
    685 1570   }
    686 1571   
    skipped 2 lines
    689 1574   t.Fatalf("canvas.New => unexpected error: %v", err)
    690 1575   }
    691 1576   
    692  - err = b.Draw(c, tc.meta)
     1577 + err = btn.Draw(c, tc.meta)
    693 1578   if (err != nil) != tc.wantDrawErr {
    694 1579   t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
    695 1580   }
    skipped 82 lines
    778 1663   },
    779 1664   },
    780 1665   {
    781  - desc: "custom width specified",
     1666 + desc: "custom width specified with default padding",
    782 1667   text: "hello",
    783 1668   opts: []Option{
    784 1669   Width(10),
    785 1670   },
    786 1671   want: widgetapi.Options{
     1672 + MinimumSize: image.Point{13, 4},
     1673 + MaximumSize: image.Point{13, 4},
     1674 + WantKeyboard: widgetapi.KeyScopeNone,
     1675 + WantMouse: widgetapi.MouseScopeGlobal,
     1676 + },
     1677 + },
     1678 + {
     1679 + desc: "custom width specified with custom padding",
     1680 + text: "hello",
     1681 + opts: []Option{
     1682 + Width(10),
     1683 + TextHorizontalPadding(0),
     1684 + },
     1685 + want: widgetapi.Options{
    787 1686   MinimumSize: image.Point{11, 4},
    788 1687   MaximumSize: image.Point{11, 4},
    789 1688   WantKeyboard: widgetapi.KeyScopeNone,
    790 1689   WantMouse: widgetapi.MouseScopeGlobal,
    791 1690   },
    792 1691   },
    793  - 
     1692 + {
     1693 + desc: "without shadow or padding",
     1694 + text: "hello",
     1695 + opts: []Option{
     1696 + Width(10),
     1697 + TextHorizontalPadding(0),
     1698 + DisableShadow(),
     1699 + },
     1700 + want: widgetapi.Options{
     1701 + MinimumSize: image.Point{10, 3},
     1702 + MaximumSize: image.Point{10, 3},
     1703 + WantKeyboard: widgetapi.KeyScopeNone,
     1704 + WantMouse: widgetapi.MouseScopeGlobal,
     1705 + },
     1706 + },
    794 1707   {
    795  - desc: "doesn't want keyboard by default",
     1708 + desc: "doesn't want keyboard by default without any keys",
    796 1709   text: "hello",
    797 1710   want: widgetapi.Options{
    798 1711   MinimumSize: image.Point{8, 4},
    skipped 3 lines
    802 1715   },
    803 1716   },
    804 1717   {
    805  - desc: "registers for focused keyboard events",
     1718 + desc: "registers for keyboard events when Key used",
    806 1719   text: "hello",
    807 1720   opts: []Option{
    808 1721   Key(keyboard.KeyEnter),
    skipped 1 lines
    810 1723   want: widgetapi.Options{
    811 1724   MinimumSize: image.Point{8, 4},
    812 1725   MaximumSize: image.Point{8, 4},
    813  - WantKeyboard: widgetapi.KeyScopeFocused,
     1726 + WantKeyboard: widgetapi.KeyScopeGlobal,
    814 1727   WantMouse: widgetapi.MouseScopeGlobal,
    815 1728   },
    816 1729   },
    817 1730   {
    818  - desc: "registers for global keyboard events",
     1731 + desc: "registers for keyboard events when Keys used",
     1732 + text: "hello",
     1733 + opts: []Option{
     1734 + Keys(keyboard.KeyEnter, keyboard.KeyTab),
     1735 + },
     1736 + want: widgetapi.Options{
     1737 + MinimumSize: image.Point{8, 4},
     1738 + MaximumSize: image.Point{8, 4},
     1739 + WantKeyboard: widgetapi.KeyScopeGlobal,
     1740 + WantMouse: widgetapi.MouseScopeGlobal,
     1741 + },
     1742 + },
     1743 + {
     1744 + desc: "registers for keyboard events when GlobalKey used",
    819 1745   text: "hello",
    820 1746   opts: []Option{
    821 1747   GlobalKey(keyboard.KeyEnter),
     1748 + },
     1749 + want: widgetapi.Options{
     1750 + MinimumSize: image.Point{8, 4},
     1751 + MaximumSize: image.Point{8, 4},
     1752 + WantKeyboard: widgetapi.KeyScopeGlobal,
     1753 + WantMouse: widgetapi.MouseScopeGlobal,
     1754 + },
     1755 + },
     1756 + {
     1757 + desc: "registers for keyboard events when GlobalKeys used",
     1758 + text: "hello",
     1759 + opts: []Option{
     1760 + GlobalKeys(keyboard.KeyEnter, keyboard.KeyTab),
    822 1761   },
    823 1762   want: widgetapi.Options{
    824 1763   MinimumSize: image.Point{8, 4},
    skipped 24 lines
  • ■ ■ ■ ■ ■
    widgets/button/options.go
    skipped 41 lines
    42 42   
    43 43  // options holds the provided options.
    44 44  type options struct {
    45  - fillColor cell.Color
    46  - textColor cell.Color
    47  - shadowColor cell.Color
    48  - height int
    49  - width int
    50  - key keyboard.Key
    51  - keyScope widgetapi.KeyScope
    52  - keyUpDelay time.Duration
     45 + fillColor cell.Color
     46 + focusedFillColor *cell.Color
     47 + pressedFillColor *cell.Color
     48 + textColor cell.Color
     49 + textHorizontalPadding int
     50 + shadowColor cell.Color
     51 + disableShadow bool
     52 + height int
     53 + width int
     54 + focusedKeys map[keyboard.Key]bool
     55 + globalKeys map[keyboard.Key]bool
     56 + keyUpDelay time.Duration
    53 57  }
    54 58   
    55 59  // validate validates the provided options.
    56 60  func (o *options) validate() error {
     61 + if min := 0; o.textHorizontalPadding < min {
     62 + return fmt.Errorf("invalid textHorizontalPadding %d, must be %d <= textHorizontalPadding", o.textHorizontalPadding, min)
     63 + }
    57 64   if min := 1; o.height < min {
    58 65   return fmt.Errorf("invalid height %d, must be %d <= height", o.height, min)
    59 66   }
    skipped 3 lines
    63 70   if min := time.Duration(0); o.keyUpDelay < min {
    64 71   return fmt.Errorf("invalid keyUpDelay %v, must be %v <= keyUpDelay", o.keyUpDelay, min)
    65 72   }
     73 + 
     74 + for k := range o.globalKeys {
     75 + if o.focusedKeys[k] {
     76 + return fmt.Errorf("key %q cannot be configured as both a focused key (options Key or Keys) and a global key (options GlobalKey or GlobalKeys)", k)
     77 + }
     78 + }
    66 79   return nil
    67 80  }
    68 81   
     82 +// keyScope stores a key and its scope.
     83 +type keyScope struct {
     84 + key keyboard.Key
     85 + scope widgetapi.KeyScope
     86 +}
     87 + 
    69 88  // newOptions returns options with the default values set.
    70 89  func newOptions(text string) *options {
    71 90   return &options{
    72  - fillColor: cell.ColorNumber(117),
    73  - textColor: cell.ColorBlack,
    74  - shadowColor: cell.ColorNumber(240),
    75  - height: DefaultHeight,
    76  - width: widthFor(text),
    77  - keyUpDelay: DefaultKeyUpDelay,
     91 + fillColor: cell.ColorNumber(117),
     92 + textColor: cell.ColorBlack,
     93 + textHorizontalPadding: DefaultTextHorizontalPadding,
     94 + shadowColor: cell.ColorNumber(240),
     95 + height: DefaultHeight,
     96 + width: widthFor(text),
     97 + keyUpDelay: DefaultKeyUpDelay,
     98 + focusedKeys: map[keyboard.Key]bool{},
     99 + globalKeys: map[keyboard.Key]bool{},
    78 100   }
    79 101  }
    80 102   
    skipped 4 lines
    85 107   })
    86 108  }
    87 109   
     110 +// FocusedFillColor sets the fill color of the button when the widget's
     111 +// container is focused.
     112 +// Defaults to FillColor.
     113 +func FocusedFillColor(c cell.Color) Option {
     114 + return option(func(opts *options) {
     115 + opts.focusedFillColor = &c
     116 + })
     117 +}
     118 + 
     119 +// PressedFillColor sets the fill color of the button when it is pressed.
     120 +// Defaults to FillColor.
     121 +func PressedFillColor(c cell.Color) Option {
     122 + return option(func(opts *options) {
     123 + opts.pressedFillColor = &c
     124 + })
     125 +}
     126 + 
    88 127  // TextColor sets the color of the text label in the button.
    89 128  func TextColor(c cell.Color) Option {
    90 129   return option(func(opts *options) {
    skipped 23 lines
    114 153  // Width sets the width of the button in cells.
    115 154  // Must be a positive non-zero integer.
    116 155  // Defaults to the auto-width based on the length of the text label.
     156 +// Not all the width may be available to the text if TextHorizontalPadding is
     157 +// set to a non-zero integer.
    117 158  func Width(cells int) Option {
    118 159   return option(func(opts *options) {
    119 160   opts.width = cells
    skipped 11 lines
    131 172   
    132 173  // Key configures the keyboard key that presses the button.
    133 174  // The widget responds to this key only if its container is focused.
    134  -// When not provided, the widget ignores all keyboard events.
     175 +//
     176 +// Clears all keys set by Key() or Keys() previously.
    135 177  func Key(k keyboard.Key) Option {
    136 178   return option(func(opts *options) {
    137  - opts.key = k
    138  - opts.keyScope = widgetapi.KeyScopeFocused
     179 + opts.focusedKeys = map[keyboard.Key]bool{}
     180 + opts.focusedKeys[k] = true
    139 181   })
    140 182  }
    141 183   
    142 184  // GlobalKey is like Key, but makes the widget respond to the key even if its
    143 185  // container isn't focused.
    144  -// When not provided, the widget ignores all keyboard events.
     186 +//
     187 +// Clears all keys set by GlobalKey() or GlobalKeys() previously.
    145 188  func GlobalKey(k keyboard.Key) Option {
    146 189   return option(func(opts *options) {
    147  - opts.key = k
    148  - opts.keyScope = widgetapi.KeyScopeGlobal
     190 + opts.globalKeys = map[keyboard.Key]bool{}
     191 + opts.globalKeys[k] = true
     192 + })
     193 +}
     194 + 
     195 +// Keys is like Key, but allows to configure multiple keys.
     196 +//
     197 +// Clears all keys set by Key() or Keys() previously.
     198 +func Keys(keys ...keyboard.Key) Option {
     199 + return option(func(opts *options) {
     200 + opts.focusedKeys = map[keyboard.Key]bool{}
     201 + for _, k := range keys {
     202 + opts.focusedKeys[k] = true
     203 + }
     204 + })
     205 +}
     206 + 
     207 +// GlobalKeys is like GlobalKey, but allows to configure multiple keys.
     208 +//
     209 +// Clears all keys set by GlobalKey() or GlobalKeys() previously.
     210 +func GlobalKeys(keys ...keyboard.Key) Option {
     211 + return option(func(opts *options) {
     212 + opts.globalKeys = map[keyboard.Key]bool{}
     213 + for _, k := range keys {
     214 + opts.globalKeys[k] = true
     215 + }
    149 216   })
    150 217  }
    151 218   
    skipped 13 lines
    165 232   })
    166 233  }
    167 234   
     235 +// DisableShadow when provided the button will not have a shadow area and will
     236 +// have no animation when pressed.
     237 +func DisableShadow() Option {
     238 + return option(func(opts *options) {
     239 + opts.disableShadow = true
     240 + })
     241 +}
     242 + 
     243 +// DefaultTextHorizontalPadding is the default value for the HorizontalPadding option.
     244 +const DefaultTextHorizontalPadding = 1
     245 + 
     246 +// TextHorizontalPadding sets padding on the left and right side of the
     247 +// button's text as the amount of cells.
     248 +func TextHorizontalPadding(p int) Option {
     249 + return option(func(opts *options) {
     250 + opts.textHorizontalPadding = p
     251 + })
     252 +}
     253 + 
    168 254  // widthFor returns the required width for the specified text.
    169 255  func widthFor(text string) int {
    170  - return runewidth.StringWidth(text) + 2 // One empty cell at each side.
     256 + return runewidth.StringWidth(text)
    171 257  }
    172 258   
  • ■ ■ ■ ■ ■ ■
    widgets/button/text_options.go
     1 +// Copyright 2020 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 button
     16 + 
     17 +// text_options.go contains options used for the text displayed by the button.
     18 + 
     19 +import "github.com/mum4k/termdash/cell"
     20 + 
     21 +// TextOption is used to provide options to NewChunk().
     22 +type TextOption interface {
     23 + // set sets the provided option.
     24 + set(*textOptions)
     25 +}
     26 + 
     27 +// textOptions stores the provided options.
     28 +type textOptions struct {
     29 + cellOpts []cell.Option
     30 + focusedCellOpts []cell.Option
     31 + pressedCellOpts []cell.Option
     32 +}
     33 + 
     34 +// setDefaultFgColor configures a default color for text if one isn't specified
     35 +// in the text options.
     36 +func (to *textOptions) setDefaultFgColor(c cell.Color) {
     37 + to.cellOpts = append(
     38 + []cell.Option{cell.FgColor(c)},
     39 + to.cellOpts...,
     40 + )
     41 +}
     42 + 
     43 +// newTextOptions returns new textOptions instance.
     44 +func newTextOptions(tOpts ...TextOption) *textOptions {
     45 + to := &textOptions{}
     46 + for _, o := range tOpts {
     47 + o.set(to)
     48 + }
     49 + return to
     50 +}
     51 + 
     52 +// textOption implements TextOption.
     53 +type textOption func(*textOptions)
     54 + 
     55 +// set implements TextOption.set.
     56 +func (to textOption) set(tOpts *textOptions) {
     57 + to(tOpts)
     58 +}
     59 + 
     60 +// TextCellOpts sets options on the cells that contain the button text.
     61 +// If not specified, all cells will just have their foreground color set to the
     62 +// value of TextColor().
     63 +func TextCellOpts(opts ...cell.Option) TextOption {
     64 + return textOption(func(tOpts *textOptions) {
     65 + tOpts.cellOpts = opts
     66 + })
     67 +}
     68 + 
     69 +// FocusedTextCellOpts sets options on the cells that contain the button text
     70 +// when the widget's container is focused.
     71 +// If not specified, TextCellOpts will be used instead.
     72 +func FocusedTextCellOpts(opts ...cell.Option) TextOption {
     73 + return textOption(func(tOpts *textOptions) {
     74 + tOpts.focusedCellOpts = opts
     75 + })
     76 +}
     77 + 
     78 +// PressedTextCellOpts sets options on the cells that contain the button text
     79 +// when it is pressed.
     80 +// If not specified, TextCellOpts will be used instead.
     81 +func PressedTextCellOpts(opts ...cell.Option) TextOption {
     82 + return textOption(func(tOpts *textOptions) {
     83 + tOpts.pressedCellOpts = opts
     84 + })
     85 +}
     86 + 
  • ■ ■ ■ ■ ■ ■
    widgets/donut/donut.go
    skipped 272 lines
    273 273  }
    274 274   
    275 275  // Keyboard input isn't supported on the Donut widget.
    276  -func (*Donut) Keyboard(k *terminalapi.Keyboard) error {
     276 +func (*Donut) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    277 277   return errors.New("the Donut widget doesn't support keyboard events")
    278 278  }
    279 279   
    280 280  // Mouse input isn't supported on the Donut widget.
    281  -func (*Donut) Mouse(m *terminalapi.Mouse) error {
     281 +func (*Donut) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    282 282   return errors.New("the Donut widget doesn't support mouse events")
    283 283  }
    284 284   
    skipped 31 lines
  • ■ ■ ■ ■ ■ ■
    widgets/donut/donut_test.go
    skipped 882 lines
    883 883   if err != nil {
    884 884   t.Fatalf("New => unexpected error: %v", err)
    885 885   }
    886  - if err := d.Keyboard(&terminalapi.Keyboard{}); err == nil {
     886 + if err := d.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
    887 887   t.Errorf("Keyboard => got nil err, wanted one")
    888 888   }
    889 889  }
    skipped 3 lines
    893 893   if err != nil {
    894 894   t.Fatalf("New => unexpected error: %v", err)
    895 895   }
    896  - if err := d.Mouse(&terminalapi.Mouse{}); err == nil {
     896 + if err := d.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err == nil {
    897 897   t.Errorf("Mouse => got nil err, wanted one")
    898 898   }
    899 899  }
    skipped 20 lines
  • ■ ■ ■ ■ ■ ■
    widgets/gauge/gauge.go
    skipped 287 lines
    288 288  }
    289 289   
    290 290  // Keyboard input isn't supported on the Gauge widget.
    291  -func (g *Gauge) Keyboard(k *terminalapi.Keyboard) error {
     291 +func (g *Gauge) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    292 292   return errors.New("the Gauge widget doesn't support keyboard events")
    293 293  }
    294 294   
    295 295  // Mouse input isn't supported on the Gauge widget.
    296  -func (g *Gauge) Mouse(m *terminalapi.Mouse) error {
     296 +func (g *Gauge) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    297 297   return errors.New("the Gauge widget doesn't support mouse events")
    298 298  }
    299 299   
    skipped 34 lines
  • ■ ■ ■ ■ ■ ■
    widgets/gauge/gauge_test.go
    skipped 907 lines
    908 908   if err != nil {
    909 909   t.Fatalf("New => unexpected error: %v", err)
    910 910   }
    911  - if err := g.Keyboard(&terminalapi.Keyboard{}); err == nil {
     911 + if err := g.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
    912 912   t.Errorf("Keyboard => got nil err, wanted one")
    913 913   }
    914 914  }
    skipped 3 lines
    918 918   if err != nil {
    919 919   t.Fatalf("New => unexpected error: %v", err)
    920 920   }
    921  - if err := g.Mouse(&terminalapi.Mouse{}); err == nil {
     921 + if err := g.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err == nil {
    922 922   t.Errorf("Mouse => got nil err, wanted one")
    923 923   }
    924 924  }
    skipped 79 lines
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/heatmap.go
     1 +// Copyright 2020 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 heatmap contains a widget that displays heat maps.
     16 +package heatmap
     17 + 
     18 +import (
     19 + "errors"
     20 + "image"
     21 + "sync"
     22 + 
     23 + "github.com/mum4k/termdash/cell"
     24 + "github.com/mum4k/termdash/private/canvas"
     25 + "github.com/mum4k/termdash/terminal/terminalapi"
     26 + "github.com/mum4k/termdash/widgetapi"
     27 + "github.com/mum4k/termdash/widgets/heatmap/internal/axes"
     28 +)
     29 + 
     30 +// HeatMap draws heat map charts.
     31 +//
     32 +// Heatmap consists of several cells. Each cell represents a value.
     33 +// The larger the value, the darker the color of the cell (from white to black).
     34 +//
     35 +// The two dimensions of the values (cells) array are determined by the length of
     36 +// the xLabels and yLabels arrays respectively.
     37 +//
     38 +// HeatMap does not support mouse based zoom.
     39 +//
     40 +// Implements widgetapi.Widget. This object is thread-safe.
     41 +type HeatMap struct {
     42 + // values are the values in the heat map.
     43 + values [][]float64
     44 + 
     45 + // xLabels are the labels on the X axis in an increasing order.
     46 + xLabels []string
     47 + // yLabels are the labels on the Y axis in an increasing order.
     48 + yLabels []string
     49 + 
     50 + // minValue and maxValue are the Min and Max values in the values,
     51 + // which will be used to calculate the color of each cell.
     52 + minValue, maxValue float64
     53 + 
     54 + // lastWidth is the width of the canvas as of the last time when Draw was called.
     55 + lastWidth int
     56 + 
     57 + // opts are the provided options.
     58 + opts *options
     59 + 
     60 + // mu protects the HeatMap widget.
     61 + mu sync.RWMutex
     62 +}
     63 + 
     64 +// New returns a new HeatMap widget.
     65 +func New(opts ...Option) (*HeatMap, error) {
     66 + return nil, errors.New("not implemented")
     67 +}
     68 + 
     69 +// Values sets the values to be displayed by the HeatMap.
     70 +//
     71 +// Each value in values has a xLabel and a yLabel, which means
     72 +// len(yLabels) == len(values) and len(xLabels) == len(values[i]).
     73 +// But labels could be empty strings.
     74 +// When no labels are provided, labels will be "0", "1", "2"...
     75 +//
     76 +// Each call to Values overwrites any previously provided values.
     77 +// Provided options override values set when New() was called.
     78 +func (hp *HeatMap) Values(xLabels []string, yLabels []string, values [][]float64, opts ...Option) error {
     79 + return errors.New("not implemented")
     80 +}
     81 + 
     82 +// ClearXLabels clear the X labels.
     83 +func (hp *HeatMap) ClearXLabels() {
     84 + hp.xLabels = nil
     85 +}
     86 + 
     87 +// ClearYLabels clear the Y labels.
     88 +func (hp *HeatMap) ClearYLabels() {
     89 + hp.yLabels = nil
     90 +}
     91 + 
     92 +// ValueCapacity returns the number of values that can fit into the canvas.
     93 +// This is essentially the number of available cells on the canvas as observed
     94 +// on the last call to draw. Returns zero if draw wasn't called.
     95 +//
     96 +// Note that this capacity changes each time the terminal resizes, so there is
     97 +// no guarantee this remains the same next time Draw is called.
     98 +// Should be used as a hint only.
     99 +func (hp *HeatMap) ValueCapacity() int {
     100 + return 0
     101 +}
     102 + 
     103 +// axesDetails determines the details about the X and Y axes.
     104 +func (hp *HeatMap) axesDetails(cvs *canvas.Canvas) (*axes.XDetails, *axes.YDetails, error) {
     105 + return nil, nil, errors.New("not implemented")
     106 +}
     107 + 
     108 +// Draw draws cells, X labels and Y labels as HeatMap.
     109 +// Implements widgetapi.Widget.Draw.
     110 +func (hp *HeatMap) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
     111 + return errors.New("not implemented")
     112 +}
     113 + 
     114 +// drawCells draws m*n cells (rectangles) representing the stored values.
     115 +// The height of each cell is 1 and the default width is 3.
     116 +func (hp *HeatMap) drawCells(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
     117 + return errors.New("not implemented")
     118 +}
     119 + 
     120 +// drawAxes draws X labels (under the cells) and Y Labels (on the left side of the cell).
     121 +func (hp *HeatMap) drawLabels(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
     122 + return errors.New("not implemented")
     123 +}
     124 + 
     125 +// minSize determines the minimum required size to draw HeatMap.
     126 +func (hp *HeatMap) minSize() image.Point {
     127 + return image.Point{}
     128 +}
     129 + 
     130 +// Keyboard input isn't supported on the HeatMap widget.
     131 +func (*HeatMap) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
     132 + return errors.New("the HeatMap widget doesn't support keyboard events")
     133 +}
     134 + 
     135 +// Mouse input isn't supported on the HeatMap widget.
     136 +func (*HeatMap) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
     137 + return errors.New("the HeatMap widget doesn't support mouse events")
     138 +}
     139 + 
     140 +// Options implements widgetapi.Widget.Options.
     141 +func (hp *HeatMap) Options() widgetapi.Options {
     142 + hp.mu.Lock()
     143 + defer hp.mu.Unlock()
     144 + return widgetapi.Options{}
     145 +}
     146 + 
     147 +// getCellColor returns the color of the cell according to its value.
     148 +// The larger the value, the darker the color.
     149 +// The color range is in Xterm color, from 232 to 255.
     150 +// Refer to https://jonasjacek.github.io/colors/.
     151 +func (hp *HeatMap) getCellColor(value float64) cell.Color {
     152 + return cell.ColorDefault
     153 +}
     154 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/heatmap_test.go
     1 +// Copyright 2020 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 heatmap
     16 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/heatmapdemo/heatmapdemo.go
     1 +// Copyright 2020 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 heatmapdemo displays a heatmap widget.
     16 +// Exist when 'q' is pressed.
     17 +package main
     18 + 
     19 +import (
     20 + "context"
     21 + "github.com/mum4k/termdash"
     22 + "github.com/mum4k/termdash/container"
     23 + "github.com/mum4k/termdash/linestyle"
     24 + "github.com/mum4k/termdash/terminal/tcell"
     25 + "github.com/mum4k/termdash/terminal/terminalapi"
     26 + "github.com/mum4k/termdash/widgets/heatmap"
     27 +)
     28 + 
     29 +func main() {
     30 + t, err := tcell.New()
     31 + if err != nil {
     32 + panic(err)
     33 + }
     34 + defer t.Close()
     35 + 
     36 + hp, err := heatmap.New()
     37 + if err != nil {
     38 + panic(err)
     39 + }
     40 + 
     41 + // TODO: set heatmap's data
     42 + 
     43 + c, err := container.New(
     44 + t,
     45 + container.Border(linestyle.Light),
     46 + container.BorderTitle("PRESS Q TO QUIT"),
     47 + container.PlaceWidget(hp),
     48 + )
     49 + if err != nil {
     50 + panic(err)
     51 + }
     52 + 
     53 + ctx, cancel := context.WithCancel(context.Background())
     54 + 
     55 + quitter := func(k *terminalapi.Keyboard) {
     56 + if k.Key == 'q' || k.Key == 'Q' {
     57 + cancel()
     58 + }
     59 + }
     60 + 
     61 + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter)); err != nil {
     62 + panic(err)
     63 + }
     64 +}
     65 + 
  • ■ ■ ■ ■ ■
    widgets/heatmap/internal/README.md
     1 +# Internal termdash libraries
     2 + 
     3 +The packages under this directory are private to termdash. Stability of the
     4 +private packages isn't guaranteed and changes won't be backward compatible.
     5 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/internal/axes/axes.go
     1 +// Copyright 2020 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 axes calculates the required layout and draws the X and Y axes of a heat map.
     16 +package axes
     17 + 
     18 +import (
     19 + "errors"
     20 + "image"
     21 + 
     22 + "github.com/mum4k/termdash/private/runewidth"
     23 +)
     24 + 
     25 +// axisWidth is width of an axis.
     26 +const axisWidth = 1
     27 + 
     28 +// YDetails contain information about the Y axis
     29 +// that will NOT be drawn onto the canvas, but will take up space.
     30 +type YDetails struct {
     31 + // Width in character cells of the Y axis and its character labels.
     32 + Width int
     33 + 
     34 + // Start is the point where the Y axis starts.
     35 + // The Y coordinate of Start is less than the Y coordinate of End.
     36 + Start image.Point
     37 + 
     38 + // End is the point where the Y axis ends.
     39 + End image.Point
     40 + 
     41 + // Labels are the labels for values on the Y axis in an increasing order.
     42 + Labels []*Label
     43 +}
     44 + 
     45 +// RequiredWidth calculates the minimum width required
     46 +// in order to draw the Y axis and its labels.
     47 +// The parameter ls is the longest string in yLabels.
     48 +func RequiredWidth(ls string) int {
     49 + return runewidth.StringWidth(ls) + axisWidth
     50 +}
     51 + 
     52 +// NewYDetails retrieves details about the Y axis required
     53 +// to draw it on a canvas of the provided area.
     54 +func NewYDetails(labels []string) (*YDetails, error) {
     55 + return nil, errors.New("not implemented")
     56 +}
     57 + 
     58 +// LongestString returns the length of the longest string in the string array.
     59 +func LongestString(strings []string) int {
     60 + var widest int
     61 + for _, s := range strings {
     62 + if l := runewidth.StringWidth(s); l > widest {
     63 + widest = l
     64 + }
     65 + }
     66 + return widest
     67 +}
     68 + 
     69 +// XDetails contain information about the X axis
     70 +// that will NOT be drawn onto the canvas.
     71 +type XDetails struct {
     72 + // Start is the point where the X axis starts.
     73 + // Both coordinates of Start are less than End.
     74 + Start image.Point
     75 + // End is the point where the X axis ends.
     76 + End image.Point
     77 + 
     78 + // Labels are the labels for values on the X axis in an increasing order.
     79 + Labels []*Label
     80 +}
     81 + 
     82 +// NewXDetails retrieves details about the X axis required to draw it on a canvas
     83 +// of the provided area.
     84 +// The yEnd is the point where the Y axis ends.
     85 +func NewXDetails(cvsAr image.Rectangle, yEnd image.Point, labels []string, cellWidth int) (*XDetails, error) {
     86 + return nil, errors.New("not implemented")
     87 +}
     88 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/internal/axes/axes_test.go
     1 +// Copyright 2020 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 axes
     16 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/internal/axes/label.go
     1 +// Copyright 2020 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 axes
     16 + 
     17 +// label.go contains code that calculates the positions of labels on the axes.
     18 + 
     19 +import (
     20 + "errors"
     21 + "image"
     22 +)
     23 + 
     24 +// Label is one text label on an axis.
     25 +type Label struct {
     26 + // Label content.
     27 + Text string
     28 + 
     29 + // Position of the label within the canvas.
     30 + Pos image.Point
     31 +}
     32 + 
     33 +// yLabels returns labels that should be placed next to the cells.
     34 +// The labelWidth is the width of the area from the left-most side of the
     35 +// canvas until the Y axis (not including the Y axis). This is the area where
     36 +// the labels will be placed and aligned.
     37 +// Labels are returned with Y coordinates in ascending order.
     38 +// Y coordinates grow down.
     39 +func yLabels(graphHeight, labelWidth int, labels []string) ([]*Label, error) {
     40 + return nil, errors.New("not implemented")
     41 +}
     42 + 
     43 +// rowLabel returns one label for the specified row.
     44 +// The row is the Y coordinate of the row, Y coordinates grow down.
     45 +func rowLabel(row int, label string, labelWidth int) (*Label, error) {
     46 + return nil, errors.New("not implemented")
     47 +}
     48 + 
     49 +// xLabels returns labels that should be placed under the cells.
     50 +// Labels are returned with X coordinates in ascending order.
     51 +// X coordinates grow right.
     52 +func xLabels(yEnd image.Point, graphWidth int, labels []string, cellWidth int) ([]*Label, error) {
     53 + return nil, errors.New("not implemented")
     54 +}
     55 + 
     56 +// paddedLabelLength calculates the length of the padded X label and
     57 +// the column index corresponding to the label.
     58 +// For example, the longest X label's length is 5, like '12:34', and the cell's width is 3.
     59 +// So in order to better display, every three columns of cells will display a X label,
     60 +// the X label belongs to the middle column of the three columns,
     61 +// and the padded length is 3*3 (cellWidth multiplies the number of columns), which is 9.
     62 +func paddedLabelLength(graphWidth, longest, cellWidth int) (l, index int) {
     63 + return
     64 +}
     65 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/internal/axes/label_test.go
     1 +// Copyright 2020 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 axes
     16 + 
  • ■ ■ ■ ■ ■ ■
    widgets/heatmap/options.go
     1 +// Copyright 2020 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 heatmap
     16 + 
     17 +import (
     18 + "errors"
     19 + "github.com/mum4k/termdash/cell"
     20 +)
     21 + 
     22 +// options.go contains configurable options for HeatMap.
     23 + 
     24 +// Option is used to provide options.
     25 +type Option interface {
     26 + // set sets the provided option.
     27 + set(*options)
     28 +}
     29 + 
     30 +// options stores the provided options.
     31 +type options struct {
     32 + // The default value is 3
     33 + cellWidth int
     34 + xLabelCellOpts []cell.Option
     35 + yLabelCellOpts []cell.Option
     36 +}
     37 + 
     38 +// validate validates the provided options.
     39 +func (o *options) validate() error {
     40 + return errors.New("not implemented")
     41 +}
     42 + 
     43 +// newOptions returns a new options instance.
     44 +func newOptions(opts ...Option) *options {
     45 + opt := &options{
     46 + cellWidth: 3,
     47 + }
     48 + for _, o := range opts {
     49 + o.set(opt)
     50 + }
     51 + return opt
     52 +}
     53 + 
     54 +// option implements Option.
     55 +type option func(*options)
     56 + 
     57 +// set implements Option.set.
     58 +func (o option) set(opts *options) {
     59 + o(opts)
     60 +}
     61 + 
     62 +// CellWidth set the width of cells (or grids) in the heat map, not the terminal cell.
     63 +// The default height of each cell (grid) is 1 and the width is 3.
     64 +func CellWidth(w int) Option {
     65 + return option(func(opts *options) {
     66 + opts.cellWidth = w
     67 + })
     68 +}
     69 + 
     70 +// XLabelCellOpts set the cell options for the labels on the X axis.
     71 +func XLabelCellOpts(co ...cell.Option) Option {
     72 + return option(func(opts *options) {
     73 + opts.xLabelCellOpts = co
     74 + })
     75 +}
     76 + 
     77 +// YLabelCellOpts set the cell options for the labels on the Y axis.
     78 +func YLabelCellOpts(co ...cell.Option) Option {
     79 + return option(func(opts *options) {
     80 + opts.yLabelCellOpts = co
     81 + })
     82 +}
     83 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/linechart.go
    skipped 477 lines
    478 478  }
    479 479   
    480 480  // Keyboard implements widgetapi.Widget.Keyboard.
    481  -func (lc *LineChart) Keyboard(k *terminalapi.Keyboard) error {
     481 +func (lc *LineChart) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    482 482   return errors.New("the LineChart widget doesn't support keyboard events")
    483 483  }
    484 484   
    485 485  // Mouse implements widgetapi.Widget.Mouse.
    486  -func (lc *LineChart) Mouse(m *terminalapi.Mouse) error {
     486 +func (lc *LineChart) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    487 487   lc.mu.Lock()
    488 488   defer lc.mu.Unlock()
    489 489   
    skipped 60 lines
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/linechart_test.go
    skipped 1170 lines
    1171 1171   return lc.Mouse(&terminalapi.Mouse{
    1172 1172   Position: image.Point{6, 5},
    1173 1173   Button: mouse.ButtonLeft,
    1174  - })
     1174 + }, &widgetapi.EventMeta{})
    1175 1175   },
    1176 1176   wantCapacity: 28,
    1177 1177   want: func(size image.Point) *faketerm.Terminal {
    skipped 44 lines
    1222 1222   return lc.Mouse(&terminalapi.Mouse{
    1223 1223   Position: image.Point{6, 5},
    1224 1224   Button: mouse.ButtonLeft,
    1225  - })
     1225 + }, &widgetapi.EventMeta{})
    1226 1226   },
    1227 1227   wantCapacity: 28,
    1228 1228   want: func(size image.Point) *faketerm.Terminal {
    skipped 44 lines
    1273 1273   return lc.Mouse(&terminalapi.Mouse{
    1274 1274   Position: image.Point{8, 5},
    1275 1275   Button: mouse.ButtonWheelUp,
    1276  - })
     1276 + }, &widgetapi.EventMeta{})
    1277 1277   },
    1278 1278   wantCapacity: 28,
    1279 1279   want: func(size image.Point) *faketerm.Terminal {
    skipped 51 lines
    1331 1331   return lc.Mouse(&terminalapi.Mouse{
    1332 1332   Position: image.Point{6, 7},
    1333 1333   Button: mouse.ButtonLeft,
    1334  - })
     1334 + }, &widgetapi.EventMeta{})
    1335 1335   },
    1336 1336   wantCapacity: 28,
    1337 1337   want: func(size image.Point) *faketerm.Terminal {
    skipped 50 lines
    1388 1388   return lc.Mouse(&terminalapi.Mouse{
    1389 1389   Position: image.Point{5, 0},
    1390 1390   Button: mouse.ButtonWheelUp,
    1391  - })
     1391 + }, &widgetapi.EventMeta{})
    1392 1392   },
    1393 1393   wantCapacity: 10,
    1394 1394   want: func(size image.Point) *faketerm.Terminal {
    skipped 48 lines
    1443 1443   if err := lc.Mouse(&terminalapi.Mouse{
    1444 1444   Position: image.Point{5, 0},
    1445 1445   Button: mouse.ButtonWheelUp,
    1446  - }); err != nil {
     1446 + }, &widgetapi.EventMeta{}); err != nil {
    1447 1447   return err
    1448 1448   }
    1449 1449   
    skipped 451 lines
    1901 1901   if err != nil {
    1902 1902   t.Fatalf("New => unexpected error: %v", err)
    1903 1903   }
    1904  - if err := lc.Keyboard(&terminalapi.Keyboard{}); err == nil {
     1904 + if err := lc.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
    1905 1905   t.Errorf("Keyboard => got nil err, wanted one")
    1906 1906   }
    1907 1907  }
    skipped 3 lines
    1911 1911   if err != nil {
    1912 1912   t.Fatalf("New => unexpected error: %v", err)
    1913 1913   }
    1914  - if err := lc.Mouse(&terminalapi.Mouse{}); err != nil {
     1914 + if err := lc.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err != nil {
    1915 1915   t.Errorf("Mouse => unexpected error: %v", err)
    1916 1916   }
    1917 1917  }
    skipped 84 lines
  • ■ ■ ■ ■ ■ ■
    widgets/segmentdisplay/segmentdisplay.go
    skipped 291 lines
    292 292  }
    293 293   
    294 294  // Keyboard input isn't supported on the SegmentDisplay widget.
    295  -func (*SegmentDisplay) Keyboard(k *terminalapi.Keyboard) error {
     295 +func (*SegmentDisplay) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    296 296   return errors.New("the SegmentDisplay widget doesn't support keyboard events")
    297 297  }
    298 298   
    299 299  // Mouse input isn't supported on the SegmentDisplay widget.
    300  -func (*SegmentDisplay) Mouse(m *terminalapi.Mouse) error {
     300 +func (*SegmentDisplay) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    301 301   return errors.New("the SegmentDisplay widget doesn't support mouse events")
    302 302  }
    303 303   
    skipped 10 lines
  • ■ ■ ■ ■ ■ ■
    widgets/segmentdisplay/segmentdisplay_test.go
    skipped 981 lines
    982 982   if err != nil {
    983 983   t.Fatalf("New => unexpected error: %v", err)
    984 984   }
    985  - if err := sd.Keyboard(&terminalapi.Keyboard{}); err == nil {
     985 + if err := sd.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
    986 986   t.Errorf("Keyboard => got nil err, wanted one")
    987 987   }
    988 988  }
    skipped 3 lines
    992 992   if err != nil {
    993 993   t.Fatalf("New => unexpected error: %v", err)
    994 994   }
    995  - if err := sd.Mouse(&terminalapi.Mouse{}); err == nil {
     995 + if err := sd.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err == nil {
    996 996   t.Errorf("Mouse => got nil err, wanted one")
    997 997   }
    998 998  }
    skipped 18 lines
  • ■ ■ ■ ■ ■ ■
    widgets/sparkline/sparkline.go
    skipped 182 lines
    183 183  }
    184 184   
    185 185  // Keyboard input isn't supported on the SparkLine widget.
    186  -func (*SparkLine) Keyboard(k *terminalapi.Keyboard) error {
     186 +func (*SparkLine) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    187 187   return errors.New("the SparkLine widget doesn't support keyboard events")
    188 188  }
    189 189   
    190 190  // Mouse input isn't supported on the SparkLine widget.
    191  -func (*SparkLine) Mouse(m *terminalapi.Mouse) error {
     191 +func (*SparkLine) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    192 192   return errors.New("the SparkLine widget doesn't support mouse events")
    193 193  }
    194 194   
    skipped 60 lines
  • ■ ■ ■ ■ ■ ■
    widgets/text/text.go
    skipped 233 lines
    234 234  }
    235 235   
    236 236  // Keyboard implements widgetapi.Widget.Keyboard.
    237  -func (t *Text) Keyboard(k *terminalapi.Keyboard) error {
     237 +func (t *Text) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    238 238   t.mu.Lock()
    239 239   defer t.mu.Unlock()
    240 240   
    skipped 11 lines
    252 252  }
    253 253   
    254 254  // Mouse implements widgetapi.Widget.Mouse.
    255  -func (t *Text) Mouse(m *terminalapi.Mouse) error {
     255 +func (t *Text) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    256 256   t.mu.Lock()
    257 257   defer t.mu.Unlock()
    258 258   
    skipped 29 lines
  • ■ ■ ■ ■ ■ ■
    widgets/text/text_test.go
    skipped 309 lines
    310 310   events: func(widget *Text) {
    311 311   widget.Mouse(&terminalapi.Mouse{
    312 312   Button: mouse.ButtonWheelDown,
    313  - })
     313 + }, &widgetapi.EventMeta{})
    314 314   },
    315 315   want: func(size image.Point) *faketerm.Terminal {
    316 316   ft := faketerm.MustNew(size)
    skipped 15 lines
    332 332   events: func(widget *Text) {
    333 333   widget.Mouse(&terminalapi.Mouse{
    334 334   Button: mouse.ButtonWheelDown,
    335  - })
     335 + }, &widgetapi.EventMeta{})
    336 336   },
    337 337   want: func(size image.Point) *faketerm.Terminal {
    338 338   ft := faketerm.MustNew(size)
    skipped 14 lines
    353 353   events: func(widget *Text) {
    354 354   widget.Keyboard(&terminalapi.Keyboard{
    355 355   Key: keyboard.KeyArrowDown,
    356  - })
     356 + }, &widgetapi.EventMeta{})
    357 357   },
    358 358   want: func(size image.Point) *faketerm.Terminal {
    359 359   ft := faketerm.MustNew(size)
    skipped 15 lines
    375 375   events: func(widget *Text) {
    376 376   widget.Keyboard(&terminalapi.Keyboard{
    377 377   Key: keyboard.KeyPgDn,
    378  - })
     378 + }, &widgetapi.EventMeta{})
    379 379   },
    380 380   want: func(size image.Point) *faketerm.Terminal {
    381 381   ft := faketerm.MustNew(size)
    skipped 18 lines
    400 400   events: func(widget *Text) {
    401 401   widget.Mouse(&terminalapi.Mouse{
    402 402   Button: mouse.ButtonRight,
    403  - })
     403 + }, &widgetapi.EventMeta{})
    404 404   },
    405 405   want: func(size image.Point) *faketerm.Terminal {
    406 406   ft := faketerm.MustNew(size)
    skipped 18 lines
    425 425   events: func(widget *Text) {
    426 426   widget.Keyboard(&terminalapi.Keyboard{
    427 427   Key: 'd',
    428  - })
     428 + }, &widgetapi.EventMeta{})
    429 429   },
    430 430   want: func(size image.Point) *faketerm.Terminal {
    431 431   ft := faketerm.MustNew(size)
    skipped 18 lines
    450 450   events: func(widget *Text) {
    451 451   widget.Keyboard(&terminalapi.Keyboard{
    452 452   Key: 'l',
    453  - })
     453 + }, &widgetapi.EventMeta{})
    454 454   },
    455 455   want: func(size image.Point) *faketerm.Terminal {
    456 456   ft := faketerm.MustNew(size)
    skipped 191 lines
    648 648   }
    649 649   widget.Mouse(&terminalapi.Mouse{
    650 650   Button: mouse.ButtonWheelUp,
    651  - })
     651 + }, &widgetapi.EventMeta{})
    652 652   },
    653 653   want: func(size image.Point) *faketerm.Terminal {
    654 654   ft := faketerm.MustNew(size)
    skipped 22 lines
    677 677   }
    678 678   widget.Keyboard(&terminalapi.Keyboard{
    679 679   Key: keyboard.KeyArrowUp,
    680  - })
     680 + }, &widgetapi.EventMeta{})
    681 681   },
    682 682   want: func(size image.Point) *faketerm.Terminal {
    683 683   ft := faketerm.MustNew(size)
    skipped 22 lines
    706 706   }
    707 707   widget.Keyboard(&terminalapi.Keyboard{
    708 708   Key: keyboard.KeyPgUp,
    709  - })
     709 + }, &widgetapi.EventMeta{})
    710 710   },
    711 711   want: func(size image.Point) *faketerm.Terminal {
    712 712   ft := faketerm.MustNew(size)
    skipped 23 lines
    736 736   }
    737 737   widget.Mouse(&terminalapi.Mouse{
    738 738   Button: mouse.ButtonLeft,
    739  - })
     739 + }, &widgetapi.EventMeta{})
    740 740   },
    741 741   want: func(size image.Point) *faketerm.Terminal {
    742 742   ft := faketerm.MustNew(size)
    skipped 23 lines
    766 766   }
    767 767   widget.Keyboard(&terminalapi.Keyboard{
    768 768   Key: 'u',
    769  - })
     769 + }, &widgetapi.EventMeta{})
    770 770   },
    771 771   want: func(size image.Point) *faketerm.Terminal {
    772 772   ft := faketerm.MustNew(size)
    skipped 23 lines
    796 796   }
    797 797   widget.Keyboard(&terminalapi.Keyboard{
    798 798   Key: 'k',
    799  - })
     799 + }, &widgetapi.EventMeta{})
    800 800   },
    801 801   want: func(size image.Point) *faketerm.Terminal {
    802 802   ft := faketerm.MustNew(size)
    skipped 102 lines
  • ■ ■ ■ ■ ■ ■
    widgets/textinput/formdemo/formdemo.go
     1 +// Copyright 2020 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 formdemo creates a form that accepts text inputs and supports
     16 +// keyboard navigation.
     17 +package main
     18 + 
     19 +import (
     20 + "context"
     21 + "fmt"
     22 + "os/user"
     23 + "time"
     24 + 
     25 + "github.com/mum4k/termdash"
     26 + "github.com/mum4k/termdash/align"
     27 + "github.com/mum4k/termdash/cell"
     28 + "github.com/mum4k/termdash/container"
     29 + "github.com/mum4k/termdash/keyboard"
     30 + "github.com/mum4k/termdash/linestyle"
     31 + "github.com/mum4k/termdash/terminal/tcell"
     32 + "github.com/mum4k/termdash/widgets/button"
     33 + "github.com/mum4k/termdash/widgets/text"
     34 + "github.com/mum4k/termdash/widgets/textinput"
     35 +)
     36 + 
     37 +// buttonChunks creates the text chunks for a button from the provided text.
     38 +func buttonChunks(text string) []*button.TextChunk {
     39 + if len(text) == 0 {
     40 + return nil
     41 + }
     42 + first := string(text[0])
     43 + rest := string(text[1:])
     44 + 
     45 + return []*button.TextChunk{
     46 + button.NewChunk(
     47 + "<",
     48 + button.TextCellOpts(cell.FgColor(cell.ColorWhite)),
     49 + button.FocusedTextCellOpts(cell.FgColor(cell.ColorBlack)),
     50 + button.PressedTextCellOpts(cell.FgColor(cell.ColorBlack)),
     51 + ),
     52 + button.NewChunk(
     53 + first,
     54 + button.TextCellOpts(cell.FgColor(cell.ColorRed)),
     55 + ),
     56 + button.NewChunk(
     57 + rest,
     58 + button.TextCellOpts(cell.FgColor(cell.ColorWhite)),
     59 + button.FocusedTextCellOpts(cell.FgColor(cell.ColorBlack)),
     60 + button.PressedTextCellOpts(cell.FgColor(cell.ColorBlack)),
     61 + ),
     62 + button.NewChunk(
     63 + ">",
     64 + button.TextCellOpts(cell.FgColor(cell.ColorWhite)),
     65 + button.FocusedTextCellOpts(cell.FgColor(cell.ColorBlack)),
     66 + button.PressedTextCellOpts(cell.FgColor(cell.ColorBlack)),
     67 + ),
     68 + }
     69 +}
     70 + 
     71 +// form contains the elements of a text input form.
     72 +type form struct {
     73 + // userInput is a text input that accepts user name.
     74 + userInput *textinput.TextInput
     75 + // uidInput is a text input that accepts UID.
     76 + uidInput *textinput.TextInput
     77 + // gidInput is a text input that accepts GID.
     78 + gidInput *textinput.TextInput
     79 + // homeInput is a text input that accepts path to the home folder.
     80 + homeInput *textinput.TextInput
     81 + 
     82 + // submitB is a button that submits the form.
     83 + submitB *button.Button
     84 + // cancelB is a button that exist the application.
     85 + cancelB *button.Button
     86 +}
     87 + 
     88 +// newForm returns a new form instance.
     89 +// The cancel argument is a function that terminates the application when called.
     90 +func newForm(cancel context.CancelFunc) (*form, error) {
     91 + var username string
     92 + u, err := user.Current()
     93 + if err != nil {
     94 + username = "mum4k"
     95 + } else {
     96 + username = u.Username
     97 + }
     98 + 
     99 + userInput, err := textinput.New(
     100 + textinput.Label("Username: ", cell.FgColor(cell.ColorNumber(33))),
     101 + textinput.DefaultText(username),
     102 + textinput.MaxWidthCells(20),
     103 + textinput.ExclusiveKeyboardOnFocus(),
     104 + )
     105 + uidInput, err := textinput.New(
     106 + textinput.Label("UID: ", cell.FgColor(cell.ColorNumber(33))),
     107 + textinput.DefaultText("1000"),
     108 + textinput.MaxWidthCells(20),
     109 + textinput.ExclusiveKeyboardOnFocus(),
     110 + )
     111 + gidInput, err := textinput.New(
     112 + textinput.Label("GID: ", cell.FgColor(cell.ColorNumber(33))),
     113 + textinput.DefaultText("1000"),
     114 + textinput.MaxWidthCells(20),
     115 + textinput.ExclusiveKeyboardOnFocus(),
     116 + )
     117 + homeInput, err := textinput.New(
     118 + textinput.Label("Home: ", cell.FgColor(cell.ColorNumber(33))),
     119 + textinput.DefaultText(fmt.Sprintf("/home/%s", username)),
     120 + textinput.MaxWidthCells(20),
     121 + textinput.ExclusiveKeyboardOnFocus(),
     122 + )
     123 + 
     124 + submitB, err := button.NewFromChunks(buttonChunks("Submit"), nil,
     125 + button.Key(keyboard.KeyEnter),
     126 + button.GlobalKeys('s', 'S'),
     127 + button.DisableShadow(),
     128 + button.Height(1),
     129 + button.TextHorizontalPadding(0),
     130 + button.FillColor(cell.ColorBlack),
     131 + button.FocusedFillColor(cell.ColorNumber(117)),
     132 + button.PressedFillColor(cell.ColorNumber(220)),
     133 + )
     134 + if err != nil {
     135 + panic(err)
     136 + }
     137 + 
     138 + cancelB, err := button.NewFromChunks(buttonChunks("Cancel"), func() error {
     139 + cancel()
     140 + return nil
     141 + },
     142 + button.FillColor(cell.ColorNumber(220)),
     143 + button.Key(keyboard.KeyEnter),
     144 + button.GlobalKeys('c', 'C'),
     145 + button.DisableShadow(),
     146 + button.Height(1),
     147 + button.TextHorizontalPadding(0),
     148 + button.FillColor(cell.ColorBlack),
     149 + button.FocusedFillColor(cell.ColorNumber(117)),
     150 + button.PressedFillColor(cell.ColorNumber(220)),
     151 + )
     152 + if err != nil {
     153 + panic(err)
     154 + }
     155 + 
     156 + return &form{
     157 + userInput: userInput,
     158 + uidInput: uidInput,
     159 + gidInput: gidInput,
     160 + homeInput: homeInput,
     161 + submitB: submitB,
     162 + cancelB: cancelB,
     163 + }, nil
     164 +}
     165 + 
     166 +// formLayout updates the container into a layout with text inputs and buttons.
     167 +func formLayout(c *container.Container, f *form) error {
     168 + return c.Update("root",
     169 + container.KeyFocusNext(keyboard.KeyTab),
     170 + container.KeyFocusGroupsNext(keyboard.KeyArrowDown, 1),
     171 + container.KeyFocusGroupsPrevious(keyboard.KeyArrowUp, 1),
     172 + container.KeyFocusGroupsNext(keyboard.KeyArrowRight, 2),
     173 + container.KeyFocusGroupsPrevious(keyboard.KeyArrowLeft, 2),
     174 + container.SplitHorizontal(
     175 + container.Top(
     176 + container.Border(linestyle.Light),
     177 + container.SplitHorizontal(
     178 + container.Top(
     179 + container.SplitHorizontal(
     180 + container.Top(
     181 + container.Focused(),
     182 + container.KeyFocusGroups(1),
     183 + container.PlaceWidget(f.userInput),
     184 + ),
     185 + container.Bottom(
     186 + container.KeyFocusGroups(1),
     187 + container.KeyFocusSkip(),
     188 + container.PlaceWidget(f.uidInput),
     189 + ),
     190 + ),
     191 + ),
     192 + container.Bottom(
     193 + container.SplitHorizontal(
     194 + container.Top(
     195 + container.KeyFocusGroups(1),
     196 + container.KeyFocusSkip(),
     197 + container.PlaceWidget(f.gidInput),
     198 + ),
     199 + container.Bottom(
     200 + container.KeyFocusGroups(1),
     201 + container.KeyFocusSkip(),
     202 + container.PlaceWidget(f.homeInput),
     203 + ),
     204 + ),
     205 + ),
     206 + ),
     207 + ),
     208 + container.Bottom(
     209 + container.SplitHorizontal(
     210 + container.Top(
     211 + container.SplitVertical(
     212 + container.Left(
     213 + container.KeyFocusGroups(1, 2),
     214 + container.PlaceWidget(f.submitB),
     215 + container.AlignHorizontal(align.HorizontalRight),
     216 + container.PaddingRight(5),
     217 + ),
     218 + container.Right(
     219 + container.KeyFocusGroups(1, 2),
     220 + container.PlaceWidget(f.cancelB),
     221 + container.AlignHorizontal(align.HorizontalLeft),
     222 + container.PaddingLeft(5),
     223 + ),
     224 + ),
     225 + ),
     226 + container.Bottom(
     227 + container.KeyFocusSkip(),
     228 + ),
     229 + container.SplitFixed(3),
     230 + ),
     231 + ),
     232 + container.SplitFixed(6),
     233 + ),
     234 + )
     235 +}
     236 + 
     237 +// submitLayout updates the container into a layout that displays the submitted data.
     238 +// The cancel argument is a function that terminates Termdash when called.
     239 +func submitLayout(c *container.Container, f *form, cancel context.CancelFunc) error {
     240 + t, err := text.New()
     241 + if err != nil {
     242 + return err
     243 + }
     244 + 
     245 + if err := t.Write("Submitted data:\n\n"); err != nil {
     246 + return err
     247 + }
     248 + if err := t.Write(fmt.Sprintf("Username: %s\n", f.userInput.Read())); err != nil {
     249 + return err
     250 + }
     251 + if err := t.Write(fmt.Sprintf("UID: %s\n", f.uidInput.Read())); err != nil {
     252 + return err
     253 + }
     254 + if err := t.Write(fmt.Sprintf("GID: %s\n", f.gidInput.Read())); err != nil {
     255 + return err
     256 + }
     257 + if err := t.Write(fmt.Sprintf("Home: %s\n", f.homeInput.Read())); err != nil {
     258 + return err
     259 + }
     260 + 
     261 + okB, err := button.NewFromChunks(buttonChunks("OK"), func() error {
     262 + cancel()
     263 + return nil
     264 + },
     265 + button.FillColor(cell.ColorNumber(220)),
     266 + button.Key(keyboard.KeyEnter),
     267 + button.GlobalKeys('o', 'O'),
     268 + button.DisableShadow(),
     269 + button.Height(1),
     270 + button.TextHorizontalPadding(0),
     271 + button.FillColor(cell.ColorBlack),
     272 + button.FocusedFillColor(cell.ColorNumber(117)),
     273 + button.PressedFillColor(cell.ColorNumber(220)),
     274 + )
     275 + if err != nil {
     276 + return err
     277 + }
     278 + 
     279 + return c.Update("root",
     280 + container.SplitHorizontal(
     281 + container.Top(
     282 + container.SplitVertical(
     283 + container.Left(),
     284 + container.Right(
     285 + container.PlaceWidget(t),
     286 + ),
     287 + container.SplitPercent(33),
     288 + ),
     289 + ),
     290 + container.Bottom(
     291 + container.Focused(),
     292 + container.PlaceWidget(okB),
     293 + ),
     294 + container.SplitFixed(7),
     295 + ),
     296 + )
     297 +}
     298 + 
     299 +func main() {
     300 + t, err := tcell.New()
     301 + if err != nil {
     302 + panic(err)
     303 + }
     304 + defer t.Close()
     305 + 
     306 + ctx, cancel := context.WithCancel(context.Background())
     307 + c, err := container.New(t, container.ID("root"))
     308 + if err != nil {
     309 + panic(err)
     310 + }
     311 + 
     312 + f, err := newForm(cancel)
     313 + if err != nil {
     314 + panic(err)
     315 + }
     316 + f.submitB.SetCallback(func() error {
     317 + return submitLayout(c, f, cancel)
     318 + })
     319 + if err := formLayout(c, f); err != nil {
     320 + panic(err)
     321 + }
     322 + 
     323 + if err := termdash.Run(ctx, t, c, termdash.RedrawInterval(100*time.Millisecond)); err != nil {
     324 + panic(err)
     325 + }
     326 +}
     327 + 
  • ■ ■ ■ ■ ■ ■
    widgets/textinput/options.go
    skipped 16 lines
    17 17  // options.go contains configurable options for TextInput.
    18 18   
    19 19  import (
     20 + "errors"
    20 21   "fmt"
    21 22   
    22 23   "github.com/mum4k/termdash/align"
    skipped 35 lines
    58 59   
    59 60   placeHolder string
    60 61   hideTextWith rune
     62 + defaultText string
    61 63   
    62  - filter FilterFn
    63  - onSubmit SubmitFn
    64  - clearOnSubmit bool
     64 + filter FilterFn
     65 + onSubmit SubmitFn
     66 + clearOnSubmit bool
     67 + exclusiveKeyboardOnFocus bool
    65 68  }
    66 69   
    67 70  // validate validates the provided options.
    skipped 10 lines
    78 81   }
    79 82   if got, want := runewidth.RuneWidth(r), 1; got != want {
    80 83   return fmt.Errorf("invalid HideTextWidth rune %c(%d), has rune width of %d cells, only runes with width of %d are accepted", r, r, got, want)
     84 + }
     85 + }
     86 + if o.defaultText != "" {
     87 + if err := wrap.ValidText(o.defaultText); err != nil {
     88 + return fmt.Errorf("invalid DefaultText: %v", err)
     89 + }
     90 + for _, r := range o.defaultText {
     91 + if r == '\n' {
     92 + return errors.New("invalid DefaultText: newline characters aren't allowed")
     93 + }
    81 94   }
    82 95   }
    83 96   return nil
    skipped 180 lines
    264 277   })
    265 278  }
    266 279   
     280 +// ExclusiveKeyboardOnFocus when set ensures that when this widget is focused,
     281 +// no other widget receives any keyboard events.
     282 +func ExclusiveKeyboardOnFocus() Option {
     283 + return option(func(opts *options) {
     284 + opts.exclusiveKeyboardOnFocus = true
     285 + })
     286 +}
     287 + 
     288 +// DefaultText sets the text to be present in a newly created input field.
     289 +// The text must not contain any control or space characters other than ' '.
     290 +// The user can edit this text as normal.
     291 +func DefaultText(text string) Option {
     292 + return option(func(opts *options) {
     293 + opts.defaultText = text
     294 + })
     295 +}
     296 + 
  • ■ ■ ■ ■ ■ ■
    widgets/textinput/textinput.go
    skipped 68 lines
    69 69   if err := opt.validate(); err != nil {
    70 70   return nil, err
    71 71   }
    72  - return &TextInput{
     72 + ti := &TextInput{
    73 73   editor: newFieldEditor(),
    74 74   opts: opt,
    75  - }, nil
     75 + }
     76 + 
     77 + for _, r := range ti.opts.defaultText {
     78 + ti.editor.insert(r)
     79 + }
     80 + return ti, nil
    76 81  }
    77 82   
    78 83  // Vars to be replaced from tests.
    skipped 188 lines
    267 272   
    268 273  // Keyboard processes keyboard events.
    269 274  // Implements widgetapi.Widget.Keyboard.
    270  -func (ti *TextInput) Keyboard(k *terminalapi.Keyboard) error {
     275 +func (ti *TextInput) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
    271 276   if submitted, text := ti.keyboard(k); submitted {
    272 277   // Mutex must be released when calling the callback.
    273 278   // Users might call container methods from the callback like the
    skipped 5 lines
    279 284   
    280 285  // Mouse processes mouse events.
    281 286  // Implements widgetapi.Widget.Mouse.
    282  -func (ti *TextInput) Mouse(m *terminalapi.Mouse) error {
     287 +func (ti *TextInput) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
    283 288   ti.mu.Lock()
    284 289   defer ti.mu.Unlock()
    285 290   
    skipped 40 lines
    326 331   maxWidth,
    327 332   needHeight,
    328 333   },
    329  - WantKeyboard: widgetapi.KeyScopeFocused,
    330  - WantMouse: widgetapi.MouseScopeWidget,
     334 + WantKeyboard: widgetapi.KeyScopeFocused,
     335 + WantMouse: widgetapi.MouseScopeWidget,
     336 + ExclusiveKeyboardOnFocus: ti.opts.exclusiveKeyboardOnFocus,
    331 337   }
    332 338  }
    333 339   
    skipped 53 lines
Please wait...
Page is in error, reload to recover