@ -6,10 +6,12 @@ go:
|
||||
- stable
|
||||
script:
|
||||
- go get -t ./...
|
||||
- go get -u golang.org/x/lint/golint
|
||||
- go test ./...
|
||||
- go test -race ./...
|
||||
- go vet ./...
|
||||
- diff -u <(echo -n) <(gofmt -d -s .)
|
||||
- diff -u <(echo -n) <(./scripts/autogen_licences.sh .)
|
||||
- diff -u <(echo -n) <(./internal/scripts/autogen_licences.sh .)
|
||||
- diff -u <(echo -n) <(golint ./...)
|
||||
after_success:
|
||||
- ./scripts/coverage.sh
|
||||
|
82
CHANGELOG.md
@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] - 24-Feb-2019
|
||||
|
||||
### Added
|
||||
|
||||
#### New widgets
|
||||
|
||||
- The Button widget.
|
||||
|
||||
#### Improvements to documentation
|
||||
|
||||
- Clearly marked the public API surface by moving private packages into
|
||||
internal directory.
|
||||
- Started a GitHub wiki for Termdash.
|
||||
|
||||
#### Improvements to the LineChart widget
|
||||
|
||||
- The LineChart widget can display X axis labels in vertical orientation.
|
||||
- The LineChart widget allows the user to specify a custom scale for the Y
|
||||
axis.
|
||||
- The LineChart widget now has an option that disables scaling of the X axis.
|
||||
Useful for applications that want to continuously feed data and make them
|
||||
"roll" through the linechart.
|
||||
- The LineChart widget now has a method that returns the observed capacity of
|
||||
the LineChart the last time Draw was called.
|
||||
- The LineChart widget now supports zoom of the content triggered by mouse
|
||||
events.
|
||||
|
||||
#### Improvements to the Text widget
|
||||
|
||||
- The Text widget now has a Write option that atomically replaces the entire
|
||||
text content.
|
||||
|
||||
|
||||
#### Improvements to the infrastructure
|
||||
|
||||
- A function that draws text vertically.
|
||||
- A non-blocking event distribution system that can throttle repetitive events.
|
||||
- Generalized mouse button FSM for use in widgets that need to track mouse
|
||||
button clicks.
|
||||
|
||||
### Changed
|
||||
|
||||
- Termbox is now initialized in 256 color mode by default.
|
||||
- The infrastructure now uses the non-blocking event distribution system to
|
||||
distribute events to subscribers. Each widget is now an individual
|
||||
subscriber.
|
||||
- The infrastructure now throttles event driven screen redraw rather than
|
||||
redrawing for each input event.
|
||||
- Widgets can now specify the scope at which they want to receive keyboard and
|
||||
mouse events.
|
||||
|
||||
#### Breaking API changes
|
||||
|
||||
##### High impact
|
||||
|
||||
- The constructors of all the widgets now also return an error so that they
|
||||
can validate the options. This is a breaking change for the following
|
||||
widgets: BarChart, Gauge, LineChart, SparkLine, Text. The callers will have
|
||||
to handle the returned error.
|
||||
|
||||
##### Low impact
|
||||
|
||||
- The container package no longer exports separate methods to receive Keyboard
|
||||
and Mouse events which were replaced by a Subscribe method for the event
|
||||
distribution system. This shouldn't affect users as the removed methods
|
||||
aren't needed by container users.
|
||||
- The widgetapi.Options struct now uses an enum instead of a boolean when
|
||||
widget specifies if it wants keyboard or mouse events. This only impacts
|
||||
development of new widgets.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The LineChart widget now correctly determines the Y axis scale when multiple
|
||||
series are provided.
|
||||
- Lint issues in the codebase, and updated Travis configuration so that golint
|
||||
is executed on every run.
|
||||
- Termdash now correctly starts in locales like zh_CN.UTF-8 where some of the
|
||||
characters it uses internally can have ambiguous width.
|
||||
|
||||
## [0.6.1] - 12-Feb-2019
|
||||
|
||||
### Fixed
|
||||
@ -89,7 +168,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- The Gauge widget.
|
||||
- The Text widget.
|
||||
|
||||
[Unreleased]: https://github.com/mum4k/termdash/compare/v0.6.1...devel
|
||||
[Unreleased]: https://github.com/mum4k/termdash/compare/v0.7.0...devel
|
||||
[0.7.0]: https://github.com/mum4k/termdash/compare/v0.6.1...v0.7.0
|
||||
[0.6.1]: https://github.com/mum4k/termdash/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/mum4k/termdash/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://github.com/mum4k/termdash/compare/v0.4.0...v0.5.0
|
||||
|
73
README.md
@ -1,5 +1,6 @@
|
||||
[](https://godoc.org/github.com/mum4k/termdash)
|
||||
[](https://travis-ci.org/mum4k/termdash)
|
||||
[](https://sourcegraph.com/github.com/mum4k/termdash?badge)
|
||||
[](https://coveralls.io/github/mum4k/termdash?branch=master)
|
||||
[](https://goreportcard.com/report/github.com/mum4k/termdash)
|
||||
[](https://github.com/mum4k/termdash/blob/master/LICENSE)
|
||||
@ -7,12 +8,12 @@
|
||||
|
||||
# termdash
|
||||
|
||||
[<img src="./images/termdashdemo_0_6_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
|
||||
[<img src="./doc/images/termdashdemo_0_7_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
|
||||
|
||||
This project implements a cross-platform customizable terminal based dashboard.
|
||||
Termdash is a cross-platform customizable terminal based dashboard.
|
||||
The feature set is inspired by the
|
||||
[gizak/termui](http://github.com/gizak/termui) project, which in turn was
|
||||
inspired by a javascript based
|
||||
inspired by
|
||||
[yaronn/blessed-contrib](http://github.com/yaronn/blessed-contrib).
|
||||
|
||||
This rewrite focuses on code readability, maintainability and testability, see
|
||||
@ -20,6 +21,19 @@ the [design goals](doc/design_goals.md). It aims to achieve the following
|
||||
[requirements](doc/requirements.md). See the [high-level design](doc/hld.md)
|
||||
for more details.
|
||||
|
||||
# Public API and status
|
||||
|
||||
The public API surface is documented in the
|
||||
[wiki](http://github.com/mum4k/termdash/wiki).
|
||||
|
||||
Private packages can be identified by the presence of the **/internal/**
|
||||
directory in their import path. Stability of the private packages isn't
|
||||
guaranteed and changes won't be backward compatible.
|
||||
|
||||
There might still be breaking changes to the public API, at least until the
|
||||
project reaches version 1.0.0. Any breaking changes will be published in the
|
||||
[changelog](CHANGELOG.md).
|
||||
|
||||
# Current feature set
|
||||
|
||||
- Full support for terminal window resizing throughout the infrastructure.
|
||||
@ -32,8 +46,6 @@ for more details.
|
||||
- Drawing primitives (Go functions) for widget development with character and
|
||||
sub-character resolution.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for more details.
|
||||
|
||||
# Installation
|
||||
|
||||
To install this library, run the following:
|
||||
@ -54,14 +66,24 @@ go run github.com/mum4k/termdash/termdashdemo/termdashdemo.go
|
||||
|
||||
# Documentation
|
||||
|
||||
Code documentation can be viewed in
|
||||
[godoc](https://godoc.org/github.com/mum4k/termdash).
|
||||
Please refer to the [Termdash wiki](http://github.com/mum4k/termdash/wiki) for
|
||||
all documentation and resources.
|
||||
|
||||
Project documentation is available in the [doc](doc/) directory.
|
||||
# Implemented Widgets
|
||||
|
||||
## Implemented Widgets
|
||||
## The Button
|
||||
|
||||
### The Gauge
|
||||
Allows users to interact with the application, each button press runs a callback function.
|
||||
Run the
|
||||
[buttondemo](widgets/button/buttondemo/buttondemo.go).
|
||||
|
||||
```go
|
||||
go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go
|
||||
```
|
||||
|
||||
[<img src="./doc/images/buttondemo.gif" alt="buttondemo" type="image/gif" width="50%">](widgets/button/buttondemo/buttondemo.go)
|
||||
|
||||
## The Gauge
|
||||
|
||||
Displays the progress of an operation. Run the
|
||||
[gaugedemo](widgets/gauge/gaugedemo/gaugedemo.go).
|
||||
@ -70,9 +92,9 @@ Displays the progress of an operation. Run the
|
||||
go run github.com/mum4k/termdash/widgets/gauge/gaugedemo/gaugedemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/gaugedemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/gaugedemo/gaugedemo.go)
|
||||
[<img src="./doc/images/gaugedemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/gaugedemo/gaugedemo.go)
|
||||
|
||||
### The Donut
|
||||
## The Donut
|
||||
|
||||
Visualizes progress of an operation as a partial or a complete donut. Run the
|
||||
[donutdemo](widgets/donut/donutdemo/donutdemo.go).
|
||||
@ -81,9 +103,9 @@ Visualizes progress of an operation as a partial or a complete donut. Run the
|
||||
go run github.com/mum4k/termdash/widgets/donut/donutdemo/donutdemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/donutdemo.gif" alt="donutdemo" type="image/gif">](widgets/donut/donutdemo/donutdemo.go)
|
||||
[<img src="./doc/images/donutdemo.gif" alt="donutdemo" type="image/gif">](widgets/donut/donutdemo/donutdemo.go)
|
||||
|
||||
### The Text
|
||||
## The Text
|
||||
|
||||
Displays text content, supports trimming and scrolling of content. Run the
|
||||
[textdemo](widgets/text/textdemo/textdemo.go).
|
||||
@ -92,9 +114,9 @@ Displays text content, supports trimming and scrolling of content. Run the
|
||||
go run github.com/mum4k/termdash/widgets/text/textdemo/textdemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/textdemo.gif" alt="textdemo" type="image/gif">](widgets/text/textdemo/textdemo.go)
|
||||
[<img src="./doc/images/textdemo.gif" alt="textdemo" type="image/gif">](widgets/text/textdemo/textdemo.go)
|
||||
|
||||
### The SparkLine
|
||||
## The SparkLine
|
||||
|
||||
Draws a graph showing a series of values as vertical bars. The bars can have
|
||||
sub-cell height. Run the
|
||||
@ -104,9 +126,9 @@ sub-cell height. Run the
|
||||
go run github.com/mum4k/termdash/widgets/sparkline/sparklinedemo/sparklinedemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/sparklinedemo.gif" alt="sparklinedemo" type="image/gif" width="50%">](widgets/sparkline/sparklinedemo/sparklinedemo.go)
|
||||
[<img src="./doc/images/sparklinedemo.gif" alt="sparklinedemo" type="image/gif" width="50%">](widgets/sparkline/sparklinedemo/sparklinedemo.go)
|
||||
|
||||
### The BarChart
|
||||
## The BarChart
|
||||
|
||||
Displays multiple bars showing relative ratios of values. Run the
|
||||
[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go).
|
||||
@ -115,20 +137,21 @@ Displays multiple bars showing relative ratios of values. Run the
|
||||
go run github.com/mum4k/termdash/widgets/barchart/barchartdemo/barchartdemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/barchartdemo.gif" alt="barchartdemo" type="image/gif" width="50%">](widgets/barchart/barchartdemo/barchartdemo.go)
|
||||
[<img src="./doc/images/barchartdemo.gif" alt="barchartdemo" type="image/gif" width="50%">](widgets/barchart/barchartdemo/barchartdemo.go)
|
||||
|
||||
### The LineChart
|
||||
## The LineChart
|
||||
|
||||
Displays series of values on a line chart. Run the
|
||||
Displays series of values on a line chart, supports zoom triggered by mouse
|
||||
events. Run the
|
||||
[linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go).
|
||||
|
||||
```go
|
||||
go run github.com/mum4k/termdash/widgets/linechart/linechartdemo/linechartdemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/linechartdemo.gif" alt="linechartdemo" type="image/gif" width="70%">](widgets/linechart/linechartdemo/linechartdemo.go)
|
||||
[<img src="./doc/images/linechartdemo.gif" alt="linechartdemo" type="image/gif" width="70%">](widgets/linechart/linechartdemo/linechartdemo.go)
|
||||
|
||||
### The SegmentDisplay
|
||||
## The SegmentDisplay
|
||||
|
||||
Displays text by simulating a 16-segment display. Run the
|
||||
[segmentdisplaydemo](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go).
|
||||
@ -137,7 +160,7 @@ Displays text by simulating a 16-segment display. Run the
|
||||
go run github.com/mum4k/termdash/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go
|
||||
```
|
||||
|
||||
[<img src="./images/segmentdisplaydemo.gif" alt="segmentdisplaydemo" type="image/gif">](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go)
|
||||
[<img src="./doc/images/segmentdisplaydemo.gif" alt="segmentdisplaydemo" type="image/gif">](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go)
|
||||
|
||||
# Contributing
|
||||
|
||||
@ -157,6 +180,6 @@ If you're developing a new widget, please see the [widget
|
||||
development](doc/widget_development.md) section.
|
||||
|
||||
|
||||
## Disclaimer
|
||||
# Disclaimer
|
||||
|
||||
This is not an official Google product.
|
||||
|
@ -24,16 +24,19 @@ package container
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/align"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/event"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/widgetapi"
|
||||
)
|
||||
|
||||
// Container wraps either sub containers or widgets and positions them on the
|
||||
// terminal.
|
||||
// This is not thread-safe.
|
||||
// This is thread-safe.
|
||||
type Container struct {
|
||||
// parent is the parent container, nil if this is the root container.
|
||||
parent *Container
|
||||
@ -54,6 +57,10 @@ type Container struct {
|
||||
|
||||
// opts are the options provided to the container.
|
||||
opts *options
|
||||
|
||||
// mu protects the container tree.
|
||||
// All containers in the tree share the same lock.
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
// String represents the container metadata in a human readable format.
|
||||
@ -71,6 +78,7 @@ func New(t terminalapi.Terminal, opts ...Option) (*Container, error) {
|
||||
// The root container has access to the entire terminal.
|
||||
area: image.Rect(0, 0, size.X, size.Y),
|
||||
opts: newOptions( /* parent = */ nil),
|
||||
mu: &sync.Mutex{},
|
||||
}
|
||||
|
||||
// Initially the root is focused.
|
||||
@ -89,6 +97,7 @@ func newChild(parent *Container, area image.Rectangle) *Container {
|
||||
focusTracker: parent.focusTracker,
|
||||
area: area,
|
||||
opts: newOptions(parent.opts),
|
||||
mu: parent.mu,
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,51 +181,56 @@ func (c *Container) createSecond() (*Container, error) {
|
||||
|
||||
// Draw draws this container and all of its sub containers.
|
||||
func (c *Container) Draw() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return drawTree(c)
|
||||
}
|
||||
|
||||
// Keyboard is used to forward a keyboard event to the container.
|
||||
// Keyboard events are forwarded to the widget in the currently focused
|
||||
// container, assuming that the widget registered for keyboard events.
|
||||
func (c *Container) Keyboard(k *terminalapi.Keyboard) error {
|
||||
w := c.focusTracker.active().opts.widget
|
||||
if w == nil || !w.Options().WantKeyboard {
|
||||
return nil
|
||||
// updateFocus processes the mouse event and determines if it changes the
|
||||
// focused container.
|
||||
func (c *Container) updateFocus(m *terminalapi.Mouse) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
target := pointCont(c, m.Position)
|
||||
if target == nil { // Ignore mouse clicks where no containers are.
|
||||
return
|
||||
}
|
||||
return w.Keyboard(k)
|
||||
c.focusTracker.mouse(target, m)
|
||||
}
|
||||
|
||||
// Mouse is used to forward a mouse event to the container.
|
||||
// Container uses mouse events to track and change which is the active
|
||||
// (focused) container.
|
||||
//
|
||||
// If the container that receives the mouse click contains a widget that
|
||||
// registered for mouse events, the mouse event is further forwarded to that
|
||||
// widget. Only mouse events that fall within the widget's canvas are forwarded
|
||||
// and the coordinates are adjusted relative to the widget's canvas.
|
||||
func (c *Container) Mouse(m *terminalapi.Mouse) error {
|
||||
c.focusTracker.mouse(m)
|
||||
// keyboardToWidget forwards the keyboard event to the widget unconditionally.
|
||||
func (c *Container) keyboardToWidget(k *terminalapi.Keyboard, scope widgetapi.KeyScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if scope == widgetapi.KeyScopeFocused && !c.focusTracker.isActive(c) {
|
||||
return nil
|
||||
}
|
||||
return c.opts.widget.Keyboard(k)
|
||||
}
|
||||
|
||||
// mouseToWidget forwards the mouse event to the widget.
|
||||
func (c *Container) mouseToWidget(m *terminalapi.Mouse, scope widgetapi.MouseScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
target := pointCont(c, m.Position)
|
||||
if target == nil { // Ignore mouse clicks where no containers are.
|
||||
return nil
|
||||
}
|
||||
w := target.opts.widget
|
||||
if w == nil || !w.Options().WantMouse {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ignore clicks falling outside of the container.
|
||||
if !m.Position.In(target.usable()) {
|
||||
if scope != widgetapi.MouseScopeGlobal && !m.Position.In(c.area) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ignore clicks falling outside of the widget's canvas.
|
||||
wa, err := target.widgetArea()
|
||||
wa, err := c.widgetArea()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.Position.In(wa) {
|
||||
if scope == widgetapi.MouseScopeWidget && !m.Position.In(wa) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -224,9 +238,68 @@ func (c *Container) Mouse(m *terminalapi.Mouse) error {
|
||||
// based, even though the widget might not be in the top left corner on the
|
||||
// terminal.
|
||||
offset := wa.Min
|
||||
wm := &terminalapi.Mouse{
|
||||
Position: m.Position.Sub(offset),
|
||||
Button: m.Button,
|
||||
var wm *terminalapi.Mouse
|
||||
if m.Position.In(wa) {
|
||||
wm = &terminalapi.Mouse{
|
||||
Position: m.Position.Sub(offset),
|
||||
Button: m.Button,
|
||||
}
|
||||
} else {
|
||||
wm = &terminalapi.Mouse{
|
||||
Position: image.Point{-1, -1},
|
||||
Button: m.Button,
|
||||
}
|
||||
}
|
||||
return w.Mouse(wm)
|
||||
return c.opts.widget.Mouse(wm)
|
||||
}
|
||||
|
||||
// Subscribe tells the container to subscribe itself and widgets to the
|
||||
// provided event distribution system.
|
||||
// This method is private to termdash, stability isn't guaranteed and changes
|
||||
// won't be backward compatible.
|
||||
func (c *Container) Subscribe(eds *event.DistributionSystem) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// maxReps is the maximum number of repetitive events towards widgets
|
||||
// before we throttle them.
|
||||
const maxReps = 10
|
||||
|
||||
root := rootCont(c)
|
||||
// Subscriber the container itself in order to track keyboard focus.
|
||||
eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) {
|
||||
root.updateFocus(ev.(*terminalapi.Mouse))
|
||||
}, event.MaxRepetitive(0)) // One event is enough to change the focus.
|
||||
|
||||
// Subscribe any widgets that specify Keyboard or Mouse in their options.
|
||||
var errStr string
|
||||
preOrder(root, &errStr, visitFunc(func(c *Container) error {
|
||||
if c.hasWidget() {
|
||||
wOpt := c.opts.widget.Options()
|
||||
switch scope := wOpt.WantKeyboard; scope {
|
||||
case widgetapi.KeyScopeNone:
|
||||
// Widget doesn't want any keyboard events.
|
||||
|
||||
default:
|
||||
eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) {
|
||||
if err := c.keyboardToWidget(ev.(*terminalapi.Keyboard), scope); err != nil {
|
||||
eds.Event(terminalapi.NewErrorf("failed to send global keyboard event %v to widget %T: %v", ev, c.opts.widget, err))
|
||||
}
|
||||
}, event.MaxRepetitive(maxReps))
|
||||
}
|
||||
|
||||
switch scope := wOpt.WantMouse; scope {
|
||||
case widgetapi.MouseScopeNone:
|
||||
// Widget doesn't want any mouse events.
|
||||
|
||||
default:
|
||||
eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) {
|
||||
if err := c.mouseToWidget(ev.(*terminalapi.Mouse), scope); err != nil {
|
||||
eds.Event(terminalapi.NewErrorf("failed to send mouse event %v to widget %T: %v", ev, c.opts.widget, err))
|
||||
}
|
||||
}, event.MaxRepetitive(maxReps))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
@ -15,19 +15,24 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/draw/testdraw"
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
"github.com/mum4k/termdash/internal/align"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/draw/testdraw"
|
||||
"github.com/mum4k/termdash/internal/event"
|
||||
"github.com/mum4k/termdash/internal/event/testevent"
|
||||
"github.com/mum4k/termdash/internal/keyboard"
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/widgetapi"
|
||||
"github.com/mum4k/termdash/widgets/fakewidget"
|
||||
)
|
||||
|
||||
@ -523,14 +528,40 @@ func TestNew(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
// eventGroup is a group of events to be delivered with synchronization.
|
||||
// I.e. the test execution waits until the specified number is processed before
|
||||
// proceeding with test execution.
|
||||
type eventGroup struct {
|
||||
events []terminalapi.Event
|
||||
wantProcessed int
|
||||
}
|
||||
|
||||
// errorHandler just stores the last error received.
|
||||
type errorHandler struct {
|
||||
err error
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (eh *errorHandler) get() error {
|
||||
eh.mu.Lock()
|
||||
defer eh.mu.Unlock()
|
||||
return eh.err
|
||||
}
|
||||
|
||||
func (eh *errorHandler) handle(err error) {
|
||||
eh.mu.Lock()
|
||||
defer eh.mu.Unlock()
|
||||
eh.err = err
|
||||
}
|
||||
|
||||
func TestKeyboard(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
termSize image.Point
|
||||
container func(ft *faketerm.Terminal) (*Container, error)
|
||||
events []terminalapi.Event
|
||||
want func(size image.Point) *faketerm.Terminal
|
||||
wantErr bool
|
||||
desc string
|
||||
termSize image.Point
|
||||
container func(ft *faketerm.Terminal) (*Container, error)
|
||||
eventGroups []*eventGroup
|
||||
want func(size image.Point) *faketerm.Terminal
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "event not forwarded if container has no widget",
|
||||
@ -538,8 +569,13 @@ func TestKeyboard(t *testing.T) {
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(ft)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
eventGroups: []*eventGroup{
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
wantProcessed: 0,
|
||||
},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
@ -553,26 +589,39 @@ func TestKeyboard(t *testing.T) {
|
||||
ft,
|
||||
SplitVertical(
|
||||
Left(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
|
||||
),
|
||||
Right(
|
||||
SplitHorizontal(
|
||||
Top(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
|
||||
),
|
||||
Bottom(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
|
||||
&terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
eventGroups: []*eventGroup{
|
||||
// Move focus to the target container.
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
|
||||
&terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
// Send the keyboard event.
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
wantProcessed: 5,
|
||||
},
|
||||
},
|
||||
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
@ -580,19 +629,89 @@ func TestKeyboard(t *testing.T) {
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
|
||||
widgetapi.Options{WantKeyboard: true},
|
||||
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
|
||||
)
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(20, 0, 40, 10)),
|
||||
widgetapi.Options{WantKeyboard: true},
|
||||
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
|
||||
)
|
||||
|
||||
// The focused widget receives the key.
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
|
||||
widgetapi.Options{WantKeyboard: true},
|
||||
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "event forwarded to all widgets that requested global key scope",
|
||||
termSize: image.Point{40, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
SplitVertical(
|
||||
Left(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
|
||||
),
|
||||
Right(
|
||||
SplitHorizontal(
|
||||
Top(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
|
||||
),
|
||||
Bottom(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
eventGroups: []*eventGroup{
|
||||
// Move focus to the target container.
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
|
||||
&terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
// Send the keyboard event.
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
wantProcessed: 5,
|
||||
},
|
||||
},
|
||||
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
// Widget that isn't focused, but registered for global
|
||||
// keyboard events.
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
|
||||
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
)
|
||||
|
||||
// Widget that isn't focused and only wants focused events.
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(20, 0, 40, 10)),
|
||||
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
|
||||
)
|
||||
|
||||
// The focused widget receives the key.
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
|
||||
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
)
|
||||
return ft
|
||||
@ -604,11 +723,16 @@ func TestKeyboard(t *testing.T) {
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: false})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeNone})),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
eventGroups: []*eventGroup{
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
wantProcessed: 0,
|
||||
},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
@ -627,11 +751,16 @@ func TestKeyboard(t *testing.T) {
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc},
|
||||
eventGroups: []*eventGroup{
|
||||
{
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc},
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
@ -658,21 +787,26 @@ func TestKeyboard(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("tc.container => unexpected error: %v", err)
|
||||
}
|
||||
for _, ev := range tc.events {
|
||||
switch e := ev.(type) {
|
||||
case *terminalapi.Mouse:
|
||||
if err := c.Mouse(e); err != nil {
|
||||
t.Fatalf("Mouse => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
case *terminalapi.Keyboard:
|
||||
err := c.Keyboard(e)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Keyboard => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
eds := event.NewDistributionSystem()
|
||||
eh := &errorHandler{}
|
||||
// Subscribe to receive errors.
|
||||
eds.Subscribe([]terminalapi.Event{terminalapi.NewError("")}, func(ev terminalapi.Event) {
|
||||
eh.handle(ev.(*terminalapi.Error).Error())
|
||||
})
|
||||
|
||||
default:
|
||||
t.Fatalf("Unsupported event %T.", e)
|
||||
c.Subscribe(eds)
|
||||
for _, eg := range tc.eventGroups {
|
||||
for _, ev := range eg.events {
|
||||
eds.Event(ev)
|
||||
}
|
||||
if err := testevent.WaitFor(5*time.Second, func() error {
|
||||
if got, want := eds.Processed(), eg.wantProcessed; got != want {
|
||||
return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("testevent.WaitFor => %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -683,18 +817,23 @@ func TestKeyboard(t *testing.T) {
|
||||
if diff := faketerm.Diff(tc.want(tc.termSize), got); diff != "" {
|
||||
t.Errorf("Draw => %v", diff)
|
||||
}
|
||||
|
||||
if err := eh.get(); (err != nil) != tc.wantErr {
|
||||
t.Errorf("errorHandler => unexpected error %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouse(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
termSize image.Point
|
||||
container func(ft *faketerm.Terminal) (*Container, error)
|
||||
events []terminalapi.Event
|
||||
want func(size image.Point) *faketerm.Terminal
|
||||
wantErr bool
|
||||
desc string
|
||||
termSize image.Point
|
||||
container func(ft *faketerm.Terminal) (*Container, error)
|
||||
events []terminalapi.Event
|
||||
want func(size image.Point) *faketerm.Terminal
|
||||
wantProcessed int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "mouse click outside of the terminal is ignored",
|
||||
@ -702,7 +841,7 @@ func TestMouse(t *testing.T) {
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
@ -719,6 +858,7 @@ func TestMouse(t *testing.T) {
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 4,
|
||||
},
|
||||
{
|
||||
desc: "event not forwarded if container has no widget",
|
||||
@ -733,6 +873,7 @@ func TestMouse(t *testing.T) {
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "event forwarded to container at that point",
|
||||
@ -742,15 +883,15 @@ func TestMouse(t *testing.T) {
|
||||
ft,
|
||||
SplitVertical(
|
||||
Left(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
|
||||
),
|
||||
Right(
|
||||
SplitHorizontal(
|
||||
Top(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
|
||||
),
|
||||
Bottom(
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -772,7 +913,7 @@ func TestMouse(t *testing.T) {
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
|
||||
widgetapi.Options{WantMouse: true},
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Keyboard{},
|
||||
)
|
||||
|
||||
@ -780,12 +921,13 @@ func TestMouse(t *testing.T) {
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(25, 0, 50, 10)),
|
||||
widgetapi.Options{WantMouse: true},
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonLeft},
|
||||
&terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonRelease},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 8,
|
||||
},
|
||||
{
|
||||
desc: "event not forwarded if the widget didn't request it",
|
||||
@ -793,7 +935,7 @@ func TestMouse(t *testing.T) {
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: false})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeNone})),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
@ -809,16 +951,17 @@ func TestMouse(t *testing.T) {
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 1,
|
||||
},
|
||||
{
|
||||
desc: "event not forwarded if it falls on the container's border",
|
||||
desc: "MouseScopeWidget, event not forwarded if it falls on the container's border",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
Border(draw.LineStyleLight),
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{WantMouse: true}),
|
||||
fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}),
|
||||
),
|
||||
)
|
||||
},
|
||||
@ -843,16 +986,89 @@ func TestMouse(t *testing.T) {
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "event not forwarded if it falls outside of widget's canvas",
|
||||
desc: "MouseScopeContainer, event forwarded if it falls on the container's border",
|
||||
termSize: image.Point{21, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
Border(draw.LineStyleLight),
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeContainer}),
|
||||
),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
testdraw.MustBorder(
|
||||
cvs,
|
||||
ft.Area(),
|
||||
draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
|
||||
)
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "MouseScopeGlobal, event forwarded if it falls on the container's border",
|
||||
termSize: image.Point{21, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
Border(draw.LineStyleLight),
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeGlobal}),
|
||||
),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
testdraw.MustBorder(
|
||||
cvs,
|
||||
ft.Area(),
|
||||
draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
|
||||
)
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "MouseScopeWidget event not forwarded if it falls outside of widget's canvas",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: true,
|
||||
WantMouse: widgetapi.MouseScopeWidget,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
@ -873,16 +1089,190 @@ func TestMouse(t *testing.T) {
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "mouse poisition adjusted relative to widget's canvas, vertical offset",
|
||||
desc: "MouseScopeContainer event forwarded if it falls outside of widget's canvas",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: true,
|
||||
WantMouse: widgetapi.MouseScopeContainer,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
AlignVertical(align.VerticalMiddle),
|
||||
AlignHorizontal(align.HorizontalCenter),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "MouseScopeGlobal event forwarded if it falls outside of widget's canvas",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: widgetapi.MouseScopeGlobal,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
AlignVertical(align.VerticalMiddle),
|
||||
AlignHorizontal(align.HorizontalCenter),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "MouseScopeWidget event not forwarded if it falls to another container",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
SplitHorizontal(
|
||||
Top(),
|
||||
Bottom(
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: widgetapi.MouseScopeWidget,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
AlignVertical(align.VerticalMiddle),
|
||||
AlignHorizontal(align.HorizontalCenter),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 10, 20, 20)),
|
||||
widgetapi.Options{},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "MouseScopeContainer event not forwarded if it falls to another container",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
SplitHorizontal(
|
||||
Top(),
|
||||
Bottom(
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: widgetapi.MouseScopeContainer,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
AlignVertical(align.VerticalMiddle),
|
||||
AlignHorizontal(align.HorizontalCenter),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 10, 20, 20)),
|
||||
widgetapi.Options{},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "MouseScopeGlobal event forwarded if it falls to another container",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
SplitHorizontal(
|
||||
Top(),
|
||||
Bottom(
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: widgetapi.MouseScopeGlobal,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
AlignVertical(align.VerticalMiddle),
|
||||
AlignHorizontal(align.HorizontalCenter),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 10, 20, 20)),
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "mouse position adjusted relative to widget's canvas, vertical offset",
|
||||
termSize: image.Point{20, 20},
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: widgetapi.MouseScopeWidget,
|
||||
Ratio: image.Point{2, 1},
|
||||
}),
|
||||
),
|
||||
@ -899,11 +1289,12 @@ func TestMouse(t *testing.T) {
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
|
||||
widgetapi.Options{WantMouse: true},
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "mouse poisition adjusted relative to widget's canvas, horizontal offset",
|
||||
@ -913,7 +1304,7 @@ func TestMouse(t *testing.T) {
|
||||
ft,
|
||||
PlaceWidget(
|
||||
fakewidget.New(widgetapi.Options{
|
||||
WantMouse: true,
|
||||
WantMouse: widgetapi.MouseScopeWidget,
|
||||
Ratio: image.Point{9, 10},
|
||||
}),
|
||||
),
|
||||
@ -930,11 +1321,12 @@ func TestMouse(t *testing.T) {
|
||||
fakewidget.MustDraw(
|
||||
ft,
|
||||
testcanvas.MustNew(image.Rect(6, 0, 24, 20)),
|
||||
widgetapi.Options{WantMouse: true},
|
||||
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "widget returns an error when processing the event",
|
||||
@ -942,7 +1334,7 @@ func TestMouse(t *testing.T) {
|
||||
container: func(ft *faketerm.Terminal) (*Container, error) {
|
||||
return New(
|
||||
ft,
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
|
||||
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
|
||||
)
|
||||
},
|
||||
events: []terminalapi.Event{
|
||||
@ -958,7 +1350,8 @@ func TestMouse(t *testing.T) {
|
||||
)
|
||||
return ft
|
||||
},
|
||||
wantErr: true,
|
||||
wantProcessed: 3,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
@ -973,22 +1366,24 @@ func TestMouse(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("tc.container => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
eds := event.NewDistributionSystem()
|
||||
eh := &errorHandler{}
|
||||
// Subscribe to receive errors.
|
||||
eds.Subscribe([]terminalapi.Event{terminalapi.NewError("")}, func(ev terminalapi.Event) {
|
||||
eh.handle(ev.(*terminalapi.Error).Error())
|
||||
})
|
||||
c.Subscribe(eds)
|
||||
for _, ev := range tc.events {
|
||||
switch e := ev.(type) {
|
||||
case *terminalapi.Mouse:
|
||||
err := c.Mouse(e)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Mouse => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
|
||||
case *terminalapi.Keyboard:
|
||||
if err := c.Keyboard(e); err != nil {
|
||||
t.Fatalf("Keyboard => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
default:
|
||||
t.Fatalf("Unsupported event %T.", e)
|
||||
eds.Event(ev)
|
||||
}
|
||||
if err := testevent.WaitFor(5*time.Second, func() error {
|
||||
if got, want := eds.Processed(), tc.wantProcessed; got != want {
|
||||
return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("testevent.WaitFor => %v", err)
|
||||
}
|
||||
|
||||
if err := c.Draw(); err != nil {
|
||||
@ -998,6 +1393,10 @@ func TestMouse(t *testing.T) {
|
||||
if diff := faketerm.Diff(tc.want(tc.termSize), got); diff != "" {
|
||||
t.Errorf("Draw => %v", diff)
|
||||
}
|
||||
|
||||
if err := eh.get(); (err != nil) != tc.wantErr {
|
||||
t.Errorf("errorHandler => unexpected error %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -21,10 +21,10 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
)
|
||||
|
||||
// drawTree draws this container and all of its sub containers.
|
||||
|
@ -18,13 +18,13 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/draw/testdraw"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
"github.com/mum4k/termdash/internal/align"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/draw/testdraw"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/widgetapi"
|
||||
"github.com/mum4k/termdash/widgets/fakewidget"
|
||||
)
|
||||
|
||||
|
@ -19,7 +19,9 @@ package container
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/mouse/button"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// pointCont finds the top-most (on the screen) container whose area contains
|
||||
@ -50,9 +52,9 @@ type focusTracker struct {
|
||||
// a mouse click and now waiting for a release or a timeout.
|
||||
candidate *Container
|
||||
|
||||
// mouseFSM is a state machine tracking mouse clicks in containers and
|
||||
// buttonFSM is a state machine tracking mouse clicks in containers and
|
||||
// moving focus from one container to the next.
|
||||
mouseFSM mouseStateFn
|
||||
buttonFSM *button.FSM
|
||||
}
|
||||
|
||||
// newFocusTracker returns a new focus tracker with focus set at the provided
|
||||
@ -60,7 +62,9 @@ type focusTracker struct {
|
||||
func newFocusTracker(c *Container) *focusTracker {
|
||||
return &focusTracker{
|
||||
container: c,
|
||||
mouseFSM: mouseWantLeftButton,
|
||||
// Mouse FSM tracking clicks inside the entire area for the root
|
||||
// container.
|
||||
buttonFSM: button.NewFSM(mouse.ButtonLeft, c.area),
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +80,15 @@ func (ft *focusTracker) active() *Container {
|
||||
|
||||
// mouse identifies mouse events that change the focused container and track
|
||||
// the focused container in the tree.
|
||||
func (ft *focusTracker) mouse(m *terminalapi.Mouse) {
|
||||
ft.mouseFSM = ft.mouseFSM(ft, m)
|
||||
// The argument c is the container onto which the mouse event landed.
|
||||
func (ft *focusTracker) mouse(target *Container, m *terminalapi.Mouse) {
|
||||
clicked, bs := ft.buttonFSM.Event(m)
|
||||
switch {
|
||||
case bs == button.Down:
|
||||
ft.candidate = target
|
||||
case bs == button.Up && clicked:
|
||||
if target == ft.candidate {
|
||||
ft.container = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,14 +15,18 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/event"
|
||||
"github.com/mum4k/termdash/internal/event/testevent"
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// pointCase is a test case for the pointCont function.
|
||||
@ -290,8 +294,9 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
// Can be either the mouse event or a time.Duration to pause for.
|
||||
events []*terminalapi.Mouse
|
||||
wantFocused contLoc
|
||||
events []*terminalapi.Mouse
|
||||
wantFocused contLoc
|
||||
wantProcessed int
|
||||
}{
|
||||
{
|
||||
desc: "initially the root is focused",
|
||||
@ -303,7 +308,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
{Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocLeft,
|
||||
wantFocused: contLocLeft,
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "click and release moves focus to the right",
|
||||
@ -311,7 +317,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: image.Point{5, 5}, Button: mouse.ButtonLeft},
|
||||
{Position: image.Point{6, 6}, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocRight,
|
||||
wantFocused: contLocRight,
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "click in the same container is a no-op",
|
||||
@ -321,7 +328,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: insideRight, Button: mouse.ButtonLeft},
|
||||
{Position: insideRight, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocRight,
|
||||
wantFocused: contLocRight,
|
||||
wantProcessed: 4,
|
||||
},
|
||||
{
|
||||
desc: "click in the same container and release never happens",
|
||||
@ -330,7 +338,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: insideLeft, Button: mouse.ButtonLeft},
|
||||
{Position: insideLeft, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocLeft,
|
||||
wantFocused: contLocLeft,
|
||||
wantProcessed: 3,
|
||||
},
|
||||
{
|
||||
desc: "click in the same container, release elsewhere",
|
||||
@ -338,7 +347,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: insideRight, Button: mouse.ButtonLeft},
|
||||
{Position: insideLeft, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocRoot,
|
||||
wantFocused: contLocRoot,
|
||||
wantProcessed: 2,
|
||||
},
|
||||
{
|
||||
desc: "other buttons are ignored",
|
||||
@ -350,7 +360,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: insideLeft, Button: mouse.ButtonWheelUp},
|
||||
{Position: insideLeft, Button: mouse.ButtonWheelDown},
|
||||
},
|
||||
wantFocused: contLocRoot,
|
||||
wantFocused: contLocRoot,
|
||||
wantProcessed: 6,
|
||||
},
|
||||
{
|
||||
desc: "moving mouse with pressed button and then releasing moves focus",
|
||||
@ -359,7 +370,8 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: image.Point{1, 1}, Button: mouse.ButtonLeft},
|
||||
{Position: image.Point{2, 2}, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocLeft,
|
||||
wantFocused: contLocLeft,
|
||||
wantProcessed: 3,
|
||||
},
|
||||
{
|
||||
desc: "click ignored if followed by another click of the same button elsewhere",
|
||||
@ -367,9 +379,9 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: insideRight, Button: mouse.ButtonLeft},
|
||||
{Position: insideLeft, Button: mouse.ButtonLeft},
|
||||
{Position: insideRight, Button: mouse.ButtonRelease},
|
||||
{Position: insideRight, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocRoot,
|
||||
wantFocused: contLocRoot,
|
||||
wantProcessed: 3,
|
||||
},
|
||||
{
|
||||
desc: "click ignored if followed by another click of a different button",
|
||||
@ -377,9 +389,9 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
{Position: insideRight, Button: mouse.ButtonLeft},
|
||||
{Position: insideRight, Button: mouse.ButtonMiddle},
|
||||
{Position: insideRight, Button: mouse.ButtonRelease},
|
||||
{Position: insideRight, Button: mouse.ButtonRelease},
|
||||
},
|
||||
wantFocused: contLocRoot,
|
||||
wantFocused: contLocRoot,
|
||||
wantProcessed: 3,
|
||||
},
|
||||
}
|
||||
|
||||
@ -396,8 +408,18 @@ func TestFocusTrackerMouse(t *testing.T) {
|
||||
t.Fatalf("New => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
eds := event.NewDistributionSystem()
|
||||
root.Subscribe(eds)
|
||||
for _, ev := range tc.events {
|
||||
root.Mouse(ev)
|
||||
eds.Event(ev)
|
||||
}
|
||||
if err := testevent.WaitFor(5*time.Second, func() error {
|
||||
if got, want := eds.Processed(), tc.wantProcessed; got != want {
|
||||
return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("testevent.WaitFor => %v", err)
|
||||
}
|
||||
|
||||
var wantFocused *Container
|
||||
|
@ -1,67 +0,0 @@
|
||||
// Copyright 2018 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package container
|
||||
|
||||
// mouse_fsm.go implements a state machine that tracks mouse clicks in regards
|
||||
// to changing which container is focused.
|
||||
|
||||
import (
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
)
|
||||
|
||||
// mouseStateFn is a single state in the focus tracking state machine.
|
||||
// Returns the next state.
|
||||
type mouseStateFn func(ft *focusTracker, m *terminalapi.Mouse) mouseStateFn
|
||||
|
||||
// nextForLeftClick determines the next state for a left mouse click.
|
||||
func nextForLeftClick(ft *focusTracker, m *terminalapi.Mouse) mouseStateFn {
|
||||
// The click isn't in any known container.
|
||||
if ft.candidate = pointCont(ft.container, m.Position); ft.candidate == nil {
|
||||
return mouseWantLeftButton
|
||||
}
|
||||
return mouseWantRelease
|
||||
}
|
||||
|
||||
// mouseWantLeftButton is the initial state, expecting a left button click inside a container.
|
||||
func mouseWantLeftButton(ft *focusTracker, m *terminalapi.Mouse) mouseStateFn {
|
||||
if m.Button != mouse.ButtonLeft {
|
||||
return mouseWantLeftButton
|
||||
}
|
||||
return nextForLeftClick(ft, m)
|
||||
}
|
||||
|
||||
// mouseWantRelease waits for a mouse button release in the same container as
|
||||
// the click or a timeout or other left mouse button click.
|
||||
func mouseWantRelease(ft *focusTracker, m *terminalapi.Mouse) mouseStateFn {
|
||||
switch m.Button {
|
||||
case mouse.ButtonLeft:
|
||||
return nextForLeftClick(ft, m)
|
||||
|
||||
case mouse.ButtonRelease:
|
||||
// Process the release.
|
||||
default:
|
||||
return mouseWantLeftButton
|
||||
}
|
||||
|
||||
// The release happened in another container.
|
||||
if ft.candidate != pointCont(ft.container, m.Position) {
|
||||
return mouseWantLeftButton
|
||||
}
|
||||
|
||||
ft.container = ft.candidate
|
||||
ft.candidate = nil
|
||||
return mouseWantLeftButton
|
||||
}
|
@ -19,10 +19,10 @@ package container
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
"github.com/mum4k/termdash/internal/align"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/widgetapi"
|
||||
)
|
||||
|
||||
// applyOptions applies the options to the container.
|
||||
|
@ -20,8 +20,8 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestRoot(t *testing.T) {
|
||||
|
67
doc/hld.md
@ -36,10 +36,11 @@ neither the users of this library nor the widgets interact with the terminal
|
||||
directly.
|
||||
|
||||
The **infrastructure layer** is responsible for container management, tracking
|
||||
of keyboard and mouse focus and handling external events like resizing of the
|
||||
terminal. The infrastructure layer also decides when to flush the buffer and
|
||||
refresh the screen. I.e. The widgets update content of a back buffer and the
|
||||
infrastructure decides when it is synchronized to the terminal.
|
||||
of keyboard and mouse focus and distribution and handling of external events
|
||||
like resizing of the terminal. The infrastructure layer also decides when to
|
||||
flush the buffer and refresh the screen. I.e. The widgets update content of a
|
||||
back buffer and the infrastructure decides when it is synchronized to the
|
||||
terminal.
|
||||
|
||||
The **widgets layer** contains the implementations of individual widgets. Each
|
||||
widget receives a canvas from the container on which it presents its content to
|
||||
@ -50,7 +51,7 @@ The user interacts with the widget API when constructing individual widgets and
|
||||
with the container API when placing the widgets onto the dashboard.
|
||||
|
||||
<p align="center">
|
||||
<img src="hld.png" width="50%">
|
||||
<img src="images/hld.png" width="50%">
|
||||
</p>
|
||||
|
||||
## Detailed design
|
||||
@ -65,29 +66,39 @@ It allows to:
|
||||
canvas.
|
||||
- Flush the content of the back buffer to the output.
|
||||
- Manipulate the cursor position and visibility.
|
||||
- Read input events (keyboard, mouse, terminal resize, etc...).
|
||||
|
||||
The terminal buffers input events until they are read by the client. The buffer
|
||||
is bound, if the client isn't picking up events fast enough, new events are
|
||||
dropped and a message is logged.
|
||||
- Allow the infrastructure to read input events (keyboard, mouse, terminal
|
||||
resize, etc...).
|
||||
|
||||
### Infrastructure
|
||||
|
||||
The infrastructure handles terminal setup, input events and manages containers.
|
||||
|
||||
#### Keyboard and mouse input
|
||||
#### Input events
|
||||
|
||||
The raw keyboard and mouse events received from the terminal are pre-processed
|
||||
by the infrastructure. The pre-processing involves recognizing keyboard
|
||||
shortcuts (i.e. Key combination). The infrastructure recognizes globally
|
||||
configurable keyboard shortcuts that are processed by the infrastructure. All
|
||||
other keyboard and mouse events are forwarded to the currently focused widget.
|
||||
The infrastructure regularly polls events from the terminal layer and feeds
|
||||
them into the event distribution system (EDS). The EDS fulfills the following
|
||||
functions:
|
||||
|
||||
#### Input focus
|
||||
- Allow subscribers to specify the type of events they want to receive.
|
||||
- Distributeis events in a non-blocking manner, i.e. a single slow subscriber
|
||||
cannot slow down other subscribers.
|
||||
- Events to each subscriber are throttled, if a subscriber builds a long tail
|
||||
of unprocessed input events, the EDS selectively drops repetitive events
|
||||
towards the subscriber and eventually implements a tail-drop strategy.
|
||||
|
||||
The infrastructure tracks focus. Only the focused widget receives keyboard and
|
||||
mouse events. Focus can be changed using mouse or global keyboard shortcuts.
|
||||
The focused widget is highlighted on the dashboard.
|
||||
The infrastructure itself is an input event subscriber and processes resize and
|
||||
error events. The infrastructure panics on error events by default, unless an
|
||||
error handler is provided by the user. Each widget that registers for keyboard
|
||||
or mouse events is also an event subscriber. Any errors that happen while
|
||||
processing an input event are send back to the EDS in the form of an Error
|
||||
event and are processed by the infrastructure.
|
||||
|
||||
|
||||
#### Input keyboard focus
|
||||
|
||||
The infrastructure tracks focus. Only the focused widget receives keyboard
|
||||
events. Focus can be changed using mouse or keyboard shortcuts. The focused
|
||||
widget is highlighted on the dashboard.
|
||||
|
||||
#### Containers
|
||||
|
||||
@ -105,8 +116,9 @@ Containers can be styled with borders and other options.
|
||||
|
||||
#### Flushing the terminal
|
||||
|
||||
All widgets indirectly write to the back buffer of the terminal implementation. The changes
|
||||
to the back buffer only become visible when the infrastructure flushes its content.
|
||||
All widgets indirectly write to the back buffer of the terminal implementation.
|
||||
The changes to the back buffer only become visible when the infrastructure
|
||||
flushes its content.
|
||||
|
||||
#### Terminal resizing
|
||||
|
||||
@ -133,7 +145,7 @@ container splits and place each widget into a dedicated container.
|
||||
Each widget receives a canvas from the parent container, the widget can draw
|
||||
anything on the canvas as long as it stays within the limits. Helper libraries
|
||||
are developed that allow placement and drawing of common elements like lines or
|
||||
geometrical shapes.
|
||||
geometric shapes.
|
||||
|
||||
## APIs
|
||||
|
||||
@ -185,14 +197,17 @@ package.
|
||||
|
||||
## Testing plan
|
||||
|
||||
Unit test helpers are provided with the terminal dashboard library, these include:
|
||||
Unit test helpers are provided with the terminal dashboard library, these
|
||||
include:
|
||||
|
||||
- A fake implementation of the terminal API.
|
||||
- Unit test comparison helpers to verify the content of the fake terminal.
|
||||
- Visualization tools to display differences between the expected and the actual.
|
||||
- Visualization tools to display differences between the expected and the
|
||||
actual.
|
||||
|
||||
## Document history
|
||||
|
||||
Date | Author | Description
|
||||
------------|--------|---------------
|
||||
------------|--------|-----------------------------------
|
||||
24-Mar-2018 | mum4k | Initial draft.
|
||||
20-Feb-2019 | mum4k | Added notes on event distribution.
|
||||
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
BIN
doc/images/buttondemo.gif
Normal file
After Width: | Height: | Size: 767 KiB |
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 430 KiB |
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 288 KiB |
BIN
doc/images/linechartdemo.gif
Normal file
After Width: | Height: | Size: 2.4 MiB |
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 4.8 MiB |
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 504 KiB |
BIN
doc/images/termdashdemo_0_7_0.gif
Normal file
After Width: | Height: | Size: 8.2 MiB |
Before Width: | Height: | Size: 7.2 MiB After Width: | Height: | Size: 7.2 MiB |
@ -1,123 +0,0 @@
|
||||
// Copyright 2018 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package eventqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
)
|
||||
|
||||
func TestQueue(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
pushes []terminalapi.Event
|
||||
wantEmpty bool // Checked after pushes and before pops.
|
||||
wantPops []terminalapi.Event
|
||||
}{
|
||||
{
|
||||
desc: "empty queue returns nil",
|
||||
wantEmpty: true,
|
||||
wantPops: []terminalapi.Event{
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "queue is FIFO",
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
q := New()
|
||||
defer q.Close()
|
||||
for _, ev := range tc.pushes {
|
||||
q.Push(ev)
|
||||
}
|
||||
|
||||
gotEmpty := q.Empty()
|
||||
if gotEmpty != tc.wantEmpty {
|
||||
t.Errorf("Empty => got %v, want %v", gotEmpty, tc.wantEmpty)
|
||||
}
|
||||
|
||||
for i, want := range tc.wantPops {
|
||||
got := q.Pop()
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pop[%d] => unexpected diff (-want, +got):\n%s", i, diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullEventAvailable(t *testing.T) {
|
||||
q := New()
|
||||
defer q.Close()
|
||||
want := terminalapi.NewError("error event")
|
||||
q.Push(want)
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := q.Pull(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Pull => unexpected error: %v", err)
|
||||
}
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pull => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullBlocksUntilAvailable(t *testing.T) {
|
||||
q := New()
|
||||
defer q.Close()
|
||||
want := terminalapi.NewError("error event")
|
||||
|
||||
ch := make(chan struct{})
|
||||
go func() {
|
||||
<-ch
|
||||
q.Push(want)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := q.Pull(ctx); err == nil {
|
||||
t.Fatal("Pull => expected timeout error, got nil")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
got, err := q.Pull(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Pull => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pull => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 7.0 MiB |
4
internal/README.md
Normal file
@ -0,0 +1,4 @@
|
||||
# Internal termdash libraries
|
||||
|
||||
The packages under this directory are private to termdash. Stability of the
|
||||
private packages isn't guaranteed and changes won't be backward compatible.
|
@ -20,7 +20,7 @@ import (
|
||||
"image"
|
||||
"strings"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/mum4k/termdash/internal/cell/runewidth"
|
||||
)
|
||||
|
||||
// Horizontal indicates the type of horizontal alignment.
|
@ -19,7 +19,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/numbers"
|
||||
"github.com/mum4k/termdash/internal/numbers"
|
||||
)
|
||||
|
||||
// Size returns the size of the provided area.
|
||||
@ -96,42 +96,11 @@ func ExcludeBorder(area image.Rectangle) image.Rectangle {
|
||||
)
|
||||
}
|
||||
|
||||
// findGCF finds the greatest common factor of two integers.
|
||||
func findGCF(a, b int) int {
|
||||
if a == 0 || b == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Euclidean_algorithm
|
||||
for {
|
||||
rem := a % b
|
||||
a = b
|
||||
b = rem
|
||||
|
||||
if b == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// simplifyRatio simplifies the given ratio.
|
||||
func simplifyRatio(ratio image.Point) image.Point {
|
||||
gcf := findGCF(ratio.X, ratio.Y)
|
||||
if gcf == 0 {
|
||||
return image.ZP
|
||||
}
|
||||
return image.Point{
|
||||
X: ratio.X / gcf,
|
||||
Y: ratio.Y / gcf,
|
||||
}
|
||||
}
|
||||
|
||||
// WithRatio returns the largest area that has the requested ratio but is
|
||||
// either equal or smaller than the provided area. Returns zero area if the
|
||||
// area or the ratio are zero, or if there is no such area.
|
||||
func WithRatio(area image.Rectangle, ratio image.Point) image.Rectangle {
|
||||
ratio = simplifyRatio(ratio)
|
||||
ratio = numbers.SimplifyRatio(ratio)
|
||||
if area == image.ZR || ratio == image.ZP {
|
||||
return image.ZR
|
||||
}
|
@ -15,7 +15,6 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
@ -321,30 +320,6 @@ func TestExcludeBorder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGCF(t *testing.T) {
|
||||
tests := []struct {
|
||||
a int
|
||||
b int
|
||||
want int
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 1, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 1},
|
||||
{2, 2, 2},
|
||||
{50, 35, 5},
|
||||
{16, 88, 8},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf("findGCF(%d,%d)", tc.a, tc.b), func(t *testing.T) {
|
||||
if got := findGCF(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("findGCF(%d,%d) => got %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
@ -19,7 +19,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
func Example() {
|
@ -45,9 +45,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -110,6 +110,11 @@ func (c *Canvas) Size() image.Point {
|
||||
return image.Point{s.X * ColMult, s.Y * RowMult}
|
||||
}
|
||||
|
||||
// CellArea returns the area of the underlying cell canvas in cells.
|
||||
func (c *Canvas) CellArea() image.Rectangle {
|
||||
return c.regular.Area()
|
||||
}
|
||||
|
||||
// Area returns the area of the braille canvas in pixels.
|
||||
// This will be zero-based area that is two times wider and four times taller
|
||||
// than the area used to create the braille canvas.
|
||||
@ -186,17 +191,57 @@ func (c *Canvas) TogglePixel(p image.Point, opts ...cell.Option) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cell, err := c.regular.Cell(cp)
|
||||
curCell, err := c.regular.Cell(cp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isBraille(cell.Rune) && pixelSet(cell.Rune, p) {
|
||||
if isBraille(curCell.Rune) && pixelSet(curCell.Rune, p) {
|
||||
return c.ClearPixel(p, opts...)
|
||||
}
|
||||
return c.SetPixel(p, opts...)
|
||||
}
|
||||
|
||||
// SetCellOpts sets options on the specified cell of the braille canvas without
|
||||
// modifying the content of the cell.
|
||||
// Sets the default cell options if no options are provided.
|
||||
// This method is idempotent.
|
||||
func (c *Canvas) SetCellOpts(cellPoint image.Point, opts ...cell.Option) error {
|
||||
curCell, err := c.regular.Cell(cellPoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(opts) == 0 {
|
||||
// Set the default options.
|
||||
opts = []cell.Option{
|
||||
cell.FgColor(cell.ColorDefault),
|
||||
cell.BgColor(cell.ColorDefault),
|
||||
}
|
||||
}
|
||||
if _, err := c.regular.SetCell(cellPoint, curCell.Rune, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAreaCellOpts is like SetCellOpts, but sets the specified options on all
|
||||
// the cells within the provided area.
|
||||
func (c *Canvas) SetAreaCellOpts(cellArea image.Rectangle, opts ...cell.Option) error {
|
||||
haveArea := c.regular.Area()
|
||||
if !cellArea.In(haveArea) {
|
||||
return fmt.Errorf("unable to set cell options in area %v, it must fit inside the available cell area is %v", cellArea, haveArea)
|
||||
}
|
||||
for col := cellArea.Min.X; col < cellArea.Max.X; col++ {
|
||||
for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ {
|
||||
if err := c.SetCellOpts(image.Point{col, row}, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply applies the canvas to the corresponding area of the terminal.
|
||||
// Guarantees to stay within limits of the area the canvas was created with.
|
||||
func (c *Canvas) Apply(t terminalapi.Terminal) error {
|
@ -19,11 +19,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func Example_copiedToCanvas() {
|
||||
@ -74,11 +74,12 @@ func Example_appliedToTerminal() {
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
ar image.Rectangle
|
||||
wantSize image.Point
|
||||
wantArea image.Rectangle
|
||||
wantErr bool
|
||||
desc string
|
||||
ar image.Rectangle
|
||||
wantSize image.Point
|
||||
wantArea image.Rectangle
|
||||
wantCellArea image.Rectangle
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "fails on a negative area",
|
||||
@ -86,34 +87,39 @@ func TestNew(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "braille from zero-based single-cell area",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
desc: "braille from zero-based single-cell area",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
wantCellArea: image.Rect(0, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
desc: "braille from non-zero-based single-cell area",
|
||||
ar: image.Rect(3, 3, 4, 4),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
desc: "braille from non-zero-based single-cell area",
|
||||
ar: image.Rect(3, 3, 4, 4),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
wantCellArea: image.Rect(0, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
desc: "braille from zero-based multi-cell area",
|
||||
ar: image.Rect(0, 0, 3, 3),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
desc: "braille from zero-based multi-cell area",
|
||||
ar: image.Rect(0, 0, 3, 3),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
wantCellArea: image.Rect(0, 0, 3, 3),
|
||||
},
|
||||
{
|
||||
desc: "braille from non-zero-based multi-cell area",
|
||||
ar: image.Rect(6, 6, 9, 9),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
desc: "braille from non-zero-based multi-cell area",
|
||||
ar: image.Rect(6, 6, 9, 9),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
wantCellArea: image.Rect(0, 0, 3, 3),
|
||||
},
|
||||
{
|
||||
desc: "braille from non-zero-based multi-cell rectangular area",
|
||||
ar: image.Rect(6, 6, 9, 10),
|
||||
wantSize: image.Point{6, 16},
|
||||
wantArea: image.Rect(0, 0, 6, 16),
|
||||
desc: "braille from non-zero-based multi-cell rectangular area",
|
||||
ar: image.Rect(6, 6, 9, 10),
|
||||
wantSize: image.Point{6, 16},
|
||||
wantArea: image.Rect(0, 0, 6, 16),
|
||||
wantCellArea: image.Rect(0, 0, 3, 4),
|
||||
},
|
||||
}
|
||||
|
||||
@ -136,6 +142,11 @@ func TestNew(t *testing.T) {
|
||||
if diff := pretty.Compare(tc.wantArea, gotArea); diff != "" {
|
||||
t.Errorf("Area => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
gotCellArea := got.CellArea()
|
||||
if diff := pretty.Compare(tc.wantCellArea, gotCellArea); diff != "" {
|
||||
t.Errorf("CellArea => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -170,6 +181,162 @@ func TestBraille(t *testing.T) {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions fails on a cell outside of the braille canvas",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetCellOpts(image.Point{0, -1})
|
||||
},
|
||||
wantErr: true,
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions sets options on cell with no options",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
c := testcanvas.MustCell(cvs, image.Point{0, 0})
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions preserves the cell rune",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetPixel(image.Point{0, 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions overwrites options set previously",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetPixel(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁', cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions sets default options when no options provided",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetPixel(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0})
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁')
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions is idempotent",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
c := testcanvas.MustCell(cvs, image.Point{0, 0})
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOptions fails on area too large",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOptions sets the cell options in full area",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
} {
|
||||
c := testcanvas.MustCell(cvs, p)
|
||||
testcanvas.MustSetCell(cvs, p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
}
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOptions sets the cell options in a sub-area",
|
||||
ar: image.Rect(0, 0, 3, 3),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
{0, 1},
|
||||
{1, 0},
|
||||
{1, 1},
|
||||
} {
|
||||
c := testcanvas.MustCell(cvs, p)
|
||||
testcanvas.MustSetCell(cvs, p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
}
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "set pixel 0,0",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
@ -19,10 +19,10 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
// MustNew returns a new canvas or panics.
|
||||
@ -61,3 +61,17 @@ func MustCopyTo(bc *braille.Canvas, dst *canvas.Canvas) {
|
||||
panic(fmt.Sprintf("bc.CopyTo => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// MustSetCellOpts sets the cell options or panics.
|
||||
func MustSetCellOpts(bc *braille.Canvas, cellPoint image.Point, opts ...cell.Option) {
|
||||
if err := bc.SetCellOpts(cellPoint, opts...); err != nil {
|
||||
panic(fmt.Sprintf("bc.SetCellOpts => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// MustSetAreaCellOpts sets the cell options in the area or panics.
|
||||
func MustSetAreaCellOpts(bc *braille.Canvas, cellArea image.Rectangle, opts ...cell.Option) {
|
||||
if err := bc.SetAreaCellOpts(cellArea, opts...); err != nil {
|
||||
panic(fmt.Sprintf("bc.SetAreaCellOpts => unexpected error: %v", err))
|
||||
}
|
||||
}
|
@ -19,9 +19,10 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/cell/runewidth"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// Canvas is where a widget draws its output for display on the terminal.
|
||||
@ -94,6 +95,72 @@ func (c *Canvas) Cell(p image.Point) (*cell.Cell, error) {
|
||||
return c.buffer[p.X][p.Y].Copy(), nil
|
||||
}
|
||||
|
||||
// SetCellOpts sets options on the specified cell of the canvas without
|
||||
// modifying the content of the cell.
|
||||
// Sets the default cell options if no options are provided.
|
||||
// This method is idempotent.
|
||||
func (c *Canvas) SetCellOpts(p image.Point, opts ...cell.Option) error {
|
||||
curCell, err := c.Cell(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(opts) == 0 {
|
||||
// Set the default options.
|
||||
opts = []cell.Option{
|
||||
cell.FgColor(cell.ColorDefault),
|
||||
cell.BgColor(cell.ColorDefault),
|
||||
}
|
||||
}
|
||||
if _, err := c.SetCell(p, curCell.Rune, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAreaCells is like SetCell, but sets the specified rune and options on all
|
||||
// the cells within the provided area.
|
||||
// This method is idempotent.
|
||||
func (c *Canvas) SetAreaCells(cellArea image.Rectangle, r rune, opts ...cell.Option) error {
|
||||
haveArea := c.Area()
|
||||
if !cellArea.In(haveArea) {
|
||||
return fmt.Errorf("unable to set cell runes in area %v, it must fit inside the available cell area is %v", cellArea, haveArea)
|
||||
}
|
||||
|
||||
rw := runewidth.RuneWidth(r)
|
||||
for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ {
|
||||
for col := cellArea.Min.X; col < cellArea.Max.X; {
|
||||
p := image.Point{col, row}
|
||||
if col+rw > cellArea.Max.X {
|
||||
break
|
||||
}
|
||||
cells, err := c.SetCell(p, r, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
col += cells
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAreaCellOpts is like SetCellOpts, but sets the specified options on all
|
||||
// the cells within the provided area.
|
||||
func (c *Canvas) SetAreaCellOpts(cellArea image.Rectangle, opts ...cell.Option) error {
|
||||
haveArea := c.Area()
|
||||
if !cellArea.In(haveArea) {
|
||||
return fmt.Errorf("unable to set cell options in area %v, it must fit inside the available cell area is %v", cellArea, haveArea)
|
||||
}
|
||||
for col := cellArea.Min.X; col < cellArea.Max.X; col++ {
|
||||
for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ {
|
||||
if err := c.SetCellOpts(image.Point{col, row}, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setCellFunc is a function that sets cell content on a terminal or a canvas.
|
||||
type setCellFunc func(image.Point, rune, ...cell.Option) error
|
||||
|
@ -19,9 +19,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@ -100,6 +100,445 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvas(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
canvas image.Rectangle
|
||||
ops func(*Canvas) error
|
||||
want func(size image.Point) (*faketerm.Terminal, error)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "SetCellOpts fails on a point outside of the canvas",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetCellOpts(image.Point{1, 1})
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "SetCellOpts sets options on a cell with no options",
|
||||
canvas: image.Rect(0, 0, 2, 2),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c, err := cvs.Cell(image.Point{0, 1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOpts preserves cell rune",
|
||||
canvas: image.Rect(0, 0, 2, 2),
|
||||
ops: func(cvs *Canvas) error {
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil {
|
||||
return err
|
||||
}
|
||||
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOpts overwrites options set previously",
|
||||
canvas: image.Rect(0, 0, 2, 2),
|
||||
ops: func(cvs *Canvas) error {
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOpts sets default options when no options provided",
|
||||
canvas: image.Rect(0, 0, 2, 2),
|
||||
ops: func(cvs *Canvas) error {
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return cvs.SetCellOpts(image.Point{0, 1})
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOpts is idempotent",
|
||||
canvas: image.Rect(0, 0, 2, 2),
|
||||
ops: func(cvs *Canvas) error {
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOpts fails on area too large",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOpts sets options in the full canvas",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
} {
|
||||
c, err := cvs.Cell(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := cvs.SetCell(p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOpts sets options in a sub-area",
|
||||
canvas: image.Rect(0, 0, 3, 3),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
{0, 1},
|
||||
{1, 0},
|
||||
{1, 1},
|
||||
} {
|
||||
c, err := cvs.Cell(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := cvs.SetCell(p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells sets cells in the full canvas",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r')
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 0}, 'r'); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells is idempotent",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
if err := cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r'); err != nil {
|
||||
return err
|
||||
}
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r')
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 0}, 'r'); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells fails on area too large",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells sets cell options",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := cvs.SetCell(image.Point{0, 0}, 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells sets cell in a sub-area",
|
||||
canvas: image.Rect(0, 0, 3, 3),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), 'p')
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
{0, 1},
|
||||
{1, 0},
|
||||
{1, 1},
|
||||
} {
|
||||
if _, err := cvs.SetCell(p, 'p'); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells sets full-width runes that fit",
|
||||
canvas: image.Rect(0, 0, 3, 3),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), '世')
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
{0, 1},
|
||||
} {
|
||||
if _, err := cvs.SetCell(p, '世'); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCells sets full-width runes that will leave a gap at the end of each row",
|
||||
canvas: image.Rect(0, 0, 3, 3),
|
||||
ops: func(cvs *Canvas) error {
|
||||
return cvs.SetAreaCells(image.Rect(0, 0, 3, 3), '世')
|
||||
},
|
||||
want: func(size image.Point) (*faketerm.Terminal, error) {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs, err := New(ft.Area())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
{0, 1},
|
||||
{0, 2},
|
||||
} {
|
||||
if _, err := cvs.SetCell(p, '世'); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cvs.Apply(ft); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ft, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
cvs, err := New(tc.canvas)
|
||||
if err != nil {
|
||||
t.Fatalf("New => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tc.ops != nil {
|
||||
err := tc.ops(cvs)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("tc.ops => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
size := cvs.Size()
|
||||
got, err := faketerm.New(size)
|
||||
if err != nil {
|
||||
t.Fatalf("faketerm.New => unexpected error: %v", err)
|
||||
}
|
||||
if err := cvs.Apply(got); err != nil {
|
||||
t.Fatalf("cvs.Apply => %v", err)
|
||||
}
|
||||
|
||||
var want *faketerm.Terminal
|
||||
if tc.want != nil {
|
||||
want, err = tc.want(size)
|
||||
if err != nil {
|
||||
t.Fatalf("tc.want => unexpected error: %v", err)
|
||||
}
|
||||
} else {
|
||||
w, err := faketerm.New(size)
|
||||
if err != nil {
|
||||
t.Fatalf("faketerm.New => unexpected error: %v", err)
|
||||
}
|
||||
want = w
|
||||
}
|
||||
|
||||
if diff := faketerm.Diff(want, got); diff != "" {
|
||||
t.Errorf("cvs.SetCellOpts => %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCellAndApply(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
@ -19,9 +19,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
// MustNew returns a new canvas or panics.
|
||||
@ -51,6 +51,22 @@ func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) i
|
||||
return cells
|
||||
}
|
||||
|
||||
// MustSetAreaCells sets the cells in the area or panics.
|
||||
func MustSetAreaCells(c *canvas.Canvas, cellArea image.Rectangle, r rune, opts ...cell.Option) {
|
||||
if err := c.SetAreaCells(cellArea, r, opts...); err != nil {
|
||||
panic(fmt.Sprintf("canvas.SetAreaCells => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// MustCell returns the cell or panics.
|
||||
func MustCell(c *canvas.Canvas, p image.Point) *cell.Cell {
|
||||
cell, err := c.Cell(p)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("canvas.Cell => unexpected error: %v", err))
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
// MustCopyTo copies the content of the source canvas onto the destination
|
||||
// canvas or panics.
|
||||
func MustCopyTo(src, dst *canvas.Canvas) {
|
@ -23,8 +23,8 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/cell/runewidth"
|
||||
)
|
||||
|
||||
// Option is used to provide options for cells on a 2-D terminal.
|
98
internal/cell/runewidth/runewidth.go
Normal file
@ -0,0 +1,98 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package runewidth is a wrapper over github.com/mattn/go-runewidth which
|
||||
// gives different treatment to certain runes with ambiguous width.
|
||||
package runewidth
|
||||
|
||||
import runewidth "github.com/mattn/go-runewidth"
|
||||
|
||||
// RuneWidth returns the number of cells needed to draw r.
|
||||
// Background in http://www.unicode.org/reports/tr11/.
|
||||
//
|
||||
// Treats runes used internally by termdash as single-cell (half-width) runes
|
||||
// regardless of the locale. I.e. runes that are used to draw lines, boxes,
|
||||
// indicate resize or text trimming was needed and runes used by the braille
|
||||
// canvas.
|
||||
//
|
||||
// This should be safe, since even in locales where these runes have ambiguous
|
||||
// width, we still place all the character content around them so they should
|
||||
// have be half-width.
|
||||
func RuneWidth(r rune) int {
|
||||
if inTable(r, exceptions) {
|
||||
return 1
|
||||
}
|
||||
return runewidth.RuneWidth(r)
|
||||
}
|
||||
|
||||
// StringWidth is like RuneWidth, but returns the number of cells occupied by
|
||||
// all the runes in the string.
|
||||
func StringWidth(s string) int {
|
||||
var width int
|
||||
for _, r := range []rune(s) {
|
||||
width += RuneWidth(r)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// inTable determines if the rune falls within the table.
|
||||
// Copied from github.com/mattn/go-runewidth/blob/master/runewidth.go.
|
||||
func inTable(r rune, t table) bool {
|
||||
// func (t table) IncludesRune(r rune) bool {
|
||||
if r < t[0].first {
|
||||
return false
|
||||
}
|
||||
|
||||
bot := 0
|
||||
top := len(t) - 1
|
||||
for top >= bot {
|
||||
mid := (bot + top) >> 1
|
||||
|
||||
switch {
|
||||
case t[mid].last < r:
|
||||
bot = mid + 1
|
||||
case t[mid].first > r:
|
||||
top = mid - 1
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type interval struct {
|
||||
first rune
|
||||
last rune
|
||||
}
|
||||
|
||||
type table []interval
|
||||
|
||||
// exceptions runes defined here are always considered to be half-width even if
|
||||
// they might be ambiguous in some contexts.
|
||||
var exceptions = table{
|
||||
// Characters used by termdash to indicate text trim or scroll.
|
||||
{0x2026, 0x2026},
|
||||
{0x21c4, 0x21c4},
|
||||
{0x21e7, 0x21e7},
|
||||
{0x21e9, 0x21e9},
|
||||
|
||||
// Box drawing, used as line-styles.
|
||||
// https://en.wikipedia.org/wiki/Box-drawing_character
|
||||
{0x2500, 0x257F},
|
||||
|
||||
// Block elements used as sparks.
|
||||
// https://en.wikipedia.org/wiki/Box-drawing_character
|
||||
{0x2580, 0x258F},
|
||||
}
|
166
internal/cell/runewidth/runewidth_test.go
Normal file
@ -0,0 +1,166 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package runewidth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
func TestRuneWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
runes []rune
|
||||
eastAsian bool
|
||||
want int
|
||||
}{
|
||||
{
|
||||
desc: "ascii characters",
|
||||
runes: []rune{'a', 'f', '#'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "non-printable characters from mattn/runewidth/runewidth_test",
|
||||
runes: []rune{'\x00', '\x01', '\u0300', '\u2028', '\u2029'},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
desc: "half-width runes from mattn/runewidth/runewidth_test",
|
||||
runes: []rune{'セ', 'カ', 'イ', '☆'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "full-width runes from mattn/runewidth/runewidth_test",
|
||||
runes: []rune{'世', '界'},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
desc: "ambiguous so double-width in eastAsian from mattn/runewidth/runewidth_test",
|
||||
runes: []rune{'☆'},
|
||||
eastAsian: true,
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
desc: "braille runes",
|
||||
runes: []rune{'⠀', '⠴', '⠷', '⣿'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "braille runes in eastAsian",
|
||||
runes: []rune{'⠀', '⠴', '⠷', '⣿'},
|
||||
eastAsian: true,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash special runes",
|
||||
runes: []rune{'⇄', '…', '⇧', '⇩'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash special runes in eastAsian",
|
||||
runes: []rune{'⇄', '…', '⇧', '⇩'},
|
||||
eastAsian: true,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash sparks",
|
||||
runes: []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash sparks in eastAsian",
|
||||
runes: []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'},
|
||||
eastAsian: true,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash line styles",
|
||||
runes: []rune{'─', '═', '─', '┼', '╬', '┼'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash line styles in eastAsian",
|
||||
runes: []rune{'─', '═', '─', '┼', '╬', '┼'},
|
||||
eastAsian: true,
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
runewidth.DefaultCondition.EastAsianWidth = tc.eastAsian
|
||||
defer func() {
|
||||
runewidth.DefaultCondition.EastAsianWidth = false
|
||||
}()
|
||||
|
||||
for _, r := range tc.runes {
|
||||
if got := RuneWidth(r); got != tc.want {
|
||||
t.Errorf("RuneWidth(%c, %#x) => %v, want %v", r, r, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
str string
|
||||
eastAsian bool
|
||||
want int
|
||||
}{
|
||||
{
|
||||
desc: "ascii characters",
|
||||
str: "hello",
|
||||
want: 5,
|
||||
},
|
||||
{
|
||||
desc: "string from mattn/runewidth/runewidth_test",
|
||||
str: "■㈱の世界①",
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
desc: "string in eastAsian from mattn/runewidth/runewidth_test",
|
||||
str: "■㈱の世界①",
|
||||
eastAsian: true,
|
||||
want: 12,
|
||||
},
|
||||
{
|
||||
desc: "string using termdash characters",
|
||||
str: "⇄…⇧⇩",
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
desc: "string in eastAsien using termdash characters",
|
||||
str: "⇄…⇧⇩",
|
||||
eastAsian: true,
|
||||
want: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
runewidth.DefaultCondition.EastAsianWidth = tc.eastAsian
|
||||
defer func() {
|
||||
runewidth.DefaultCondition.EastAsianWidth = false
|
||||
}()
|
||||
|
||||
if got := StringWidth(tc.str); got != tc.want {
|
||||
t.Errorf("StringWidth(%q) => %v, want %v", tc.str, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -20,9 +20,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/align"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
// BorderOption is used to provide options to Border().
|
@ -18,11 +18,11 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/align"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestBorder(t *testing.T) {
|
@ -20,9 +20,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/trig"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/numbers/trig"
|
||||
)
|
||||
|
||||
// BrailleCircleOption is used to provide options to BrailleCircle.
|
@ -18,11 +18,11 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
// mustBrailleLine draws the braille line or panics.
|
@ -20,8 +20,8 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
// BrailleFillOption is used to provide options to BrailleFill.
|
@ -18,11 +18,11 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestBrailleFill(t *testing.T) {
|
@ -20,9 +20,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/numbers"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/numbers"
|
||||
)
|
||||
|
||||
// braillePixelChange represents an action on a pixel on the braille canvas.
|
@ -18,11 +18,11 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestBrailleLine(t *testing.T) {
|
@ -20,8 +20,8 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
// HVLineOption is used to provide options to HVLine().
|
@ -20,7 +20,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
)
|
||||
|
||||
func TestMultiEdgeNodes(t *testing.T) {
|
@ -18,10 +18,10 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestHVLines(t *testing.T) {
|
@ -17,7 +17,7 @@ package draw
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/mum4k/termdash/internal/cell/runewidth"
|
||||
)
|
||||
|
||||
// line_style.go contains the Unicode characters used for drawing lines of
|
@ -20,8 +20,8 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
// RectangleOption is used to provide options to the Rectangle function.
|
@ -18,10 +18,10 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestRectangle(t *testing.T) {
|
@ -19,9 +19,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
)
|
||||
|
||||
// Type identifies the type of the segment that is drawn.
|
@ -19,13 +19,13 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/draw/testdraw"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/draw/testdraw"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestHV(t *testing.T) {
|
@ -19,8 +19,8 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/draw/segdisp/segment"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/draw/segdisp/segment"
|
||||
)
|
||||
|
||||
// MustHV draws the segment or panics.
|
@ -22,8 +22,8 @@ import (
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"github.com/mum4k/termdash/draw/segdisp/segment"
|
||||
"github.com/mum4k/termdash/numbers"
|
||||
"github.com/mum4k/termdash/internal/draw/segdisp/segment"
|
||||
"github.com/mum4k/termdash/internal/numbers"
|
||||
)
|
||||
|
||||
// hvSegType maps horizontal and vertical segments to their type.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -45,11 +45,11 @@ import (
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw/segdisp/segment"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw/segdisp/segment"
|
||||
)
|
||||
|
||||
// Segment represents a single segment in the display.
|
||||
@ -86,21 +86,37 @@ var segmentNames = map[Segment]string{
|
||||
const (
|
||||
segmentUnknown Segment = iota
|
||||
|
||||
// A1 is a segment, see the diagram above.
|
||||
A1
|
||||
// A2 is a segment, see the diagram above.
|
||||
A2
|
||||
// B is a segment, see the diagram above.
|
||||
B
|
||||
// C is a segment, see the diagram above.
|
||||
C
|
||||
// D1 is a segment, see the diagram above.
|
||||
D1
|
||||
// D2 is a segment, see the diagram above.
|
||||
D2
|
||||
// E is a segment, see the diagram above.
|
||||
E
|
||||
// F is a segment, see the diagram above.
|
||||
F
|
||||
// G1 is a segment, see the diagram above.
|
||||
G1
|
||||
// G2 is a segment, see the diagram above.
|
||||
G2
|
||||
// H is a segment, see the diagram above.
|
||||
H
|
||||
// J is a segment, see the diagram above.
|
||||
J
|
||||
// K is a segment, see the diagram above.
|
||||
K
|
||||
// L is a segment, see the diagram above.
|
||||
L
|
||||
// M is a segment, see the diagram above.
|
||||
M
|
||||
// N is a segment, see the diagram above.
|
||||
N
|
||||
|
||||
segmentMax // Used for validation.
|
||||
@ -340,7 +356,8 @@ func (d *Display) ToggleSegment(s Segment) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Character sets all the segments that are needed to display the provided character.
|
||||
// SetCharacter sets all the segments that are needed to display the provided
|
||||
// character.
|
||||
// The display only supports a subset of ASCII characters, use SupportsChars()
|
||||
// or Sanitize() to ensure the provided character is supported.
|
||||
// Doesn't clear the display of segments set previously.
|
||||
@ -435,7 +452,7 @@ func (d *Display) Draw(cvs *canvas.Canvas, opts ...Option) error {
|
||||
return bc.CopyTo(cvs)
|
||||
}
|
||||
|
||||
// Required, when given an area of cells, returns either an area of the same
|
||||
// Required when given an area of cells, returns either an area of the same
|
||||
// size or a smaller area that is required to draw one display.
|
||||
// Returns a smaller area when the provided area didn't have the required
|
||||
// aspect ratio.
|
@ -20,14 +20,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/area"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw/segdisp/segment"
|
||||
"github.com/mum4k/termdash/draw/segdisp/segment/testsegment"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille/testbraille"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/draw/segdisp/segment"
|
||||
"github.com/mum4k/termdash/internal/draw/segdisp/segment/testsegment"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestDraw(t *testing.T) {
|
@ -18,8 +18,8 @@ package testsixteen
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/draw/segdisp/sixteen"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/draw/segdisp/sixteen"
|
||||
)
|
||||
|
||||
// MustSetCharacter sets the character on the display or panics.
|
@ -19,9 +19,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/braille"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/braille"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
)
|
||||
|
||||
// MustBorder draws border on the canvas or panics.
|
||||
@ -38,6 +38,13 @@ func MustText(c *canvas.Canvas, text string, start image.Point, opts ...draw.Tex
|
||||
}
|
||||
}
|
||||
|
||||
// MustVerticalText draws the vertical text on the canvas or panics.
|
||||
func MustVerticalText(c *canvas.Canvas, text string, start image.Point, opts ...draw.VerticalTextOption) {
|
||||
if err := draw.VerticalText(c, text, start, opts...); err != nil {
|
||||
panic(fmt.Sprintf("draw.VerticalText => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// MustRectangle draws the rectangle on the canvas or panics.
|
||||
func MustRectangle(c *canvas.Canvas, r image.Rectangle, opts ...draw.RectangleOption) {
|
||||
if err := draw.Rectangle(c, r, opts...); err != nil {
|
@ -21,9 +21,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/cell/runewidth"
|
||||
)
|
||||
|
||||
// OverrunMode represents
|
@ -18,10 +18,10 @@ import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestTrimText(t *testing.T) {
|
120
internal/draw/vertical_text.go
Normal file
@ -0,0 +1,120 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package draw
|
||||
|
||||
// vertical_text.go contains code that prints UTF-8 encoded strings on the
|
||||
// canvas in vertical columns instead of lines.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
// VerticalTextOption is used to provide options to Text().
|
||||
type VerticalTextOption interface {
|
||||
// set sets the provided option.
|
||||
set(*verticalTextOptions)
|
||||
}
|
||||
|
||||
// verticalTextOptions stores the provided options.
|
||||
type verticalTextOptions struct {
|
||||
cellOpts []cell.Option
|
||||
maxY int
|
||||
overrunMode OverrunMode
|
||||
}
|
||||
|
||||
// verticalTextOption implements VerticalTextOption.
|
||||
type verticalTextOption func(*verticalTextOptions)
|
||||
|
||||
// set implements VerticalTextOption.set.
|
||||
func (vto verticalTextOption) set(vtOpts *verticalTextOptions) {
|
||||
vto(vtOpts)
|
||||
}
|
||||
|
||||
// VerticalTextCellOpts sets options on the cells that contain the text.
|
||||
func VerticalTextCellOpts(opts ...cell.Option) VerticalTextOption {
|
||||
return verticalTextOption(func(vtOpts *verticalTextOptions) {
|
||||
vtOpts.cellOpts = opts
|
||||
})
|
||||
}
|
||||
|
||||
// VerticalTextMaxY sets a limit on the Y coordinate (row) of the drawn text.
|
||||
// The Y coordinate of all cells used by the vertical text must be within
|
||||
// start.Y <= Y < VerticalTextMaxY.
|
||||
// If not provided, the height of the canvas is used as VerticalTextMaxY.
|
||||
func VerticalTextMaxY(y int) VerticalTextOption {
|
||||
return verticalTextOption(func(vtOpts *verticalTextOptions) {
|
||||
vtOpts.maxY = y
|
||||
})
|
||||
}
|
||||
|
||||
// VerticalTextOverrunMode indicates what to do with text that overruns the
|
||||
// VerticalTextMaxY() or the width of the canvas if VerticalTextMaxY() isn't
|
||||
// specified.
|
||||
// Defaults to OverrunModeStrict.
|
||||
func VerticalTextOverrunMode(om OverrunMode) VerticalTextOption {
|
||||
return verticalTextOption(func(vtOpts *verticalTextOptions) {
|
||||
vtOpts.overrunMode = om
|
||||
})
|
||||
}
|
||||
|
||||
// VerticalText prints the provided text on the canvas starting at the provided point.
|
||||
// The text is printed in a vertical orientation, i.e:
|
||||
// H
|
||||
// e
|
||||
// l
|
||||
// l
|
||||
// o
|
||||
func VerticalText(c *canvas.Canvas, text string, start image.Point, opts ...VerticalTextOption) error {
|
||||
ar := c.Area()
|
||||
if !start.In(ar) {
|
||||
return fmt.Errorf("the requested start point %v falls outside of the provided canvas %v", start, ar)
|
||||
}
|
||||
|
||||
opt := &verticalTextOptions{}
|
||||
for _, o := range opts {
|
||||
o.set(opt)
|
||||
}
|
||||
|
||||
if opt.maxY < 0 || opt.maxY > ar.Max.Y {
|
||||
return fmt.Errorf("invalid VerticalTextMaxY(%v), must be a positive number that is <= canvas.width %v", opt.maxY, ar.Dy())
|
||||
}
|
||||
|
||||
var wantMaxY int
|
||||
if opt.maxY == 0 {
|
||||
wantMaxY = ar.Max.Y
|
||||
} else {
|
||||
wantMaxY = opt.maxY
|
||||
}
|
||||
|
||||
maxCells := wantMaxY - start.Y
|
||||
trimmed, err := TrimText(text, maxCells, opt.overrunMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cur := start
|
||||
for _, r := range trimmed {
|
||||
cells, err := c.SetCell(cur, r, opt.cellOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur = image.Point{cur.X, cur.Y + cells}
|
||||
}
|
||||
return nil
|
||||
}
|
421
internal/draw/vertical_text_test.go
Normal file
@ -0,0 +1,421 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package draw
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/canvas/testcanvas"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/terminal/faketerm"
|
||||
)
|
||||
|
||||
func TestVerticalText(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
canvas image.Rectangle
|
||||
text string
|
||||
start image.Point
|
||||
opts []VerticalTextOption
|
||||
want func(size image.Point) *faketerm.Terminal
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "start falls outside of the canvas",
|
||||
canvas: image.Rect(0, 0, 2, 2),
|
||||
start: image.Point{2, 2},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "unsupported overrun mode specified",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "ab",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunMode(-1)),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "zero text",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of the canvas on OverrunModeStrict",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "ab",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of the canvas because the rune is full-width on OverrunModeStrict",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "界",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of the canvas on OverrunModeTrim",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "ab",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeTrim),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of the canvas because the rune is full-width on OverrunModeTrim",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "界",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeTrim),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "OverrunModeTrim trims longer text",
|
||||
canvas: image.Rect(0, 0, 1, 2),
|
||||
text: "abcdef",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeTrim),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 1}, 'b')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "OverrunModeTrim trims longer text with full-width runes, trim falls before the rune",
|
||||
canvas: image.Rect(0, 0, 1, 2),
|
||||
text: "ab界",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeTrim),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 1}, 'b')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "OverrunModeTrim trims longer text with full-width runes, trim falls on the rune",
|
||||
canvas: image.Rect(0, 0, 1, 2),
|
||||
text: "a界",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeTrim),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of the canvas on OverrunModeThreeDot",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "ab",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeThreeDot),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, '…')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of the canvas because the rune is full-width on OverrunModeThreeDot",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "界",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeThreeDot),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, '…')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "OverrunModeThreeDot trims longer text",
|
||||
canvas: image.Rect(0, 0, 1, 2),
|
||||
text: "abcdef",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeThreeDot),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 1}, '…')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "OverrunModeThreeDot trims longer text with full-width runes, trim falls before the rune",
|
||||
canvas: image.Rect(0, 0, 1, 2),
|
||||
text: "ab界",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeThreeDot),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 1}, '…')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "OverrunModeThreeDot trims longer text with full-width runes, trim falls on the rune",
|
||||
canvas: image.Rect(0, 0, 1, 2),
|
||||
text: "a界",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextOverrunMode(OverrunModeThreeDot),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 1}, '…')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "requested MaxY is negative",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextMaxY(-1),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "requested MaxY is greater than canvas height",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "",
|
||||
start: image.Point{0, 0},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextMaxY(2),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "text falls outside of requested MaxY",
|
||||
canvas: image.Rect(0, 0, 2, 3),
|
||||
text: "ab",
|
||||
start: image.Point{1, 1},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextMaxY(2),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "text is empty, nothing to do",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "draws text",
|
||||
canvas: image.Rect(0, 0, 2, 3),
|
||||
text: "ab",
|
||||
start: image.Point{1, 1},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{1, 1}, 'a')
|
||||
testcanvas.MustSetCell(c, image.Point{1, 2}, 'b')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "draws text with cell options",
|
||||
canvas: image.Rect(0, 0, 2, 3),
|
||||
text: "ab",
|
||||
start: image.Point{1, 1},
|
||||
opts: []VerticalTextOption{
|
||||
VerticalTextCellOpts(cell.FgColor(cell.ColorRed)),
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{1, 1}, 'a', cell.FgColor(cell.ColorRed))
|
||||
testcanvas.MustSetCell(c, image.Point{1, 2}, 'b', cell.FgColor(cell.ColorRed))
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "draws a half-width unicode character",
|
||||
canvas: image.Rect(0, 0, 1, 1),
|
||||
text: "⇄",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, '⇄')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "draws multiple half-width unicode characters",
|
||||
canvas: image.Rect(0, 0, 3, 3),
|
||||
text: "⇄࿃°",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, '⇄')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 1}, '࿃')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 2}, '°')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "draws multiple full-width unicode characters",
|
||||
canvas: image.Rect(0, 0, 3, 10),
|
||||
text: "你好,世界",
|
||||
start: image.Point{0, 0},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
c := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(c, image.Point{0, 0}, '你')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 2}, '好')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 4}, ',')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 6}, '世')
|
||||
testcanvas.MustSetCell(c, image.Point{0, 8}, '界')
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
c, err := canvas.New(tc.canvas)
|
||||
if err != nil {
|
||||
t.Fatalf("canvas.New => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = VerticalText(c, tc.text, tc.start, tc.opts...)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("VerticalText => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
got, err := faketerm.New(c.Size())
|
||||
if err != nil {
|
||||
t.Fatalf("faketerm.New => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Apply(got); err != nil {
|
||||
t.Fatalf("Apply => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if diff := faketerm.Diff(tc.want(c.Size()), got); diff != "" {
|
||||
t.Errorf("VerticalText => %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
260
internal/event/event.go
Normal file
@ -0,0 +1,260 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package event provides a non-blocking event distribution and subscription
|
||||
// system.
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/mum4k/termdash/internal/event/eventqueue"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// Callback is a function provided by an event subscriber.
|
||||
// It gets called with each event that passed the subscription filter.
|
||||
// Implementations must be thread-safe, events come from a separate goroutine.
|
||||
// Implementation should be light-weight, otherwise a slow-processing
|
||||
// subscriber can build a long tail of events.
|
||||
type Callback func(terminalapi.Event)
|
||||
|
||||
// queue is a queue of terminal events.
|
||||
type queue interface {
|
||||
Push(e terminalapi.Event)
|
||||
Pull(ctx context.Context) terminalapi.Event
|
||||
Close()
|
||||
}
|
||||
|
||||
// subscriber represents a single subscriber.
|
||||
type subscriber struct {
|
||||
// cb is the callback the subscriber receives events on.
|
||||
cb Callback
|
||||
|
||||
// filter filters events towards the subscriber.
|
||||
// An empty filter receives all events.
|
||||
filter map[reflect.Type]bool
|
||||
|
||||
// queue is a queue of events towards the subscriber.
|
||||
queue queue
|
||||
|
||||
// cancel when called terminates the goroutine that forwards events towards
|
||||
// this subscriber.
|
||||
cancel context.CancelFunc
|
||||
|
||||
// processes is the number of events that were fully processed, i.e.
|
||||
// delivered to the callback.
|
||||
processed int
|
||||
|
||||
// mu protects busy.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newSubscriber creates a new event subscriber.
|
||||
func newSubscriber(filter []terminalapi.Event, cb Callback, opts *subscribeOptions) *subscriber {
|
||||
f := map[reflect.Type]bool{}
|
||||
for _, ev := range filter {
|
||||
f[reflect.TypeOf(ev)] = true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var q queue
|
||||
if opts.throttle {
|
||||
q = eventqueue.NewThrottled(opts.maxRep)
|
||||
} else {
|
||||
q = eventqueue.New()
|
||||
}
|
||||
|
||||
s := &subscriber{
|
||||
cb: cb,
|
||||
filter: f,
|
||||
queue: q,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Terminates when stop() is called.
|
||||
go s.run(ctx)
|
||||
return s
|
||||
}
|
||||
|
||||
// callback sends the event to the callback.
|
||||
func (s *subscriber) callback(ev terminalapi.Event) {
|
||||
s.cb(ev)
|
||||
|
||||
func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.processed++
|
||||
}()
|
||||
}
|
||||
|
||||
// run periodically forwards events towards the subscriber.
|
||||
// Terminates when the context expires.
|
||||
func (s *subscriber) run(ctx context.Context) {
|
||||
for {
|
||||
ev := s.queue.Pull(ctx)
|
||||
if ev != nil {
|
||||
s.callback(ev)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// event forwards an event to the subscriber.
|
||||
func (s *subscriber) event(ev terminalapi.Event) {
|
||||
if len(s.filter) == 0 {
|
||||
s.queue.Push(ev)
|
||||
}
|
||||
|
||||
t := reflect.TypeOf(ev)
|
||||
if s.filter[t] {
|
||||
s.queue.Push(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// processedEvents returns the number of events processed by this subscriber.
|
||||
func (s *subscriber) processedEvents() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.processed
|
||||
}
|
||||
|
||||
// stop stops the event subscriber.
|
||||
func (s *subscriber) stop() {
|
||||
s.cancel()
|
||||
s.queue.Close()
|
||||
}
|
||||
|
||||
// DistributionSystem distributes events to subscribers.
|
||||
//
|
||||
// Subscribers can request filtering of events they get based on event type or
|
||||
// subscribe to all events.
|
||||
//
|
||||
// The distribution system maintains a queue towards each subscriber, making
|
||||
// sure that a single slow subscriber only slows itself down, rather than the
|
||||
// entire application.
|
||||
//
|
||||
// This object is thread-safe.
|
||||
type DistributionSystem struct {
|
||||
// subscribers subscribe to events.
|
||||
// maps subscriber id to subscriber.
|
||||
subscribers map[int]*subscriber
|
||||
|
||||
// nextID is id for the next subscriber.
|
||||
nextID int
|
||||
|
||||
// mu protects the distribution system.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewDistributionSystem creates a new event distribution system.
|
||||
func NewDistributionSystem() *DistributionSystem {
|
||||
return &DistributionSystem{
|
||||
subscribers: map[int]*subscriber{},
|
||||
}
|
||||
}
|
||||
|
||||
// Event should be called with events coming from the terminal.
|
||||
// The distribution system will distribute these to all the subscribers.
|
||||
func (eds *DistributionSystem) Event(ev terminalapi.Event) {
|
||||
eds.mu.Lock()
|
||||
defer eds.mu.Unlock()
|
||||
|
||||
for _, sub := range eds.subscribers {
|
||||
sub.event(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// StopFunc when called unsubscribes the subscriber from all events and
|
||||
// releases resources tied to the subscriber.
|
||||
type StopFunc func()
|
||||
|
||||
// SubscribeOption is used to provide options to Subscribe.
|
||||
type SubscribeOption interface {
|
||||
// set sets the provided option.
|
||||
set(*subscribeOptions)
|
||||
}
|
||||
|
||||
// subscribeOptions stores the provided options.
|
||||
type subscribeOptions struct {
|
||||
throttle bool
|
||||
maxRep int
|
||||
}
|
||||
|
||||
// subscribeOption implements Option.
|
||||
type subscribeOption func(*subscribeOptions)
|
||||
|
||||
// set implements SubscribeOption.set.
|
||||
func (o subscribeOption) set(sOpts *subscribeOptions) {
|
||||
o(sOpts)
|
||||
}
|
||||
|
||||
// MaxRepetitive when provided, instructs the system to drop repetitive
|
||||
// events instead of delivering them.
|
||||
// The argument maxRep indicates the maximum number of repetitive events to
|
||||
// enqueue towards the subscriber.
|
||||
func MaxRepetitive(maxRep int) SubscribeOption {
|
||||
return subscribeOption(func(sOpts *subscribeOptions) {
|
||||
sOpts.throttle = true
|
||||
sOpts.maxRep = maxRep
|
||||
})
|
||||
}
|
||||
|
||||
// Subscribe subscribes to events according to the filter.
|
||||
// An empty filter indicates that the subscriber wishes to receive events of
|
||||
// all kinds. If the filter is non-empty, only events of the provided type will
|
||||
// be sent to the subscriber.
|
||||
// Returns a function that allows the subscriber to unsubscribe.
|
||||
func (eds *DistributionSystem) Subscribe(filter []terminalapi.Event, cb Callback, opts ...SubscribeOption) StopFunc {
|
||||
eds.mu.Lock()
|
||||
defer eds.mu.Unlock()
|
||||
|
||||
opt := &subscribeOptions{}
|
||||
for _, o := range opts {
|
||||
o.set(opt)
|
||||
}
|
||||
|
||||
id := eds.nextID
|
||||
eds.nextID++
|
||||
sub := newSubscriber(filter, cb, opt)
|
||||
eds.subscribers[id] = sub
|
||||
|
||||
return func() {
|
||||
eds.mu.Lock()
|
||||
defer eds.mu.Unlock()
|
||||
|
||||
sub.stop()
|
||||
delete(eds.subscribers, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Processed returns the number of events that were fully processed, i.e.
|
||||
// delivered to all the subscribers and their callbacks returned.
|
||||
func (eds *DistributionSystem) Processed() int {
|
||||
eds.mu.Lock()
|
||||
defer eds.mu.Unlock()
|
||||
|
||||
var res int
|
||||
for _, sub := range eds.subscribers {
|
||||
res += sub.processedEvents()
|
||||
}
|
||||
return res
|
||||
}
|
425
internal/event/event_test.go
Normal file
@ -0,0 +1,425 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/internal/event/testevent"
|
||||
"github.com/mum4k/termdash/internal/keyboard"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// receiverMode defines how the receiver behaves.
|
||||
type receiverMode int
|
||||
|
||||
const (
|
||||
// receiverModeReceive tells the receiver to process the events
|
||||
receiverModeReceive receiverMode = iota
|
||||
|
||||
// receiverModeBlock tells the receiver to block on the call to receive.
|
||||
receiverModeBlock
|
||||
|
||||
// receiverModePause tells the receiver to pause before starting to
|
||||
// receive.
|
||||
receiverModePause
|
||||
)
|
||||
|
||||
// receiver receives events from the distribution system.
|
||||
type receiver struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// mode sets how the receiver behaves when receive(0 is called.
|
||||
mode receiverMode
|
||||
|
||||
// events are the received events.
|
||||
events []terminalapi.Event
|
||||
|
||||
// resumed indicates if the receiver was resumed.
|
||||
resumed bool
|
||||
}
|
||||
|
||||
// newReceiver returns a new event receiver.
|
||||
func newReceiver(mode receiverMode) *receiver {
|
||||
return &receiver{
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// receive receives an event.
|
||||
func (r *receiver) receive(ev terminalapi.Event) {
|
||||
switch r.mode {
|
||||
case receiverModeBlock:
|
||||
for {
|
||||
time.Sleep(1 * time.Minute)
|
||||
}
|
||||
case receiverModePause:
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.events = append(r.events, ev)
|
||||
}
|
||||
|
||||
// getEvents returns the received events.
|
||||
func (r *receiver) getEvents() map[terminalapi.Event]bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
res := map[terminalapi.Event]bool{}
|
||||
for _, ev := range r.events {
|
||||
res[ev] = true
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// subscriberCase holds test case specifics for one subscriber.
|
||||
type subscriberCase struct {
|
||||
// filter is the subscribers filter.
|
||||
filter []terminalapi.Event
|
||||
|
||||
// opts are the options to provide when subscribing.
|
||||
opts []SubscribeOption
|
||||
|
||||
// rec receives the events.
|
||||
rec *receiver
|
||||
|
||||
// want are the expected events that should be delivered to this subscriber.
|
||||
want map[terminalapi.Event]bool
|
||||
|
||||
// wantErr asserts whether we want an error from testevent.WaitFor.
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
func TestDistributionSystem(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
// events will be sent down the distribution system.
|
||||
events []terminalapi.Event
|
||||
|
||||
// subCase are the event subscribers and their expectations.
|
||||
subCase []*subscriberCase
|
||||
}{
|
||||
{
|
||||
desc: "no events and no subscribers",
|
||||
},
|
||||
{
|
||||
desc: "events and no subscribers",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Mouse{},
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "single subscriber, wants all events and gets them",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}},
|
||||
&terminalapi.Resize{Size: image.Point{2, 2}},
|
||||
terminalapi.NewError("error"),
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: nil,
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter}: true,
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}}: true,
|
||||
&terminalapi.Resize{Size: image.Point{2, 2}}: true,
|
||||
terminalapi.NewError("error"): true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "single subscriber, filters events",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}},
|
||||
&terminalapi.Resize{Size: image.Point{2, 2}},
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
&terminalapi.Mouse{},
|
||||
},
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter}: true,
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}}: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "single subscriber, wants errors only",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}},
|
||||
&terminalapi.Resize{Size: image.Point{2, 2}},
|
||||
terminalapi.NewError("error"),
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
terminalapi.NewError(""),
|
||||
},
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
want: map[terminalapi.Event]bool{
|
||||
terminalapi.NewError("error"): true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "multiple subscribers and events",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc},
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}},
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}},
|
||||
&terminalapi.Resize{Size: image.Point{1, 1}},
|
||||
&terminalapi.Resize{Size: image.Point{2, 2}},
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter}: true,
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc}: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Mouse{},
|
||||
&terminalapi.Resize{},
|
||||
},
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Mouse{Position: image.Point{0, 0}}: true,
|
||||
&terminalapi.Mouse{Position: image.Point{1, 1}}: true,
|
||||
&terminalapi.Resize{Size: image.Point{1, 1}}: true,
|
||||
&terminalapi.Resize{Size: image.Point{2, 2}}: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "a misbehaving receiver only blocks itself",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc},
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter}: true,
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc}: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
rec: newReceiver(receiverModeBlock),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter}: true,
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc}: true,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "throttles repetitive events",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc},
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
opts: []SubscribeOption{
|
||||
MaxRepetitive(0),
|
||||
},
|
||||
rec: newReceiver(receiverModePause),
|
||||
want: map[terminalapi.Event]bool{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc}: true,
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter}: true,
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEsc}: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
tc := tc
|
||||
t.Parallel()
|
||||
|
||||
eds := NewDistributionSystem()
|
||||
for _, sc := range tc.subCase {
|
||||
stop := eds.Subscribe(sc.filter, sc.rec.receive, sc.opts...)
|
||||
defer stop()
|
||||
}
|
||||
|
||||
for _, ev := range tc.events {
|
||||
eds.Event(ev)
|
||||
}
|
||||
|
||||
for i, sc := range tc.subCase {
|
||||
gotEv := map[terminalapi.Event]bool{}
|
||||
err := testevent.WaitFor(10*time.Second, func() error {
|
||||
ev := sc.rec.getEvents()
|
||||
want := len(sc.want)
|
||||
switch got := len(ev); {
|
||||
case got == want:
|
||||
gotEv = ev
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("got %d events %v, want %d", got, ev, want)
|
||||
}
|
||||
})
|
||||
if (err != nil) != sc.wantErr {
|
||||
t.Errorf("testevent.WaitFor subscriber[%d] => unexpected error: %v, wantErr: %v", i, err, sc.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if diff := pretty.Compare(sc.want, gotEv); diff != "" {
|
||||
t.Errorf("testevent.WaitFor subscriber[%d] => unexpected diff (-want, +got):\n%s", i, diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
// events will be sent down the distribution system.
|
||||
events []terminalapi.Event
|
||||
|
||||
// subCase are the event subscribers and their expectations.
|
||||
subCase []*subscriberCase
|
||||
|
||||
want int
|
||||
}{
|
||||
{
|
||||
desc: "zero without events",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
desc: "zero without subscribers",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
desc: "zero when a receiver blocks",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
rec: newReceiver(receiverModeBlock),
|
||||
},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
desc: "counts processed events",
|
||||
events: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
|
||||
},
|
||||
subCase: []*subscriberCase{
|
||||
{
|
||||
filter: []terminalapi.Event{
|
||||
&terminalapi.Keyboard{},
|
||||
},
|
||||
rec: newReceiver(receiverModeReceive),
|
||||
},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
tc := tc
|
||||
t.Parallel()
|
||||
|
||||
eds := NewDistributionSystem()
|
||||
for _, sc := range tc.subCase {
|
||||
stop := eds.Subscribe(sc.filter, sc.rec.receive)
|
||||
defer stop()
|
||||
}
|
||||
|
||||
for _, ev := range tc.events {
|
||||
eds.Event(ev)
|
||||
}
|
||||
|
||||
for _, sc := range tc.subCase {
|
||||
testevent.WaitFor(5*time.Second, func() error {
|
||||
if len(sc.rec.getEvents()) > 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("the receiver got no events")
|
||||
})
|
||||
}
|
||||
|
||||
if got := eds.Processed(); got != tc.want {
|
||||
t.Errorf("Processed => %v, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -17,14 +17,16 @@ package eventqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// node is a single data item on the queue.
|
||||
type node struct {
|
||||
prev *node
|
||||
next *node
|
||||
event terminalapi.Event
|
||||
}
|
||||
@ -91,7 +93,12 @@ func (u *Unbound) empty() bool {
|
||||
func (u *Unbound) Push(e terminalapi.Event) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.push(e)
|
||||
}
|
||||
|
||||
// push is the implementation of Push.
|
||||
// Caller must hold u.mu.
|
||||
func (u *Unbound) push(e terminalapi.Event) {
|
||||
n := &node{
|
||||
event: e,
|
||||
}
|
||||
@ -99,8 +106,10 @@ func (u *Unbound) Push(e terminalapi.Event) {
|
||||
u.first = n
|
||||
u.last = n
|
||||
} else {
|
||||
prev := u.last
|
||||
u.last.next = n
|
||||
u.last = n
|
||||
u.last.prev = prev
|
||||
}
|
||||
u.cond.Signal()
|
||||
}
|
||||
@ -124,10 +133,10 @@ func (u *Unbound) Pop() terminalapi.Event {
|
||||
}
|
||||
|
||||
// Pull is like Pop(), but blocks until an item is available or the context
|
||||
// expires.
|
||||
func (u *Unbound) Pull(ctx context.Context) (terminalapi.Event, error) {
|
||||
// expires. Returns a nil event if the context expired.
|
||||
func (u *Unbound) Pull(ctx context.Context) terminalapi.Event {
|
||||
if e := u.Pop(); e != nil {
|
||||
return e, nil
|
||||
return e
|
||||
}
|
||||
|
||||
u.cond.L.Lock()
|
||||
@ -135,12 +144,12 @@ func (u *Unbound) Pull(ctx context.Context) (terminalapi.Event, error) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
if e := u.Pop(); e != nil {
|
||||
return e, nil
|
||||
return e
|
||||
}
|
||||
u.cond.Wait()
|
||||
}
|
||||
@ -150,3 +159,73 @@ func (u *Unbound) Pull(ctx context.Context) (terminalapi.Event, error) {
|
||||
func (u *Unbound) Close() {
|
||||
close(u.done)
|
||||
}
|
||||
|
||||
// Throttled is an unbound and throttled FIFO queue of terminal events.
|
||||
// Throttled must not be copied, pass it by reference only.
|
||||
// This implementation is thread-safe.
|
||||
type Throttled struct {
|
||||
queue *Unbound
|
||||
max int
|
||||
}
|
||||
|
||||
// NewThrottled returns a new Throttled queue of terminal events.
|
||||
//
|
||||
// This queue scans the queue content on each Push call and won't Push the
|
||||
// event if there already is a continuous chain of exactly the same events
|
||||
// en queued. The argument maxRep specifies the maximum number of repetitive
|
||||
// events.
|
||||
//
|
||||
// Call Close() when done with the queue.
|
||||
func NewThrottled(maxRep int) *Throttled {
|
||||
t := &Throttled{
|
||||
queue: New(),
|
||||
max: maxRep,
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Empty determines if the queue is empty.
|
||||
func (t *Throttled) Empty() bool {
|
||||
return t.queue.empty()
|
||||
}
|
||||
|
||||
// Push pushes an event onto the queue.
|
||||
func (t *Throttled) Push(e terminalapi.Event) {
|
||||
t.queue.mu.Lock()
|
||||
defer t.queue.mu.Unlock()
|
||||
|
||||
if t.queue.empty() {
|
||||
t.queue.push(e)
|
||||
return
|
||||
}
|
||||
|
||||
var same int
|
||||
for n := t.queue.last; n != nil; n = n.prev {
|
||||
if reflect.DeepEqual(e, n.event) {
|
||||
same++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if same > t.max {
|
||||
return // Drop the repetitive event.
|
||||
}
|
||||
}
|
||||
t.queue.push(e)
|
||||
}
|
||||
|
||||
// Pop pops an event from the queue. Returns nil if the queue is empty.
|
||||
func (t *Throttled) Pop() terminalapi.Event {
|
||||
return t.queue.Pop()
|
||||
}
|
||||
|
||||
// Pull is like Pop(), but blocks until an item is available or the context
|
||||
// expires. Returns a nil event if the context expired.
|
||||
func (t *Throttled) Pull(ctx context.Context) terminalapi.Event {
|
||||
return t.queue.Pull(ctx)
|
||||
}
|
||||
|
||||
// Close should be called when the queue isn't needed anymore.
|
||||
func (t *Throttled) Close() {
|
||||
close(t.queue.done)
|
||||
}
|
249
internal/event/eventqueue/eventqueue_test.go
Normal file
@ -0,0 +1,249 @@
|
||||
// Copyright 2018 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package eventqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
func TestQueue(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
pushes []terminalapi.Event
|
||||
wantEmpty bool // Checked after pushes and before pops.
|
||||
wantPops []terminalapi.Event
|
||||
}{
|
||||
{
|
||||
desc: "empty queue returns nil",
|
||||
wantEmpty: true,
|
||||
wantPops: []terminalapi.Event{
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "queue is FIFO",
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
q := New()
|
||||
defer q.Close()
|
||||
for _, ev := range tc.pushes {
|
||||
q.Push(ev)
|
||||
}
|
||||
|
||||
gotEmpty := q.Empty()
|
||||
if gotEmpty != tc.wantEmpty {
|
||||
t.Errorf("Empty => got %v, want %v", gotEmpty, tc.wantEmpty)
|
||||
}
|
||||
|
||||
for i, want := range tc.wantPops {
|
||||
got := q.Pop()
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pop[%d] => unexpected diff (-want, +got):\n%s", i, diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullEventAvailable(t *testing.T) {
|
||||
q := New()
|
||||
defer q.Close()
|
||||
want := terminalapi.NewError("error event")
|
||||
q.Push(want)
|
||||
|
||||
ctx := context.Background()
|
||||
got := q.Pull(ctx)
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pull => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullBlocksUntilAvailable(t *testing.T) {
|
||||
q := New()
|
||||
defer q.Close()
|
||||
want := terminalapi.NewError("error event")
|
||||
|
||||
ch := make(chan struct{})
|
||||
go func() {
|
||||
<-ch
|
||||
q.Push(want)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if got := q.Pull(ctx); got != nil {
|
||||
t.Fatalf("Pull => %v, want <nil>", got)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
got := q.Pull(context.Background())
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pull => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottled(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
maxRep int
|
||||
pushes []terminalapi.Event
|
||||
wantEmpty bool // Checked after pushes and before pops.
|
||||
wantPops []terminalapi.Event
|
||||
}{
|
||||
{
|
||||
desc: "empty queue returns nil",
|
||||
wantEmpty: true,
|
||||
wantPops: []terminalapi.Event{
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "queue is FIFO",
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "allows distinct events",
|
||||
maxRep: 0,
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error3"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "throttles equal events to zero repetitions",
|
||||
maxRep: 0,
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "throttles equal events to two repetitions",
|
||||
maxRep: 2,
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error1"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "repetitions not recognized when interleaved with other events",
|
||||
maxRep: 0,
|
||||
pushes: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error3"),
|
||||
},
|
||||
wantEmpty: false,
|
||||
wantPops: []terminalapi.Event{
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error2"),
|
||||
terminalapi.NewError("error1"),
|
||||
terminalapi.NewError("error3"),
|
||||
nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
q := NewThrottled(tc.maxRep)
|
||||
defer q.Close()
|
||||
for _, ev := range tc.pushes {
|
||||
q.Push(ev)
|
||||
}
|
||||
|
||||
gotEmpty := q.Empty()
|
||||
if gotEmpty != tc.wantEmpty {
|
||||
t.Errorf("Empty => got %v, want %v", gotEmpty, tc.wantEmpty)
|
||||
}
|
||||
|
||||
for i, want := range tc.wantPops {
|
||||
got := q.Pop()
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pop[%d] => unexpected diff (-want, +got):\n%s", i, diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottledPullEventAvailable(t *testing.T) {
|
||||
q := NewThrottled(0)
|
||||
defer q.Close()
|
||||
want := terminalapi.NewError("error event")
|
||||
q.Push(want)
|
||||
|
||||
ctx := context.Background()
|
||||
got := q.Pull(ctx)
|
||||
if diff := pretty.Compare(want, got); diff != "" {
|
||||
t.Errorf("Pull => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
}
|
46
internal/event/testevent/testevent.go
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package testevent provides utilities for tests that deal with concurrent
|
||||
// events.
|
||||
package testevent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WaitFor waits until the provided function returns a nil error or the timeout.
|
||||
// If the function doesn't return a nil error before the timeout expires,
|
||||
// returns the last returned error.
|
||||
func WaitFor(timeout time.Duration, fn func() error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
for {
|
||||
tick := time.NewTimer(5 * time.Millisecond)
|
||||
select {
|
||||
case <-tick.C:
|
||||
if err = fn(); err != nil {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout expired, error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
135
internal/mouse/button/button.go
Normal file
@ -0,0 +1,135 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package button implements a state machine that tracks mouse button clicks.
|
||||
package button
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// State represents the state of the mouse button.
|
||||
type State int
|
||||
|
||||
// String implements fmt.Stringer()
|
||||
func (s State) String() string {
|
||||
if n, ok := stateNames[s]; ok {
|
||||
return n
|
||||
}
|
||||
return "StateUnknown"
|
||||
}
|
||||
|
||||
// stateNames maps State values to human readable names.
|
||||
var stateNames = map[State]string{
|
||||
Up: "StateUp",
|
||||
Down: "StateDown",
|
||||
}
|
||||
|
||||
const (
|
||||
// Up is the default idle state of the mouse button.
|
||||
Up State = iota
|
||||
|
||||
// Down is a state where the mouse button is pressed down and held.
|
||||
Down
|
||||
)
|
||||
|
||||
// FSM implements a finite-state machine that tracks mouse clicks within an
|
||||
// area.
|
||||
//
|
||||
// Simplifies tracking of mouse button clicks, i.e. when the caller wants to
|
||||
// perform an action only if both the button press and release happen within
|
||||
// the specified area.
|
||||
//
|
||||
// This object is not thread-safe.
|
||||
type FSM struct {
|
||||
// button is the mouse button whose state this FSM tracks.
|
||||
button mouse.Button
|
||||
|
||||
// area is the area provided to NewFSM.
|
||||
area image.Rectangle
|
||||
|
||||
// state is the current state of the FSM.
|
||||
state stateFn
|
||||
}
|
||||
|
||||
// NewFSM creates a new FSM instance that tracks the state of the specified
|
||||
// mouse button through button events that fall within the provided area.
|
||||
func NewFSM(button mouse.Button, area image.Rectangle) *FSM {
|
||||
return &FSM{
|
||||
button: button,
|
||||
area: area,
|
||||
state: wantPress,
|
||||
}
|
||||
}
|
||||
|
||||
// Event is used to forward mouse events to the state machine.
|
||||
// Only events related to the button specified on a call to NewFSM are
|
||||
// processed.
|
||||
//
|
||||
// Returns a bool indicating if an action guarded by the button should be
|
||||
// performed and the state of the button after the provided event.
|
||||
// The bool is true if the button click should take an effect, i.e. if the
|
||||
// FSM saw both the button click and its release.
|
||||
func (fsm *FSM) Event(m *terminalapi.Mouse) (bool, State) {
|
||||
clicked, bs, next := fsm.state(fsm, m)
|
||||
fsm.state = next
|
||||
return clicked, bs
|
||||
}
|
||||
|
||||
// UpdateArea informs FSM of an area change.
|
||||
// This method is idempotent.
|
||||
func (fsm *FSM) UpdateArea(area image.Rectangle) {
|
||||
fsm.area = area
|
||||
}
|
||||
|
||||
// stateFn is a single state in the state machine.
|
||||
// Returns bool indicating if a click happened, the state of the button and the
|
||||
// next state of the FSM.
|
||||
type stateFn func(fsm *FSM, m *terminalapi.Mouse) (bool, State, stateFn)
|
||||
|
||||
// wantPress is the initial state, expecting a button press inside the area.
|
||||
func wantPress(fsm *FSM, m *terminalapi.Mouse) (bool, State, stateFn) {
|
||||
if m.Button != fsm.button || !m.Position.In(fsm.area) {
|
||||
return false, Up, wantPress
|
||||
}
|
||||
return false, Down, wantRelease
|
||||
}
|
||||
|
||||
// wantRelease waits for a mouse button release in the same area as
|
||||
// the press.
|
||||
func wantRelease(fsm *FSM, m *terminalapi.Mouse) (bool, State, stateFn) {
|
||||
switch m.Button {
|
||||
case fsm.button:
|
||||
if m.Position.In(fsm.area) {
|
||||
// Remain in the same state, since termbox reports move of mouse with
|
||||
// button held down as a series of clicks, one per position.
|
||||
return false, Down, wantRelease
|
||||
}
|
||||
return false, Up, wantPress
|
||||
|
||||
case mouse.ButtonRelease:
|
||||
if m.Position.In(fsm.area) {
|
||||
// Seen both press and release, report a click.
|
||||
return true, Up, wantPress
|
||||
}
|
||||
// Release the button even if the release event happened outside of the area.
|
||||
return false, Up, wantPress
|
||||
|
||||
default:
|
||||
return false, Up, wantPress
|
||||
}
|
||||
}
|
315
internal/mouse/button/button_test.go
Normal file
@ -0,0 +1,315 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package button
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// eventTestCase is one mouse event and the output expectation.
|
||||
type eventTestCase struct {
|
||||
// area if specified, will be provided to UpdateArea *before* processing the event.
|
||||
area *image.Rectangle
|
||||
|
||||
// event is the mouse event to send.
|
||||
event *terminalapi.Mouse
|
||||
|
||||
// wantClick indicates whether we expect the FSM to recognize a mouse click.
|
||||
wantClick bool
|
||||
|
||||
// wantState is the expected button state.
|
||||
wantState State
|
||||
}
|
||||
|
||||
func TestFSM(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
button mouse.Button
|
||||
area image.Rectangle
|
||||
eventCases []*eventTestCase
|
||||
}{
|
||||
{
|
||||
desc: "tracks single left button click",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "updates area so the clicks falls outside",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
area: func() *image.Rectangle {
|
||||
ar := image.Rect(1, 1, 2, 2)
|
||||
return &ar
|
||||
}(),
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "updates area before release, so the release falls outside",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
area: func() *image.Rectangle {
|
||||
ar := image.Rect(1, 1, 2, 2)
|
||||
return &ar
|
||||
}(),
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "increased area makes the release count",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
area: func() *image.Rectangle {
|
||||
ar := image.Rect(0, 0, 2, 2)
|
||||
return &ar
|
||||
}(),
|
||||
event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "tracks single right button click",
|
||||
button: mouse.ButtonRight,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRight},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "ignores unrelated button in state wantPress",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRight},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "reverts to wantPress on unrelated button in state wantRelease",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRight},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "reports button as down when the tracked button is pressed again in the area",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "reports button as up when the tracked button is pressed again outside the area",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "ignores clicks outside of area in state wantPress",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "release outside of area releases button too",
|
||||
button: mouse.ButtonLeft,
|
||||
area: image.Rect(0, 0, 1, 1),
|
||||
eventCases: []*eventTestCase{
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
|
||||
wantClick: false,
|
||||
wantState: Up,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
|
||||
wantClick: false,
|
||||
wantState: Down,
|
||||
},
|
||||
{
|
||||
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
|
||||
wantClick: true,
|
||||
wantState: Up,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf(tc.desc), func(t *testing.T) {
|
||||
fsm := NewFSM(tc.button, tc.area)
|
||||
for _, etc := range tc.eventCases {
|
||||
if etc.area != nil {
|
||||
fsm.UpdateArea(*etc.area)
|
||||
}
|
||||
|
||||
gotClick, gotState := fsm.Event(etc.event)
|
||||
t.Logf("Called fsm.Event(%v) => %v, %v", etc.event, gotClick, gotState)
|
||||
if gotClick != etc.wantClick || gotState != etc.wantState {
|
||||
t.Errorf("fsm.Event(%v) => %v, %v, want %v, %v", etc.event, gotClick, gotState, etc.wantClick, etc.wantState)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -16,6 +16,7 @@
|
||||
package numbers
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
|
||||
@ -170,3 +171,51 @@ func Abs(x int) int {
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// findGCF finds the greatest common factor of two integers.
|
||||
func findGCF(a, b int) int {
|
||||
if a == 0 || b == 0 {
|
||||
return 0
|
||||
}
|
||||
a = Abs(a)
|
||||
b = Abs(b)
|
||||
|
||||
// https://en.wikipedia.org/wiki/Euclidean_algorithm
|
||||
for {
|
||||
rem := a % b
|
||||
a = b
|
||||
b = rem
|
||||
|
||||
if b == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SimplifyRatio simplifies the given ratio.
|
||||
func SimplifyRatio(ratio image.Point) image.Point {
|
||||
gcf := findGCF(ratio.X, ratio.Y)
|
||||
if gcf == 0 {
|
||||
return image.ZP
|
||||
}
|
||||
return image.Point{
|
||||
X: ratio.X / gcf,
|
||||
Y: ratio.Y / gcf,
|
||||
}
|
||||
}
|
||||
|
||||
// SplitByRatio splits the provided number by the specified ratio.
|
||||
func SplitByRatio(n int, ratio image.Point) image.Point {
|
||||
sr := SimplifyRatio(ratio)
|
||||
if sr.Eq(image.ZP) {
|
||||
return image.ZP
|
||||
}
|
||||
fn := float64(n)
|
||||
sum := float64(sr.X + sr.Y)
|
||||
fact := fn / sum
|
||||
return image.Point{
|
||||
int(Round(fact * float64(sr.X))),
|
||||
int(Round(fact * float64(sr.Y))),
|
||||
}
|
||||
}
|
@ -16,8 +16,11 @@ package numbers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
)
|
||||
|
||||
func TestRoundToNonZeroPlaces(t *testing.T) {
|
||||
@ -349,3 +352,138 @@ func TestAbs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGCF(t *testing.T) {
|
||||
tests := []struct {
|
||||
a int
|
||||
b int
|
||||
want int
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 1, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 1},
|
||||
{2, 2, 2},
|
||||
{50, 35, 5},
|
||||
{16, 88, 8},
|
||||
{-16, 88, 8},
|
||||
{16, -88, 8},
|
||||
{-16, -88, 8},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf("findGCF(%d,%d)", tc.a, tc.b), func(t *testing.T) {
|
||||
if got := findGCF(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("findGCF(%d,%d) => got %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimplifyRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
ratio image.Point
|
||||
want image.Point
|
||||
}{
|
||||
{
|
||||
desc: "zero ratio",
|
||||
ratio: image.Point{0, 0},
|
||||
want: image.Point{0, 0},
|
||||
},
|
||||
{
|
||||
desc: "already simplified",
|
||||
ratio: image.Point{1, 3},
|
||||
want: image.Point{1, 3},
|
||||
},
|
||||
{
|
||||
desc: "already simplified and X is negative",
|
||||
ratio: image.Point{-1, 3},
|
||||
want: image.Point{-1, 3},
|
||||
},
|
||||
{
|
||||
desc: "already simplified and Y is negative",
|
||||
ratio: image.Point{1, -3},
|
||||
want: image.Point{1, -3},
|
||||
},
|
||||
{
|
||||
desc: "already simplified and both are negative",
|
||||
ratio: image.Point{-1, -3},
|
||||
want: image.Point{-1, -3},
|
||||
},
|
||||
{
|
||||
desc: "simplifies positive ratio",
|
||||
ratio: image.Point{27, 42},
|
||||
want: image.Point{9, 14},
|
||||
},
|
||||
{
|
||||
desc: "simplifies negative ratio",
|
||||
ratio: image.Point{-30, 50},
|
||||
want: image.Point{-3, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := SimplifyRatio(tc.ratio)
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("SimplifyRatio => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
number int
|
||||
ratio image.Point
|
||||
want image.Point
|
||||
}{
|
||||
{
|
||||
desc: "zero numerator",
|
||||
number: 10,
|
||||
ratio: image.Point{0, 2},
|
||||
want: image.ZP,
|
||||
},
|
||||
{
|
||||
desc: "zero denominator",
|
||||
number: 10,
|
||||
ratio: image.Point{2, 0},
|
||||
want: image.ZP,
|
||||
},
|
||||
{
|
||||
desc: "zero number",
|
||||
number: 0,
|
||||
ratio: image.Point{1, 2},
|
||||
want: image.ZP,
|
||||
},
|
||||
{
|
||||
desc: "equal ratio",
|
||||
number: 2,
|
||||
ratio: image.Point{2, 2},
|
||||
want: image.Point{1, 1},
|
||||
},
|
||||
{
|
||||
desc: "unequal ratio",
|
||||
number: 15,
|
||||
ratio: image.Point{1, 2},
|
||||
want: image.Point{5, 10},
|
||||
},
|
||||
{
|
||||
desc: "large ratio",
|
||||
number: 19,
|
||||
ratio: image.Point{78, 121},
|
||||
want: image.Point{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := SplitByRatio(tc.number, tc.ratio)
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("SplitByRatio => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -21,7 +21,7 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/mum4k/termdash/numbers"
|
||||
"github.com/mum4k/termdash/internal/numbers"
|
||||
)
|
||||
|
||||
// CirclePointAtAngle given an angle in degrees and a circle midpoint and
|
@ -23,7 +23,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
)
|
||||
|
||||
// optDiff is used to display differences in cell options.
|
@ -23,9 +23,9 @@ import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/eventqueue"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/event/eventqueue"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
)
|
||||
|
||||
// Option is used to provide options.
|
||||
@ -195,9 +195,9 @@ func (t *Terminal) Event(ctx context.Context) terminalapi.Event {
|
||||
return terminalapi.NewErrorf("no event queue provided, use the WithEventQueue option when creating the fake terminal")
|
||||
}
|
||||
|
||||
ev, err := t.events.Pull(ctx)
|
||||
if err != nil {
|
||||
return terminalapi.NewErrorf("unable to pull the next event: %v", err)
|
||||
ev := t.events.Pull(ctx)
|
||||
if ev == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if res, ok := ev.(*terminalapi.Resize); ok {
|
@ -17,7 +17,7 @@ package termbox
|
||||
// cell_options.go converts termdash cell options to the termbox format.
|
||||
|
||||
import (
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
@ -17,7 +17,7 @@ package termbox
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
@ -17,7 +17,7 @@ package termbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
@ -19,9 +19,9 @@ package termbox
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/keyboard"
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
@ -21,9 +21,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/keyboard"
|
||||
"github.com/mum4k/termdash/internal/mouse"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
@ -19,9 +19,9 @@ import (
|
||||
"context"
|
||||
"image"
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/eventqueue"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/internal/cell"
|
||||
"github.com/mum4k/termdash/internal/event/eventqueue"
|
||||
"github.com/mum4k/termdash/internal/terminalapi"
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
@ -39,7 +39,11 @@ func (o option) set(t *Terminal) {
|
||||
o(t)
|
||||
}
|
||||
|
||||
// DefaultColorMode is the default value for the ColorMode option.
|
||||
const DefaultColorMode = terminalapi.ColorMode256
|
||||
|
||||
// ColorMode sets the terminal color mode.
|
||||
// Defaults to DefaultColorMode.
|
||||
func ColorMode(cm terminalapi.ColorMode) Option {
|
||||
return option(func(t *Terminal) {
|
||||
t.colorMode = cm
|
||||
@ -60,6 +64,19 @@ type Terminal struct {
|
||||
colorMode terminalapi.ColorMode
|
||||
}
|
||||
|
||||
// newTerminal creates the terminal and applies the options.
|
||||
func newTerminal(opts ...Option) *Terminal {
|
||||
t := &Terminal{
|
||||
events: eventqueue.New(),
|
||||
done: make(chan struct{}),
|
||||
colorMode: DefaultColorMode,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.set(t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// New returns a new termbox based Terminal.
|
||||
// Call Close() when the terminal isn't required anymore.
|
||||
func New(opts ...Option) (*Terminal, error) {
|
||||
@ -68,14 +85,7 @@ func New(opts ...Option) (*Terminal, error) {
|
||||
}
|
||||
tbx.SetInputMode(tbx.InputEsc | tbx.InputMouse)
|
||||
|
||||
t := &Terminal{
|
||||
events: eventqueue.New(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.set(t)
|
||||
}
|
||||
|
||||
t := newTerminal(opts...)
|
||||
om, err := colorMode(t.colorMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -138,9 +148,9 @@ func (t *Terminal) pollEvents() {
|
||||
|
||||
// Event implements terminalapi.Terminal.Event.
|
||||
func (t *Terminal) Event(ctx context.Context) terminalapi.Event {
|
||||
ev, err := t.events.Pull(ctx)
|
||||
if err != nil {
|
||||
return terminalapi.NewErrorf("unable to pull the next event: %v", err)
|
||||
ev := t.events.Pull(ctx)
|
||||
if ev == nil {
|
||||
return nil
|
||||
}
|
||||
return ev
|
||||
}
|