1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-27 13:48:49 +08:00

Merge pull request #152 from mum4k/release-0-7-0

Release v0.7.0
This commit is contained in:
Jakub Sobon 2019-02-24 00:34:52 -05:00 committed by GitHub
commit c861ecef30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
163 changed files with 12028 additions and 1847 deletions

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,6 @@
[![Doc Status](https://godoc.org/github.com/mum4k/termdash?status.png)](https://godoc.org/github.com/mum4k/termdash)
[![Build Status](https://travis-ci.org/mum4k/termdash.svg?branch=master)](https://travis-ci.org/mum4k/termdash)
[![Sourcegraph](https://sourcegraph.com/github.com/mum4k/termdash/-/badge.svg)](https://sourcegraph.com/github.com/mum4k/termdash?badge)
[![Coverage Status](https://coveralls.io/repos/github/mum4k/termdash/badge.svg?branch=master)](https://coveralls.io/github/mum4k/termdash?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/mum4k/termdash)](https://goreportcard.com/report/github.com/mum4k/termdash)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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.

View File

@ -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
}))
}

View File

@ -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)
}
})
}
}

View File

@ -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.

View File

@ -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"
)

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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.

View File

@ -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) {

View File

@ -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.

View File

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

BIN
doc/images/buttondemo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 430 KiB

View File

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

Before

Width:  |  Height:  |  Size: 504 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

View File

Before

Width:  |  Height:  |  Size: 7.2 MiB

After

Width:  |  Height:  |  Size: 7.2 MiB

View File

@ -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)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 MiB

4
internal/README.md Normal file
View 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.

View File

@ -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.

View File

@ -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
}

View File

@ -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

View File

@ -19,7 +19,7 @@ import (
"testing"
"github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/internal/cell"
)
func Example() {

View File

@ -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 {

View File

@ -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),

View File

@ -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))
}
}

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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.

View 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},
}

View 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)
}
})
}
}

View File

@ -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().

View File

@ -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) {

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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) {

View File

@ -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.

View File

@ -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) {

View File

@ -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().

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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.

View File

@ -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) {

View File

@ -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.

View File

@ -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) {

View File

@ -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.

View File

@ -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.

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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.

View File

@ -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) {

View File

@ -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.

View File

@ -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 {

View File

@ -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

View File

@ -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) {

View 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
}

View 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
View 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
}

View 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)
}
})
}
}

View File

@ -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)
}

View 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)
}
}

View 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)
}
}
}

View 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
}
}

View 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)
}
}
})
}
}

View File

@ -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))),
}
}

View File

@ -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)
}
})
}
}

View File

@ -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

View File

@ -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.

View File

@ -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 {

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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
}

Some files were not shown because too many files have changed in this diff Show More