diff --git a/.travis.yml b/.travis.yml index be1df6f..c66664e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4173acc..79cfb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 24-Feb-2019 + +### Added + +#### New widgets + +- The Button widget. + +#### Improvements to documentation + +- Clearly marked the public API surface by moving private packages into + internal directory. +- Started a GitHub wiki for Termdash. + +#### Improvements to the LineChart widget + +- The LineChart widget can display X axis labels in vertical orientation. +- The LineChart widget allows the user to specify a custom scale for the Y + axis. +- The LineChart widget now has an option that disables scaling of the X axis. + Useful for applications that want to continuously feed data and make them + "roll" through the linechart. +- The LineChart widget now has a method that returns the observed capacity of + the LineChart the last time Draw was called. +- The LineChart widget now supports zoom of the content triggered by mouse + events. + +#### Improvements to the Text widget + +- The Text widget now has a Write option that atomically replaces the entire + text content. + + +#### Improvements to the infrastructure + +- A function that draws text vertically. +- A non-blocking event distribution system that can throttle repetitive events. +- Generalized mouse button FSM for use in widgets that need to track mouse + button clicks. + +### Changed + +- Termbox is now initialized in 256 color mode by default. +- The infrastructure now uses the non-blocking event distribution system to + distribute events to subscribers. Each widget is now an individual + subscriber. +- The infrastructure now throttles event driven screen redraw rather than + redrawing for each input event. +- Widgets can now specify the scope at which they want to receive keyboard and + mouse events. + +#### Breaking API changes + +##### High impact + +- The constructors of all the widgets now also return an error so that they + can validate the options. This is a breaking change for the following + widgets: BarChart, Gauge, LineChart, SparkLine, Text. The callers will have + to handle the returned error. + +##### Low impact + +- The container package no longer exports separate methods to receive Keyboard + and Mouse events which were replaced by a Subscribe method for the event + distribution system. This shouldn't affect users as the removed methods + aren't needed by container users. +- The widgetapi.Options struct now uses an enum instead of a boolean when + widget specifies if it wants keyboard or mouse events. This only impacts + development of new widgets. + +### Fixed + +- The LineChart widget now correctly determines the Y axis scale when multiple + series are provided. +- Lint issues in the codebase, and updated Travis configuration so that golint + is executed on every run. +- Termdash now correctly starts in locales like zh_CN.UTF-8 where some of the + characters it uses internally can have ambiguous width. + ## [0.6.1] - 12-Feb-2019 ### Fixed @@ -89,7 +168,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Gauge widget. - The Text widget. -[Unreleased]: https://github.com/mum4k/termdash/compare/v0.6.1...devel +[Unreleased]: https://github.com/mum4k/termdash/compare/v0.7.0...devel +[0.7.0]: https://github.com/mum4k/termdash/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/mum4k/termdash/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/mum4k/termdash/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/mum4k/termdash/compare/v0.4.0...v0.5.0 diff --git a/README.md b/README.md index 1d6d42d..bcfca65 100644 --- a/README.md +++ b/README.md @@ -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 -[termdashdemo](termdashdemo/termdashdemo.go) +[termdashdemo](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 +``` + +[buttondemo](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 ``` -[gaugedemo](widgets/gauge/gaugedemo/gaugedemo.go) +[gaugedemo](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 ``` -[donutdemo](widgets/donut/donutdemo/donutdemo.go) +[donutdemo](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 ``` -[textdemo](widgets/text/textdemo/textdemo.go) +[textdemo](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 ``` -[sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go) +[sparklinedemo](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 ``` -[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go) +[barchartdemo](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 ``` -[linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go) +[linechartdemo](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 ``` -[segmentdisplaydemo](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go) +[segmentdisplaydemo](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. diff --git a/container/container.go b/container/container.go index fa008e3..a7a42bd 100644 --- a/container/container.go +++ b/container/container.go @@ -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 + })) } diff --git a/container/container_test.go b/container/container_test.go index 7bf1276..77ea229 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -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) + } }) } } diff --git a/container/draw.go b/container/draw.go index 41a1bec..befb4fe 100644 --- a/container/draw.go +++ b/container/draw.go @@ -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. diff --git a/container/draw_test.go b/container/draw_test.go index 6c4a273..2b18d62 100644 --- a/container/draw_test.go +++ b/container/draw_test.go @@ -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" ) diff --git a/container/focus.go b/container/focus.go index 445969f..38b4f06 100644 --- a/container/focus.go +++ b/container/focus.go @@ -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 + } + } } diff --git a/container/focus_test.go b/container/focus_test.go index c9331e8..306fadd 100644 --- a/container/focus_test.go +++ b/container/focus_test.go @@ -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 diff --git a/container/mouse_fsm.go b/container/mouse_fsm.go deleted file mode 100644 index 955344f..0000000 --- a/container/mouse_fsm.go +++ /dev/null @@ -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 -} diff --git a/container/options.go b/container/options.go index de508ba..3e6aa27 100644 --- a/container/options.go +++ b/container/options.go @@ -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. diff --git a/container/traversal_test.go b/container/traversal_test.go index f31ae66..6b26d60 100644 --- a/container/traversal_test.go +++ b/container/traversal_test.go @@ -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) { diff --git a/doc/hld.md b/doc/hld.md index d10988a..5e663e0 100644 --- a/doc/hld.md +++ b/doc/hld.md @@ -36,10 +36,11 @@ neither the users of this library nor the widgets interact with the terminal directly. The **infrastructure layer** is responsible for container management, tracking -of keyboard and mouse focus and handling external events like resizing of the -terminal. The infrastructure layer also decides when to flush the buffer and -refresh the screen. I.e. The widgets update content of a back buffer and the -infrastructure decides when it is synchronized to the terminal. +of keyboard and mouse focus and distribution and handling of external events +like resizing of the terminal. The infrastructure layer also decides when to +flush the buffer and refresh the screen. I.e. The widgets update content of a +back buffer and the infrastructure decides when it is synchronized to the +terminal. The **widgets layer** contains the implementations of individual widgets. Each widget receives a canvas from the container on which it presents its content to @@ -50,7 +51,7 @@ The user interacts with the widget API when constructing individual widgets and with the container API when placing the widgets onto the dashboard.

- +

## 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. diff --git a/images/barchartdemo.gif b/doc/images/barchartdemo.gif similarity index 100% rename from images/barchartdemo.gif rename to doc/images/barchartdemo.gif diff --git a/doc/images/buttondemo.gif b/doc/images/buttondemo.gif new file mode 100644 index 0000000..c621f28 Binary files /dev/null and b/doc/images/buttondemo.gif differ diff --git a/images/donutdemo.gif b/doc/images/donutdemo.gif similarity index 100% rename from images/donutdemo.gif rename to doc/images/donutdemo.gif diff --git a/images/gaugedemo.gif b/doc/images/gaugedemo.gif similarity index 100% rename from images/gaugedemo.gif rename to doc/images/gaugedemo.gif diff --git a/doc/hld.graffle b/doc/images/hld.graffle similarity index 100% rename from doc/hld.graffle rename to doc/images/hld.graffle diff --git a/doc/hld.png b/doc/images/hld.png similarity index 100% rename from doc/hld.png rename to doc/images/hld.png diff --git a/doc/images/linechartdemo.gif b/doc/images/linechartdemo.gif new file mode 100644 index 0000000..3cb385a Binary files /dev/null and b/doc/images/linechartdemo.gif differ diff --git a/images/segmentdisplaydemo.gif b/doc/images/segmentdisplaydemo.gif similarity index 100% rename from images/segmentdisplaydemo.gif rename to doc/images/segmentdisplaydemo.gif diff --git a/images/sparklinedemo.gif b/doc/images/sparklinedemo.gif similarity index 100% rename from images/sparklinedemo.gif rename to doc/images/sparklinedemo.gif diff --git a/doc/images/termdashdemo_0_7_0.gif b/doc/images/termdashdemo_0_7_0.gif new file mode 100644 index 0000000..b49214b Binary files /dev/null and b/doc/images/termdashdemo_0_7_0.gif differ diff --git a/images/textdemo.gif b/doc/images/textdemo.gif similarity index 100% rename from images/textdemo.gif rename to doc/images/textdemo.gif diff --git a/eventqueue/eventqueue_test.go b/eventqueue/eventqueue_test.go deleted file mode 100644 index 70c3ecd..0000000 --- a/eventqueue/eventqueue_test.go +++ /dev/null @@ -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) - } -} diff --git a/images/linechartdemo.gif b/images/linechartdemo.gif deleted file mode 100644 index c505762..0000000 Binary files a/images/linechartdemo.gif and /dev/null differ diff --git a/images/termdashdemo_0_6_0.gif b/images/termdashdemo_0_6_0.gif deleted file mode 100644 index df63a8f..0000000 Binary files a/images/termdashdemo_0_6_0.gif and /dev/null differ diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..a989273 --- /dev/null +++ b/internal/README.md @@ -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. diff --git a/align/align.go b/internal/align/align.go similarity index 98% rename from align/align.go rename to internal/align/align.go index 4be0aa3..1a2acc7 100644 --- a/align/align.go +++ b/internal/align/align.go @@ -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. diff --git a/align/align_test.go b/internal/align/align_test.go similarity index 100% rename from align/align_test.go rename to internal/align/align_test.go diff --git a/area/area.go b/internal/area/area.go similarity index 87% rename from area/area.go rename to internal/area/area.go index 4c437cc..47a3bca 100644 --- a/area/area.go +++ b/internal/area/area.go @@ -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 } diff --git a/area/area_test.go b/internal/area/area_test.go similarity index 95% rename from area/area_test.go rename to internal/area/area_test.go index c998ed0..27c0475 100644 --- a/area/area_test.go +++ b/internal/area/area_test.go @@ -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 diff --git a/attrrange/attrrange.go b/internal/attrrange/attrrange.go similarity index 100% rename from attrrange/attrrange.go rename to internal/attrrange/attrrange.go diff --git a/attrrange/attrrange_test.go b/internal/attrrange/attrrange_test.go similarity index 98% rename from attrrange/attrrange_test.go rename to internal/attrrange/attrrange_test.go index 13031ee..bfdd460 100644 --- a/attrrange/attrrange_test.go +++ b/internal/attrrange/attrrange_test.go @@ -19,7 +19,7 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/cell" ) func Example() { diff --git a/canvas/braille/braille.go b/internal/canvas/braille/braille.go similarity index 81% rename from canvas/braille/braille.go rename to internal/canvas/braille/braille.go index 196f7e5..7799509 100644 --- a/canvas/braille/braille.go +++ b/internal/canvas/braille/braille.go @@ -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 { diff --git a/canvas/braille/braille_test.go b/internal/canvas/braille/braille_test.go similarity index 74% rename from canvas/braille/braille_test.go rename to internal/canvas/braille/braille_test.go index 38dfd09..786e483 100644 --- a/canvas/braille/braille_test.go +++ b/internal/canvas/braille/braille_test.go @@ -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), diff --git a/canvas/braille/testbraille/testbraille.go b/internal/canvas/braille/testbraille/testbraille.go similarity index 71% rename from canvas/braille/testbraille/testbraille.go rename to internal/canvas/braille/testbraille/testbraille.go index fe86b5d..355fd6c 100644 --- a/canvas/braille/testbraille/testbraille.go +++ b/internal/canvas/braille/testbraille/testbraille.go @@ -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)) + } +} diff --git a/canvas/canvas.go b/internal/canvas/canvas.go similarity index 72% rename from canvas/canvas.go rename to internal/canvas/canvas.go index 5f02023..b2bbc1f 100644 --- a/canvas/canvas.go +++ b/internal/canvas/canvas.go @@ -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 diff --git a/canvas/canvas_test.go b/internal/canvas/canvas_test.go similarity index 58% rename from canvas/canvas_test.go rename to internal/canvas/canvas_test.go index 071ec93..ac0781b 100644 --- a/canvas/canvas_test.go +++ b/internal/canvas/canvas_test.go @@ -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 diff --git a/canvas/testcanvas/testcanvas.go b/internal/canvas/testcanvas/testcanvas.go similarity index 73% rename from canvas/testcanvas/testcanvas.go rename to internal/canvas/testcanvas/testcanvas.go index 375e066..a80e6ae 100644 --- a/canvas/testcanvas/testcanvas.go +++ b/internal/canvas/testcanvas/testcanvas.go @@ -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) { diff --git a/cell/cell.go b/internal/cell/cell.go similarity index 98% rename from cell/cell.go rename to internal/cell/cell.go index 7d3bb96..37e4df8 100644 --- a/cell/cell.go +++ b/internal/cell/cell.go @@ -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. diff --git a/cell/cell_test.go b/internal/cell/cell_test.go similarity index 100% rename from cell/cell_test.go rename to internal/cell/cell_test.go diff --git a/cell/color.go b/internal/cell/color.go similarity index 100% rename from cell/color.go rename to internal/cell/color.go diff --git a/cell/color_test.go b/internal/cell/color_test.go similarity index 100% rename from cell/color_test.go rename to internal/cell/color_test.go diff --git a/internal/cell/runewidth/runewidth.go b/internal/cell/runewidth/runewidth.go new file mode 100644 index 0000000..4f2f63a --- /dev/null +++ b/internal/cell/runewidth/runewidth.go @@ -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}, +} diff --git a/internal/cell/runewidth/runewidth_test.go b/internal/cell/runewidth/runewidth_test.go new file mode 100644 index 0000000..ce3ee6b --- /dev/null +++ b/internal/cell/runewidth/runewidth_test.go @@ -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) + } + }) + } +} diff --git a/draw/border.go b/internal/draw/border.go similarity index 97% rename from draw/border.go rename to internal/draw/border.go index 81d2efd..78a3139 100644 --- a/draw/border.go +++ b/internal/draw/border.go @@ -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(). diff --git a/draw/border_test.go b/internal/draw/border_test.go similarity index 98% rename from draw/border_test.go rename to internal/draw/border_test.go index 4cf770a..478a1d8 100644 --- a/draw/border_test.go +++ b/internal/draw/border_test.go @@ -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) { diff --git a/draw/braille_circle.go b/internal/draw/braille_circle.go similarity index 98% rename from draw/braille_circle.go rename to internal/draw/braille_circle.go index e013a18..65f1861 100644 --- a/draw/braille_circle.go +++ b/internal/draw/braille_circle.go @@ -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. diff --git a/draw/braille_circle_test.go b/internal/draw/braille_circle_test.go similarity index 99% rename from draw/braille_circle_test.go rename to internal/draw/braille_circle_test.go index 731578b..0be67e9 100644 --- a/draw/braille_circle_test.go +++ b/internal/draw/braille_circle_test.go @@ -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. diff --git a/draw/braille_fill.go b/internal/draw/braille_fill.go similarity index 97% rename from draw/braille_fill.go rename to internal/draw/braille_fill.go index 4c966ad..c7aea3f 100644 --- a/draw/braille_fill.go +++ b/internal/draw/braille_fill.go @@ -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. diff --git a/draw/braille_fill_test.go b/internal/draw/braille_fill_test.go similarity index 96% rename from draw/braille_fill_test.go rename to internal/draw/braille_fill_test.go index 9a18e68..367a045 100644 --- a/draw/braille_fill_test.go +++ b/internal/draw/braille_fill_test.go @@ -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) { diff --git a/draw/braille_line.go b/internal/draw/braille_line.go similarity index 97% rename from draw/braille_line.go rename to internal/draw/braille_line.go index e9b34eb..bb8c375 100644 --- a/draw/braille_line.go +++ b/internal/draw/braille_line.go @@ -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. diff --git a/draw/braille_line_test.go b/internal/draw/braille_line_test.go similarity index 98% rename from draw/braille_line_test.go rename to internal/draw/braille_line_test.go index 9a9edaa..4d1e7a6 100644 --- a/draw/braille_line_test.go +++ b/internal/draw/braille_line_test.go @@ -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) { diff --git a/draw/draw.go b/internal/draw/draw.go similarity index 100% rename from draw/draw.go rename to internal/draw/draw.go diff --git a/draw/hv_line.go b/internal/draw/hv_line.go similarity index 98% rename from draw/hv_line.go rename to internal/draw/hv_line.go index 8971e5d..6e9f8c4 100644 --- a/draw/hv_line.go +++ b/internal/draw/hv_line.go @@ -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(). diff --git a/draw/hv_line_graph.go b/internal/draw/hv_line_graph.go similarity index 100% rename from draw/hv_line_graph.go rename to internal/draw/hv_line_graph.go diff --git a/draw/hv_line_graph_test.go b/internal/draw/hv_line_graph_test.go similarity index 99% rename from draw/hv_line_graph_test.go rename to internal/draw/hv_line_graph_test.go index a9d6381..28332af 100644 --- a/draw/hv_line_graph_test.go +++ b/internal/draw/hv_line_graph_test.go @@ -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) { diff --git a/draw/hv_line_test.go b/internal/draw/hv_line_test.go similarity index 98% rename from draw/hv_line_test.go rename to internal/draw/hv_line_test.go index bbd5117..6816af6 100644 --- a/draw/hv_line_test.go +++ b/internal/draw/hv_line_test.go @@ -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) { diff --git a/draw/line_style.go b/internal/draw/line_style.go similarity index 98% rename from draw/line_style.go rename to internal/draw/line_style.go index 0e914a7..64aa75b 100644 --- a/draw/line_style.go +++ b/internal/draw/line_style.go @@ -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 diff --git a/draw/rectangle.go b/internal/draw/rectangle.go similarity index 96% rename from draw/rectangle.go rename to internal/draw/rectangle.go index fcc94ff..a7f90bc 100644 --- a/draw/rectangle.go +++ b/internal/draw/rectangle.go @@ -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. diff --git a/draw/rectangle_test.go b/internal/draw/rectangle_test.go similarity index 95% rename from draw/rectangle_test.go rename to internal/draw/rectangle_test.go index 4a3cfda..ede962f 100644 --- a/draw/rectangle_test.go +++ b/internal/draw/rectangle_test.go @@ -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) { diff --git a/draw/segdisp/segment/segment.go b/internal/draw/segdisp/segment/segment.go similarity index 98% rename from draw/segdisp/segment/segment.go rename to internal/draw/segdisp/segment/segment.go index 92da56c..23b98fa 100644 --- a/draw/segdisp/segment/segment.go +++ b/internal/draw/segdisp/segment/segment.go @@ -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. diff --git a/draw/segdisp/segment/segment_test.go b/internal/draw/segdisp/segment/segment_test.go similarity index 99% rename from draw/segdisp/segment/segment_test.go rename to internal/draw/segdisp/segment/segment_test.go index ba9ea7e..9238877 100644 --- a/draw/segdisp/segment/segment_test.go +++ b/internal/draw/segdisp/segment/segment_test.go @@ -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) { diff --git a/draw/segdisp/segment/testsegment/testsegment.go b/internal/draw/segdisp/segment/testsegment/testsegment.go similarity index 92% rename from draw/segdisp/segment/testsegment/testsegment.go rename to internal/draw/segdisp/segment/testsegment/testsegment.go index 50b078e..1862c8d 100644 --- a/draw/segdisp/segment/testsegment/testsegment.go +++ b/internal/draw/segdisp/segment/testsegment/testsegment.go @@ -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. diff --git a/draw/segdisp/sixteen/attributes.go b/internal/draw/segdisp/sixteen/attributes.go similarity index 98% rename from draw/segdisp/sixteen/attributes.go rename to internal/draw/segdisp/sixteen/attributes.go index f1a1f11..2bf5bce 100644 --- a/draw/segdisp/sixteen/attributes.go +++ b/internal/draw/segdisp/sixteen/attributes.go @@ -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. diff --git a/draw/segdisp/sixteen/doc/16-Segment-ASCII-All.jpg b/internal/draw/segdisp/sixteen/doc/16-Segment-ASCII-All.jpg similarity index 100% rename from draw/segdisp/sixteen/doc/16-Segment-ASCII-All.jpg rename to internal/draw/segdisp/sixteen/doc/16-Segment-ASCII-All.jpg diff --git a/draw/segdisp/sixteen/doc/segment_placement.graffle b/internal/draw/segdisp/sixteen/doc/segment_placement.graffle similarity index 100% rename from draw/segdisp/sixteen/doc/segment_placement.graffle rename to internal/draw/segdisp/sixteen/doc/segment_placement.graffle diff --git a/draw/segdisp/sixteen/doc/segment_placement.svg b/internal/draw/segdisp/sixteen/doc/segment_placement.svg similarity index 100% rename from draw/segdisp/sixteen/doc/segment_placement.svg rename to internal/draw/segdisp/sixteen/doc/segment_placement.svg diff --git a/draw/segdisp/sixteen/sixteen.go b/internal/draw/segdisp/sixteen/sixteen.go similarity index 91% rename from draw/segdisp/sixteen/sixteen.go rename to internal/draw/segdisp/sixteen/sixteen.go index 410aec6..5a893b5 100644 --- a/draw/segdisp/sixteen/sixteen.go +++ b/internal/draw/segdisp/sixteen/sixteen.go @@ -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. diff --git a/draw/segdisp/sixteen/sixteen_test.go b/internal/draw/segdisp/sixteen/sixteen_test.go similarity index 99% rename from draw/segdisp/sixteen/sixteen_test.go rename to internal/draw/segdisp/sixteen/sixteen_test.go index cca480b..0175f2e 100644 --- a/draw/segdisp/sixteen/sixteen_test.go +++ b/internal/draw/segdisp/sixteen/sixteen_test.go @@ -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) { diff --git a/draw/segdisp/sixteen/testsixteen/testsixteen.go b/internal/draw/segdisp/sixteen/testsixteen/testsixteen.go similarity index 92% rename from draw/segdisp/sixteen/testsixteen/testsixteen.go rename to internal/draw/segdisp/sixteen/testsixteen/testsixteen.go index 779a4ad..884a17c 100644 --- a/draw/segdisp/sixteen/testsixteen/testsixteen.go +++ b/internal/draw/segdisp/sixteen/testsixteen/testsixteen.go @@ -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. diff --git a/draw/testdraw/testdraw.go b/internal/draw/testdraw/testdraw.go similarity index 85% rename from draw/testdraw/testdraw.go rename to internal/draw/testdraw/testdraw.go index 22b7cb2..e29f499 100644 --- a/draw/testdraw/testdraw.go +++ b/internal/draw/testdraw/testdraw.go @@ -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 { diff --git a/draw/text.go b/internal/draw/text.go similarity index 97% rename from draw/text.go rename to internal/draw/text.go index 06f823d..330f708 100644 --- a/draw/text.go +++ b/internal/draw/text.go @@ -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 diff --git a/draw/text_test.go b/internal/draw/text_test.go similarity index 98% rename from draw/text_test.go rename to internal/draw/text_test.go index c4baa9d..dc05ff0 100644 --- a/draw/text_test.go +++ b/internal/draw/text_test.go @@ -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) { diff --git a/internal/draw/vertical_text.go b/internal/draw/vertical_text.go new file mode 100644 index 0000000..e44bf6b --- /dev/null +++ b/internal/draw/vertical_text.go @@ -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 +} diff --git a/internal/draw/vertical_text_test.go b/internal/draw/vertical_text_test.go new file mode 100644 index 0000000..3d97c33 --- /dev/null +++ b/internal/draw/vertical_text_test.go @@ -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) + } + }) + } +} diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000..b9e04d8 --- /dev/null +++ b/internal/event/event.go @@ -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 +} diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000..9b12be5 --- /dev/null +++ b/internal/event/event_test.go @@ -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) + } + }) + } +} diff --git a/eventqueue/eventqueue.go b/internal/event/eventqueue/eventqueue.go similarity index 58% rename from eventqueue/eventqueue.go rename to internal/event/eventqueue/eventqueue.go index 6735255..589b987 100644 --- a/eventqueue/eventqueue.go +++ b/internal/event/eventqueue/eventqueue.go @@ -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) +} diff --git a/internal/event/eventqueue/eventqueue_test.go b/internal/event/eventqueue/eventqueue_test.go new file mode 100644 index 0000000..5868dd3 --- /dev/null +++ b/internal/event/eventqueue/eventqueue_test.go @@ -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 ", 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) + } +} diff --git a/internal/event/testevent/testevent.go b/internal/event/testevent/testevent.go new file mode 100644 index 0000000..77b2b44 --- /dev/null +++ b/internal/event/testevent/testevent.go @@ -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) + } + } +} diff --git a/keyboard/keyboard.go b/internal/keyboard/keyboard.go similarity index 100% rename from keyboard/keyboard.go rename to internal/keyboard/keyboard.go diff --git a/internal/mouse/button/button.go b/internal/mouse/button/button.go new file mode 100644 index 0000000..b25e252 --- /dev/null +++ b/internal/mouse/button/button.go @@ -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 + } +} diff --git a/internal/mouse/button/button_test.go b/internal/mouse/button/button_test.go new file mode 100644 index 0000000..ceec635 --- /dev/null +++ b/internal/mouse/button/button_test.go @@ -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) + } + } + }) + } +} diff --git a/mouse/mouse.go b/internal/mouse/mouse.go similarity index 100% rename from mouse/mouse.go rename to internal/mouse/mouse.go diff --git a/numbers/numbers.go b/internal/numbers/numbers.go similarity index 81% rename from numbers/numbers.go rename to internal/numbers/numbers.go index 2c6199f..f88a6b3 100644 --- a/numbers/numbers.go +++ b/internal/numbers/numbers.go @@ -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))), + } +} diff --git a/numbers/numbers_test.go b/internal/numbers/numbers_test.go similarity index 74% rename from numbers/numbers_test.go rename to internal/numbers/numbers_test.go index 7815e5f..b47cbbf 100644 --- a/numbers/numbers_test.go +++ b/internal/numbers/numbers_test.go @@ -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) + } + }) + } +} diff --git a/trig/trig.go b/internal/numbers/trig/trig.go similarity index 99% rename from trig/trig.go rename to internal/numbers/trig/trig.go index 3b832bf..1a2da34 100644 --- a/trig/trig.go +++ b/internal/numbers/trig/trig.go @@ -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 diff --git a/trig/trig_test.go b/internal/numbers/trig/trig_test.go similarity index 100% rename from trig/trig_test.go rename to internal/numbers/trig/trig_test.go diff --git a/scripts/autogen_licences.sh b/internal/scripts/autogen_licences.sh similarity index 100% rename from scripts/autogen_licences.sh rename to internal/scripts/autogen_licences.sh diff --git a/scripts/coverage.sh b/internal/scripts/coverage.sh similarity index 100% rename from scripts/coverage.sh rename to internal/scripts/coverage.sh diff --git a/terminal/faketerm/diff.go b/internal/terminal/faketerm/diff.go similarity index 98% rename from terminal/faketerm/diff.go rename to internal/terminal/faketerm/diff.go index 3a178a7..a64045c 100644 --- a/terminal/faketerm/diff.go +++ b/internal/terminal/faketerm/diff.go @@ -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. diff --git a/terminal/faketerm/faketerm.go b/internal/terminal/faketerm/faketerm.go similarity index 95% rename from terminal/faketerm/faketerm.go rename to internal/terminal/faketerm/faketerm.go index 3b75dc2..12b3e51 100644 --- a/terminal/faketerm/faketerm.go +++ b/internal/terminal/faketerm/faketerm.go @@ -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 { diff --git a/terminal/termbox/cell_options.go b/internal/terminal/termbox/cell_options.go similarity index 96% rename from terminal/termbox/cell_options.go rename to internal/terminal/termbox/cell_options.go index 41ee760..a4e28cb 100644 --- a/terminal/termbox/cell_options.go +++ b/internal/terminal/termbox/cell_options.go @@ -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" ) diff --git a/terminal/termbox/cell_options_test.go b/internal/terminal/termbox/cell_options_test.go similarity index 96% rename from terminal/termbox/cell_options_test.go rename to internal/terminal/termbox/cell_options_test.go index f74e9bb..25585da 100644 --- a/terminal/termbox/cell_options_test.go +++ b/internal/terminal/termbox/cell_options_test.go @@ -17,7 +17,7 @@ package termbox import ( "testing" - "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/cell" tbx "github.com/nsf/termbox-go" ) diff --git a/terminal/termbox/color_mode.go b/internal/terminal/termbox/color_mode.go similarity index 95% rename from terminal/termbox/color_mode.go rename to internal/terminal/termbox/color_mode.go index 0e42ee7..8d830ca 100644 --- a/terminal/termbox/color_mode.go +++ b/internal/terminal/termbox/color_mode.go @@ -17,7 +17,7 @@ package termbox import ( "fmt" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/terminalapi" tbx "github.com/nsf/termbox-go" ) diff --git a/terminal/termbox/event.go b/internal/terminal/termbox/event.go similarity index 98% rename from terminal/termbox/event.go rename to internal/terminal/termbox/event.go index e43bb4d..f33ccb8 100644 --- a/terminal/termbox/event.go +++ b/internal/terminal/termbox/event.go @@ -19,9 +19,9 @@ package termbox import ( "image" - "github.com/mum4k/termdash/keyboard" - "github.com/mum4k/termdash/mouse" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/keyboard" + "github.com/mum4k/termdash/internal/mouse" + "github.com/mum4k/termdash/internal/terminalapi" tbx "github.com/nsf/termbox-go" ) diff --git a/terminal/termbox/event_test.go b/internal/terminal/termbox/event_test.go similarity index 98% rename from terminal/termbox/event_test.go rename to internal/terminal/termbox/event_test.go index b0c61fc..22535df 100644 --- a/terminal/termbox/event_test.go +++ b/internal/terminal/termbox/event_test.go @@ -21,9 +21,9 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/keyboard" - "github.com/mum4k/termdash/mouse" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/keyboard" + "github.com/mum4k/termdash/internal/mouse" + "github.com/mum4k/termdash/internal/terminalapi" tbx "github.com/nsf/termbox-go" ) diff --git a/terminal/termbox/termbox.go b/internal/terminal/termbox/termbox.go similarity index 85% rename from terminal/termbox/termbox.go rename to internal/terminal/termbox/termbox.go index 70d232c..7c5889a 100644 --- a/terminal/termbox/termbox.go +++ b/internal/terminal/termbox/termbox.go @@ -19,9 +19,9 @@ import ( "context" "image" - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/eventqueue" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/event/eventqueue" + "github.com/mum4k/termdash/internal/terminalapi" tbx "github.com/nsf/termbox-go" ) @@ -39,7 +39,11 @@ func (o option) set(t *Terminal) { o(t) } +// DefaultColorMode is the default value for the ColorMode option. +const DefaultColorMode = terminalapi.ColorMode256 + // ColorMode sets the terminal color mode. +// Defaults to DefaultColorMode. func ColorMode(cm terminalapi.ColorMode) Option { return option(func(t *Terminal) { t.colorMode = cm @@ -60,6 +64,19 @@ type Terminal struct { colorMode terminalapi.ColorMode } +// newTerminal creates the terminal and applies the options. +func newTerminal(opts ...Option) *Terminal { + t := &Terminal{ + events: eventqueue.New(), + done: make(chan struct{}), + colorMode: DefaultColorMode, + } + for _, opt := range opts { + opt.set(t) + } + return t +} + // New returns a new termbox based Terminal. // Call Close() when the terminal isn't required anymore. func New(opts ...Option) (*Terminal, error) { @@ -68,14 +85,7 @@ func New(opts ...Option) (*Terminal, error) { } tbx.SetInputMode(tbx.InputEsc | tbx.InputMouse) - t := &Terminal{ - events: eventqueue.New(), - done: make(chan struct{}), - } - for _, opt := range opts { - opt.set(t) - } - + t := newTerminal(opts...) om, err := colorMode(t.colorMode) if err != nil { return nil, err @@ -138,9 +148,9 @@ func (t *Terminal) pollEvents() { // Event implements terminalapi.Terminal.Event. func (t *Terminal) Event(ctx context.Context) terminalapi.Event { - ev, err := t.events.Pull(ctx) - if err != nil { - return terminalapi.NewErrorf("unable to pull the next event: %v", err) + ev := t.events.Pull(ctx) + if ev == nil { + return nil } return ev } diff --git a/internal/terminal/termbox/termbox_test.go b/internal/terminal/termbox/termbox_test.go new file mode 100644 index 0000000..641b84e --- /dev/null +++ b/internal/terminal/termbox/termbox_test.go @@ -0,0 +1,60 @@ +// 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 termbox + +import ( + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/internal/terminalapi" +) + +func TestNewTerminal(t *testing.T) { + tests := []struct { + desc string + opts []Option + want *Terminal + }{ + { + desc: "default options", + want: &Terminal{ + colorMode: terminalapi.ColorMode256, + }, + }, + { + desc: "sets color mode", + opts: []Option{ + ColorMode(terminalapi.ColorModeNormal), + }, + want: &Terminal{ + colorMode: terminalapi.ColorModeNormal, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := newTerminal(tc.opts...) + + // Ignore these fields. + got.events = nil + got.done = nil + + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("newTerminal => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/terminalapi/color_mode.go b/internal/terminalapi/color_mode.go similarity index 100% rename from terminalapi/color_mode.go rename to internal/terminalapi/color_mode.go diff --git a/terminalapi/event.go b/internal/terminalapi/event.go similarity index 96% rename from terminalapi/event.go rename to internal/terminalapi/event.go index a543e84..f5fc8f8 100644 --- a/terminalapi/event.go +++ b/internal/terminalapi/event.go @@ -19,8 +19,8 @@ import ( "fmt" "image" - "github.com/mum4k/termdash/keyboard" - "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/internal/keyboard" + "github.com/mum4k/termdash/internal/mouse" ) // event.go defines events that can be received through the terminal API. diff --git a/terminalapi/terminalapi.go b/internal/terminalapi/terminalapi.go similarity index 95% rename from terminalapi/terminalapi.go rename to internal/terminalapi/terminalapi.go index e6d9b2c..f471e4f 100644 --- a/terminalapi/terminalapi.go +++ b/internal/terminalapi/terminalapi.go @@ -19,7 +19,7 @@ import ( "context" "image" - "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/cell" ) // Terminal abstracts an implementation of a 2-D terminal. @@ -47,5 +47,6 @@ type Terminal interface { // Event waits for the next event and returns it. // This call blocks until the next event or cancellation of the context. + // Returns nil when the context gets canceled. Event(ctx context.Context) Event } diff --git a/widgetapi/widgetapi.go b/internal/widgetapi/widgetapi.go similarity index 50% rename from widgetapi/widgetapi.go rename to internal/widgetapi/widgetapi.go index 01211b8..8f72bfb 100644 --- a/widgetapi/widgetapi.go +++ b/internal/widgetapi/widgetapi.go @@ -18,8 +18,89 @@ package widgetapi import ( "image" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/terminalapi" +) + +// KeyScope indicates the scope at which the widget wants to receive keyboard +// events. +type KeyScope int + +// String implements fmt.Stringer() +func (ks KeyScope) String() string { + if n, ok := keyScopeNames[ks]; ok { + return n + } + return "KeyScopeUnknown" +} + +// keyScopeNames maps KeyScope values to human readable names. +var keyScopeNames = map[KeyScope]string{ + KeyScopeNone: "KeyScopeNone", + KeyScopeFocused: "KeyScopeFocused", + KeyScopeGlobal: "KeyScopeGlobal", +} + +const ( + // KeyScopeNone is used when the widget doesn't want to receive any + // keyboard events. + KeyScopeNone KeyScope = iota + + // KeyScopeFocused is used when the widget wants to only receive keyboard + // events when its container is focused. + KeyScopeFocused + + // KeyScopeGlobal is used when the widget wants to receive all keyboard + // events regardless of which container is focused. + KeyScopeGlobal +) + +// MouseScope indicates the scope at which the widget wants to receive mouse +// events. +type MouseScope int + +// String implements fmt.Stringer() +func (ms MouseScope) String() string { + if n, ok := mouseScopeNames[ms]; ok { + return n + } + return "MouseScopeUnknown" +} + +// mouseScopeNames maps MouseScope values to human readable names. +var mouseScopeNames = map[MouseScope]string{ + MouseScopeNone: "MouseScopeNone", + MouseScopeWidget: "MouseScopeWidget", + MouseScopeContainer: "MouseScopeContainer", + MouseScopeGlobal: "MouseScopeGlobal", +} + +const ( + // MouseScopeNone is used when the widget doesn't want to receive any mouse + // events. + MouseScopeNone MouseScope = iota + + // MouseScopeWidget is used when the widget only wants mouse events that + // fall onto its canvas. + // The position of these widgets is always relative to widget's canvas. + MouseScopeWidget + + // MouseScopeContainer is used when the widget only wants mouse events that + // fall onto its container. The area size of a container is always larger + // or equal to the one of the widget's canvas. So a widget selecting + // MouseScopeContainer will either receive the same or larger amount of + // events as compared to MouseScopeWidget. + // The position of mouse events that fall outside of widget's canvas is + // reset to image.Point{-1, -1}. + // The widgets are allowed to process the button event. + MouseScopeContainer + + // MouseScopeGlobal is used when the widget wants to receive all mouse + // events regardless on where on the terminal they land. + // The position of mouse events that fall outside of widget's canvas is + // reset to image.Point{-1, -1} and must not be used by the widgets. + // The widgets are allowed to process the button event. + MouseScopeGlobal ) // Options contains registration options for a widget. @@ -44,17 +125,18 @@ type Options struct { // unlimited. MaximumSize image.Point - // WantKeyboard allows a widget to request keyboard events. - // If false, keyboard events won't be forwarded to the widget. - // If true, the widget receives keyboard events if its container is - // focused. - WantKeyboard bool + // WantKeyboard allows a widget to request keyboard events and specify + // their desired scope. If set to KeyScopeNone, no keyboard events are + // forwarded to the widget. + WantKeyboard KeyScope - // WantMouse allows a widget to request mouse events. - // If false, mouse events won't be forwarded to the widget. - // If true, the widget receives all mouse events whose coordinates fall - // within its canvas. - WantMouse bool + // WantMouse allows a widget to request mouse events and specify their + // desired scope. If set to MouseScopeNone, no mouse events are forwarded + // to the widget. + // Note that the widget is only able to see the position of the mouse event + // if it falls onto its canvas. See the documentation next to individual + // MouseScope values for details. + WantMouse MouseScope } // Widget is a single widget on the dashboard. diff --git a/termdash.go b/termdash.go index fa02238..fd71e00 100644 --- a/termdash.go +++ b/termdash.go @@ -31,7 +31,8 @@ import ( "time" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/event" + "github.com/mum4k/termdash/internal/terminalapi" ) // DefaultRedrawInterval is the default for the RedrawInterval option. @@ -71,8 +72,7 @@ func ErrorHandler(f func(error)) Option { // KeyboardSubscriber registers a subscriber for Keyboard events. Each // keyboard event is forwarded to the container and the registered subscriber. -// The provided function must be non-blocking, ideally just storing the value -// and returning as termdash blocks on each subscriber. +// The provided function must be thread-safe. func KeyboardSubscriber(f func(*terminalapi.Keyboard)) Option { return option(func(td *termdash) { td.keyboardSubscriber = f @@ -81,8 +81,7 @@ func KeyboardSubscriber(f func(*terminalapi.Keyboard)) Option { // MouseSubscriber registers a subscriber for Mouse events. Each mouse event // is forwarded to the container and the registered subscriber. -// The provided function must be non-blocking, ideally just storing the value -// and returning as termdash blocks on each subscriber. +// The provided function must be thread-safe. func MouseSubscriber(f func(*terminalapi.Mouse)) Option { return option(func(td *termdash) { td.mouseSubscriber = f @@ -157,6 +156,9 @@ type termdash struct { // container maintains terminal splits and places widgets. container *container.Container + // eds distributes input events to subscribers. + eds *event.DistributionSystem + // closeCh gets closed when Stop() is called, which tells the event // collecting goroutine to exit. closeCh chan struct{} @@ -182,6 +184,7 @@ func newTermdash(t terminalapi.Terminal, c *container.Container, opts ...Option) td := &termdash{ term: t, container: c, + eds: event.NewDistributionSystem(), closeCh: make(chan struct{}), exitCh: make(chan struct{}), redrawInterval: DefaultRedrawInterval, @@ -190,9 +193,46 @@ func newTermdash(t terminalapi.Terminal, c *container.Container, opts ...Option) for _, opt := range opts { opt.set(td) } + td.subscribers() + c.Subscribe(td.eds) return td } +// subscribers subscribes event receivers that live in this package to EDS. +func (td *termdash) subscribers() { + // Handler for all errors that occur during input event processing. + td.eds.Subscribe([]terminalapi.Event{terminalapi.NewError("")}, func(ev terminalapi.Event) { + td.handleError(ev.(*terminalapi.Error).Error()) + }) + + // Handles terminal resize events. + td.eds.Subscribe([]terminalapi.Event{&terminalapi.Resize{}}, func(terminalapi.Event) { + td.setClearNeeded() + }) + + // Redraws the screen on Keyboard and Mouse events. + // These events very likely change the content of the widgets (e.g. zooming + // a LineChart) so a redraw is needed to make that visible. + td.eds.Subscribe([]terminalapi.Event{ + &terminalapi.Keyboard{}, + &terminalapi.Mouse{}, + }, func(terminalapi.Event) { + td.evRedraw() + }, event.MaxRepetitive(0)) // No repetitive events that cause terminal redraw. + + // Keyboard and Mouse subscribers specified via options. + if td.keyboardSubscriber != nil { + td.eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) { + td.keyboardSubscriber(ev.(*terminalapi.Keyboard)) + }) + } + if td.mouseSubscriber != nil { + td.eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) { + td.mouseSubscriber(ev.(*terminalapi.Mouse)) + }) + } +} + // handleError forwards the error to the error handler if one was // provided or panics. func (td *termdash) handleError(err error) { @@ -231,33 +271,16 @@ func (td *termdash) redraw() error { return nil } -// keyEvRedraw forwards the keyboard event and redraws the container and its -// widgets. -func (td *termdash) keyEvRedraw(ev *terminalapi.Keyboard) error { +// evRedraw redraws the container and its widgets. +func (td *termdash) evRedraw() error { td.mu.Lock() defer td.mu.Unlock() - if err := td.container.Keyboard(ev); err != nil { - return err - } - if td.keyboardSubscriber != nil { - td.keyboardSubscriber(ev) - } - return td.redraw() -} - -// mouseEvRedraw forwards the mouse event and redraws the container and its -// widgets. -func (td *termdash) mouseEvRedraw(ev *terminalapi.Mouse) error { - td.mu.Lock() - defer td.mu.Unlock() - - if err := td.container.Mouse(ev); err != nil { - return err - } - if td.mouseSubscriber != nil { - td.mouseSubscriber(ev) - } + // Don't redraw immediately, give widgets that are performing enough time + // to update. + // We don't want to actually synchronize until all widgets update, we are + // purposefully leaving slow widgets behind. + time.Sleep(25 * time.Millisecond) return td.redraw() } @@ -274,29 +297,9 @@ func (td *termdash) processEvents(ctx context.Context) { defer close(td.exitCh) for { - event := td.term.Event(ctx) - switch ev := event.(type) { - case *terminalapi.Keyboard: - if err := td.keyEvRedraw(ev); err != nil { - td.handleError(err) - } - - case *terminalapi.Mouse: - if err := td.mouseEvRedraw(ev); err != nil { - td.handleError(err) - } - - case *terminalapi.Resize: - td.setClearNeeded() - - case *terminalapi.Error: - // Don't forward the error if the context is closed. - // It just says that the context expired. - select { - case <-ctx.Done(): - default: - td.handleError(ev.Error()) - } + ev := td.term.Event(ctx) + if ev != nil { + td.eds.Event(ev) } select { diff --git a/termdash_test.go b/termdash_test.go index d720b56..9e2423b 100644 --- a/termdash_test.go +++ b/termdash_test.go @@ -16,21 +16,24 @@ package termdash import ( "context" + "errors" "fmt" "image" + "sync" "testing" "time" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/canvas/testcanvas" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/eventqueue" - "github.com/mum4k/termdash/keyboard" - "github.com/mum4k/termdash/mouse" - "github.com/mum4k/termdash/terminal/faketerm" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/canvas/testcanvas" + "github.com/mum4k/termdash/internal/event/eventqueue" + "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/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" "github.com/mum4k/termdash/widgets/fakewidget" ) @@ -45,8 +48,8 @@ func Example() { wOpts := widgetapi.Options{ MinimumSize: fakewidget.MinimumSize, - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, } // Create the container with two fake widgets. @@ -85,8 +88,8 @@ func Example_triggered() { wOpts := widgetapi.Options{ MinimumSize: fakewidget.MinimumSize, - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, } // Create the container with a widget. @@ -115,52 +118,83 @@ func Example_triggered() { // 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 } // keySubscriber just stores the last pressed key. type keySubscriber struct { received terminalapi.Keyboard + mu sync.Mutex +} + +func (ks *keySubscriber) get() terminalapi.Keyboard { + ks.mu.Lock() + defer ks.mu.Unlock() + return ks.received } func (ks *keySubscriber) receive(k *terminalapi.Keyboard) { + ks.mu.Lock() + defer ks.mu.Unlock() ks.received = *k } // mouseSubscriber just stores the last mouse event. type mouseSubscriber struct { received terminalapi.Mouse + mu sync.Mutex +} + +func (ms *mouseSubscriber) get() terminalapi.Mouse { + ms.mu.Lock() + defer ms.mu.Unlock() + return ms.received } func (ms *mouseSubscriber) receive(m *terminalapi.Mouse) { + ms.mu.Lock() + defer ms.mu.Unlock() ms.received = *m } +type eventHandlers struct { + handler errorHandler + keySub keySubscriber + mouseSub mouseSubscriber +} + func TestRun(t *testing.T) { - var ( - handler errorHandler - keySub keySubscriber - mouseSub mouseSubscriber - ) + t.Parallel() tests := []struct { desc string size image.Point - opts []Option + opts func(*eventHandlers) []Option events []terminalapi.Event // function to execute after the test case, can do additional comparison. - after func() error + after func(*eventHandlers) error want func(size image.Point) *faketerm.Terminal wantErr bool }{ { desc: "draws the dashboard until closed", size: image.Point{60, 10}, - opts: []Option{ - RedrawInterval(1), + opts: func(*eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + } }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -176,8 +210,10 @@ func TestRun(t *testing.T) { { desc: "fails when the widget doesn't draw due to size too small", size: image.Point{1, 1}, - opts: []Option{ - RedrawInterval(1), + opts: func(*eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + } }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -188,8 +224,10 @@ func TestRun(t *testing.T) { { desc: "forwards mouse events to container", size: image.Point{60, 10}, - opts: []Option{ - RedrawInterval(1), + opts: func(*eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + } }, events: []terminalapi.Event{ &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, @@ -201,7 +239,7 @@ func TestRun(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, ) @@ -211,8 +249,10 @@ func TestRun(t *testing.T) { { desc: "forwards keyboard events to container", size: image.Point{60, 10}, - opts: []Option{ - RedrawInterval(1), + opts: func(*eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + } }, events: []terminalapi.Event{ &terminalapi.Keyboard{Key: keyboard.KeyEnter}, @@ -224,8 +264,8 @@ func TestRun(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Keyboard{Key: keyboard.KeyEnter}, ) @@ -235,16 +275,18 @@ func TestRun(t *testing.T) { { desc: "forwards input errors to the error handler", size: image.Point{60, 10}, - opts: []Option{ - RedrawInterval(1), - ErrorHandler(handler.handle), + opts: func(eh *eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + ErrorHandler(eh.handler.handle), + } }, events: []terminalapi.Event{ terminalapi.NewError("input error"), }, - after: func() error { - if want := "input error"; handler.err.Error() != want { - return fmt.Errorf("errorHandler got %v, want %v", handler.err, want) + after: func(eh *eventHandlers) error { + if want := "input error"; eh.handler.get().Error() != want { + return fmt.Errorf("errorHandler got %v, want %v", eh.handler.get(), want) } return nil }, @@ -262,16 +304,18 @@ func TestRun(t *testing.T) { { desc: "forwards keyboard events to the subscriber", size: image.Point{60, 10}, - opts: []Option{ - RedrawInterval(1), - KeyboardSubscriber(keySub.receive), + opts: func(eh *eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + KeyboardSubscriber(eh.keySub.receive), + } }, events: []terminalapi.Event{ &terminalapi.Keyboard{Key: keyboard.KeyF1}, }, - after: func() error { + after: func(eh *eventHandlers) error { want := terminalapi.Keyboard{Key: keyboard.KeyF1} - if diff := pretty.Compare(want, keySub.received); diff != "" { + if diff := pretty.Compare(want, eh.keySub.get()); diff != "" { return fmt.Errorf("keySubscriber got unexpected value, diff (-want, +got):\n%s", diff) } return nil @@ -283,7 +327,7 @@ func TestRun(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantKeyboard: true, + WantKeyboard: widgetapi.KeyScopeFocused, }, &terminalapi.Keyboard{Key: keyboard.KeyF1}, ) @@ -293,16 +337,18 @@ func TestRun(t *testing.T) { { desc: "forwards mouse events to the subscriber", size: image.Point{60, 10}, - opts: []Option{ - RedrawInterval(1), - MouseSubscriber(mouseSub.receive), + opts: func(eh *eventHandlers) []Option { + return []Option{ + RedrawInterval(1), + MouseSubscriber(eh.mouseSub.receive), + } }, events: []terminalapi.Event{ &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp}, }, - after: func() error { + after: func(eh *eventHandlers) error { want := terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp} - if diff := pretty.Compare(want, mouseSub.received); diff != "" { + if diff := pretty.Compare(want, eh.mouseSub.get()); diff != "" { return fmt.Errorf("mouseSubscriber got unexpected value, diff (-want, +got):\n%s", diff) } return nil @@ -314,7 +360,7 @@ func TestRun(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp}, ) @@ -325,9 +371,14 @@ func TestRun(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - handler = errorHandler{} - keySub = keySubscriber{} - mouseSub = mouseSubscriber{} + tc := tc + t.Parallel() + + handlers := &eventHandlers{ + handler: errorHandler{}, + keySub: keySubscriber{}, + mouseSub: mouseSubscriber{}, + } eq := eventqueue.New() for _, ev := range tc.events { @@ -342,8 +393,8 @@ func TestRun(t *testing.T) { cont, err := container.New( got, container.PlaceWidget(fakewidget.New(widgetapi.Options{ - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, })), ) if err != nil { @@ -351,7 +402,7 @@ func TestRun(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - err = Run(ctx, got, cont, tc.opts...) + err = Run(ctx, got, cont, tc.opts(handlers)...) cancel() if (err != nil) != tc.wantErr { t.Errorf("Run => unexpected error: %v, wantErr: %v", err, tc.wantErr) @@ -360,12 +411,17 @@ func TestRun(t *testing.T) { return } - if err := untilEmpty(5*time.Second, eq); err != nil { - t.Fatalf("untilEmpty => %v", err) + if err := testevent.WaitFor(5*time.Second, func() error { + if !eq.Empty() { + return errors.New("event queue not empty") + } + return nil + }); err != nil { + t.Fatalf("testevent.WaitFor => %v", err) } if tc.after != nil { - if err := tc.after(); err != nil { + if err := tc.after(handlers); err != nil { t.Errorf("after => unexpected error: %v", err) } } @@ -378,6 +434,8 @@ func TestRun(t *testing.T) { } func TestController(t *testing.T) { + t.Parallel() + tests := []struct { desc string size image.Point @@ -401,8 +459,8 @@ func TestController(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Keyboard{Key: keyboard.KeyEnter}, ) @@ -507,6 +565,9 @@ func TestController(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { + tc := tc + t.Parallel() + eq := eventqueue.New() for _, ev := range tc.events { eq.Push(ev) @@ -518,8 +579,8 @@ func TestController(t *testing.T) { } mi := fakewidget.New(widgetapi.Options{ - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, }) cont, err := container.New( got, @@ -541,8 +602,13 @@ func TestController(t *testing.T) { tc.apiEvents(mi) } - if err := untilEmpty(5*time.Second, eq); err != nil { - t.Fatalf("untilEmpty => %v", err) + if err := testevent.WaitFor(5*time.Second, func() error { + if !eq.Empty() { + return errors.New("event queue not empty") + } + return nil + }); err != nil { + t.Fatalf("testevent.WaitFor => %v", err) } if tc.controls != nil { if err := tc.controls(ctrl); err != nil { @@ -557,24 +623,3 @@ func TestController(t *testing.T) { }) } } - -// untilEmpty waits until the queue empties. -// Waits at most the specified duration. -func untilEmpty(timeout time.Duration, q *eventqueue.Unbound) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - tick := time.NewTimer(5 * time.Millisecond) - defer tick.Stop() - for { - select { - case <-tick.C: - if q.Empty() { - return nil - } - - case <-ctx.Done(): - return fmt.Errorf("while waiting for the event queue to empty: %v", ctx.Err()) - } - } -} diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go index 60820d1..7ba9252 100644 --- a/termdashdemo/termdashdemo.go +++ b/termdashdemo/termdashdemo.go @@ -21,15 +21,18 @@ import ( "fmt" "math" "math/rand" + "sync" "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/barchart" + "github.com/mum4k/termdash/widgets/button" "github.com/mum4k/termdash/widgets/donut" "github.com/mum4k/termdash/widgets/gauge" "github.com/mum4k/termdash/widgets/linechart" @@ -48,7 +51,14 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, if err != nil { return nil, err } - spGreen, spRed := newSparkLines(ctx) + rollT, err := newRollText(ctx) + if err != nil { + return nil, err + } + spGreen, spRed, err := newSparkLines(ctx) + if err != nil { + return nil, err + } segmentTextSpark := []container.Option{ container.SplitHorizontal( container.Top( @@ -61,7 +71,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, container.Left( container.Border(draw.LineStyleLight), container.BorderTitle("A rolling text"), - container.PlaceWidget(newRollText(ctx)), + container.PlaceWidget(rollT), ), container.Right( container.Border(draw.LineStyleLight), @@ -77,18 +87,27 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, ), } + g, err := newGauge(ctx) + if err != nil { + return nil, err + } + + heartLC, err := newHeartbeat(ctx) + if err != nil { + return nil, err + } gaugeAndHeartbeat := []container.Option{ container.SplitHorizontal( container.Top( container.Border(draw.LineStyleLight), container.BorderTitle("A Gauge"), container.BorderColor(cell.ColorNumber(39)), - container.PlaceWidget(newGauge(ctx)), + container.PlaceWidget(g), ), container.Bottom( container.Border(draw.LineStyleLight), container.BorderTitle("A LineChart"), - container.PlaceWidget(newHeartbeat(ctx)), + container.PlaceWidget(heartLC), ), container.SplitPercent(20), ), @@ -102,17 +121,50 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, ), } + bc, err := newBarChart(ctx) + if err != nil { + return nil, err + } + don, err := newDonut(ctx) if err != nil { return nil, err } + leftB, rightB, sineLC, err := newSines(ctx) + if err != nil { + return nil, err + } + lcAndButtons := []container.Option{ + container.SplitHorizontal( + container.Top( + container.Border(draw.LineStyleLight), + container.BorderTitle("Multiple series"), + container.BorderTitleAlignRight(), + container.PlaceWidget(sineLC), + ), + container.Bottom( + container.SplitVertical( + container.Left( + container.PlaceWidget(leftB), + container.AlignHorizontal(align.HorizontalRight), + ), + container.Right( + container.PlaceWidget(rightB), + container.AlignHorizontal(align.HorizontalLeft), + ), + ), + ), + container.SplitPercent(80), + ), + } + rightSide := []container.Option{ container.SplitHorizontal( container.Top( container.Border(draw.LineStyleLight), container.BorderTitle("BarChart"), - container.PlaceWidget(newBarChart(ctx)), + container.PlaceWidget(bc), container.BorderTitleAlignRight(), ), container.Bottom( @@ -123,12 +175,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, container.BorderTitleAlignRight(), container.PlaceWidget(don), ), - container.Bottom( - container.Border(draw.LineStyleLight), - container.BorderTitle("Multiple series"), - container.BorderTitleAlignRight(), - container.PlaceWidget(newSines(ctx)), - ), + container.Bottom(lcAndButtons...), container.SplitPercent(30), ), ), @@ -232,8 +279,11 @@ func newSegmentDisplay(ctx context.Context) (*segmentdisplay.SegmentDisplay, err } // newRollText creates a new Text widget that displays rolling text. -func newRollText(ctx context.Context) *text.Text { - t := text.New(text.RollContent()) +func newRollText(ctx context.Context) (*text.Text, error) { + t, err := text.New(text.RollContent()) + if err != nil { + return nil, err + } i := 0 go periodic(ctx, 1*time.Second, func() error { @@ -243,15 +293,18 @@ func newRollText(ctx context.Context) *text.Text { i++ return nil }) - return t + return t, nil } // newSparkLines creates two new sparklines displaying random values. -func newSparkLines(ctx context.Context) (*sparkline.SparkLine, *sparkline.SparkLine) { - spGreen := sparkline.New( +func newSparkLines(ctx context.Context) (*sparkline.SparkLine, *sparkline.SparkLine, error) { + spGreen, err := sparkline.New( sparkline.Label("Green SparkLine", cell.FgColor(cell.ColorBlue)), sparkline.Color(cell.ColorGreen), ) + if err != nil { + return nil, nil, err + } const max = 100 go periodic(ctx, 250*time.Millisecond, func() error { @@ -259,21 +312,27 @@ func newSparkLines(ctx context.Context) (*sparkline.SparkLine, *sparkline.SparkL return spGreen.Add([]int{v}) }) - spRed := sparkline.New( + spRed, err := sparkline.New( sparkline.Label("Red SparkLine", cell.FgColor(cell.ColorBlue)), sparkline.Color(cell.ColorRed), ) + if err != nil { + return nil, nil, err + } go periodic(ctx, 500*time.Millisecond, func() error { v := int(rand.Int31n(max + 1)) return spRed.Add([]int{v}) }) - return spGreen, spRed + return spGreen, spRed, nil } // newGauge creates a demo Gauge widget. -func newGauge(ctx context.Context) *gauge.Gauge { - g := gauge.New() +func newGauge(ctx context.Context) (*gauge.Gauge, error) { + g, err := gauge.New() + if err != nil { + return nil, err + } const start = 35 progress := start @@ -288,7 +347,7 @@ func newGauge(ctx context.Context) *gauge.Gauge { } return nil }) - return g + return g, nil } // newDonut creates a demo Donut widget. @@ -317,18 +376,21 @@ func newDonut(ctx context.Context) (*donut.Donut, error) { } // newHeartbeat returns a line chart that displays a heartbeat-like progression. -func newHeartbeat(ctx context.Context) *linechart.LineChart { +func newHeartbeat(ctx context.Context) (*linechart.LineChart, error) { var inputs []float64 for i := 0; i < 100; i++ { v := math.Pow(math.Sin(float64(i)), 63) * math.Sin(float64(i)+1.5) * 8 inputs = append(inputs, v) } - lc := linechart.New( + lc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), ) + if err != nil { + return nil, err + } step := 0 go periodic(ctx, redrawInterval/3, func() error { step = (step + 1) % len(inputs) @@ -339,12 +401,12 @@ func newHeartbeat(ctx context.Context) *linechart.LineChart { }), ) }) - return lc + return lc, nil } // newBarChart returns a BarcChart that displays random values on multiple bars. -func newBarChart(ctx context.Context) *barchart.BarChart { - bc := barchart.New( +func newBarChart(ctx context.Context) (*barchart.BarChart, error) { + bc, err := barchart.New( barchart.BarColors([]cell.Color{ cell.ColorNumber(33), cell.ColorNumber(39), @@ -363,6 +425,9 @@ func newBarChart(ctx context.Context) *barchart.BarChart { }), barchart.ShowValues(), ) + if err != nil { + return nil, err + } const ( bars = 6 @@ -376,23 +441,50 @@ func newBarChart(ctx context.Context) *barchart.BarChart { return bc.Values(values, max) }) - return bc + return bc, nil } -// newSines returns a line chart that displays multiple sine series. -func newSines(ctx context.Context) *linechart.LineChart { +// distance is a thread-safe int value used by the newSince method. +// Buttons write it and the line chart reads it. +type distance struct { + v int + mu sync.Mutex +} + +// add adds the provided value to the one stored. +func (d *distance) add(v int) { + d.mu.Lock() + defer d.mu.Unlock() + d.v += v +} + +// get returns the current value. +func (d *distance) get() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.v +} + +// newSines returns a line chart that displays multiple sine series and two buttons. +// The left button shifts the second series relative to the first series to +// the left and the right button shifts it to the right. +func newSines(ctx context.Context) (left, right *button.Button, lc *linechart.LineChart, err error) { var inputs []float64 for i := 0; i < 200; i++ { v := math.Sin(float64(i) / 100 * math.Pi) inputs = append(inputs, v) } - lc := linechart.New( + sineLc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), ) + if err != nil { + return nil, nil, nil, err + } step1 := 0 + secondDist := &distance{v: 100} go periodic(ctx, redrawInterval/3, func() error { step1 = (step1 + 1) % len(inputs) if err := lc.Series("first", rotateFloats(inputs, step1), @@ -401,10 +493,30 @@ func newSines(ctx context.Context) *linechart.LineChart { return err } - step2 := (step1 + 100) % len(inputs) + step2 := (step1 + secondDist.get()) % len(inputs) return lc.Series("second", rotateFloats(inputs, step2), linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite))) }) - return lc + + // diff is the difference a single button press adds or removes to the + // second series. + const diff = 20 + leftB, err := button.New("(l)eft", func() error { + secondDist.add(diff) + return nil + }, + button.GlobalKey('l'), + button.WidthFor("(r)ight"), + button.FillColor(cell.ColorNumber(220)), + ) + + rightB, err := button.New("(r)ight", func() error { + secondDist.add(-diff) + return nil + }, + button.GlobalKey('r'), + button.FillColor(cell.ColorNumber(196)), + ) + return leftB, rightB, sineLc, nil } // rotateFloats returns a new slice with inputs rotated by step. diff --git a/widgets/barchart/barchart.go b/widgets/barchart/barchart.go index 5a7238d..bc425eb 100644 --- a/widgets/barchart/barchart.go +++ b/widgets/barchart/barchart.go @@ -22,13 +22,13 @@ import ( "image" "sync" - "github.com/mum4k/termdash/align" - "github.com/mum4k/termdash/area" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/area" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // BarChart displays multiple bars showing relative ratios of values. @@ -53,14 +53,17 @@ type BarChart struct { } // New returns a new BarChart. -func New(opts ...Option) *BarChart { +func New(opts ...Option) (*BarChart, error) { opt := newOptions() for _, o := range opts { o.set(opt) } + if err := opt.validate(); err != nil { + return nil, err + } return &BarChart{ opts: opt, - } + }, nil } // Draw draws the BarChart widget onto the canvas. @@ -155,7 +158,7 @@ func (bc *BarChart) barWidth(cvs *canvas.Canvas) int { } if bc.opts.barWidth >= 1 { - // Prefer width set via the options if it is positive. + // Prefer width set via the options. return bc.opts.barWidth } @@ -266,8 +269,8 @@ func (bc *BarChart) Options() widgetapi.Options { defer bc.mu.Unlock() return widgetapi.Options{ MinimumSize: bc.minSize(), - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/barchart/barchart_test.go b/widgets/barchart/barchart_test.go index c6f36e6..fe83e67 100644 --- a/widgets/barchart/barchart_test.go +++ b/widgets/barchart/barchart_test.go @@ -19,30 +19,59 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/canvas" - "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/canvas" + "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" ) -func TestGauge(t *testing.T) { +func TestBarChart(t *testing.T) { tests := []struct { desc string - bc *BarChart + opts []Option update func(*BarChart) error // update gets called before drawing of the widget. canvas image.Rectangle want func(size image.Point) *faketerm.Terminal + wantErr bool wantUpdateErr bool // whether to expect an error on a call to the update function wantDrawErr bool }{ + { + desc: "fails on negative bar width", + opts: []Option{ + BarWidth(-1), + }, + update: func(bc *BarChart) error { + return nil + }, + canvas: image.Rect(0, 0, 3, 10), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "fails on negative bar gap", + opts: []Option{ + BarGap(-1), + }, + update: func(bc *BarChart) error { + return nil + }, + canvas: image.Rect(0, 0, 3, 10), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, { desc: "draws empty for no values", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return nil }, @@ -53,9 +82,9 @@ func TestGauge(t *testing.T) { }, { desc: "fails for zero max", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, 2, 5, 10}, 0) }, @@ -67,9 +96,9 @@ func TestGauge(t *testing.T) { }, { desc: "fails for negative max", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, 2, 5, 10}, -1) }, @@ -81,9 +110,9 @@ func TestGauge(t *testing.T) { }, { desc: "fails when negative value", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, -2, 5, 10}, 10) }, @@ -95,9 +124,9 @@ func TestGauge(t *testing.T) { }, { desc: "fails for value larger than max", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, 2, 5, 11}, 10) }, @@ -109,9 +138,9 @@ func TestGauge(t *testing.T) { }, { desc: "draws resize needed character when canvas is smaller than requested", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, 2, 5, 10}, 10) }, @@ -127,9 +156,9 @@ func TestGauge(t *testing.T) { }, { desc: "displays bars", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, 2, 5, 10}, 10) }, @@ -156,14 +185,14 @@ func TestGauge(t *testing.T) { }, { desc: "displays bars with labels", - bc: New( + opts: []Option{ Char('o'), Labels([]string{ "1", "2", "3", }), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2, 5, 10}, 10) }, @@ -205,14 +234,14 @@ func TestGauge(t *testing.T) { }, { desc: "trims too long labels", - bc: New( + opts: []Option{ Char('o'), Labels([]string{ "1", "22", "3", }), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2, 5, 10}, 10) }, @@ -254,7 +283,7 @@ func TestGauge(t *testing.T) { }, { desc: "displays bars with labels and values", - bc: New( + opts: []Option{ Char('o'), Labels([]string{ "1", @@ -262,7 +291,7 @@ func TestGauge(t *testing.T) { "3", }), ShowValues(), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2, 5, 10}, 10) }, @@ -320,9 +349,9 @@ func TestGauge(t *testing.T) { }, { desc: "bars take as much width as available", - bc: New( + opts: []Option{ Char('o'), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2}, 10) }, @@ -345,10 +374,10 @@ func TestGauge(t *testing.T) { }, { desc: "respects set bar width", - bc: New( + opts: []Option{ Char('o'), BarWidth(1), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2}, 10) }, @@ -371,7 +400,6 @@ func TestGauge(t *testing.T) { }, { desc: "options can be set on a call to Values", - bc: New(), update: func(bc *BarChart) error { return bc.Values([]int{1, 2}, 10, Char('o'), BarWidth(1)) }, @@ -394,10 +422,10 @@ func TestGauge(t *testing.T) { }, { desc: "respects set bar gap", - bc: New( + opts: []Option{ Char('o'), BarGap(2), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2}, 10) }, @@ -420,11 +448,11 @@ func TestGauge(t *testing.T) { }, { desc: "respects both width and gap", - bc: New( + opts: []Option{ Char('o'), BarGap(2), BarWidth(2), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{5, 3}, 10) }, @@ -447,7 +475,7 @@ func TestGauge(t *testing.T) { }, { desc: "respects bar and label colors", - bc: New( + opts: []Option{ Char('o'), BarColors([]cell.Color{ cell.ColorBlue, @@ -461,7 +489,7 @@ func TestGauge(t *testing.T) { "1", "2", }), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{1, 2, 3}, 10) }, @@ -496,14 +524,14 @@ func TestGauge(t *testing.T) { }, { desc: "respects value colors", - bc: New( + opts: []Option{ Char('o'), ValueColors([]cell.Color{ cell.ColorBlue, cell.ColorBlack, }), ShowValues(), - ), + }, update: func(bc *BarChart) error { return bc.Values([]int{0, 2, 3}, 10) }, @@ -541,12 +569,20 @@ func TestGauge(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { + bc, err := New(tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + c, err := canvas.New(tc.canvas) if err != nil { t.Fatalf("canvas.New => unexpected error: %v", err) } - err = tc.update(tc.bc) + err = tc.update(bc) if (err != nil) != tc.wantUpdateErr { t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) @@ -555,7 +591,7 @@ func TestGauge(t *testing.T) { return } - err = tc.bc.Draw(c) + err = bc.Draw(c) if (err != nil) != tc.wantDrawErr { t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } @@ -588,12 +624,12 @@ func TestOptions(t *testing.T) { { desc: "minimum size for no bars", create: func() (*BarChart, error) { - return New(), nil + return New() }, want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -601,18 +637,21 @@ func TestOptions(t *testing.T) { create: func() (*BarChart, error) { return New( Labels([]string{"foo"}), - ), nil + ) }, want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "minimum size for one bar, default width, gap and no labels", create: func() (*BarChart, error) { - bc := New() + bc, err := New() + if err != nil { + return nil, err + } if err := bc.Values([]int{1}, 3); err != nil { return nil, err } @@ -620,14 +659,17 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "minimum size for two bars, default width, gap and no labels", create: func() (*BarChart, error) { - bc := New() + bc, err := New() + if err != nil { + return nil, err + } if err := bc.Values([]int{1, 2}, 3); err != nil { return nil, err } @@ -635,17 +677,20 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{3, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "minimum size for two bars, custom width, gap and no labels", create: func() (*BarChart, error) { - bc := New( + bc, err := New( BarWidth(3), BarGap(2), ) + if err != nil { + return nil, err + } if err := bc.Values([]int{1, 2}, 3); err != nil { return nil, err } @@ -653,17 +698,20 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{8, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "minimum size for two bars, custom width, gap and labels", create: func() (*BarChart, error) { - bc := New( + bc, err := New( BarWidth(3), BarGap(2), ) + if err != nil { + return nil, err + } if err := bc.Values([]int{1, 2}, 3, Labels([]string{"foo", "bar"})); err != nil { return nil, err } @@ -671,8 +719,8 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{8, 2}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, } diff --git a/widgets/barchart/barchartdemo/barchartdemo.go b/widgets/barchart/barchartdemo/barchartdemo.go index 9c06113..5086191 100644 --- a/widgets/barchart/barchartdemo/barchartdemo.go +++ b/widgets/barchart/barchartdemo/barchartdemo.go @@ -22,11 +22,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/barchart" ) @@ -67,7 +67,7 @@ func main() { defer t.Close() ctx, cancel := context.WithCancel(context.Background()) - bc := barchart.New( + bc, err := barchart.New( barchart.BarColors([]cell.Color{ cell.ColorBlue, cell.ColorRed, @@ -91,6 +91,9 @@ func main() { "CPU3", }), ) + if err != nil { + panic(err) + } go playBarChart(ctx, bc, 1*time.Second) c, err := container.New( diff --git a/widgets/barchart/options.go b/widgets/barchart/options.go index bfb2ae8..77534f6 100644 --- a/widgets/barchart/options.go +++ b/widgets/barchart/options.go @@ -17,8 +17,10 @@ package barchart // options.go contains configurable options for BarChart. import ( - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/draw" + "fmt" + + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" ) // Option is used to provide options. @@ -47,6 +49,17 @@ type options struct { labels []string } +// validate validates the provided options. +func (o *options) validate() error { + if got, min := o.barWidth, 0; got < min { + return fmt.Errorf("invalid BarWidth %d, must be %d <= BarWidth", got, min) + } + if got, min := o.barGap, 0; got < min { + return fmt.Errorf("invalid BarGap %d, must be %d <= BarGap", got, min) + } + return nil +} + // newOptions returns options with the default values set. func newOptions() *options { return &options{ @@ -66,8 +79,9 @@ func Char(ch rune) Option { }) } -// BarWidth sets the width of the bars. If not set, the bars use all the space -// available to the widget. +// BarWidth sets the width of the bars. If not set, or set to zero, the bars +// use all the space available to the widget. Must be a positive or zero +// integer. func BarWidth(width int) Option { return option(func(opts *options) { opts.barWidth = width @@ -78,6 +92,7 @@ func BarWidth(width int) Option { const DefaultBarGap = 1 // BarGap sets the width of the space between the bars. +// Must be a positive or zero integer. // Defaults to DefaultBarGap. func BarGap(width int) Option { return option(func(opts *options) { diff --git a/widgets/button/button.go b/widgets/button/button.go new file mode 100644 index 0000000..2ae5603 --- /dev/null +++ b/widgets/button/button.go @@ -0,0 +1,209 @@ +// 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 an interactive widget that can be pressed to +// activate. +package button + +import ( + "errors" + "image" + "sync" + "time" + + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/mouse" + "github.com/mum4k/termdash/internal/mouse/button" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" +) + +// CallbackFn is the function called when the button is pressed. +// The callback function must be light-weight, ideally just storing a value and +// returning, since more button presses might occur. +// +// The callback function must be thread-safe as the mouse or keyboard events +// that press the button are processed in a separate goroutine. +// +// If the function returns an error, the widget will forward it back to the +// termdash infrastructure which causes a panic, unless the user provided a +// termdash.ErrorHandler. +type CallbackFn func() error + +// Button can be pressed using a mouse click or a configured keyboard key. +// +// Upon each press, the button invokes a callback provided by the user. +// +// Implements widgetapi.Widget. This object is thread-safe. +type Button struct { + // text in the text label displayed in the button. + text string + + // mouseFSM tracks left mouse clicks. + mouseFSM *button.FSM + // state is the current state of the button. + state button.State + + // keyTriggerTime is the last time the button was pressed using a keyboard + // key. It is nil if the button was triggered by a mouse event. + // Used to draw button presses on keyboard events, since termbox doesn't + // provide us with release events for keys. + keyTriggerTime *time.Time + + // callback gets called on each button press. + callback CallbackFn + + // mu protects the widget. + mu sync.Mutex + + // opts are the provided options. + opts *options +} + +// New returns a new Button that will display the provided text. +// Each press of the button will invoke the callback function. +func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) { + if cFn == nil { + return nil, errors.New("the CallbackFn argument cannot be nil") + } + + opt := newOptions(text) + for _, o := range opts { + o.set(opt) + } + if err := opt.validate(); err != nil { + return nil, err + } + return &Button{ + text: text, + mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR), + callback: cFn, + opts: opt, + }, nil +} + +// Vars to be replaced from tests. +var ( + // Runes to use in cells that contain the button. + // Changed from tests to provide readable test failures. + buttonRune = ' ' + // Runes to use in cells that contain the shadow. + // Changed from tests to provide readable test failures. + shadowRune = ' ' + + // timeSince is a function that calculates duration since some time. + timeSince = time.Since +) + +// Draw draws the Button widget onto the canvas. +// Implements widgetapi.Widget.Draw. +func (b *Button) Draw(cvs *canvas.Canvas) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.keyTriggerTime != nil { + since := timeSince(*b.keyTriggerTime) + if since > b.opts.keyUpDelay { + b.state = button.Up + } + } + + cvsAr := cvs.Area() + b.mouseFSM.UpdateArea(cvsAr) + + shadowAr := image.Rect(shadowWidth, shadowWidth, cvsAr.Dx(), cvsAr.Dy()) + if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil { + return err + } + + var buttonAr image.Rectangle + if b.state == button.Up { + buttonAr = image.Rect(0, 0, cvsAr.Dx()-shadowWidth, cvsAr.Dy()-shadowWidth) + } else { + buttonAr = shadowAr + } + + if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil { + return err + } + + textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y) + start, err := align.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle) + if err != nil { + return err + } + if err := draw.Text(cvs, b.text, start, + draw.TextOverrunMode(draw.OverrunModeThreeDot), + draw.TextMaxX(buttonAr.Max.X), + draw.TextCellOpts(cell.FgColor(b.opts.textColor)), + ); err != nil { + return err + } + return nil +} + +// Keyboard processes keyboard events, acts as a button press on the configured +// Key. +// +// Implements widgetapi.Widget.Keyboard. +func (b *Button) Keyboard(k *terminalapi.Keyboard) error { + b.mu.Lock() + defer b.mu.Unlock() + + if k.Key == b.opts.key { + b.state = button.Down + now := time.Now().UTC() + b.keyTriggerTime = &now + return b.callback() + } + return nil +} + +// Mouse processes mouse events, acts as a button press if both the press and +// the release happen inside the button. +// +// Implements widgetapi.Widget.Mouse. +func (b *Button) Mouse(m *terminalapi.Mouse) error { + b.mu.Lock() + defer b.mu.Unlock() + + clicked, state := b.mouseFSM.Event(m) + b.state = state + b.keyTriggerTime = nil + + if clicked { + return b.callback() + } + return nil +} + +// shadowWidth is the width of the shadow under the button in cell. +const shadowWidth = 1 + +// Options implements widgetapi.Widget.Options. +func (b *Button) Options() widgetapi.Options { + // No need to lock, as the height and width get fixed when New is called. + + width := b.opts.width + shadowWidth + height := b.opts.height + shadowWidth + return widgetapi.Options{ + MinimumSize: image.Point{width, height}, + MaximumSize: image.Point{width, height}, + WantKeyboard: b.opts.keyScope, + WantMouse: widgetapi.MouseScopeGlobal, + } +} diff --git a/widgets/button/button_test.go b/widgets/button/button_test.go new file mode 100644 index 0000000..72f1af6 --- /dev/null +++ b/widgets/button/button_test.go @@ -0,0 +1,846 @@ +// 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 ( + "errors" + "image" + "sync" + "testing" + "time" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/internal/canvas" + "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/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" +) + +// callbackTracker tracks whether callback was called. +type callbackTracker struct { + // wantErr when set to true, makes callback return an error. + wantErr bool + + // called asserts whether the callback was called. + called bool + + // count is the number of times the callback was called. + count int + + // mu protects the tracker. + mu sync.Mutex +} + +// callback is the callback function. +func (ct *callbackTracker) callback() error { + ct.mu.Lock() + defer ct.mu.Unlock() + + if ct.wantErr { + return errors.New("ct.wantErr set to true") + } + + ct.count++ + ct.called = true + return nil +} + +func TestButton(t *testing.T) { + tests := []struct { + desc string + text string + callback *callbackTracker + opts []Option + events []terminalapi.Event + canvas image.Rectangle + + // timeSince is used to replace time.Since for tests, leave nil to use + // the original. + timeSince func(time.Time) time.Duration + + want func(size image.Point) *faketerm.Terminal + wantCallback *callbackTracker + wantNewErr bool + wantDrawErr bool + wantCallbackErr bool + }{ + { + desc: "New fails with nil callback", + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with negative keyUpDelay", + callback: &callbackTracker{}, + opts: []Option{ + KeyUpDelay(-1 * time.Second), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with zero Height", + callback: &callbackTracker{}, + opts: []Option{ + Height(0), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with zero Width", + callback: &callbackTracker{}, + opts: []Option{ + Width(0), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "draw fails on canvas too small", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 1, 1), + wantDrawErr: true, + }, + { + desc: "draws button in up state", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button in down state due to a mouse event", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + 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()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "mouse triggered the callback", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "draws button in down state due to a keyboard event, callback triggered", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event ignored when no key specified", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "keyboard event triggers the button, trigger time didn't expire so button is down", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event triggers the button, custom trigger time expired so button is up", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + KeyUpDelay(100 * time.Millisecond), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event triggers the button multiple times", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + KeyUpDelay(100 * time.Millisecond), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 3, + }, + }, + { + desc: "mouse event triggers the button multiple times", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 2, + }, + }, + { + desc: "the callback returns an error after a mouse event", + callback: &callbackTracker{ + wantErr: true, + }, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + wantCallbackErr: true, + }, + { + desc: "the callback returns an error after a keyboard event", + callback: &callbackTracker{ + wantErr: true, + }, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + KeyUpDelay(100 * time.Millisecond), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + wantCallbackErr: true, + }, + { + desc: "draws button with custom height (infra gives smaller canvas)", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 2), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 1), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 0}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "button width adjusts to width (infra gives smaller canvas)", + callback: &callbackTracker{}, + text: "h", + canvas: image.Rect(0, 0, 4, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 4, 2), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 3, 1), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 0}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom text color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + TextColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom fill color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + FillColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorRed)) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorRed)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom shadow color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + ShadowColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorRed)) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + } + + buttonRune = 'x' + shadowRune = 's' + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + if tc.timeSince != nil { + timeSince = tc.timeSince + } else { + timeSince = time.Since + } + + gotCallback := tc.callback + var cFn CallbackFn + if gotCallback == nil { + cFn = nil + } else { + cFn = gotCallback.callback + } + b, err := New(tc.text, cFn, tc.opts...) + if (err != nil) != tc.wantNewErr { + t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) + } + if err != nil { + return + } + + { + // Draw once which initializes the mouse state machine with the current canvas area. + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + err = b.Draw(c) + if (err != nil) != tc.wantDrawErr { + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) + } + if err != nil { + return + } + } + + for i, ev := range tc.events { + switch e := ev.(type) { + case *terminalapi.Mouse: + err := b.Mouse(e) + // Only the last event in test cases is the one that triggers the callback. + if i == len(tc.events)-1 { + if (err != nil) != tc.wantCallbackErr { + t.Errorf("Mouse => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr) + } + if err != nil { + return + } + } else { + if err != nil { + t.Fatalf("Mouse => unexpected error: %v", err) + } + } + + case *terminalapi.Keyboard: + err := b.Keyboard(e) + // Only the last event in test cases is the one that triggers the callback. + if i == len(tc.events)-1 { + if (err != nil) != tc.wantCallbackErr { + t.Errorf("Keyboard => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr) + } + if err != nil { + return + } + } else { + if err != nil { + t.Fatalf("Keyboard => unexpected error: %v", err) + } + } + + default: + t.Fatalf("unsupported event type: %T", ev) + } + } + + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + err = b.Draw(c) + if (err != nil) != tc.wantDrawErr { + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) + } + 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) + } + + var want *faketerm.Terminal + if tc.want != nil { + want = tc.want(c.Size()) + } else { + want = faketerm.MustNew(c.Size()) + } + + if diff := faketerm.Diff(want, got); diff != "" { + t.Errorf("Draw => %v", diff) + } + + if diff := pretty.Compare(tc.wantCallback, gotCallback); diff != "" { + t.Errorf("CallbackFn => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestOptions(t *testing.T) { + tests := []struct { + desc string + text string + opts []Option + want widgetapi.Options + }{ + { + desc: "width is based on the text width by default", + text: "hello world", + want: widgetapi.Options{ + MinimumSize: image.Point{14, 4}, + MaximumSize: image.Point{14, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "width supports full-width unicode characters", + text: "■㈱の世界①", + want: widgetapi.Options{ + MinimumSize: image.Point{13, 4}, + MaximumSize: image.Point{13, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "width specified via WidthFor", + text: "hello", + opts: []Option{ + WidthFor("■㈱の世界①"), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{13, 4}, + MaximumSize: image.Point{13, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "custom height specified", + text: "hello", + opts: []Option{ + Height(10), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 11}, + MaximumSize: image.Point{8, 11}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "custom width specified", + text: "hello", + opts: []Option{ + Width(10), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{11, 4}, + MaximumSize: image.Point{11, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + + { + desc: "doesn't want keyboard by default", + text: "hello", + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "registers for focused keyboard events", + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "registers for global keyboard events", + text: "hello", + opts: []Option{ + GlobalKey(keyboard.KeyEnter), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeGlobal, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + ct := &callbackTracker{} + b, err := New(tc.text, ct.callback, tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + + got := b.Options() + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) + } + }) + } + +} diff --git a/widgets/button/buttondemo/buttondemo.go b/widgets/button/buttondemo/buttondemo.go new file mode 100644 index 0000000..1029019 --- /dev/null +++ b/widgets/button/buttondemo/buttondemo.go @@ -0,0 +1,116 @@ +// 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. + +// Binary buttondemo shows the functionality of a button widget. +package main + +import ( + "context" + "fmt" + "time" + + "github.com/mum4k/termdash" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/widgets/button" + "github.com/mum4k/termdash/widgets/segmentdisplay" +) + +func main() { + t, err := termbox.New() + if err != nil { + panic(err) + } + defer t.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + val := 0 + display, err := segmentdisplay.New() + if err != nil { + panic(err) + } + if err := display.Write([]*segmentdisplay.TextChunk{ + segmentdisplay.NewChunk(fmt.Sprintf("%d", val)), + }); err != nil { + panic(err) + } + + addB, err := button.New("(a)dd", func() error { + val++ + return display.Write([]*segmentdisplay.TextChunk{ + segmentdisplay.NewChunk(fmt.Sprintf("%d", val)), + }) + }, + button.GlobalKey('a'), + button.WidthFor("(s)ubtract"), + ) + if err != nil { + panic(err) + } + + subB, err := button.New("(s)ubtract", func() error { + val-- + return display.Write([]*segmentdisplay.TextChunk{ + segmentdisplay.NewChunk(fmt.Sprintf("%d", val)), + }) + }, + button.FillColor(cell.ColorNumber(220)), + button.GlobalKey('s'), + ) + if err != nil { + panic(err) + } + + c, err := container.New( + t, + container.Border(draw.LineStyleLight), + container.BorderTitle("PRESS Q TO QUIT"), + container.SplitHorizontal( + container.Top( + container.PlaceWidget(display), + ), + container.Bottom( + container.SplitVertical( + container.Left( + container.PlaceWidget(addB), + container.AlignHorizontal(align.HorizontalRight), + ), + container.Right( + container.PlaceWidget(subB), + container.AlignHorizontal(align.HorizontalLeft), + ), + ), + ), + container.SplitPercent(60), + ), + ) + if err != nil { + panic(err) + } + + quitter := func(k *terminalapi.Keyboard) { + if k.Key == 'q' || k.Key == 'Q' { + cancel() + } + } + + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(100*time.Millisecond)); err != nil { + panic(err) + } +} diff --git a/widgets/button/options.go b/widgets/button/options.go new file mode 100644 index 0000000..1bc2ed4 --- /dev/null +++ b/widgets/button/options.go @@ -0,0 +1,171 @@ +// 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 + +// options.go contains configurable options for Button. + +import ( + "fmt" + "time" + + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/cell/runewidth" + "github.com/mum4k/termdash/internal/keyboard" + "github.com/mum4k/termdash/internal/widgetapi" +) + +// Option is used to provide options. +type Option interface { + // set sets the provided option. + set(*options) +} + +// option implements Option. +type option func(*options) + +// set implements Option.set. +func (o option) set(opts *options) { + o(opts) +} + +// options holds the provided options. +type options struct { + fillColor cell.Color + textColor cell.Color + shadowColor cell.Color + height int + width int + key keyboard.Key + keyScope widgetapi.KeyScope + keyUpDelay time.Duration +} + +// validate validates the provided options. +func (o *options) validate() error { + if min := 1; o.height < min { + return fmt.Errorf("invalid height %d, must be %d <= height", o.height, min) + } + if min := 1; o.width < min { + return fmt.Errorf("invalid width %d, must be %d <= width", o.width, min) + } + if min := time.Duration(0); o.keyUpDelay < min { + return fmt.Errorf("invalid keyUpDelay %v, must be %v <= keyUpDelay", o.keyUpDelay, min) + } + return nil +} + +// newOptions returns options with the default values set. +func newOptions(text string) *options { + return &options{ + fillColor: cell.ColorNumber(117), + textColor: cell.ColorBlack, + shadowColor: cell.ColorNumber(240), + height: DefaultHeight, + width: widthFor(text), + keyUpDelay: DefaultKeyUpDelay, + } +} + +// FillColor sets the fill color of the button. +func FillColor(c cell.Color) Option { + return option(func(opts *options) { + opts.fillColor = c + }) +} + +// TextColor sets the color of the text label in the button. +func TextColor(c cell.Color) Option { + return option(func(opts *options) { + opts.textColor = c + }) +} + +// ShadowColor sets the color of the shadow under the button. +func ShadowColor(c cell.Color) Option { + return option(func(opts *options) { + opts.shadowColor = c + }) +} + +// DefaultHeight is the default for the Height option. +const DefaultHeight = 3 + +// Height sets the height of the button in cells. +// Must be a positive non-zero integer. +// Defaults to DefaultHeight. +func Height(cells int) Option { + return option(func(opts *options) { + opts.height = cells + }) +} + +// Width sets the width of the button in cells. +// Must be a positive non-zero integer. +// Defaults to the auto-width based on the length of the text label. +func Width(cells int) Option { + return option(func(opts *options) { + opts.width = cells + }) +} + +// WidthFor sets the width of the button as if it was displaying the provided text. +// Useful when displaying multiple buttons with the intention to set all of +// their sizes equal to the one with the longest text. +func WidthFor(text string) Option { + return option(func(opts *options) { + opts.width = widthFor(text) + }) +} + +// Key configures the keyboard key that presses the button. +// The widget responds to this key only if its container if focused. +// When not provided, the widget ignores all keyboard events. +func Key(k keyboard.Key) Option { + return option(func(opts *options) { + opts.key = k + opts.keyScope = widgetapi.KeyScopeFocused + }) +} + +// GlobalKey is like Key, but makes the widget respond to the key even if its +// container isn't focused. +// When not provided, the widget ignores all keyboard events. +func GlobalKey(k keyboard.Key) Option { + return option(func(opts *options) { + opts.key = k + opts.keyScope = widgetapi.KeyScopeGlobal + }) +} + +// DefaultKeyUpDelay is the default value for the KeyUpDelay option. +const DefaultKeyUpDelay = 250 * time.Millisecond + +// KeyUpDelay is the amount of time the button will remain "pressed down" after +// triggered by the configured key. Termbox doesn't emit events for key +// releases so the button simulates it by timing it. +// This only works if the manual termdash redraw or the periodic redraw +// interval are reasonably close to this delay. +// The duration cannot be negative. +// Defaults to DefaultKeyUpDelay. +func KeyUpDelay(d time.Duration) Option { + return option(func(opts *options) { + opts.keyUpDelay = d + }) +} + +// widthFor returns the required width for the specified text. +func widthFor(text string) int { + return runewidth.StringWidth(text) + 2 // One empty cell at each side. +} diff --git a/widgets/donut/circle.go b/widgets/donut/circle.go index 466abe8..d9a586a 100644 --- a/widgets/donut/circle.go +++ b/widgets/donut/circle.go @@ -19,8 +19,8 @@ package donut import ( "image" - "github.com/mum4k/termdash/canvas/braille" - "github.com/mum4k/termdash/numbers" + "github.com/mum4k/termdash/internal/canvas/braille" + "github.com/mum4k/termdash/internal/numbers" ) // startEndAngles given progress indicators and the desired start angle and @@ -64,14 +64,14 @@ func startEndAngles(current, total, startAngle, direction int) (start, end int) func midAndRadius(ar image.Rectangle) (image.Point, int) { mid := image.Point{ar.Dx() / 2, ar.Dy() / 2} if mid.X%2 != 0 { - mid.X -= 1 + mid.X-- } switch mid.Y % 4 { case 0: - mid.Y += 1 + mid.Y++ case 1: case 2: - mid.Y -= 1 + mid.Y-- case 3: mid.Y -= 2 diff --git a/widgets/donut/donut.go b/widgets/donut/donut.go index ef3a11a..ed6ea28 100644 --- a/widgets/donut/donut.go +++ b/widgets/donut/donut.go @@ -22,14 +22,14 @@ import ( "image" "sync" - runewidth "github.com/mattn/go-runewidth" - "github.com/mum4k/termdash/align" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/canvas/braille" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/numbers" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/canvas/braille" + "github.com/mum4k/termdash/internal/cell/runewidth" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/numbers" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // progressType indicates how was the current progress provided by the caller. @@ -247,7 +247,7 @@ func (d *Donut) Options() widgetapi.Options { // The smallest circle that "looks" like a circle on the canvas. MinimumSize: image.Point{3, 3}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/donut/donut_test.go b/widgets/donut/donut_test.go index eafcc82..1f30720 100644 --- a/widgets/donut/donut_test.go +++ b/widgets/donut/donut_test.go @@ -19,15 +19,15 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "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" - "github.com/mum4k/termdash/draw/testdraw" - "github.com/mum4k/termdash/terminal/faketerm" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "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" + "github.com/mum4k/termdash/internal/draw/testdraw" + "github.com/mum4k/termdash/internal/terminal/faketerm" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) func TestDonut(t *testing.T) { @@ -665,8 +665,8 @@ func TestOptions(t *testing.T) { want := widgetapi.Options{ Ratio: image.Point{4, 2}, MinimumSize: image.Point{3, 3}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } if diff := pretty.Compare(want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) diff --git a/widgets/donut/donutdemo/donutdemo.go b/widgets/donut/donutdemo/donutdemo.go index c177a59..3b79818 100644 --- a/widgets/donut/donutdemo/donutdemo.go +++ b/widgets/donut/donutdemo/donutdemo.go @@ -21,11 +21,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/donut" ) diff --git a/widgets/donut/options.go b/widgets/donut/options.go index b09b8ab..6a7c35e 100644 --- a/widgets/donut/options.go +++ b/widgets/donut/options.go @@ -19,7 +19,7 @@ package donut import ( "fmt" - "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/cell" ) // Option is used to provide options. diff --git a/widgets/fakewidget/fakewidget.go b/widgets/fakewidget/fakewidget.go index e4ab451..c936f71 100644 --- a/widgets/fakewidget/fakewidget.go +++ b/widgets/fakewidget/fakewidget.go @@ -21,13 +21,13 @@ import ( "image" "sync" - "github.com/mum4k/termdash/area" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/keyboard" - "github.com/mum4k/termdash/mouse" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/area" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/keyboard" + "github.com/mum4k/termdash/internal/mouse" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // outputLines are the number of lines written by this plugin. @@ -173,14 +173,14 @@ func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, for _, ev := range events { switch e := ev.(type) { case *terminalapi.Mouse: - if !mirror.opts.WantMouse { + if mirror.opts.WantMouse == widgetapi.MouseScopeNone { continue } if err := mirror.Mouse(e); err != nil { return err } case *terminalapi.Keyboard: - if !mirror.opts.WantKeyboard { + if mirror.opts.WantKeyboard == widgetapi.KeyScopeNone { continue } if err := mirror.Keyboard(e); err != nil { diff --git a/widgets/fakewidget/fakewidget_test.go b/widgets/fakewidget/fakewidget_test.go index 88f5dd0..6985f1e 100644 --- a/widgets/fakewidget/fakewidget_test.go +++ b/widgets/fakewidget/fakewidget_test.go @@ -19,14 +19,14 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/canvas/testcanvas" - "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/canvas" + "github.com/mum4k/termdash/internal/canvas/testcanvas" + "github.com/mum4k/termdash/internal/draw/testdraw" + "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" ) // keyEvents are keyboard events to send to the widget. @@ -281,7 +281,7 @@ func TestMirror(t *testing.T) { func TestOptions(t *testing.T) { want := widgetapi.Options{ Ratio: image.Point{1, 2}, - WantKeyboard: true, + WantKeyboard: widgetapi.KeyScopeFocused, } w := New(want) @@ -323,8 +323,8 @@ func TestDraw(t *testing.T) { { desc: "draws both keyboard and mouse events", opts: widgetapi.Options{ - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, }, cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)), events: []terminalapi.Event{ diff --git a/widgets/gauge/gauge.go b/widgets/gauge/gauge.go index 2ce5c78..16f9874 100644 --- a/widgets/gauge/gauge.go +++ b/widgets/gauge/gauge.go @@ -22,14 +22,14 @@ import ( "image" "sync" - runewidth "github.com/mattn/go-runewidth" - "github.com/mum4k/termdash/align" - "github.com/mum4k/termdash/area" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/area" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/cell/runewidth" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // progressType indicates how was the current progress provided by the caller. @@ -77,14 +77,18 @@ type Gauge struct { } // New returns a new Gauge. -func New(opts ...Option) *Gauge { +func New(opts ...Option) (*Gauge, error) { opt := newOptions() for _, o := range opts { o.set(opt) } + if err := opt.validate(); err != nil { + return nil, err + } + return &Gauge{ opts: opt, - } + }, nil } // Absolute sets the progress in absolute numbers, i.e. 7 out of 10. @@ -325,7 +329,7 @@ func (g *Gauge) Options() widgetapi.Options { return widgetapi.Options{ MaximumSize: g.maxSize(), MinimumSize: g.minSize(), - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/gauge/gauge_test.go b/widgets/gauge/gauge_test.go index fe38aba..2264565 100644 --- a/widgets/gauge/gauge_test.go +++ b/widgets/gauge/gauge_test.go @@ -19,14 +19,14 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "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/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" + "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" ) // percentCall contains arguments for a call to GaugePercent(). @@ -45,20 +45,31 @@ type absoluteCall struct { func TestGauge(t *testing.T) { tests := []struct { desc string - gauge *Gauge + opts []Option percent *percentCall // if set, the test case calls Gauge.Percent(). absolute *absoluteCall // if set the test case calls Gauge.Absolute(). canvas image.Rectangle - opts []Option want func(size image.Point) *faketerm.Terminal + wantErr bool wantUpdateErr bool // whether to expect an error on a call to Gauge.Percent() or Gauge.Absolute(). wantDrawErr bool }{ + { + desc: "fails on negative height", + opts: []Option{ + Height(-1), + }, + canvas: image.Rect(0, 0, 10, 3), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, { desc: "gauge showing percentage", - gauge: New( + opts: []Option{ Char('o'), - ), + }, percent: &percentCall{p: 35}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -76,10 +87,10 @@ func TestGauge(t *testing.T) { }, { desc: "draws resize needed character when canvas is smaller than requested", - gauge: New( + opts: []Option{ Char('o'), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 35}, canvas: image.Rect(0, 0, 1, 1), want: func(size image.Point) *faketerm.Terminal { @@ -93,11 +104,11 @@ func TestGauge(t *testing.T) { }, { desc: "aligns the progress text top and left", - gauge: New( + opts: []Option{ Char('o'), HorizontalTextAlign(align.HorizontalLeft), VerticalTextAlign(align.VerticalTop), - ), + }, percent: &percentCall{p: 0}, canvas: image.Rect(0, 0, 10, 4), want: func(size image.Point) *faketerm.Terminal { @@ -111,12 +122,12 @@ func TestGauge(t *testing.T) { }, { desc: "aligns the progress text top and left with border", - gauge: New( + opts: []Option{ Char('o'), HorizontalTextAlign(align.HorizontalLeft), VerticalTextAlign(align.VerticalTop), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 0}, canvas: image.Rect(0, 0, 10, 4), want: func(size image.Point) *faketerm.Terminal { @@ -131,11 +142,11 @@ func TestGauge(t *testing.T) { }, { desc: "aligns the progress text bottom and right", - gauge: New( + opts: []Option{ Char('o'), HorizontalTextAlign(align.HorizontalRight), VerticalTextAlign(align.VerticalBottom), - ), + }, percent: &percentCall{p: 0}, canvas: image.Rect(0, 0, 10, 4), want: func(size image.Point) *faketerm.Terminal { @@ -149,12 +160,12 @@ func TestGauge(t *testing.T) { }, { desc: "aligns the progress text bottom and right with border", - gauge: New( + opts: []Option{ Char('o'), HorizontalTextAlign(align.HorizontalRight), VerticalTextAlign(align.VerticalBottom), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 0}, canvas: image.Rect(0, 0, 10, 4), want: func(size image.Point) *faketerm.Terminal { @@ -169,11 +180,11 @@ func TestGauge(t *testing.T) { }, { desc: "gauge showing percentage with border", - gauge: New( + opts: []Option{ Char('o'), Border(draw.LineStyleLight), BorderTitle("title"), - ), + }, percent: &percentCall{p: 35}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -194,12 +205,12 @@ func TestGauge(t *testing.T) { }, { desc: "respects border options", - gauge: New( + opts: []Option{ Char('o'), Border(draw.LineStyleLight, cell.FgColor(cell.ColorBlue)), BorderTitle("title"), BorderTitleAlign(align.HorizontalRight), - ), + }, percent: &percentCall{p: 35}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -222,9 +233,9 @@ func TestGauge(t *testing.T) { }, { desc: "gauge showing zero percentage", - gauge: New( + opts: []Option{ Char('o'), - ), + }, percent: &percentCall{}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -238,9 +249,9 @@ func TestGauge(t *testing.T) { }, { desc: "gauge showing 100 percent", - gauge: New( + opts: []Option{ Char('o'), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -260,10 +271,10 @@ func TestGauge(t *testing.T) { }, { desc: "gauge showing 100 percent with border", - gauge: New( + opts: []Option{ Char('o'), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -284,9 +295,9 @@ func TestGauge(t *testing.T) { }, { desc: "gauge showing absolute progress", - gauge: New( + opts: []Option{ Char('o'), - ), + }, absolute: &absoluteCall{done: 20, total: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -304,10 +315,10 @@ func TestGauge(t *testing.T) { }, { desc: "gauge without text progress", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), - ), + }, percent: &percentCall{p: 35}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -324,10 +335,10 @@ func TestGauge(t *testing.T) { }, { desc: "passing option to Percent() overrides one provided to New()", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), - ), + }, percent: &percentCall{p: 35, opts: []Option{ShowTextProgress()}}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -345,10 +356,10 @@ func TestGauge(t *testing.T) { }, { desc: "passing option to Absolute() overrides one provided to New()", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), - ), + }, absolute: &absoluteCall{done: 20, total: 100, opts: []Option{ShowTextProgress()}}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -366,10 +377,10 @@ func TestGauge(t *testing.T) { }, { desc: "gauge takes full size of the canvas", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 5, 2), want: func(size image.Point) *faketerm.Terminal { @@ -386,11 +397,11 @@ func TestGauge(t *testing.T) { }, { desc: "gauge with text label, half-width runes", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), TextLabel("label"), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -410,11 +421,11 @@ func TestGauge(t *testing.T) { }, { desc: "gauge with text label, full-width runes", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), TextLabel("你好"), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -434,11 +445,11 @@ func TestGauge(t *testing.T) { }, { desc: "gauge with text label, full-width runes, gauge falls on rune boundary", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), TextLabel("你好"), - ), + }, percent: &percentCall{p: 50}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -461,11 +472,11 @@ func TestGauge(t *testing.T) { }, { desc: "gauge with text label, full-width runes, gauge extended to cover full rune", - gauge: New( + opts: []Option{ Char('o'), HideTextProgress(), TextLabel("你好"), - ), + }, percent: &percentCall{p: 40}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -488,10 +499,10 @@ func TestGauge(t *testing.T) { }, { desc: "gauge with progress text and text label", - gauge: New( + opts: []Option{ Char('o'), TextLabel("l"), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -511,12 +522,12 @@ func TestGauge(t *testing.T) { }, { desc: "text fully outside of gauge respects EmptyTextColor", - gauge: New( + opts: []Option{ Char('o'), TextLabel("l"), EmptyTextColor(cell.ColorMagenta), FilledTextColor(cell.ColorBlue), - ), + }, percent: &percentCall{p: 10}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -536,12 +547,12 @@ func TestGauge(t *testing.T) { }, { desc: "text fully inside of gauge respects FilledTextColor", - gauge: New( + opts: []Option{ Char('o'), TextLabel("l"), EmptyTextColor(cell.ColorMagenta), FilledTextColor(cell.ColorBlue), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -561,12 +572,12 @@ func TestGauge(t *testing.T) { }, { desc: "part of the text is inside and part outside of gauge", - gauge: New( + opts: []Option{ Char('o'), TextLabel("l"), EmptyTextColor(cell.ColorMagenta), FilledTextColor(cell.ColorBlue), - ), + }, percent: &percentCall{p: 50}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -589,10 +600,10 @@ func TestGauge(t *testing.T) { }, { desc: "truncates text that is outside of gauge", - gauge: New( + opts: []Option{ Char('o'), TextLabel("long label"), - ), + }, percent: &percentCall{p: 0}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -608,11 +619,11 @@ func TestGauge(t *testing.T) { }, { desc: "truncates text that is outside of gauge when drawn with border", - gauge: New( + opts: []Option{ Char('o'), TextLabel("long label"), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 0}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -629,10 +640,10 @@ func TestGauge(t *testing.T) { }, { desc: "truncates text that is inside of gauge", - gauge: New( + opts: []Option{ Char('o'), TextLabel("long label"), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -652,11 +663,11 @@ func TestGauge(t *testing.T) { }, { desc: "truncates text that is inside of gauge when drawn with border", - gauge: New( + opts: []Option{ Char('o'), TextLabel("long label"), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 100}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -677,10 +688,10 @@ func TestGauge(t *testing.T) { }, { desc: "truncates text that is inside and outside of gauge", - gauge: New( + opts: []Option{ Char('o'), TextLabel("long label"), - ), + }, percent: &percentCall{p: 50}, canvas: image.Rect(0, 0, 10, 3), want: func(size image.Point) *faketerm.Terminal { @@ -703,11 +714,11 @@ func TestGauge(t *testing.T) { }, { desc: "truncates text that is inside and outside of gauge with border", - gauge: New( + opts: []Option{ Char('o'), TextLabel("long label"), Border(draw.LineStyleLight), - ), + }, percent: &percentCall{p: 50}, canvas: image.Rect(0, 0, 10, 4), want: func(size image.Point) *faketerm.Terminal { @@ -733,6 +744,14 @@ func TestGauge(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { + g, err := New(tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + c, err := canvas.New(tc.canvas) if err != nil { t.Fatalf("canvas.New => unexpected error: %v", err) @@ -740,7 +759,7 @@ func TestGauge(t *testing.T) { switch { case tc.percent != nil: - err := tc.gauge.Percent(tc.percent.p, tc.percent.opts...) + err := g.Percent(tc.percent.p, tc.percent.opts...) if (err != nil) != tc.wantUpdateErr { t.Errorf("Percent => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) } @@ -749,7 +768,7 @@ func TestGauge(t *testing.T) { } case tc.absolute != nil: - err := tc.gauge.Absolute(tc.absolute.done, tc.absolute.total, tc.absolute.opts...) + err := g.Absolute(tc.absolute.done, tc.absolute.total, tc.absolute.opts...) if (err != nil) != tc.wantUpdateErr { t.Errorf("Absolute => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) } @@ -759,7 +778,7 @@ func TestGauge(t *testing.T) { } - err = tc.gauge.Draw(c) + err = g.Draw(c) if (err != nil) != tc.wantDrawErr { t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } @@ -785,50 +804,53 @@ func TestGauge(t *testing.T) { func TestOptions(t *testing.T) { tests := []struct { - desc string - gauge *Gauge - want widgetapi.Options + desc string + opts []Option + want widgetapi.Options }{ { - desc: "reports correct minimum and maximum size", - gauge: New(), + desc: "reports correct minimum and maximum size", want: widgetapi.Options{ MaximumSize: image.Point{0, 0}, // Unlimited. MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "maximum size is limited when height is specified", - gauge: New( + opts: []Option{ Height(2), - ), + }, want: widgetapi.Options{ MaximumSize: image.Point{0, 2}, MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "border is accounted for in maximum and minimum size", - gauge: New( + opts: []Option{ Border(draw.LineStyleLight), Height(2), - ), + }, want: widgetapi.Options{ MaximumSize: image.Point{0, 4}, MinimumSize: image.Point{3, 3}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - got := tc.gauge.Options() + g, err := New(tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + got := g.Options() if diff := pretty.Compare(tc.want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) diff --git a/widgets/gauge/gaugedemo/gaugedemo.go b/widgets/gauge/gaugedemo/gaugedemo.go index 2a55910..936fe78 100644 --- a/widgets/gauge/gaugedemo/gaugedemo.go +++ b/widgets/gauge/gaugedemo/gaugedemo.go @@ -21,11 +21,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/gauge" ) @@ -86,33 +86,48 @@ func main() { defer t.Close() ctx, cancel := context.WithCancel(context.Background()) - slim := gauge.New( + slim, err := gauge.New( gauge.Height(1), gauge.Border(draw.LineStyleLight), gauge.BorderTitle("Percentage progress"), ) + if err != nil { + panic(err) + } go playGauge(ctx, slim, 10, 500*time.Millisecond, playTypePercent) - absolute := gauge.New( + + absolute, err := gauge.New( gauge.Height(1), gauge.Color(cell.ColorBlue), gauge.Border(draw.LineStyleLight), gauge.BorderTitle("Absolute progress"), ) + if err != nil { + panic(err) + } go playGauge(ctx, absolute, 17, 500*time.Millisecond, playTypeAbsolute) - noProgress := gauge.New( + + noProgress, err := gauge.New( gauge.Height(1), gauge.Border(draw.LineStyleLight, cell.FgColor(cell.ColorMagenta)), gauge.BorderTitle("Without progress text"), gauge.HideTextProgress(), ) + if err != nil { + panic(err) + } go playGauge(ctx, noProgress, 5, 250*time.Millisecond, playTypePercent) - withLabel := gauge.New( + + withLabel, err := gauge.New( gauge.Height(3), gauge.TextLabel("你好,世界! text label and no border"), gauge.Color(cell.ColorRed), gauge.FilledTextColor(cell.ColorBlack), gauge.EmptyTextColor(cell.ColorYellow), ) + if err != nil { + panic(err) + } go playGauge(ctx, withLabel, 3, 500*time.Millisecond, playTypePercent) c, err := container.New( diff --git a/widgets/gauge/options.go b/widgets/gauge/options.go index f25f9d7..1c10fad 100644 --- a/widgets/gauge/options.go +++ b/widgets/gauge/options.go @@ -17,9 +17,11 @@ package gauge // options.go contains configurable options for Gauge. import ( - "github.com/mum4k/termdash/align" - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/draw" + "fmt" + + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" ) // Option is used to provide options. @@ -58,6 +60,14 @@ func newOptions() *options { } } +// validate validates the provided options. +func (o *options) validate() error { + if got, min := o.height, 0; got < min { + return fmt.Errorf("invalid Height %d, must be %d <= Height", got, min) + } + return nil +} + // option implements Option. type option func(*options) @@ -95,8 +105,8 @@ func HideTextProgress() Option { }) } -// Height sets the height of the drawn Gauge. -// Defaults to the height of the container. +// Height sets the height of the drawn Gauge. Must be a positive number. +// Defaults to zero which means the height of the container. func Height(height int) Option { return option(func(opts *options) { opts.height = height diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go deleted file mode 100644 index ddeceeb..0000000 --- a/widgets/linechart/axes/axes.go +++ /dev/null @@ -1,195 +0,0 @@ -// 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 axes calculates the required layout and draws the X and Y axes of a line chart. -package axes - -import ( - "fmt" - "image" -) - -const ( - // nonZeroDecimals determines the overall precision of values displayed on the - // graph, it indicates the number of non-zero decimal places the values will be - // rounded up to. - nonZeroDecimals = 2 - - // yAxisWidth is width of the Y axis. - yAxisWidth = 1 -) - -// YDetails contain information about the Y axis that will be drawn onto the -// canvas. -type YDetails struct { - // Width in character cells of the Y axis and its character labels. - Width int - - // Start is the point where the Y axis starts. - // Both coordinates of Start are less than End. - Start image.Point - // End is the point where the Y axis ends. - End image.Point - - // Scale is the scale of the Y axis. - Scale *YScale - - // Labels are the labels for values on the Y axis in an increasing order. - Labels []*Label -} - -// Y tracks the state of the Y axis throughout the lifetime of a line chart. -// Implements lazy resize of the axis to decrease visual "jumping". -// This object is not thread-safe. -type Y struct { - // min is the smallest value on the Y axis. - min *Value - // max is the largest value on the Y axis. - max *Value - // details about the Y axis as it will be drawn. - details *YDetails -} - -// NewY returns a new Y instance. -// The minVal and maxVal represent the minimum and maximum value that will be -// displayed on the line chart among all of the series. -func NewY(minVal, maxVal float64) *Y { - y := &Y{} - y.Update(minVal, maxVal) - return y -} - -// Update updates the stored minVal and maxVal. -func (y *Y) Update(minVal, maxVal float64) { - y.min, y.max = NewValue(minVal, nonZeroDecimals), NewValue(maxVal, nonZeroDecimals) -} - -// RequiredWidth calculates the minimum width required in order to draw the Y axis. -func (y *Y) RequiredWidth() int { - // This is an estimation only, it is possible that more labels in the - // middle will be generated and might be wider than this. Such cases are - // handled on the call to Details when the size of canvas is known. - return widestLabel([]*Label{ - {Value: y.min}, - {Value: y.max}, - }) + yAxisWidth -} - -// Details retrieves details about the Y axis required to draw it on a canvas -// of the provided area. -func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) { - cvsWidth := cvsAr.Dx() - cvsHeight := cvsAr.Dy() - maxWidth := cvsWidth - 1 // Reserve one row for the line chart itself. - if req := y.RequiredWidth(); maxWidth < req { - return nil, fmt.Errorf("the received maxWidth %d is smaller than the reported required width %d", maxWidth, req) - } - - graphHeight := cvsHeight - 2 // One row for the X axis and one for its labels. - scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals, mode) - if err != nil { - return nil, err - } - - // See how the labels would look like on the entire maxWidth. - maxLabelWidth := maxWidth - yAxisWidth - labels, err := yLabels(scale, maxLabelWidth) - if err != nil { - return nil, err - } - - var width int - // Determine the largest label, which might be less than maxWidth. - // Such case would allow us to save more space for the line chart itself. - widest := widestLabel(labels) - if widest < maxLabelWidth { - // Save the space and recalculate the labels, since they need to be realigned. - l, err := yLabels(scale, widest) - if err != nil { - return nil, err - } - labels = l - width = widest + yAxisWidth // One for the axis itself. - } else { - width = maxWidth - } - - return &YDetails{ - Width: width, - Start: image.Point{width - 1, 0}, - End: image.Point{width - 1, graphHeight}, - Scale: scale, - Labels: labels, - }, nil -} - -// widestLabel returns the width of the widest label. -func widestLabel(labels []*Label) int { - var widest int - for _, label := range labels { - if l := len(label.Value.Text()); l > widest { - widest = l - } - } - return widest -} - -// XDetails contain information about the X axis that will be drawn onto the -// canvas. -type XDetails struct { - // Start is the point where the X axis starts. - // Both coordinates of Start are less than End. - Start image.Point - // End is the point where the X axis ends. - End image.Point - - // Scale is the scale of the X axis. - Scale *XScale - - // Labels are the labels for values on the X axis in an increasing order. - Labels []*Label -} - -// NewXDetails retrieves details about the X axis required to draw it on a canvas -// of the provided area. The yStart is the point where the Y axis starts. -// The numPoints is the number of points in the largest series that will be -// plotted. -// customLabels are the desired labels for the X axis, these are preferred if -// provided. -func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, customLabels map[int]string) (*XDetails, error) { - if min := 3; cvsAr.Dy() < min { - return nil, fmt.Errorf("the canvas isn't tall enough to accommodate the X axis, its labels and the line chart, got height %d, minimum is %d", cvsAr.Dy(), min) - } - - // The space between the start of the axis and the end of the canvas. - graphWidth := cvsAr.Dx() - yStart.X - 1 - scale, err := NewXScale(numPoints, graphWidth, nonZeroDecimals) - if err != nil { - return nil, err - } - - // One point horizontally for the Y axis. - // Two points vertically, one for the X axis and one for its labels. - graphZero := image.Point{yStart.X + 1, cvsAr.Dy() - 3} - labels, err := xLabels(scale, graphZero, customLabels) - if err != nil { - return nil, err - } - return &XDetails{ - Start: image.Point{yStart.X, cvsAr.Dy() - 2}, // One row for the labels. - End: image.Point{yStart.X + graphWidth, cvsAr.Dy() - 2}, - Scale: scale, - Labels: labels, - }, nil -} diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go deleted file mode 100644 index e79b47c..0000000 --- a/widgets/linechart/axes/axes_test.go +++ /dev/null @@ -1,263 +0,0 @@ -// 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 axes - -import ( - "image" - "testing" - - "github.com/kylelemons/godebug/pretty" -) - -type updateY struct { - minVal float64 - maxVal float64 -} - -func TestY(t *testing.T) { - tests := []struct { - desc string - minVal float64 - maxVal float64 - update *updateY - mode YScaleMode - cvsAr image.Rectangle - wantWidth int - want *YDetails - wantErr bool - }{ - { - desc: "fails on canvas too small", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 3, 2), - wantWidth: 2, - wantErr: true, - }, - { - desc: "fails on cvsWidth less than required width", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 2, 4), - wantWidth: 2, - wantErr: true, - }, - { - desc: "fails when max is less than min", - minVal: 0, - maxVal: -1, - cvsAr: image.Rect(0, 0, 4, 4), - wantWidth: 3, - wantErr: true, - }, - { - desc: "cvsWidth equals required width", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 3, 4), - wantWidth: 2, - want: &YDetails{ - Width: 2, - Start: image.Point{1, 0}, - End: image.Point{1, 2}, - Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), - Labels: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, - {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, - }, - }, - }, - { - desc: "success for anchored scale", - minVal: 1, - maxVal: 3, - mode: YScaleModeAnchored, - cvsAr: image.Rect(0, 0, 3, 4), - wantWidth: 2, - want: &YDetails{ - Width: 2, - Start: image.Point{1, 0}, - End: image.Point{1, 2}, - Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), - Labels: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, - {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, - }, - }, - }, - { - desc: "success for adaptive scale", - minVal: 1, - maxVal: 6, - mode: YScaleModeAdaptive, - cvsAr: image.Rect(0, 0, 3, 4), - wantWidth: 2, - want: &YDetails{ - Width: 2, - Start: image.Point{1, 0}, - End: image.Point{1, 2}, - Scale: mustNewYScale(1, 6, 2, nonZeroDecimals, YScaleModeAdaptive), - Labels: []*Label{ - {NewValue(1, nonZeroDecimals), image.Point{0, 1}}, - {NewValue(3.88, nonZeroDecimals), image.Point{0, 0}}, - }, - }, - }, - { - desc: "cvsWidth just accommodates the longest label", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 6, 4), - wantWidth: 2, - want: &YDetails{ - Width: 5, - Start: image.Point{4, 0}, - End: image.Point{4, 2}, - Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), - Labels: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{3, 1}}, - {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, - }, - }, - }, - { - desc: "cvsWidth is more than we need", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 7, 4), - wantWidth: 2, - want: &YDetails{ - Width: 5, - Start: image.Point{4, 0}, - End: image.Point{4, 2}, - Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), - Labels: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{3, 1}}, - {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, - }, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.desc, func(t *testing.T) { - y := NewY(tc.minVal, tc.maxVal) - if tc.update != nil { - y.Update(tc.update.minVal, tc.update.maxVal) - } - - gotWidth := y.RequiredWidth() - if gotWidth != tc.wantWidth { - t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth) - } - - got, err := y.Details(tc.cvsAr, tc.mode) - if (err != nil) != tc.wantErr { - t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr) - } - if err != nil { - return - } - if diff := pretty.Compare(tc.want, got); diff != "" { - t.Errorf("Details => unexpected diff (-want, +got):\n%s", diff) - } - }) - } -} - -func TestNewXDetails(t *testing.T) { - tests := []struct { - desc string - numPoints int - yStart image.Point - cvsWidth int - cvsAr image.Rectangle - customLabels map[int]string - want *XDetails - wantErr bool - }{ - { - desc: "fails when numPoints is negative", - numPoints: -1, - yStart: image.Point{0, 0}, - cvsAr: image.Rect(0, 0, 2, 3), - wantErr: true, - }, - { - desc: "fails when cvsAr isn't wide enough", - numPoints: 1, - yStart: image.Point{0, 0}, - cvsAr: image.Rect(0, 0, 1, 3), - wantErr: true, - }, - { - desc: "fails when cvsAr isn't tall enough", - numPoints: 1, - yStart: image.Point{0, 0}, - cvsAr: image.Rect(0, 0, 3, 2), - wantErr: true, - }, - { - desc: "works with no data points", - numPoints: 0, - yStart: image.Point{0, 0}, - cvsAr: image.Rect(0, 0, 2, 3), - want: &XDetails{ - Start: image.Point{0, 1}, - End: image.Point{1, 1}, - Scale: mustNewXScale(0, 1, nonZeroDecimals), - Labels: []*Label{ - { - Value: NewValue(0, nonZeroDecimals), - Pos: image.Point{1, 2}, - }, - }, - }, - }, - { - desc: "accounts for non-zero yStart", - numPoints: 0, - yStart: image.Point{2, 0}, - cvsAr: image.Rect(0, 0, 4, 5), - want: &XDetails{ - Start: image.Point{2, 3}, - End: image.Point{3, 3}, - Scale: mustNewXScale(0, 1, nonZeroDecimals), - Labels: []*Label{ - { - Value: NewValue(0, nonZeroDecimals), - Pos: image.Point{3, 4}, - }, - }, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.desc, func(t *testing.T) { - got, err := NewXDetails(tc.numPoints, tc.yStart, tc.cvsAr, tc.customLabels) - if (err != nil) != tc.wantErr { - t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, tc.wantErr) - } - if err != nil { - return - } - - if diff := pretty.Compare(tc.want, got); diff != "" { - t.Errorf("NewXDetails => unexpected diff (-want, +got):\n%s", diff) - } - }) - } -} diff --git a/widgets/linechart/internal/README.md b/widgets/linechart/internal/README.md new file mode 100644 index 0000000..a989273 --- /dev/null +++ b/widgets/linechart/internal/README.md @@ -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. diff --git a/widgets/linechart/internal/axes/axes.go b/widgets/linechart/internal/axes/axes.go new file mode 100644 index 0000000..01ee686 --- /dev/null +++ b/widgets/linechart/internal/axes/axes.go @@ -0,0 +1,236 @@ +// 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 axes calculates the required layout and draws the X and Y axes of a line chart. +package axes + +import ( + "fmt" + "image" +) + +const ( + // nonZeroDecimals determines the overall precision of values displayed on the + // graph, it indicates the number of non-zero decimal places the values will be + // rounded up to. + nonZeroDecimals = 2 + + // axisWidth is width of an axis. + axisWidth = 1 +) + +// YDetails contain information about the Y axis that will be drawn onto the +// canvas. +type YDetails struct { + // Width in character cells of the Y axis and its character labels. + Width int + + // Start is the point where the Y axis starts. + // The Y coordinate of Start is less than the Y coordinate of End. + Start image.Point + // End is the point where the Y axis ends. + End image.Point + + // Scale is the scale of the Y axis. + Scale *YScale + + // Labels are the labels for values on the Y axis in an increasing order. + Labels []*Label +} + +// RequiredWidth calculates the minimum width required in order to draw the Y +// axis and its labels when displaying values that have this minimum and +// maximum among all the series. +func RequiredWidth(minVal, maxVal float64) int { + // This is an estimation only, it is possible that more labels in the + // middle will be generated and might be wider than this. Such cases are + // handled on the call to Details when the size of canvas is known. + return longestLabel([]*Label{ + {Value: NewValue(minVal, nonZeroDecimals)}, + {Value: NewValue(maxVal, nonZeroDecimals)}, + }) + axisWidth +} + +// YProperties are the properties of the Y axis. +type YProperties struct { + // Min is the minimum value on the axis. + Min float64 + // Max is the maximum value on the axis. + Max float64 + // ReqXHeight is the height required for the X axis and its labels. + ReqXHeight int + // ScaleMode determines how the Y axis scales. + ScaleMode YScaleMode +} + +// NewYDetails retrieves details about the Y axis required to draw it on a +// canvas of the provided area. +func NewYDetails(cvsAr image.Rectangle, yp *YProperties) (*YDetails, error) { + cvsWidth := cvsAr.Dx() + cvsHeight := cvsAr.Dy() + maxWidth := cvsWidth - 1 // Reserve one column for the line chart itself. + if req := RequiredWidth(yp.Min, yp.Max); maxWidth < req { + return nil, fmt.Errorf("the available maxWidth %d is smaller than the reported required width %d", maxWidth, req) + } + + graphHeight := cvsHeight - yp.ReqXHeight + scale, err := NewYScale(yp.Min, yp.Max, graphHeight, nonZeroDecimals, yp.ScaleMode) + if err != nil { + return nil, err + } + + // See how the labels would look like on the entire maxWidth. + maxLabelWidth := maxWidth - axisWidth + labels, err := yLabels(scale, maxLabelWidth) + if err != nil { + return nil, err + } + + var width int + // Determine the largest label, which might be less than maxWidth. + // Such case would allow us to save more space for the line chart itself. + widest := longestLabel(labels) + if widest < maxLabelWidth { + // Save the space and recalculate the labels, since they need to be realigned. + l, err := yLabels(scale, widest) + if err != nil { + return nil, err + } + labels = l + width = widest + axisWidth // One for the axis itself. + } else { + width = maxWidth + } + + return &YDetails{ + Width: width, + Start: image.Point{width - 1, 0}, + End: image.Point{width - 1, graphHeight}, + Scale: scale, + Labels: labels, + }, nil +} + +// longestLabel returns the width of the widest label. +func longestLabel(labels []*Label) int { + var widest int + for _, label := range labels { + if l := len(label.Value.Text()); l > widest { + widest = l + } + } + return widest +} + +// XDetails contain information about the X axis that will be drawn onto the +// canvas. +type XDetails struct { + // Start is the point where the X axis starts. + // Both coordinates of Start are less than End. + Start image.Point + // End is the point where the X axis ends. + End image.Point + + // Scale is the scale of the X axis. + Scale *XScale + + // Labels are the labels for values on the X axis in an increasing order. + Labels []*Label + + // Properties are the properties that were used on the call to NewXDetails. + Properties *XProperties +} + +// String implements fmt.Stringer. +func (xd *XDetails) String() string { + return fmt.Sprintf("XDetails{Scale:%v}", xd.Scale) +} + +// XProperties are the properties of the X axis. +type XProperties struct { + // Min is the minimum value on the axis, i.e. the position of the first + // displayed value from the series. + Min int + // Max is the maximum value on the axis, i.e. the position of the last + // displayed value from the series. + Max int + // ReqYWidth is the width required for the Y axis and its labels. + ReqYWidth int + // CustomLabels are the desired labels for the X axis, these are preferred + // if provided. + CustomLabels map[int]string + // LO is the desired orientation of labels under the X axis. + LO LabelOrientation +} + +// NewXDetails retrieves details about the X axis required to draw it on a canvas +// of the provided area. The yStart is the point where the Y axis starts. +// The numPoints is the number of points in the largest series that will be +// plotted. +// customLabels are the desired labels for the X axis, these are preferred if +// provided. +func NewXDetails(cvsAr image.Rectangle, xp *XProperties) (*XDetails, error) { + cvsHeight := cvsAr.Dy() + maxHeight := cvsHeight - 1 // Reserve one row for the line chart itself. + reqHeight := RequiredHeight(xp.Max, xp.CustomLabels, xp.LO) + if maxHeight < reqHeight { + return nil, fmt.Errorf("the available maxHeight %d is smaller than the reported required height %d", maxHeight, reqHeight) + } + + // The space between the start of the axis and the end of the canvas. + graphWidth := cvsAr.Dx() - xp.ReqYWidth - 1 + scale, err := NewXScale(xp.Min, xp.Max, graphWidth, nonZeroDecimals) + if err != nil { + return nil, err + } + + // See how the labels would look like on the entire reqHeight. + graphZero := image.Point{ + // Reserve one point horizontally for the Y axis. + xp.ReqYWidth + 1, + cvsAr.Dy() - reqHeight - 1, + } + labels, err := xLabels(scale, graphZero, xp.CustomLabels, xp.LO) + if err != nil { + return nil, err + } + + return &XDetails{ + Start: image.Point{xp.ReqYWidth, cvsAr.Dy() - reqHeight}, // Space for the labels. + End: image.Point{xp.ReqYWidth + graphWidth, cvsAr.Dy() - reqHeight}, + Scale: scale, + Labels: labels, + Properties: xp, + }, nil +} + +// RequiredHeight calculates the minimum height required in order to draw the X +// axis and its labels. +func RequiredHeight(max int, customLabels map[int]string, lo LabelOrientation) int { + if lo == LabelOrientationHorizontal { + // One row for the X axis and one row for its labels flowing + // horizontally. + return axisWidth + 1 + } + + labels := []*Label{ + {Value: NewValue(float64(max), nonZeroDecimals)}, + } + for _, cl := range customLabels { + labels = append(labels, &Label{ + Value: NewTextValue(cl), + }) + } + return longestLabel(labels) + axisWidth +} diff --git a/widgets/linechart/internal/axes/axes_test.go b/widgets/linechart/internal/axes/axes_test.go new file mode 100644 index 0000000..87f83c4 --- /dev/null +++ b/widgets/linechart/internal/axes/axes_test.go @@ -0,0 +1,502 @@ +// 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 axes + +import ( + "fmt" + "image" + "testing" + + "github.com/kylelemons/godebug/pretty" +) + +type updateY struct { + minVal float64 + maxVal float64 +} + +func TestY(t *testing.T) { + tests := []struct { + desc string + yp *YProperties + cvsAr image.Rectangle + wantWidth int + want *YDetails + wantErr bool + }{ + { + desc: "fails on canvas too small", + yp: &YProperties{ + Min: 0, + Max: 3, + ReqXHeight: 2, + }, + cvsAr: image.Rect(0, 0, 3, 2), + wantWidth: 2, + wantErr: true, + }, + { + desc: "fails on cvsWidth less than required width", + yp: &YProperties{ + Min: 0, + Max: 3, + ReqXHeight: 2, + }, + cvsAr: image.Rect(0, 0, 2, 4), + wantWidth: 2, + wantErr: true, + }, + { + desc: "fails when max is less than min", + yp: &YProperties{ + Min: 0, + Max: -1, + ReqXHeight: 2, + }, + cvsAr: image.Rect(0, 0, 4, 4), + wantWidth: 3, + wantErr: true, + }, + { + desc: "cvsWidth equals required width", + yp: &YProperties{ + Min: 0, + Max: 3, + ReqXHeight: 2, + }, + cvsAr: image.Rect(0, 0, 3, 4), + wantWidth: 2, + want: &YDetails{ + Width: 2, + Start: image.Point{1, 0}, + End: image.Point{1, 2}, + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), + Labels: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + { + desc: "success for anchored scale", + yp: &YProperties{ + Min: 1, + Max: 3, + ReqXHeight: 2, + ScaleMode: YScaleModeAnchored, + }, + cvsAr: image.Rect(0, 0, 3, 4), + wantWidth: 2, + want: &YDetails{ + Width: 2, + Start: image.Point{1, 0}, + End: image.Point{1, 2}, + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), + Labels: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + { + desc: "accommodates X scale that needs more height", + yp: &YProperties{ + Min: 1, + Max: 3, + ReqXHeight: 4, + ScaleMode: YScaleModeAnchored, + }, + cvsAr: image.Rect(0, 0, 3, 6), + wantWidth: 2, + want: &YDetails{ + Width: 2, + Start: image.Point{1, 0}, + End: image.Point{1, 2}, + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), + Labels: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + { + desc: "success for adaptive scale", + yp: &YProperties{ + Min: 1, + Max: 6, + ReqXHeight: 2, + ScaleMode: YScaleModeAdaptive, + }, + cvsAr: image.Rect(0, 0, 3, 4), + wantWidth: 2, + want: &YDetails{ + Width: 2, + Start: image.Point{1, 0}, + End: image.Point{1, 2}, + Scale: mustNewYScale(1, 6, 2, nonZeroDecimals, YScaleModeAdaptive), + Labels: []*Label{ + {NewValue(1, nonZeroDecimals), image.Point{0, 1}}, + {NewValue(3.88, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + { + desc: "cvsWidth just accommodates the longest label", + yp: &YProperties{ + Min: 0, + Max: 3, + ReqXHeight: 2, + }, + cvsAr: image.Rect(0, 0, 6, 4), + wantWidth: 2, + want: &YDetails{ + Width: 5, + Start: image.Point{4, 0}, + End: image.Point{4, 2}, + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), + Labels: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{3, 1}}, + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + { + desc: "cvsWidth is more than we need", + yp: &YProperties{ + Min: 0, + Max: 3, + ReqXHeight: 2, + }, + cvsAr: image.Rect(0, 0, 7, 4), + wantWidth: 2, + want: &YDetails{ + Width: 5, + Start: image.Point{4, 0}, + End: image.Point{4, 2}, + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), + Labels: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{3, 1}}, + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotWidth := RequiredWidth(tc.yp.Min, tc.yp.Max) + if gotWidth != tc.wantWidth { + t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth) + } + + got, err := NewYDetails(tc.cvsAr, tc.yp) + if (err != nil) != tc.wantErr { + t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("Details => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestNewXDetails(t *testing.T) { + tests := []struct { + desc string + xp *XProperties + cvsAr image.Rectangle + want *XDetails + wantErr bool + }{ + { + desc: "fails when min is negative", + xp: &XProperties{ + Min: -1, + Max: 0, + ReqYWidth: 0, + }, + cvsAr: image.Rect(0, 0, 2, 3), + wantErr: true, + }, + { + desc: "fails when cvsAr isn't wide enough", + xp: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + }, + cvsAr: image.Rect(0, 0, 1, 3), + wantErr: true, + }, + { + desc: "fails when cvsAr isn't tall enough", + xp: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + }, + cvsAr: image.Rect(0, 0, 3, 2), + wantErr: true, + }, + { + desc: "works with no data points", + xp: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + }, + cvsAr: image.Rect(0, 0, 2, 3), + want: &XDetails{ + Start: image.Point{0, 1}, + End: image.Point{1, 1}, + Scale: mustNewXScale(0, 0, 1, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{1, 2}, + }, + }, + Properties: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + }, + }, + }, + { + desc: "works with no data points, vertical", + xp: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + LO: LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 2, 3), + want: &XDetails{ + Start: image.Point{0, 1}, + End: image.Point{1, 1}, + Scale: mustNewXScale(0, 0, 1, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{1, 2}, + }, + }, + Properties: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + LO: LabelOrientationVertical, + }, + }, + }, + { + desc: "accounts for non-zero yStart", + xp: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 4, 5), + want: &XDetails{ + Start: image.Point{2, 3}, + End: image.Point{3, 3}, + Scale: mustNewXScale(0, 0, 1, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{3, 4}, + }, + }, + Properties: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }, + }, + }, + { + desc: "accounts for longer vertical labels, the tallest didn't fit", + xp: &XProperties{ + Min: 0, + Max: 1000, + ReqYWidth: 2, + LO: LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 10, 10), + want: &XDetails{ + Start: image.Point{2, 5}, + End: image.Point{9, 5}, + Scale: mustNewXScale(0, 1000, 7, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{3, 6}, + }, + { + Value: NewValue(615, nonZeroDecimals), + Pos: image.Point{7, 6}, + }, + }, + Properties: &XProperties{ + Min: 0, + Max: 1000, + ReqYWidth: 2, + LO: LabelOrientationVertical, + }, + }, + }, + { + desc: "accounts for longer vertical labels, the tallest label fits", + xp: &XProperties{ + Min: 0, + Max: 999, + ReqYWidth: 2, + LO: LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 10, 10), + want: &XDetails{ + Start: image.Point{2, 6}, + End: image.Point{9, 6}, + Scale: mustNewXScale(0, 999, 7, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{3, 7}, + }, + { + Value: NewValue(615, nonZeroDecimals), + Pos: image.Point{7, 7}, + }, + }, + Properties: &XProperties{ + Min: 0, + Max: 999, + ReqYWidth: 2, + LO: LabelOrientationVertical, + }, + }, + }, + { + desc: "accounts for longer custom labels, vertical", + xp: &XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 5, + CustomLabels: map[int]string{ + 0: "start", + 1: "end", + }, + LO: LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 20, 10), + want: &XDetails{ + Start: image.Point{5, 4}, + End: image.Point{19, 4}, + Scale: mustNewXScale(0, 1, 14, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewTextValue("start"), + Pos: image.Point{6, 5}, + }, + { + Value: NewTextValue("end"), + Pos: image.Point{19, 5}, + }, + }, + Properties: &XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 5, + CustomLabels: map[int]string{ + 0: "start", + 1: "end", + }, + LO: LabelOrientationVertical, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := NewXDetails(tc.cvsAr, tc.xp) + if (err != nil) != tc.wantErr { + t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + t.Log(fmt.Sprintf("got: %v", got)) + + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("NewXDetails => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestRequiredHeight(t *testing.T) { + tests := []struct { + desc string + max int + customLabels map[int]string + labelOrientation LabelOrientation + want int + }{ + { + desc: "horizontal orientation", + want: 2, + }, + { + desc: "vertical orientation, no custom labels, need single row for max label", + max: 8, + labelOrientation: LabelOrientationVertical, + want: 2, + }, + { + desc: "vertical orientation, no custom labels, need multiple rows for max label", + max: 100, + labelOrientation: LabelOrientationVertical, + want: 4, + }, + { + desc: "vertical orientation, custom labels but all shorter than max label", + max: 100, + customLabels: map[int]string{1: "a", 2: "b"}, + labelOrientation: LabelOrientationVertical, + want: 4, + }, + { + desc: "vertical orientation, custom labels and some longer than max label", + max: 99, + customLabels: map[int]string{1: "a", 2: "bbbbb"}, + labelOrientation: LabelOrientationVertical, + want: 6, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := RequiredHeight(tc.max, tc.customLabels, tc.labelOrientation) + if got != tc.want { + t.Errorf("RequiredHeight => %d, want %d", got, tc.want) + } + }) + } +} diff --git a/widgets/linechart/axes/label.go b/widgets/linechart/internal/axes/label.go similarity index 85% rename from widgets/linechart/axes/label.go rename to widgets/linechart/internal/axes/label.go index ce2a22b..28344fb 100644 --- a/widgets/linechart/axes/label.go +++ b/widgets/linechart/internal/axes/label.go @@ -20,7 +20,33 @@ import ( "fmt" "image" - "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/internal/align" +) + +// LabelOrientation represents the orientation of text labels. +type LabelOrientation int + +// String implements fmt.Stringer() +func (lo LabelOrientation) String() string { + if n, ok := labelOrientationNames[lo]; ok { + return n + } + return "LabelOrientationUnknown" +} + +// labelOrientationNames maps LabelOrientation values to human readable names. +var labelOrientationNames = map[LabelOrientation]string{ + LabelOrientationHorizontal: "LabelOrientationHorizontal", + LabelOrientationVertical: "LabelOrientationVertical", +} + +const ( + // LabelOrientationHorizontal is the default label orientation where text + // flows horizontally. + LabelOrientationHorizontal LabelOrientation = iota + + // LabelOrientationVertical is an orientation where text flows vertically. + LabelOrientationVertical ) // Label is one value label on an axis. @@ -162,14 +188,14 @@ func (xs *xSpace) Sub(size int) error { // fit under the width of the axis. // The customLabels map value positions in the series to the desired custom // label. These are preferred if present. -func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string) ([]*Label, error) { +func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string, lo LabelOrientation) ([]*Label, error) { space := newXSpace(graphZero, scale.GraphWidth) const minSpacing = 3 var res []*Label - next := 0 + next := int(scale.Min.Value) for haveLabels := 0; haveLabels <= int(scale.Max.Value); haveLabels = len(res) { - label, err := colLabel(scale, space, customLabels) + label, err := colLabel(scale, space, customLabels, lo) if err != nil { return nil, err } @@ -205,7 +231,7 @@ func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string) // colLabel returns a label placed at the beginning of the space. // The space is adjusted according to how much space was taken by the label. // Returns nil, nil if the label doesn't fit in the space. -func colLabel(scale *XScale, space *xSpace, customLabels map[int]string) (*Label, error) { +func colLabel(scale *XScale, space *xSpace, customLabels map[int]string, lo LabelOrientation) (*Label, error) { pos := space.Relative() label, err := scale.CellLabel(pos.X) if err != nil { @@ -216,7 +242,13 @@ func colLabel(scale *XScale, space *xSpace, customLabels map[int]string) (*Label label = NewTextValue(custom) } - labelLen := len(label.Text()) + var labelLen int + switch lo { + case LabelOrientationHorizontal: + labelLen = len(label.Text()) + case LabelOrientationVertical: + labelLen = 1 + } if labelLen > space.Remaining() { return nil, nil } diff --git a/widgets/linechart/axes/label_test.go b/widgets/linechart/internal/axes/label_test.go similarity index 59% rename from widgets/linechart/axes/label_test.go rename to widgets/linechart/internal/axes/label_test.go index d1fc746..1216efe 100644 --- a/widgets/linechart/axes/label_test.go +++ b/widgets/linechart/internal/axes/label_test.go @@ -151,26 +151,41 @@ func TestYLabels(t *testing.T) { func TestXLabels(t *testing.T) { const nonZeroDecimals = 2 tests := []struct { - desc string - numPoints int - graphWidth int - graphZero image.Point - customLabels map[int]string - want []*Label - wantErr bool + desc string + min int + max int + graphWidth int + graphZero image.Point + customLabels map[int]string + labelOrientation LabelOrientation + want []*Label + wantErr bool }{ { desc: "only one point", - numPoints: 1, + min: 0, + max: 0, graphWidth: 1, graphZero: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, }, }, + { + desc: "only one point, vertical", + min: 0, + max: 0, + graphWidth: 1, + graphZero: image.Point{0, 1}, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + }, + }, { desc: "two points, only one label fits", - numPoints: 2, + min: 0, + max: 1, graphWidth: 1, graphZero: image.Point{0, 1}, want: []*Label{ @@ -179,7 +194,8 @@ func TestXLabels(t *testing.T) { }, { desc: "two points, two labels fit exactly", - numPoints: 2, + min: 0, + max: 1, graphWidth: 5, graphZero: image.Point{0, 1}, want: []*Label{ @@ -187,9 +203,22 @@ func TestXLabels(t *testing.T) { {NewValue(1, nonZeroDecimals), image.Point{4, 3}}, }, }, + { + desc: "two points, two labels fit exactly, vertical", + min: 0, + max: 1, + graphWidth: 5, + graphZero: image.Point{0, 1}, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(1, nonZeroDecimals), image.Point{4, 3}}, + }, + }, { desc: "labels are placed according to graphZero", - numPoints: 2, + min: 0, + max: 1, graphWidth: 5, graphZero: image.Point{3, 5}, want: []*Label{ @@ -199,7 +228,8 @@ func TestXLabels(t *testing.T) { }, { desc: "skip to next value exhausts the space completely", - numPoints: 11, + min: 0, + max: 10, graphWidth: 4, graphZero: image.Point{0, 1}, want: []*Label{ @@ -208,7 +238,8 @@ func TestXLabels(t *testing.T) { }, { desc: "second label doesn't fit due to its length", - numPoints: 100, + min: 0, + max: 100, graphWidth: 5, graphZero: image.Point{0, 1}, want: []*Label{ @@ -217,7 +248,8 @@ func TestXLabels(t *testing.T) { }, { desc: "two points, two labels, more space than minSpacing so end label adjusted", - numPoints: 2, + min: 0, + max: 1, graphWidth: 6, graphZero: image.Point{0, 1}, want: []*Label{ @@ -227,7 +259,8 @@ func TestXLabels(t *testing.T) { }, { desc: "at most as many labels as there are points", - numPoints: 2, + min: 0, + max: 1, graphWidth: 100, graphZero: image.Point{0, 1}, want: []*Label{ @@ -237,7 +270,8 @@ func TestXLabels(t *testing.T) { }, { desc: "some labels in the middle", - numPoints: 4, + min: 0, + max: 3, graphWidth: 100, graphZero: image.Point{0, 1}, want: []*Label{ @@ -247,9 +281,23 @@ func TestXLabels(t *testing.T) { {NewValue(3, nonZeroDecimals), image.Point{94, 3}}, }, }, + { + desc: "some labels in the middle, min isn't zero", + min: 1, + max: 4, + graphWidth: 100, + graphZero: image.Point{0, 1}, + want: []*Label{ + {NewValue(1, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(2, nonZeroDecimals), image.Point{31, 3}}, + {NewValue(3, nonZeroDecimals), image.Point{62, 3}}, + {NewValue(4, nonZeroDecimals), image.Point{94, 3}}, + }, + }, { desc: "custom labels provided", - numPoints: 4, + min: 0, + max: 3, graphWidth: 100, graphZero: image.Point{0, 1}, customLabels: map[int]string{ @@ -265,9 +313,29 @@ func TestXLabels(t *testing.T) { {NewTextValue("d"), image.Point{94, 3}}, }, }, + { + desc: "custom labels provided, min isn't zero", + min: 1, + max: 4, + graphWidth: 100, + graphZero: image.Point{0, 1}, + customLabels: map[int]string{ + 1: "a", + 2: "b", + 3: "c", + 4: "d", + }, + want: []*Label{ + {NewTextValue("a"), image.Point{0, 3}}, + {NewTextValue("b"), image.Point{31, 3}}, + {NewTextValue("c"), image.Point{62, 3}}, + {NewTextValue("d"), image.Point{94, 3}}, + }, + }, { desc: "custom labels provided, but only some fit, regression for #117", - numPoints: 8, + min: 0, + max: 7, graphWidth: 5, graphZero: image.Point{0, 1}, customLabels: map[int]string{ @@ -287,7 +355,8 @@ func TestXLabels(t *testing.T) { }, { desc: "only some custom labels provided", - numPoints: 4, + min: 0, + max: 3, graphWidth: 100, graphZero: image.Point{0, 1}, customLabels: map[int]string{ @@ -303,7 +372,8 @@ func TestXLabels(t *testing.T) { }, { desc: "not displayed if custom labels don't fit", - numPoints: 2, + min: 0, + max: 1, graphWidth: 6, graphZero: image.Point{0, 1}, customLabels: map[int]string{ @@ -313,7 +383,8 @@ func TestXLabels(t *testing.T) { }, { desc: "more points than pixels", - numPoints: 100, + min: 0, + max: 99, graphWidth: 6, graphZero: image.Point{0, 1}, want: []*Label{ @@ -321,16 +392,73 @@ func TestXLabels(t *testing.T) { {NewValue(72, nonZeroDecimals), image.Point{4, 3}}, }, }, + { + desc: "longer labels, only two fit in horizontal", + min: 0, + max: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(421, nonZeroDecimals), image.Point{4, 3}}, + }, + }, + { + desc: "longer labels, multiple fit in vertical", + min: 0, + max: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(421, nonZeroDecimals), image.Point{4, 3}}, + {NewValue(842, nonZeroDecimals), image.Point{8, 3}}, + }, + }, + { + desc: "longer custom labels, only one fits in horizontal", + min: 0, + max: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + customLabels: map[int]string{ + 0: "zero label", + 421: "this one is even longer", + 841: "this label just keeps on going", + }, + want: []*Label{ + {NewTextValue("zero label"), image.Point{0, 3}}, + }, + }, + { + desc: "longer custom labels, all fit in vertical", + min: 0, + max: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + customLabels: map[int]string{ + 0: "zero label", + 421: "this one is even longer", + 842: "this label just keeps on going", + }, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewTextValue("zero label"), image.Point{0, 3}}, + {NewTextValue("this one is even longer"), image.Point{4, 3}}, + {NewTextValue("this label just keeps on going"), image.Point{8, 3}}, + }, + }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - scale, err := NewXScale(tc.numPoints, tc.graphWidth, nonZeroDecimals) + scale, err := NewXScale(tc.min, tc.max, tc.graphWidth, nonZeroDecimals) if err != nil { t.Fatalf("NewXScale => unexpected error: %v", err) } - t.Logf("scale step: %v", scale.Step.Rounded) - got, err := xLabels(scale, tc.graphZero, tc.customLabels) + t.Logf("scale step: %v, label orientation: %v", scale.Step.Rounded, tc.labelOrientation) + got, err := xLabels(scale, tc.graphZero, tc.customLabels, tc.labelOrientation) if (err != nil) != tc.wantErr { t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr) } @@ -343,3 +471,62 @@ func TestXLabels(t *testing.T) { }) } } + +func TestXSpace(t *testing.T) { + tests := []struct { + desc string + graphZero image.Point + graphWidth int + sub int + wantRemaining int + wantRelative image.Point + wantErr bool + }{ + { + desc: "fails to subtract when we run out of space", + graphWidth: 1, + sub: 2, + wantErr: true, + }, + { + desc: "subtracts, graph is zero based", + graphWidth: 2, + sub: 1, + wantRemaining: 1, + wantRelative: image.Point{1, 1}, + }, + { + desc: "subtracts, graph isn't zero based", + graphZero: image.Point{10, 10}, + graphWidth: 2, + sub: 1, + wantRemaining: 1, + wantRelative: image.Point{1, 11}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + xs := newXSpace(tc.graphZero, tc.graphWidth) + t.Logf("xSpace: %v", xs) + + err := xs.Sub(tc.sub) + if (err != nil) != tc.wantErr { + t.Errorf("xSpace.Sub => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + gotRemaining := xs.Remaining() + if gotRemaining != tc.wantRemaining { + t.Errorf("xSpace.Remaining => %v, want %v", gotRemaining, tc.wantRemaining) + } + + gotRelative := xs.Relative() + if diff := pretty.Compare(tc.wantRelative, gotRelative); diff != "" { + t.Errorf("xSpace.Relative => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/widgets/linechart/axes/scale.go b/widgets/linechart/internal/axes/scale.go similarity index 87% rename from widgets/linechart/axes/scale.go rename to widgets/linechart/internal/axes/scale.go index 420fa6e..8d1c3f5 100644 --- a/widgets/linechart/axes/scale.go +++ b/widgets/linechart/internal/axes/scale.go @@ -19,8 +19,8 @@ package axes import ( "fmt" - "github.com/mum4k/termdash/canvas/braille" - "github.com/mum4k/termdash/numbers" + "github.com/mum4k/termdash/internal/canvas/braille" + "github.com/mum4k/termdash/internal/numbers" ) // YScaleMode determines whether the Y scale is anchored to the zero value. @@ -202,15 +202,18 @@ type XScale struct { brailleWidth int } -// NewXScale calculates the scale of the X axis, given the number of data -// points in the series and the width on the canvas that is available to the X -// axis. The nonZeroDecimals dictates rounding of the calculated scale, see +// NewXScale calculates the scale of the X axis, given the boundary values and +// the width on the canvas that is available to the X axis. +// The nonZeroDecimals dictates rounding of the calculated scale, see // NewValue for details. -// The numPoints must be zero or positive number. The graphWidth must be a -// positive number. -func NewXScale(numPoints int, graphWidth, nonZeroDecimals int) (*XScale, error) { - if numPoints < 0 { - return nil, fmt.Errorf("numPoints cannot be negative, got %d", numPoints) +// The boundry values must be positive or zero and must be min <= max. +// The graphWidth must be a positive number. +func NewXScale(min, max int, graphWidth, nonZeroDecimals int) (*XScale, error) { + if min < 0 || max < 0 { + return nil, fmt.Errorf("invalid min:%d or max:%d, the values must not be negative", min, max) + } + if min > max { + return nil, fmt.Errorf("invalid min:%d, max:%d, must be min <= max", min, max) } if min := 1; graphWidth < min { return nil, fmt.Errorf("graphWidth must be at least %d, got %d", min, graphWidth) @@ -219,22 +222,24 @@ func NewXScale(numPoints int, graphWidth, nonZeroDecimals int) (*XScale, error) brailleWidth := graphWidth * braille.ColMult usablePixels := brailleWidth - 1 // One pixel reserved for value zero. - const min float64 = 0 - max := float64(numPoints - 1) - if max < 0 { - max = 0 - } - diff := max - min + minVal := float64(min) + maxVal := float64(max) + diff := maxVal - minVal step := NewValue(diff/float64(usablePixels), nonZeroDecimals) return &XScale{ - Min: NewValue(min, nonZeroDecimals), - Max: NewValue(max, nonZeroDecimals), + Min: NewValue(minVal, nonZeroDecimals), + Max: NewValue(maxVal, nonZeroDecimals), Step: step, GraphWidth: graphWidth, brailleWidth: brailleWidth, }, nil } +// String implements fmt.Stringer. +func (xs *XScale) String() string { + return fmt.Sprintf("XScale{Min:%v, Max:%v, Step:%v, GraphWidth:%v}", xs.Min, xs.Max, xs.Step, xs.GraphWidth) +} + // PixelToValue given a X coordinate of the pixel, returns its value according // to the scale. The coordinate must be within bounds of the canvas width // provided to NewXScale. X coordinates grow right. @@ -249,7 +254,11 @@ func (xs *XScale) PixelToValue(x int) (float64, error) { case x == xs.brailleWidth-1: return xs.Max.Rounded, nil default: - return float64(x) * xs.Step.Rounded, nil + v := float64(x) * xs.Step.Rounded + if xs.Min.Value > 0 { + v += xs.Min.Value + } + return v, nil } } @@ -265,6 +274,9 @@ func (xs *XScale) ValueToPixel(v int) (int, error) { if xs.Step.Rounded == 0 { return 0, nil } + if xs.Min.Value > 0 { + fv -= xs.Min.Value + } return int(numbers.Round(fv / xs.Step.Rounded)), nil } diff --git a/widgets/linechart/axes/scale_test.go b/widgets/linechart/internal/axes/scale_test.go similarity index 90% rename from widgets/linechart/axes/scale_test.go rename to widgets/linechart/internal/axes/scale_test.go index 51d157b..fabe84a 100644 --- a/widgets/linechart/axes/scale_test.go +++ b/widgets/linechart/internal/axes/scale_test.go @@ -31,8 +31,8 @@ func mustNewYScale(min, max float64, graphHeight, nonZeroDecimals int, mode YSca } // mustNewXScale returns a new XScale or panics. -func mustNewXScale(numPoints int, graphWidth, nonZeroDecimals int) *XScale { - s, err := NewXScale(numPoints, graphWidth, nonZeroDecimals) +func mustNewXScale(min, max int, graphWidth, nonZeroDecimals int) *XScale { + s, err := NewXScale(min, max, graphWidth, nonZeroDecimals) if err != nil { panic(err) } @@ -824,7 +824,8 @@ func TestYScale(t *testing.T) { func TestXScale(t *testing.T) { tests := []struct { desc string - numPoints int + min int + max int graphWidth int nonZeroDecimals int pixelToValueTests []pixelToValueTest @@ -834,20 +835,35 @@ func TestXScale(t *testing.T) { wantErr bool }{ { - desc: "fails when numPoints negative", - numPoints: -1, + desc: "fails when min is negative", + min: -1, + graphWidth: 1, + wantErr: true, + }, + { + desc: "fails when max is negative", + max: -1, + graphWidth: 1, + wantErr: true, + }, + { + desc: "fails when min > max", + min: 1, + max: 0, graphWidth: 1, wantErr: true, }, { desc: "fails when graphWidth zero", - numPoints: 1, + min: 0, + max: 0, graphWidth: 0, wantErr: true, }, { desc: "fails on negative pixel", - numPoints: 1, + min: 0, + max: 0, graphWidth: 1, nonZeroDecimals: 2, pixelToValueTests: []pixelToValueTest{ @@ -856,7 +872,8 @@ func TestXScale(t *testing.T) { }, { desc: "fails on pixel out of range", - numPoints: 1, + min: 0, + max: 0, graphWidth: 1, nonZeroDecimals: 2, pixelToValueTests: []pixelToValueTest{ @@ -865,7 +882,8 @@ func TestXScale(t *testing.T) { }, { desc: "fails on value or cell too small", - numPoints: 1, + min: 0, + max: 0, graphWidth: 1, nonZeroDecimals: 2, valueToPixelTests: []valueToPixelTest{ @@ -880,7 +898,8 @@ func TestXScale(t *testing.T) { }, { desc: "fails on value or cell too large", - numPoints: 1, + min: 0, + max: 0, graphWidth: 1, nonZeroDecimals: 2, valueToPixelTests: []valueToPixelTest{ @@ -895,7 +914,8 @@ func TestXScale(t *testing.T) { }, { desc: "works without data points", - numPoints: 0, + min: 0, + max: 0, graphWidth: 1, nonZeroDecimals: 2, pixelToValueTests: []pixelToValueTest{ @@ -910,7 +930,8 @@ func TestXScale(t *testing.T) { }, { desc: "integer scale, all points fit", - numPoints: 6, + min: 0, + max: 5, graphWidth: 3, nonZeroDecimals: 2, pixelToValueTests: []pixelToValueTest{ @@ -943,9 +964,48 @@ func TestXScale(t *testing.T) { {2, NewValue(4, 2), false}, }, }, + { + desc: "integer scale, min isn't zero", + min: 1, + max: 6, + graphWidth: 3, + nonZeroDecimals: 2, + pixelToValueTests: []pixelToValueTest{ + {0, 1, false}, + {1, 2, false}, + {2, 3, false}, + {3, 4, false}, + {4, 5, false}, + {5, 6, false}, + }, + valueToPixelTests: []valueToPixelTest{ + {0, 0, true}, + {1, 0, false}, + {2, 1, false}, + {3, 2, false}, + {4, 3, false}, + {5, 4, false}, + {6, 5, false}, + }, + valueToCellTests: []valueToCellTest{ + {0, 0, true}, + {1, 0, false}, + {2, 0, false}, + {3, 1, false}, + {4, 1, false}, + {5, 2, false}, + {6, 2, false}, + }, + cellLabelTests: []cellLabelTest{ + {0, NewValue(1, 2), false}, + {1, NewValue(3, 2), false}, + {2, NewValue(5, 2), false}, + }, + }, { desc: "float scale, multiple points per pixel", - numPoints: 12, + min: 0, + max: 11, graphWidth: 3, nonZeroDecimals: 2, pixelToValueTests: []pixelToValueTest{ @@ -990,9 +1050,58 @@ func TestXScale(t *testing.T) { {2, NewValue(9, 2), false}, }, }, + { + desc: "float scale, multiple points per pixel, min isn't zero", + min: 1, + max: 12, + graphWidth: 3, + nonZeroDecimals: 2, + pixelToValueTests: []pixelToValueTest{ + {0, 1, false}, + {1, 3.21, false}, + {2, 5.42, false}, + {3, 7.63, false}, + {4, 9.84, false}, + {5, 12, false}, + }, + valueToPixelTests: []valueToPixelTest{ + {0, 0, true}, + {1, 0, false}, + {2, 0, false}, + {3, 1, false}, + {4, 1, false}, + {5, 2, false}, + {6, 2, false}, + {7, 3, false}, + {8, 3, false}, + {9, 4, false}, + {10, 4, false}, + {11, 5, false}, + }, + valueToCellTests: []valueToCellTest{ + {0, 0, true}, + {1, 0, false}, + {2, 0, false}, + {3, 0, false}, + {4, 0, false}, + {5, 1, false}, + {6, 1, false}, + {7, 1, false}, + {8, 1, false}, + {9, 2, false}, + {10, 2, false}, + {11, 2, false}, + }, + cellLabelTests: []cellLabelTest{ + {0, NewValue(1, 2), false}, + {1, NewValue(5, 2), false}, + {2, NewValue(10, 2), false}, + }, + }, { desc: "float scale, multiple pixels per point", - numPoints: 2, + min: 0, + max: 1, graphWidth: 5, nonZeroDecimals: 2, pixelToValueTests: []pixelToValueTest{ @@ -1026,13 +1135,14 @@ func TestXScale(t *testing.T) { } for _, test := range tests { - scale, err := NewXScale(test.numPoints, test.graphWidth, test.nonZeroDecimals) + scale, err := NewXScale(test.min, test.max, test.graphWidth, test.nonZeroDecimals) if (err != nil) != test.wantErr { t.Errorf("NewXScale => unexpected error: %v, wantErr: %v", err, test.wantErr) } if err != nil { continue } + t.Log(fmt.Sprintf("scale:%v", scale)) t.Run(fmt.Sprintf("PixelToValue:%s", test.desc), func(t *testing.T) { for _, tc := range test.pixelToValueTests { diff --git a/widgets/linechart/axes/value.go b/widgets/linechart/internal/axes/value.go similarity index 95% rename from widgets/linechart/axes/value.go rename to widgets/linechart/internal/axes/value.go index a0f73a8..49d9efa 100644 --- a/widgets/linechart/axes/value.go +++ b/widgets/linechart/internal/axes/value.go @@ -20,7 +20,7 @@ import ( "fmt" "math" - "github.com/mum4k/termdash/numbers" + "github.com/mum4k/termdash/internal/numbers" ) // Value represents one value. @@ -43,7 +43,7 @@ type Value struct { // String implements fmt.Stringer. func (v *Value) String() string { - return fmt.Sprintf("Value{%v, %v}", v.Value, v.Rounded) + return fmt.Sprintf("Value{Round(%v) => %v}", v.Value, v.Rounded) } // NewValue returns a new instance representing the provided value, rounding diff --git a/widgets/linechart/axes/value_test.go b/widgets/linechart/internal/axes/value_test.go similarity index 100% rename from widgets/linechart/axes/value_test.go rename to widgets/linechart/internal/axes/value_test.go diff --git a/widgets/linechart/internal/zoom/zoom.go b/widgets/linechart/internal/zoom/zoom.go new file mode 100644 index 0000000..e7b0a78 --- /dev/null +++ b/widgets/linechart/internal/zoom/zoom.go @@ -0,0 +1,565 @@ +// 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 zoom contains code that tracks the current zoom level. +package zoom + +import ( + "fmt" + "image" + "reflect" + + "github.com/mum4k/termdash/internal/mouse" + "github.com/mum4k/termdash/internal/mouse/button" + "github.com/mum4k/termdash/internal/numbers" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/widgets/linechart/internal/axes" +) + +// Option is used to provide options. +type Option interface { + // set sets the provided option. + set(*options) +} + +// options stores the provided options. +type options struct { + scrollStepPerc int +} + +// newOptions creates new options instance and applies the provided options. +func newOptions(opts ...Option) *options { + o := &options{ + scrollStepPerc: DefaultScrollStep, + } + for _, opt := range opts { + opt.set(o) + } + return o +} + +// validate validates the provided options. +func (o *options) validate() error { + if min, max := 1, 100; o.scrollStepPerc < min || o.scrollStepPerc > max { + return fmt.Errorf("invalid ScrollStep %d, must be a value in the range %d <= value <= %d", o.scrollStepPerc, min, max) + } + return nil +} + +// option implements Option. +type option func(*options) + +// set implements Option.set. +func (o option) set(opts *options) { + o(opts) +} + +// DefaultScrollStep is the default value for the ScrollStep option. +const DefaultScrollStep = 10 + +// ScrollStep sets the amount of zoom in or out on a single mouse scroll event. +// This is set as a percentage of the current value size of the X axis. +// Must be a value in range 0 < value <= 100. +// Defaults to DefaultScrollStep. +func ScrollStep(perc int) Option { + return option(func(opts *options) { + opts.scrollStepPerc = perc + }) +} + +// Tracker tracks the state of mouse selection on the linechart and stores +// requests for zoom. +// This object is not thread-safe. +type Tracker struct { + // baseX is the base X axis without any zoom applied. + baseX *axes.XDetails + // zoomX is the zoomed X axis or nil if zoom isn't applied. + zoomX *axes.XDetails + + // cvsAr is the entire canvas available to the linechart widget. + cvsAr image.Rectangle + + // graphAr is a smaller part of the cvsAr that contains the linechart + // itself. I.e. an area between the axis and the borders of cvsAr. + graphAr image.Rectangle + + // fsm is the state machine tracking the state of mouse left button. + fsm *button.FSM + + // highlight is the currently highlighted area. + highlight *Range + + // opts are the provided options. + opts *options +} + +// New returns a new zoom tracker that tracks zoom requests within +// the provided graph area. The cvsAr argument indicates size of the entire +// canvas available to the widget. +func New(baseX *axes.XDetails, cvsAr, graphAr image.Rectangle, opts ...Option) (*Tracker, error) { + o := newOptions(opts...) + if err := o.validate(); err != nil { + return nil, err + } + + t := &Tracker{ + fsm: button.NewFSM(mouse.ButtonLeft, graphAr), + highlight: &Range{}, + opts: o, + } + if err := t.Update(baseX, cvsAr, graphAr); err != nil { + return nil, err + } + return t, nil +} + +// Update is used to inform the zoom tracker about the base X axis and the +// graph area. +// Should be called each time the widget redraws. +func (t *Tracker) Update(baseX *axes.XDetails, cvsAr, graphAr image.Rectangle) error { + if !graphAr.In(cvsAr) { + return fmt.Errorf("the graphAr %v doesn't fit inside the cvsAr %v", graphAr, cvsAr) + } + // If any of these parameters changed, we need to reset the FSM and ensure + // the current zoom is still within the range of the new X axis. + ac, sc := t.axisChanged(baseX), t.sizeChanged(cvsAr, graphAr) + if sc { + t.highlight.reset() + t.fsm.UpdateArea(graphAr) + } + if ac || sc { + if t.zoomX != nil { + // Input data changed and we have an existing zoom in place. + // We need to normalize it again, since it might be outside of the + // currently visible values (e.g. if the terminal size decreased). + zoomMin := int(t.zoomX.Scale.Min.Value) + zoomMax := int(t.zoomX.Scale.Max.Value) + opt := &normalizeOptions{ + oldBaseMin: t.baseX.Scale.Min, + oldBaseMax: t.baseX.Scale.Max, + } + min, max := normalize(baseX.Scale.Min, baseX.Scale.Max, zoomMin, zoomMax, opt) + if !hasMinMax(min, max, baseX) { + zoom, err := newZoomedFromBase(min, max, baseX, cvsAr) + if err != nil { + return err + } + t.zoomX = zoom + } else { + // Fully unzoom. + t.zoomX = nil + } + } + } + + t.baseX = baseX + t.cvsAr = cvsAr + t.graphAr = graphAr + return nil +} + +// sizeChanged asserts whether the physical layout of the terminal changed. +func (t *Tracker) sizeChanged(cvsAr, graphAr image.Rectangle) bool { + return !cvsAr.Eq(t.cvsAr) || !graphAr.Eq(t.graphAr) +} + +// axisChanged asserts whether the axis scale changed. +func (t *Tracker) axisChanged(baseX *axes.XDetails) bool { + return !reflect.DeepEqual(baseX, t.baseX) +} + +// baseForZoom returns the base axis before zooming. +// This is either the base provided to New or Update if no zoom was performed +// yet, or the previously zoomed axis. +func (t *Tracker) baseForZoom() *axes.XDetails { + if t.zoomX == nil { + return t.baseX + } + return t.zoomX +} + +// Mouse is used to forward mouse events to the zoom tracker. +func (t *Tracker) Mouse(m *terminalapi.Mouse) error { + if m.Position.In(t.graphAr) { + switch m.Button { + case mouse.ButtonWheelUp, mouse.ButtonWheelDown: + zoom, err := zoomToScroll(m, t.cvsAr, t.graphAr, t.baseForZoom(), t.baseX, t.opts) + if err != nil { + return err + } + t.zoomX = zoom + } + } + + clicked, bs := t.fsm.Event(m) + switch { + case bs == button.Down: + cellX := m.Position.X - t.graphAr.Min.X + t.highlight.addX(cellX) + + case clicked && bs == button.Up: + if t.highlight.length() >= 2 { + zoom, err := zoomToHighlight(t.baseForZoom(), t.highlight, t.cvsAr) + if err != nil { + return err + } + t.zoomX = zoom + } + t.highlight.reset() + + default: + t.highlight.reset() + } + return nil +} + +// Range represents a range of values. +// The range includes all values x such that Start <= x < End. +type Range struct { + // Start is the start of the range. + Start int + // End is the end of the range. + End int + + // last is the last coordinate that was added to the range. + last int +} + +// length returns the length of the range. +func (r *Range) length() int { + return numbers.Abs(r.End - r.Start) +} + +// empty asserts if the range is empty. +func (r *Range) empty() bool { + return r.Start == r.End +} + +// reset resets the range back to zero. +func (r *Range) reset() { + r.Start, r.End, r.last = 0, 0, 0 +} + +// addX adds the provided X coordinate to the range. +func (r *Range) addX(x int) { + switch { + case r.empty(): + r.Start = x + r.End = x + 1 + + case x < r.Start: + if r.last == r.End-1 { + // Handles fast mouse move to the left across Start. + // If we don't adjust the end, we would extend both ends of the + // range. + r.End = r.Start + 1 + } + r.Start = x + + case x >= r.End: + if r.last == r.Start { + // Handles fast mouse move to the right across End. + // If we don't adjust the start, we would extend both ends of the + // range. + r.Start = r.End - 1 + } + r.End = x + 1 + + case x > r.last: + // Handles change of direction from left to right. + r.Start = x + + case x < r.last: + // Handles change of direction from right to left. + r.End = x + 1 + } + r.last = x +} + +// Highlight returns true if a range on the graph area should be highlighted +// because the user is holding down the left mouse button and dragging mouse +// across the graph area. The returned range indicates the range of X cell +// coordinates within the graph area provided to New or Update. These are the +// columns that should be highlighted. +// Returns false of no area should be highlighted, in which case the state of +// the Range return value is undefined. +func (t *Tracker) Highlight() (bool, *Range) { + if t.highlight.empty() { + return false, nil + } + return true, t.highlight +} + +// Zoom returns an adjusted X axis if zoom is applied, or the same axis as was +// provided to New or Update. +func (t *Tracker) Zoom() *axes.XDetails { + if t.zoomX == nil { + return t.baseX + } + return t.zoomX +} + +// normalizeOptions are optional parameters for zoom normalization. +type normalizeOptions struct { + // oldBaseMin is the previous minimum value before an Update was called. + oldBaseMin *axes.Value + // oldBaseMax is the previous maximum value before an Update was called. + oldBaseMax *axes.Value +} + +// rolledBy returns the number of values by which the current base axis +// provided to Update rolled as compared to the previous one. +// The axis rolls if the linechart runs with the XAxisUnscaled option and runs +// out of capacity. +// Returns zero if the axis didn't role or if the call didn't provide the old +// axis boundaries. +// Returns a positive number of the axis rolled to the left or negative if it +// rolled to the right. +// A roll by one is identified if both the minimum and the maximum changed by +// one in the same direction. +func (co *normalizeOptions) rolledBy(baseMin, baseMax *axes.Value) int { + if co == nil || co.oldBaseMin == nil || co.oldBaseMax == nil { + return 0 + } + + minDiff := int(baseMin.Value) - int(co.oldBaseMin.Value) + maxDiff := int(baseMax.Value) - int(co.oldBaseMax.Value) + if minDiff != maxDiff { + // The axis didn't roll, just the layout or values changed. + return 0 + } + return minDiff +} + +// normalize normalizes the zoom range. +// This handles cases where zoom out would happen above the base axis or +// when the base axis itself changes (user provided new values) or when the +// graph areas change (terminal size changed). +// Argument opts can be nil. +func normalize(baseMin, baseMax *axes.Value, min, max int, opts *normalizeOptions) (int, int) { + bMin := int(baseMin.Value) + bMax := int(baseMax.Value) + + if rolled := opts.rolledBy(baseMin, baseMax); rolled != 0 { + min += rolled + max += rolled + } + + var newMin, newMax int + // Don't zoom-out above or below the base axis. + switch { + case min < bMin: + newMin = bMin + case min > bMax: + newMin = bMax + default: + newMin = min + } + + switch { + case max < bMin: + newMax = bMin + case max > bMax: + newMax = bMax + default: + newMax = max + } + + if newMin > newMax { + newMin, newMax = newMax, newMin + } + + if newMin == newMax { + return findValuePair(newMin, newMax, baseMin, baseMax) + } + return newMin, newMax +} + +// newZoomedFromBase returns a new X axis zoomed to the provided min and max. +func newZoomedFromBase(min, max int, base *axes.XDetails, cvsAr image.Rectangle) (*axes.XDetails, error) { + zp := *base.Properties // Shallow copy. + zp.Min = min + zp.Max = max + + zoom, err := axes.NewXDetails(cvsAr, &zp) + if err != nil { + return nil, fmt.Errorf("failed to create zoomed X axis: %v", err) + } + return zoom, nil +} + +// findValuePair given two values on the base X axis returns the closest +// possible distinct values that are still within the range pf base X. +// Returns the min and max of the base X of no such values exist. +func findValuePair(min, max int, baseMin, baseMax *axes.Value) (int, int) { + bMin := int(baseMin.Value) + bMax := int(baseMax.Value) + + // Try above the max. + for v := max; v <= bMax; v++ { + if v > min { + return min, v + } + } + + // Try below the min. + for v := min; v >= bMin; v-- { + if v < max { + return v, max + } + } + + return bMin, bMax +} + +// findCellPair given two cells on the base X axis returns the values of the +// closest or the same cells such that the values are distinct. +// Useful while zooming, if the zoom targets a view that would only have one +// value, this function adjusts the view to the closest two cells with distinct +// values. +func findCellPair(base *axes.XDetails, minCell, maxCell int) (*axes.Value, *axes.Value, error) { + minL, err := base.Scale.CellLabel(minCell) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine min label for cell %d: %v", minCell, err) + } + maxL, err := base.Scale.CellLabel(maxCell) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine max label for cell %d: %v", maxCell, err) + } + + diff := maxL.Value - minL.Value + if diff > 1 { + return minL, maxL, nil + } + + // Try above the max. + for cellNum := maxCell; cellNum < base.Scale.GraphWidth; cellNum++ { + l, err := base.Scale.CellLabel(cellNum) + if err != nil { + return nil, nil, err + } + if l.Value > minL.Value { + return minL, l, nil + } + } + + // Try below the min. + for cellNum := minCell; cellNum >= 0; cellNum-- { + l, err := base.Scale.CellLabel(cellNum) + if err != nil { + return nil, nil, err + } + if l.Value < maxL.Value { + return l, maxL, nil + } + } + + // Give up and use the first and the last cells. + firstL, err := base.Scale.CellLabel(0) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine label for the first cell: %v", err) + } + lastL, err := base.Scale.CellLabel(base.Scale.GraphWidth - 1) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine label for the last cell: %v", err) + } + return firstL, lastL, nil +} + +// zoomToHighlight zooms the base X axis according to the highlighted range. +func zoomToHighlight(base *axes.XDetails, hr *Range, cvsAr image.Rectangle) (*axes.XDetails, error) { + minL, maxL, err := findCellPair(base, hr.Start, hr.End-1) + if err != nil { + return nil, err + } + + zoom, err := newZoomedFromBase(int(minL.Value), int(maxL.Value), base, cvsAr) + if err != nil { + return nil, err + } + return zoom, nil +} + +// hasMinMax asserts whether the provided min and max values represent the +// boundary values of the base axis. +func hasMinMax(min, max int, base *axes.XDetails) bool { + return min == int(base.Scale.Min.Value) && max == int(base.Scale.Max.Value) +} + +// zoomToScroll zooms or unzooms the current X axis in or out depending on the +// direction of the scroll. Doesn't zoom out above the base X axis view. +// Can return nil, which indicates that we are at 0% zoom (fully unzoomed). +func zoomToScroll(m *terminalapi.Mouse, cvsAr, graphAr image.Rectangle, curr, base *axes.XDetails, opts *options) (*axes.XDetails, error) { + var direction int // Positive on zoom in, negative on zoom out. + var limits *axes.XDetails // Limit values for the zooming operation. + switch m.Button { + case mouse.ButtonWheelUp: + direction = 1 + limits = curr + + case mouse.ButtonWheelDown: + direction = -1 + limits = base + } + + cellX := m.Position.X - graphAr.Min.X + tgtVal, err := curr.Scale.CellLabel(cellX) + if err != nil { + return nil, fmt.Errorf("unable to determine value at the point where scrolling occurred: %v", err) + } + + currMin := int(curr.Scale.Min.Value) + currMax := int(curr.Scale.Max.Value) + baseMin := int(base.Scale.Min.Value) + baseMax := int(base.Scale.Max.Value) + size := baseMax - baseMin + step := size * opts.scrollStepPerc / 100 + _, left := numbers.MinMaxInts([]int{ + 1, + int(tgtVal.Value) - currMin, + }) + _, right := numbers.MinMaxInts([]int{ + 1, + currMax - int(tgtVal.Value), + }) + + splitStep := numbers.SplitByRatio(step, image.Point{left, right}) + newMin := currMin + (direction * splitStep.X) + newMax := currMax - (direction * splitStep.Y) + + min, max := normalize(limits.Scale.Min, limits.Scale.Max, newMin, newMax, nil) + if m.Button == mouse.ButtonWheelDown && hasMinMax(min, max, limits) { + // Fully unzoom. + return nil, nil + } + + minCell, err := limits.Scale.ValueToCell(min) + if err != nil { + return nil, err + } + maxCell, err := limits.Scale.ValueToCell(max) + if err != nil { + return nil, err + } + minL, maxL, err := findCellPair(limits, minCell, maxCell) + if err != nil { + return nil, err + } + + zoom, err := newZoomedFromBase(int(minL.Value), int(maxL.Value), curr, cvsAr) + if err != nil { + return nil, err + } + return zoom, nil +} diff --git a/widgets/linechart/internal/zoom/zoom_test.go b/widgets/linechart/internal/zoom/zoom_test.go new file mode 100644 index 0000000..2d227a0 --- /dev/null +++ b/widgets/linechart/internal/zoom/zoom_test.go @@ -0,0 +1,2028 @@ +// 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 zoom + +import ( + "image" + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/internal/mouse" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/widgets/linechart/internal/axes" +) + +// mustNewXDetails creates the XDetails or panics. +func mustNewXDetails(cvsAr image.Rectangle, xp *axes.XProperties) *axes.XDetails { + xd, err := axes.NewXDetails(cvsAr, xp) + if err != nil { + panic(err) + } + return xd +} + +func TestTracker(t *testing.T) { + tests := []struct { + desc string + opts []Option + xp *axes.XProperties + cvsAr image.Rectangle + graphAr image.Rectangle + // mutate if not nil, can mutate the state of the tracker. + // I.e. send mouse events or update the X scale or canvas areas. + mutate func(*Tracker) error + wantHighlight bool + wantHighlightRange *Range + wantZoom *axes.XDetails + wantErr bool + wantMutateErr bool + }{ + { + desc: "New fails when graph area doesn't fall inside the canvas", + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(20, 20, 30, 30), + wantErr: true, + }, + { + desc: "New fails on ScrollStep too low", + opts: []Option{ + ScrollStep(0), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(2, 0, 10, 10), + wantErr: true, + }, + { + desc: "New fails on ScrollStep too high", + opts: []Option{ + ScrollStep(101), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(2, 0, 10, 10), + wantErr: true, + }, + { + desc: "Update fails when graph area doesn't fall inside the canvas", + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(1, 1, 9, 9), + mutate: func(tr *Tracker) error { + cvsAr := image.Rect(0, 0, 10, 10) + graphAr := image.Rect(20, 20, 30, 30) + return tr.Update(tr.baseX, cvsAr, graphAr) + }, + wantMutateErr: true, + }, + { + desc: "no highlight or zoom without mouse events", + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(3, 0, 10, 10), + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 10, 10), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights single column", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 1, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights single column in a new canvas portion after size increase, regression for #148", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 4, 4), + graphAr: image.Rect(2, 0, 4, 4), + mutate: func(tr *Tracker) error { + newX, err := axes.NewXDetails(image.Rect(0, 0, 6, 6), &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }) + if err != nil { + return err + } + + if err := tr.Update( + newX, + image.Rect(0, 0, 6, 6), + image.Rect(2, 0, 6, 6), + ); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 5}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 1, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns to the right of start", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 3, last: 2}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns to the right of start then middle", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 2, last: 1}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns to the right of start then left of start", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 2, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns to the left of start", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 3, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns to the left of start then middle", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 1, End: 3, last: 1}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns to the left of start then right", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 2, End: 4, last: 3}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple columns in the middle", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 1, End: 4, last: 3}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "does not highlight for clicks outside of graph area", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{1, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "doesn't zoom when only one column highlighted", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights and zooms into the X axis once", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights and zooms into the X axis twice", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + // Zoom into values 1-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + // Zoom into values 2-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonRelease, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + ), + }, + { + desc: "doesn't zoom below two values", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + // Zoom into values 1-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + // Zoom into values 2-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + // Doesn't zoom further. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonRelease, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + ), + }, + { + desc: "fails to zoom when X coordinate of click too high", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{7, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonRelease, + }) + }, + wantMutateErr: true, + }, + { + desc: "cancels highlight and zooms on unrelated mouse button", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonMiddle, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "cancels highlight and zooms on button release outside of the graph area", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{0, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights of single columns doesn't zoom", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights of multiple columns maximizes zoom", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "ignores scroll events outside of graph area", + opts: []Option{ + ScrollStep(30), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{1, 0}, + Button: mouse.ButtonWheelUp, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "multiple scroll ups maximize zoom", + opts: []Option{ + ScrollStep(30), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "multiple scroll downs minimize zoom", + opts: []Option{ + ScrollStep(30), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "zoom normalized when axis changed (new values)", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 8, 8), &axes.XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 8, 8), + image.Rect(2, 0, 8, 8), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }, + ), + }, + { + desc: "fully unzooms when axis changes", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 8, 8), &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 8, 8), + image.Rect(2, 0, 8, 8), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "zoom normalized when terminal size changed", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 4, 4), &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 4, 4), + image.Rect(2, 0, 4, 4), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 4, 4), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "cancels highlight when terminal size changed", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 4, 4), &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 4, 4), + image.Rect(2, 0, 4, 4), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 4, 4), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + xd, err := axes.NewXDetails(tc.cvsAr, tc.xp) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + tracker, err := New(xd, tc.cvsAr, tc.graphAr, tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if tc.mutate != nil { + err := tc.mutate(tracker) + if (err != nil) != tc.wantMutateErr { + t.Errorf("tc.mutate => unexpected error: %v, wantMutateErr: %v", err, tc.wantMutateErr) + } + if err != nil { + return + } + } + + gotHighlight, gotHightlightRange := tracker.Highlight() + if gotHighlight != tc.wantHighlight { + t.Errorf("Hightlight => %v, _, want %v, _", gotHighlight, tc.wantHighlight) + } + if diff := pretty.Compare(tc.wantHighlightRange, gotHightlightRange); diff != "" { + t.Errorf("Hightlight => unexpected range, diff (-want, +got):\n%s", diff) + } + + gotZoom := tracker.Zoom() + if diff := pretty.Compare(tc.wantZoom, gotZoom); diff != "" { + t.Errorf("Zoom => unexpected XDetails, diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestNormalize(t *testing.T) { + tests := []struct { + desc string + baseMin *axes.Value + baseMax *axes.Value + min int + max int + opts *normalizeOptions + wantMin int + wantMax int + }{ + { + desc: "min and max within the base axis", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 1, + max: 2, + wantMin: 1, + wantMax: 2, + }, + { + desc: "min and max on the edges of base", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 0, + max: 3, + wantMin: 0, + wantMax: 3, + }, + { + desc: "min and max normalized", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: -1, + max: 4, + wantMin: 0, + wantMax: 3, + }, + { + desc: "min is below base, max is the first value", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: -1, + max: 0, + wantMin: 0, + wantMax: 1, + }, + { + desc: "min is below base, max is the first value, no space on the axis", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(0, 0), + min: -1, + max: 0, + wantMin: 0, + wantMax: 0, + }, + { + desc: "max is above base, min is the last value", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 3, + max: 4, + wantMin: 2, + wantMax: 3, + }, + { + desc: "min is below base, max is the first value, no space on the axis", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(0, 0), + min: 0, + max: 1, + wantMin: 0, + wantMax: 0, + }, + { + desc: "both min and max are below base, min < max", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: -2, + max: -1, + wantMin: 0, + wantMax: 1, + }, + { + desc: "both min and max are below base, min > max", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: -1, + max: -2, + wantMin: 0, + wantMax: 1, + }, + { + desc: "both min and max are above base, min < max", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 4, + max: 5, + wantMin: 2, + wantMax: 3, + }, + { + desc: "both min and max are above base, min > max", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 5, + max: 4, + wantMin: 2, + wantMax: 3, + }, + { + desc: "both min and max are below base, base only has one value", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(0, 0), + min: -2, + max: -1, + wantMin: 0, + wantMax: 0, + }, + { + desc: "max in the middle, min above base", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(4, 0), + min: 5, + max: 3, + wantMin: 3, + wantMax: 4, + }, + { + desc: "min in the middle, max below base", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(4, 0), + min: 3, + max: -1, + wantMin: 0, + wantMax: 3, + }, + { + desc: "zoom rolls when base axis rolls to the left", + opts: &normalizeOptions{ + oldBaseMin: axes.NewValue(10, 0), + oldBaseMax: axes.NewValue(20, 0), + }, + baseMin: axes.NewValue(17, 0), + baseMax: axes.NewValue(27, 0), + min: 15, + max: 16, + wantMin: 22, + wantMax: 23, + }, + { + desc: "zoom rolls when base axis rolls to the right", + opts: &normalizeOptions{ + oldBaseMin: axes.NewValue(10, 0), + oldBaseMax: axes.NewValue(20, 0), + }, + baseMin: axes.NewValue(1, 0), + baseMax: axes.NewValue(11, 0), + min: 15, + max: 16, + wantMin: 6, + wantMax: 7, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotMin, gotMax := normalize(tc.baseMin, tc.baseMax, tc.min, tc.max, tc.opts) + if gotMin != tc.wantMin || gotMax != tc.wantMax { + t.Errorf("normalize => %v, %v, want %v, %v", gotMin, gotMax, tc.wantMin, tc.wantMax) + } + }) + } +} + +func TestNewZoomedFromBase(t *testing.T) { + tests := []struct { + desc string + min int + max int + baseP *axes.XProperties + cvsAr image.Rectangle + wantP *axes.XProperties + wantErr bool + }{ + { + desc: "returns zoomed axis", + min: 1, + max: 2, + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + CustomLabels: map[int]string{ + 1: "1", + }, + LO: axes.LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 10, 10), + wantP: &axes.XProperties{ + Min: 1, + Max: 2, + ReqYWidth: 2, + CustomLabels: map[int]string{ + 1: "1", + }, + LO: axes.LabelOrientationVertical, + }, + }, + { + desc: "fails on negative max", + min: 1, + max: -2, + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + CustomLabels: map[int]string{ + 1: "1", + }, + LO: axes.LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 10, 10), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + base, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + got, err := newZoomedFromBase(tc.min, tc.max, base, tc.cvsAr) + if (err != nil) != tc.wantErr { + t.Errorf("newZoomedFromBase => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + var want *axes.XDetails + if tc.wantP != nil { + w, err := axes.NewXDetails(tc.cvsAr, tc.wantP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + + } + want = w + } + + if diff := pretty.Compare(want, got); diff != "" { + t.Errorf("newZoomedFromBase => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestFindCellPair(t *testing.T) { + tests := []struct { + desc string + cvsAr image.Rectangle + baseP *axes.XProperties + minCell int + maxCell int + wantMin *axes.Value + wantMax *axes.Value + wantErr bool + }{ + { + desc: "fails when minCell isn't on the graph", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + minCell: -1, + maxCell: 3, + wantErr: true, + }, + { + desc: "fails when maxCell isn't on the graph", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + minCell: 0, + maxCell: 4, + wantErr: true, + }, + { + desc: "nothing to do, cells point at distinct values", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 2, + }, + minCell: 0, + maxCell: 2, + wantMin: axes.NewValue(0, 2), + wantMax: axes.NewValue(2, 2), + }, + { + desc: "cells point at the same value, distinct found above max", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 2, + }, + minCell: 1, + maxCell: 2, + wantMin: axes.NewValue(1, 2), + wantMax: axes.NewValue(2, 2), + }, + { + desc: "cells point at the same value, distinct found below min", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 2, + }, + minCell: 2, + maxCell: 2, + wantMin: axes.NewValue(1, 2), + wantMax: axes.NewValue(2, 2), + }, + { + desc: "cells point at the same value, only distinct are first and last", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 0, + }, + minCell: 1, + maxCell: 2, + wantMin: axes.NewValue(0, 2), + wantMax: axes.NewValue(0, 2), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + base, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + gotMin, gotMax, err := findCellPair(base, tc.minCell, tc.maxCell) + if (err != nil) != tc.wantErr { + t.Errorf("findCellPair => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if diff := pretty.Compare(tc.wantMin, gotMin); diff != "" { + t.Errorf("findCellPair => unexpected min, diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.wantMax, gotMax); diff != "" { + t.Errorf("findCellPair => unexpected max, diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestZoomToHighlight(t *testing.T) { + tests := []struct { + desc string + baseP *axes.XProperties + hRange *Range + cvsAr image.Rectangle + wantP *axes.XProperties + wantErr bool + }{ + { + desc: "fails on impossible range", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + hRange: &Range{Start: -1, End: 2}, + wantErr: true, + }, + { + desc: "zooms to highlighted area", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + hRange: &Range{Start: 1, End: 3}, + wantP: &axes.XProperties{ + Min: 1, + Max: 2, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + base, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + got, err := zoomToHighlight(base, tc.hRange, tc.cvsAr) + if (err != nil) != tc.wantErr { + t.Errorf("zoomToHighlight => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + var want *axes.XDetails + if tc.wantP != nil { + w, err := axes.NewXDetails(tc.cvsAr, tc.wantP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + want = w + } + if diff := pretty.Compare(want, got); diff != "" { + t.Errorf("zoomToHighlight => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestZoomToScroll(t *testing.T) { + tests := []struct { + desc string + mouse *terminalapi.Mouse + cvsAr image.Rectangle + graphAr image.Rectangle + currP *axes.XProperties + baseP *axes.XProperties + opts []Option + wantP *axes.XProperties + wantErr bool + }{ + { + desc: "scroll up in the middle zooms in evenly", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "scroll up at the left edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "scroll up at the right edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "zoom in when current is already zoomed", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "zoom in moves min over the current max", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 3, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "zoom in moves max under the current min", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down in the middle zooms out evenly", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down in the middle zooms out completely", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down at the left edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down at the right edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "zoom out moves min below base, zooms out completely", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "zoom out moves max above base, zooms out completely", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + var curr *axes.XDetails + if tc.currP != nil { + c, err := axes.NewXDetails(tc.cvsAr, tc.currP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + curr = c + } + + var base *axes.XDetails + if tc.baseP != nil { + b, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + base = b + } + + got, err := zoomToScroll(tc.mouse, tc.cvsAr, tc.graphAr, curr, base, newOptions(tc.opts...)) + if (err != nil) != tc.wantErr { + t.Errorf("zoomToScroll => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + var want *axes.XDetails + if tc.wantP != nil { + w, err := axes.NewXDetails(tc.cvsAr, tc.wantP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + want = w + } + if diff := pretty.Compare(want, got); diff != "" { + t.Errorf("zoomToHighlight => unexpected diff (-want, +got):\n%s", diff) + } + + }) + } +} diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index c800fa9..8611e93 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -22,15 +22,16 @@ import ( "sort" "sync" - "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" - "github.com/mum4k/termdash/numbers" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" - "github.com/mum4k/termdash/widgets/linechart/axes" + "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" + "github.com/mum4k/termdash/internal/numbers" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" + "github.com/mum4k/termdash/widgets/linechart/internal/axes" + "github.com/mum4k/termdash/widgets/linechart/internal/zoom" ) // seriesValues represent values stored in the series. @@ -70,33 +71,46 @@ func newSeriesValues(values []float64) *seriesValues { // The Y axis will be sized so that it can conveniently accommodate the largest // value among all the labeled line charts. This determines the used scale. // +// LineChart supports mouse based zoom, zooming is achieved by either +// highlighting an area on the graph (left mouse clicking and dragging) or by +// using the mouse scroll button. +// // Implements widgetapi.Widget. This object is thread-safe. type LineChart struct { // mu protects the LineChart widget. - mu sync.Mutex + mu sync.RWMutex // series are the series that will be plotted. // Keyed by the name of the series and updated by calling Series. series map[string]*seriesValues - // yAxis is the Y axis of the line chart. - yAxis *axes.Y + // yMin are the min and max values for the Y axis. + yMin, yMax float64 + + // capacity is the last observed value capacity in pixels when Draw was + // called. + capacity int // opts are the provided options. opts *options // xLabels that were provided on a call to Series. xLabels map[int]string + + // zoom tracks the zooming of the X axis. + zoom *zoom.Tracker } // New returns a new line chart widget. -func New(opts ...Option) *LineChart { +func New(opts ...Option) (*LineChart, error) { opt := newOptions(opts...) + if err := opt.validate(); err != nil { + return nil, err + } return &LineChart{ series: map[string]*seriesValues{}, - yAxis: axes.NewY(0, 0), opts: opt, - } + }, nil } // SeriesOption is used to provide options to Series. @@ -136,6 +150,41 @@ func SeriesXLabels(labels map[int]string) SeriesOption { }) } +// yMinMax determines the min and max values for the Y axis. +func (lc *LineChart) yMinMax() (float64, float64) { + var ( + minimums []float64 + maximums []float64 + ) + for _, sv := range lc.series { + minimums = append(minimums, sv.min) + maximums = append(maximums, sv.max) + } + + if lc.opts.yAxisCustomScale != nil { + minimums = append(minimums, lc.opts.yAxisCustomScale.min) + maximums = append(maximums, lc.opts.yAxisCustomScale.max) + } + + min, _ := numbers.MinMax(minimums) + _, max := numbers.MinMax(maximums) + return min, max +} + +// ValueCapacity returns the number of values that could be fit onto the X axis +// without a need to rescale the X axis. This is essentially the number of +// available pixels on the braille canvas based on the width of the LineChart +// as observed on the last call to draw. Returns zero if draw wasn't called. +// +// Note that this capacity changes each time the terminal resizes, so there is +// no guarantee this remains the same next time Draw is called. +// Should be used as a hint only. +func (lc *LineChart) ValueCapacity() int { + lc.mu.RLock() + defer lc.mu.RUnlock() + return lc.capacity +} + // Series sets the values that should be displayed as the line chart with the // provided label. // Subsequent calls with the same label replace any previously provided values. @@ -164,10 +213,74 @@ func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption } lc.series[label] = series - lc.yAxis = axes.NewY(series.min, series.max) + yMin, yMax := lc.yMinMax() + lc.yMin = yMin + lc.yMax = yMax return nil } +// xDetails returns the details for the X axis given the specified minimum and +// maximum value to display. +func (lc *LineChart) xDetails(cvs *canvas.Canvas, reqYWidth, min, max int) (*axes.XDetails, error) { + xp := &axes.XProperties{ + Min: min, + Max: max, + ReqYWidth: reqYWidth, + CustomLabels: lc.xLabels, + LO: lc.opts.xLabelOrientation, + } + xd, err := axes.NewXDetails(cvs.Area(), xp) + if err != nil { + return nil, fmt.Errorf("NewXDetails => %v", err) + } + return xd, nil +} + +// xDetailsForCap adjusts the X details according to the capacity of the +// braille canvas (how many values can it fit). +// If the capacity cannot accommodate all the values, the starting value of the +// X axis is adjusted so that it displays the last n values that fit. +// Returns unadjusted xd if all the values fit. +func (lc *LineChart) xDetailsForCap(cvs *canvas.Canvas, bc *braille.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) { + lc.capacity = bc.Area().Dx() + values := int(xd.Scale.Max.Value) - int(xd.Scale.Min.Value) + 1 + if !lc.opts.xAxisUnscaled || values <= lc.capacity { + return xd, nil + } + + diff := values - lc.capacity + xMin := int(xd.Scale.Min.Value) + diff + xMax := int(xd.Scale.Max.Value) + unscaledXD, err := lc.xDetails(cvs, yd.Start.X, xMin, xMax) + if err != nil { + return nil, err + } + return unscaledXD, nil +} + +// axesDetails determines the details about the X and Y axes. +func (lc *LineChart) axesDetails(cvs *canvas.Canvas) (*axes.XDetails, *axes.YDetails, error) { + reqXHeight := axes.RequiredHeight(lc.maxXValue(), lc.xLabels, lc.opts.xLabelOrientation) + yp := &axes.YProperties{ + Min: lc.yMin, + Max: lc.yMax, + ReqXHeight: reqXHeight, + ScaleMode: lc.opts.yAxisMode, + } + yd, err := axes.NewYDetails(cvs.Area(), yp) + if err != nil { + return nil, nil, fmt.Errorf("NewYDetails => %v", err) + } + + const xMin = 0 + xMax := lc.maxXValue() + xd, err := lc.xDetails(cvs, yd.Start.X, xMin, xMax) + if err != nil { + return nil, nil, err + } + return xd, yd, nil +} + // Draw draws the values as line charts. // Implements widgetapi.Widget.Draw. func (lc *LineChart) Draw(cvs *canvas.Canvas) error { @@ -182,20 +295,16 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { return draw.ResizeNeeded(cvs) } - yd, err := lc.yAxis.Details(cvs.Area(), lc.opts.yAxisMode) + xd, yd, err := lc.axesDetails(cvs) if err != nil { - return fmt.Errorf("lc.yAxis.Details => %v", err) - } - - xd, err := axes.NewXDetails(lc.maxPoints(), yd.Start, cvs.Area(), lc.xLabels) - if err != nil { - return fmt.Errorf("NewXDetails => %v", err) - } - - if err := lc.drawAxes(cvs, xd, yd); err != nil { return err } - return lc.drawSeries(cvs, xd, yd) + + adjXD, err := lc.drawSeries(cvs, xd, yd) + if err != nil { + return err + } + return lc.drawAxes(cvs, adjXD, yd) } // drawAxes draws the X,Y axes and their labels. @@ -219,22 +328,58 @@ func (lc *LineChart) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YD } for _, l := range xd.Labels { - if err := draw.Text(cvs, l.Value.Text(), l.Pos, draw.TextCellOpts(lc.opts.xLabelCellOpts...)); err != nil { - return fmt.Errorf("failed to draw the X labels: %v", err) + switch lc.opts.xLabelOrientation { + case axes.LabelOrientationHorizontal: + if err := draw.Text(cvs, l.Value.Text(), l.Pos, draw.TextCellOpts(lc.opts.xLabelCellOpts...)); err != nil { + return fmt.Errorf("failed to draw the X horizontal labels: %v", err) + } + + case axes.LabelOrientationVertical: + if err := draw.VerticalText(cvs, l.Value.Text(), l.Pos, + draw.VerticalTextCellOpts(lc.opts.xLabelCellOpts...), + draw.VerticalTextOverrunMode(draw.OverrunModeThreeDot), + ); err != nil { + return fmt.Errorf("failed to draw the vertical X labels: %v", err) + } } } return nil } +// graphAr returns the area available for the graph itself sized so that it +// fits between the axes and the canvas borders. +func (lc *LineChart) graphAr(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) image.Rectangle { + return image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y) +} + // drawSeries draws the graph representing the stored series. -func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error { - // The area available to the graph. - graphAr := image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y) +// Returns XDetails that might be adjusted to not start at zero value if some +// of the series didn't fit the graphs and XAxisUnscaled was provided. +func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) { + graphAr := lc.graphAr(cvs, xd, yd) bc, err := braille.New(graphAr) if err != nil { - return fmt.Errorf("braille.New => %v", err) + return nil, err } + xdForCap, err := lc.xDetailsForCap(cvs, bc, xd, yd) + if err != nil { + return nil, err + } + + if lc.zoom == nil { + z, err := zoom.New(xdForCap, cvs.Area(), graphAr, zoom.ScrollStep(lc.opts.zoomStepPercent)) + if err != nil { + return nil, err + } + lc.zoom = z + } else { + if err := lc.zoom.Update(xdForCap, cvs.Area(), graphAr); err != nil { + return nil, err + } + } + + xdZoomed := lc.zoom.Zoom() var names []string for name := range lc.series { names = append(names, name) @@ -243,29 +388,42 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. for _, name := range names { sv := lc.series[name] - if len(sv.values) <= 1 { + // Skip over series that don't have at least two points since we can't + // draw a line for just one point. + // Skip over series that fall under the minimum value on the X axis. + if got := len(sv.values); got <= 1 { continue } - prev := sv.values[0] + var prev float64 for i := 1; i < len(sv.values); i++ { - startX, err := xd.Scale.ValueToPixel(i - 1) - if err != nil { - return fmt.Errorf("failure for series %v[%d], xd.Scale.ValueToPixel => %v", name, i-1, err) + prev = sv.values[i-1] + if i < int(xdZoomed.Scale.Min.Value)+1 || i > int(xdZoomed.Scale.Max.Value) { + // Don't draw lines for values that aren't supposed to be visible. + // These are either values outside of the current zoom or + // values at the beginning of a series that falls before athe + // start of an unscaled X axis when the XAxisUnscaled option is + // provided. + continue } - endX, err := xd.Scale.ValueToPixel(i) + + startX, err := xdZoomed.Scale.ValueToPixel(i - 1) if err != nil { - return fmt.Errorf("failure for series %v[%d], xd.Scale.ValueToPixel => %v", name, i, err) + return nil, fmt.Errorf("failure for series %v[%d], xdZoomed.Scale.ValueToPixel => %v", name, i-1, err) + } + endX, err := xdZoomed.Scale.ValueToPixel(i) + if err != nil { + return nil, fmt.Errorf("failure for series %v[%d], xdZoomed.Scale.ValueToPixel => %v", name, i, err) } startY, err := yd.Scale.ValueToPixel(prev) if err != nil { - return fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i-1, err) + return nil, fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i-1, err) } v := sv.values[i] endY, err := yd.Scale.ValueToPixel(v) if err != nil { - return fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i, err) + return nil, fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i, err) } if err := draw.BrailleLine(bc, @@ -273,15 +431,28 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. image.Point{endX, endY}, draw.BrailleLineCellOpts(sv.seriesCellOpts...), ); err != nil { - return fmt.Errorf("draw.BrailleLine => %v", err) + return nil, fmt.Errorf("draw.BrailleLine => %v", err) } - prev = v } } - if err := bc.CopyTo(cvs); err != nil { - return fmt.Errorf("bc.Apply => %v", err) + + if highlight, hRange := lc.zoom.Highlight(); highlight { + if err := lc.highlightRange(bc, hRange); err != nil { + return nil, err + } } - return nil + + if err := bc.CopyTo(cvs); err != nil { + return nil, fmt.Errorf("bc.Apply => %v", err) + } + return xdZoomed, nil +} + +// highlightRange highlights the range of X columns on the braille canvas. +func (lc *LineChart) highlightRange(bc *braille.Canvas, hRange *zoom.Range) error { + cellAr := bc.CellArea() + ar := image.Rect(hRange.Start, cellAr.Min.Y, hRange.End, cellAr.Max.Y) + return bc.SetAreaCellOpts(ar, cell.BgColor(lc.opts.zoomHightlightColor)) } // Keyboard implements widgetapi.Widget.Keyboard. @@ -291,7 +462,13 @@ func (lc *LineChart) Keyboard(k *terminalapi.Keyboard) error { // Mouse implements widgetapi.Widget.Mouse. func (lc *LineChart) Mouse(m *terminalapi.Mouse) error { - return errors.New("the LineChart widget doesn't support mouse events") + lc.mu.Lock() + defer lc.mu.Unlock() + + if lc.zoom == nil { + return nil + } + return lc.zoom.Mouse(m) } // minSize determines the minimum required size to draw the line chart. @@ -299,30 +476,37 @@ func (lc *LineChart) minSize() image.Point { // At the very least we need: // - n cells width for the Y axis and its labels as reported by it. // - at least 1 cell width for the graph. - reqWidth := lc.yAxis.RequiredWidth() + 1 - // - 2 cells height the X axis and its values and 2 for min and max labels on Y. - const reqHeight = 4 + reqWidth := axes.RequiredWidth(lc.yMin, lc.yMax) + 1 + + // And for the height: + // - n cells width for the X axis and its labels as reported by it. + // - at least 2 cell height for the graph. + reqHeight := axes.RequiredHeight(lc.maxXValue(), lc.xLabels, lc.opts.xLabelOrientation) + 2 return image.Point{reqWidth, reqHeight} } // Options implements widgetapi.Widget.Options. func (lc *LineChart) Options() widgetapi.Options { - lc.mu.Lock() - defer lc.mu.Unlock() + lc.mu.RLock() + defer lc.mu.RUnlock() return widgetapi.Options{ MinimumSize: lc.minSize(), + WantMouse: widgetapi.MouseScopeGlobal, } } -// maxPoints returns the largest number of points among all the series. +// maxXValue returns the maximum value on the X axis among all the series. // lc.mu must be held when calling this method. -func (lc *LineChart) maxPoints() int { - max := 0 +func (lc *LineChart) maxXValue() int { + maxLen := 0 for _, sv := range lc.series { - if num := len(sv.values); num > max { - max = num + if l := len(sv.values); l > maxLen { + maxLen = l } } - return max + if maxLen == 0 { + return 0 + } + return maxLen - 1 } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index bdc6b29..2e94086 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -16,17 +16,20 @@ package linechart import ( "image" + "math" "testing" "github.com/kylelemons/godebug/pretty" - "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" - "github.com/mum4k/termdash/draw/testdraw" - "github.com/mum4k/termdash/terminal/faketerm" - "github.com/mum4k/termdash/widgetapi" + "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" + "github.com/mum4k/termdash/internal/draw/testdraw" + "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" ) func TestLineChartDraws(t *testing.T) { @@ -36,9 +39,59 @@ func TestLineChartDraws(t *testing.T) { opts []Option writes func(*LineChart) error want func(size image.Point) *faketerm.Terminal - wantWriteErr bool + wantCapacity int wantErr bool + wantWriteErr bool + wantDrawErr bool }{ + { + desc: "fails with scroll step too low", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + ZoomStepPercent(0), + }, + wantErr: true, + }, + { + desc: "fails with scroll step too high", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + ZoomStepPercent(101), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where min is NaN", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(math.NaN(), 1), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where max is NaN", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(0, math.NaN()), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where min > max", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(1, 0), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where min == max", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(1, 1), + }, + wantErr: true, + }, { desc: "series fails without name for the series", canvas: image.Rect(0, 0, 3, 4), @@ -76,8 +129,9 @@ func TestLineChartDraws(t *testing.T) { }, }, { - desc: "empty without series", - canvas: image.Rect(0, 0, 3, 4), + desc: "empty without series", + canvas: image.Rect(0, 0, 3, 4), + wantCapacity: 2, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -97,6 +151,33 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "empty with just one point", + canvas: image.Rect(0, 0, 3, 4), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{1}) + }, + wantCapacity: 2, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{1, 0}, End: image.Point{1, 2}}, + {Start: image.Point{1, 2}, End: image.Point{2, 2}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "…", image.Point{0, 0}) + testdraw.MustText(c, "0", image.Point{0, 1}) + testdraw.MustText(c, "0", image.Point{2, 3}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "sets axes cell options", canvas: image.Rect(0, 0, 3, 4), @@ -106,6 +187,7 @@ func TestLineChartDraws(t *testing.T) { cell.FgColor(cell.ColorGreen), ), }, + wantCapacity: 2, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -138,6 +220,7 @@ func TestLineChartDraws(t *testing.T) { cell.FgColor(cell.ColorGreen), ), }, + wantCapacity: 2, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -163,6 +246,7 @@ func TestLineChartDraws(t *testing.T) { writes: func(lc *LineChart) error { return lc.Series("first", []float64{0, 100}) }, + wantCapacity: 28, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -190,12 +274,198 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "custom Y scale, zero based positive, values fit", + opts: []Option{ + YAxisCustomScale(0, 200), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}) + }, + wantCapacity: 26, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{6, 0}, End: image.Point{6, 8}}, + {Start: image.Point{6, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{5, 7}) + testdraw.MustText(c, "103.36", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{7, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(7, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{25, 16}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, zero based negative, values fit", + opts: []Option{ + YAxisCustomScale(-200, 0), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, -200}) + }, + wantCapacity: 26, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{6, 0}, End: image.Point{6, 8}}, + {Start: image.Point{6, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-200", image.Point{2, 7}) + testdraw.MustText(c, "-96.64", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{7, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(7, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 0}, image.Point{25, 31}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, negative and positive, values fit", + opts: []Option{ + YAxisCustomScale(-200, 200), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}) + }, + wantCapacity: 30, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-200", image.Point{0, 7}) + testdraw.MustText(c, "6.57", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{5, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 16}, image.Point{29, 8}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, negative only, values fit", + opts: []Option{ + YAxisCustomScale(-200, -100), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{-200, -100}) + }, + wantCapacity: 24, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{7, 0}, End: image.Point{7, 8}}, + {Start: image.Point{7, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-200", image.Point{3, 7}) + testdraw.MustText(c, "-148.32", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{8, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(8, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{23, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, negative and positive, values don't fit so adjusted", + opts: []Option{ + YAxisCustomScale(-200, 200), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{-400, 400}) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-400", image.Point{1, 7}) + testdraw.MustText(c, "12.96", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "draws anchored Y axis", canvas: image.Rect(0, 0, 20, 10), writes: func(lc *LineChart) error { return lc.Series("first", []float64{1600, 1900}) }, + wantCapacity: 26, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -232,6 +502,7 @@ func TestLineChartDraws(t *testing.T) { writes: func(lc *LineChart) error { return lc.Series("first", []float64{1600, 1900}) }, + wantCapacity: 24, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -260,7 +531,7 @@ func TestLineChartDraws(t *testing.T) { }, }, { - desc: "custom X labels", + desc: "custom X labels, horizontal by default", canvas: image.Rect(0, 0, 20, 10), writes: func(lc *LineChart) error { return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{ @@ -268,6 +539,7 @@ func TestLineChartDraws(t *testing.T) { 1: "end", })) }, + wantCapacity: 28, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -294,12 +566,92 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "custom X labels, horizontal with option", + opts: []Option{ + XLabelsHorizontal(), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{ + 0: "start", + 1: "end", + })) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "start", image.Point{6, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom X labels, vertical", + opts: []Option{ + XLabelsVertical(), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{ + 0: "start", + 1: "end", + })) + }, + wantCapacity: 26, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{6, 0}, End: image.Point{6, 4}}, + {Start: image.Point{6, 4}, End: image.Point{19, 4}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{5, 3}) + testdraw.MustText(c, "80.040", image.Point{0, 0}) + testdraw.MustVerticalText(c, "start", image.Point{7, 5}) + testdraw.MustVerticalText(c, "end", image.Point{19, 5}) + + // Braille line. + graphAr := image.Rect(7, 0, 20, 4) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 15}, image.Point{25, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "sets series cell options", canvas: image.Rect(0, 0, 20, 10), writes: func(lc *LineChart) error { return lc.Series("first", []float64{0, 100}, SeriesCellOpts(cell.BgColor(cell.ColorRed), cell.FgColor(cell.ColorGreen))) }, + wantCapacity: 28, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -333,6 +685,7 @@ func TestLineChartDraws(t *testing.T) { writes: func(lc *LineChart) error { return lc.Series("first", []float64{0, 50, 100}) }, + wantCapacity: 28, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -369,6 +722,7 @@ func TestLineChartDraws(t *testing.T) { writes: func(lc *LineChart) error { return lc.Series("first", []float64{0, 100}) }, + wantCapacity: 2, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -404,6 +758,7 @@ func TestLineChartDraws(t *testing.T) { } return lc.Series("second", []float64{100, 0}) }, + wantCapacity: 28, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -442,6 +797,7 @@ func TestLineChartDraws(t *testing.T) { } return lc.Series("second", []float64{100, 0}, SeriesCellOpts(cell.FgColor(cell.ColorBlue))) }, + wantCapacity: 28, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) c := testcanvas.MustNew(ft.Area()) @@ -471,6 +827,658 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "draw multiple series, the second has smaller scale than the first", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 50, 100}); err != nil { + return err + } + return lc.Series("second", []float64{10, 20}) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{13, 16}) + testdraw.MustBrailleLine(bc, image.Point{13, 16}, image.Point{27, 0}) + testdraw.MustBrailleLine(bc, image.Point{0, 28}, image.Point{13, 25}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draw multiple series, the second has larger scale than the first", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 50, 100}); err != nil { + return err + } + return lc.Series("second", []float64{-10, 200}) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-10", image.Point{2, 7}) + testdraw.MustText(c, "98.48", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 30}, image.Point{13, 22}) + testdraw.MustBrailleLine(bc, image.Point{13, 22}, image.Point{27, 15}) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{13, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "more values than capacity, X rescales", + canvas: image.Rect(0, 0, 11, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}) + }, + wantCapacity: 12, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{10, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "9.92", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{5, 9}) + testdraw.MustText(c, "14", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 11, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{1, 29}) + testdraw.MustBrailleLine(bc, image.Point{1, 29}, image.Point{1, 28}) + testdraw.MustBrailleLine(bc, image.Point{1, 28}, image.Point{2, 26}) + testdraw.MustBrailleLine(bc, image.Point{2, 26}, image.Point{2, 25}) + testdraw.MustBrailleLine(bc, image.Point{2, 25}, image.Point{3, 23}) + testdraw.MustBrailleLine(bc, image.Point{3, 23}, image.Point{3, 21}) + testdraw.MustBrailleLine(bc, image.Point{3, 21}, image.Point{4, 20}) + testdraw.MustBrailleLine(bc, image.Point{4, 20}, image.Point{5, 18}) + testdraw.MustBrailleLine(bc, image.Point{5, 18}, image.Point{5, 16}) + testdraw.MustBrailleLine(bc, image.Point{5, 16}, image.Point{6, 15}) + testdraw.MustBrailleLine(bc, image.Point{6, 15}, image.Point{6, 13}) + testdraw.MustBrailleLine(bc, image.Point{6, 13}, image.Point{7, 12}) + testdraw.MustBrailleLine(bc, image.Point{7, 12}, image.Point{8, 10}) + testdraw.MustBrailleLine(bc, image.Point{8, 10}, image.Point{8, 8}) + testdraw.MustBrailleLine(bc, image.Point{8, 8}, image.Point{9, 7}) + testdraw.MustBrailleLine(bc, image.Point{9, 7}, image.Point{9, 5}) + testdraw.MustBrailleLine(bc, image.Point{9, 5}, image.Point{10, 4}) + testdraw.MustBrailleLine(bc, image.Point{10, 4}, image.Point{10, 2}) + testdraw.MustBrailleLine(bc, image.Point{10, 2}, image.Point{11, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "more values than capacity, X unscaled", + opts: []Option{ + XAxisUnscaled(), + }, + canvas: image.Rect(0, 0, 11, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}) + }, + wantCapacity: 12, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{10, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "9.92", image.Point{0, 3}) + testdraw.MustText(c, "8", image.Point{5, 9}) + testdraw.MustText(c, "16", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 11, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 18}, image.Point{1, 16}) + testdraw.MustBrailleLine(bc, image.Point{1, 16}, image.Point{2, 15}) + testdraw.MustBrailleLine(bc, image.Point{2, 15}, image.Point{3, 13}) + testdraw.MustBrailleLine(bc, image.Point{3, 13}, image.Point{4, 12}) + testdraw.MustBrailleLine(bc, image.Point{4, 12}, image.Point{5, 10}) + testdraw.MustBrailleLine(bc, image.Point{5, 10}, image.Point{6, 8}) + testdraw.MustBrailleLine(bc, image.Point{6, 8}, image.Point{7, 7}) + testdraw.MustBrailleLine(bc, image.Point{7, 7}, image.Point{8, 5}) + testdraw.MustBrailleLine(bc, image.Point{8, 5}, image.Point{9, 4}) + testdraw.MustBrailleLine(bc, image.Point{9, 4}, image.Point{10, 2}) + testdraw.MustBrailleLine(bc, image.Point{10, 2}, image.Point{11, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "more values than capacity, X unscaled, hides shorter series", + opts: []Option{ + XAxisUnscaled(), + }, + canvas: image.Rect(0, 0, 11, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}); err != nil { + return err + } + return lc.Series("shorter", []float64{8, 7, 6, 5, 4, 3, 2, 1, 0}) + }, + wantCapacity: 12, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{10, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "9.92", image.Point{0, 3}) + testdraw.MustText(c, "8", image.Point{5, 9}) + testdraw.MustText(c, "16", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 11, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 18}, image.Point{1, 16}) + testdraw.MustBrailleLine(bc, image.Point{1, 16}, image.Point{2, 15}) + testdraw.MustBrailleLine(bc, image.Point{2, 15}, image.Point{3, 13}) + testdraw.MustBrailleLine(bc, image.Point{3, 13}, image.Point{4, 12}) + testdraw.MustBrailleLine(bc, image.Point{4, 12}, image.Point{5, 10}) + testdraw.MustBrailleLine(bc, image.Point{5, 10}, image.Point{6, 8}) + testdraw.MustBrailleLine(bc, image.Point{6, 8}, image.Point{7, 7}) + testdraw.MustBrailleLine(bc, image.Point{7, 7}, image.Point{8, 5}) + testdraw.MustBrailleLine(bc, image.Point{8, 5}, image.Point{9, 4}) + testdraw.MustBrailleLine(bc, image.Point{9, 4}, image.Point{10, 2}) + testdraw.MustBrailleLine(bc, image.Point{10, 2}, image.Point{11, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "more values than capacity, X unscaled, shorter series displayed partially", + opts: []Option{ + XAxisUnscaled(), + }, + canvas: image.Rect(0, 0, 11, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}); err != nil { + return err + } + return lc.Series("shorter", []float64{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}) + }, + wantCapacity: 12, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{10, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "9.92", image.Point{0, 3}) + testdraw.MustText(c, "8", image.Point{5, 9}) + testdraw.MustText(c, "16", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 11, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 18}, image.Point{1, 16}) + testdraw.MustBrailleLine(bc, image.Point{1, 16}, image.Point{2, 15}) + testdraw.MustBrailleLine(bc, image.Point{2, 15}, image.Point{3, 13}) + testdraw.MustBrailleLine(bc, image.Point{3, 13}, image.Point{4, 12}) + testdraw.MustBrailleLine(bc, image.Point{4, 12}, image.Point{5, 10}) + testdraw.MustBrailleLine(bc, image.Point{5, 10}, image.Point{6, 8}) + testdraw.MustBrailleLine(bc, image.Point{6, 8}, image.Point{7, 7}) + testdraw.MustBrailleLine(bc, image.Point{7, 7}, image.Point{8, 5}) + testdraw.MustBrailleLine(bc, image.Point{8, 5}, image.Point{9, 4}) + testdraw.MustBrailleLine(bc, image.Point{9, 4}, image.Point{10, 2}) + testdraw.MustBrailleLine(bc, image.Point{10, 2}, image.Point{11, 0}) + testdraw.MustBrailleLine(bc, image.Point{0, 29}, image.Point{1, 31}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "values fit capacity, X unscaled takes no effect", + opts: []Option{ + XAxisUnscaled(), + }, + canvas: image.Rect(0, 0, 11, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}) + }, + wantCapacity: 12, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{10, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "5.76", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{5, 9}) + testdraw.MustText(c, "8", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 11, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{1, 28}) + testdraw.MustBrailleLine(bc, image.Point{1, 28}, image.Point{2, 25}) + testdraw.MustBrailleLine(bc, image.Point{2, 25}, image.Point{3, 23}) + testdraw.MustBrailleLine(bc, image.Point{3, 23}, image.Point{4, 20}) + testdraw.MustBrailleLine(bc, image.Point{4, 20}, image.Point{5, 17}) + testdraw.MustBrailleLine(bc, image.Point{5, 17}, image.Point{6, 14}) + testdraw.MustBrailleLine(bc, image.Point{6, 14}, image.Point{7, 12}) + testdraw.MustBrailleLine(bc, image.Point{7, 12}, image.Point{8, 9}) + testdraw.MustBrailleLine(bc, image.Point{8, 9}, image.Point{9, 6}) + testdraw.MustBrailleLine(bc, image.Point{9, 6}, image.Point{10, 3}) + testdraw.MustBrailleLine(bc, image.Point{10, 3}, image.Point{11, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "highlights area for zoom", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 100}); err != nil { + return err + } + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 5}, + Button: mouse.ButtonLeft, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + + // Highlighted area for zoom. + testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(235))) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "highlights area for zoom to a custom color", + opts: []Option{ + ZoomHightlightColor(cell.ColorNumber(13)), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 100}); err != nil { + return err + } + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 5}, + Button: mouse.ButtonLeft, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + + // Highlighted area for zoom. + testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(13))) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "zooms in on scroll up", + opts: []Option{ + ZoomStepPercent(50), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 25, 75, 100}); err != nil { + return err + } + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{8, 5}, + Button: mouse.ButtonWheelUp, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{13, 23}) + testdraw.MustBrailleLine(bc, image.Point{13, 23}, image.Point{27, 8}) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "tracks mouse clicks when canvas size increases, regression for #148", + opts: []Option{ + ZoomHightlightColor(cell.ColorNumber(13)), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 100}); err != nil { + return err + } + // Draw twice with different canvas size to simulate resize. + { + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 7)) + if err := lc.Draw(cvs); err != nil { + return err + } + } + { + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 7}, + Button: mouse.ButtonLeft, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + + // Highlighted area for zoom. + testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(13))) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "zoom in on unscaled X axis", + opts: []Option{ + XAxisUnscaled(), + ZoomStepPercent(80), + }, + canvas: image.Rect(0, 0, 10, 10), + writes: func(lc *LineChart) error { + var values []float64 + for v := 0; v < 8; v++ { + values = append(values, float64(v)) + } + if err := lc.Series("first", values); err != nil { + return err + } + + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 11, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonWheelUp, + }) + }, + wantCapacity: 10, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{9, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "3.68", image.Point{0, 3}) + testdraw.MustText(c, "1", image.Point{5, 9}) + testdraw.MustText(c, "3", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 10, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 27}, image.Point{4, 22}) + testdraw.MustBrailleLine(bc, image.Point{4, 22}, image.Point{9, 18}) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "shifts zoom when values on unscaled X axis roll out of the base axis view", + opts: []Option{ + XAxisUnscaled(), + ZoomStepPercent(80), + }, + canvas: image.Rect(0, 0, 10, 10), + writes: func(lc *LineChart) error { + var values []float64 + for v := 0; v < 8; v++ { + values = append(values, float64(v)) + } + if err := lc.Series("first", values); err != nil { + return err + } + + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 11, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + if err := lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + + // Add move values + for v := 0; v < 8; v++ { + values = append(values, float64(v)) + } + return lc.Series("first", values) + }, + wantCapacity: 10, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{9, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "3.68", image.Point{0, 3}) + testdraw.MustText(c, "6", image.Point{5, 9}) + testdraw.MustText(c, "7", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 10, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 5}, image.Point{8, 1}) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, } for _, tc := range tests { @@ -480,7 +1488,14 @@ func TestLineChartDraws(t *testing.T) { t.Fatalf("canvas.New => unexpected error: %v", err) } - widget := New(tc.opts...) + widget, err := New(tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + if tc.writes != nil { err := tc.writes(widget) if (err != nil) != tc.wantWriteErr { @@ -493,8 +1508,8 @@ func TestLineChartDraws(t *testing.T) { { err := widget.Draw(c) - if (err != nil) != tc.wantErr { - t.Fatalf("Draw => unexpected error: %v, wantErr: %v", err, tc.wantErr) + if (err != nil) != tc.wantDrawErr { + t.Fatalf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } if err != nil { return @@ -517,13 +1532,39 @@ func TestLineChartDraws(t *testing.T) { if diff := faketerm.Diff(want, got); diff != "" { t.Errorf("Draw => %v", diff) } + + gotCapacity := widget.ValueCapacity() + if gotCapacity != tc.wantCapacity { + t.Errorf("ValueCapacity => %v, want %v", gotCapacity, tc.wantCapacity) + } }) } } +func TestKeyboard(t *testing.T) { + lc, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + if err := lc.Keyboard(&terminalapi.Keyboard{}); err == nil { + t.Errorf("Keyboard => got nil err, wanted one") + } +} + +func TestMouseDoesNothingWithoutZoomTracker(t *testing.T) { + lc, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + if err := lc.Mouse(&terminalapi.Mouse{}); err != nil { + t.Errorf("Mouse => unexpected error: %v", err) + } +} + func TestOptions(t *testing.T) { tests := []struct { desc string + opts []Option // if not nil, executed before obtaining the options. addSeries func(*LineChart) error want widgetapi.Options @@ -532,6 +1573,7 @@ func TestOptions(t *testing.T) { desc: "reserves space for axis without series", want: widgetapi.Options{ MinimumSize: image.Point{3, 4}, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -541,6 +1583,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{5, 4}, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -550,13 +1593,43 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{6, 4}, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "reserves space for longer vertical X labels", + opts: []Option{ + XLabelsVertical(), + }, + addSeries: func(lc *LineChart) error { + return lc.Series("series", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) + }, + want: widgetapi.Options{ + MinimumSize: image.Point{4, 5}, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "reserves space for longer custom vertical X labels", + opts: []Option{ + XLabelsVertical(), + }, + addSeries: func(lc *LineChart) error { + return lc.Series("series", []float64{0, 100}, SeriesXLabels(map[int]string{0: "text"})) + }, + want: widgetapi.Options{ + MinimumSize: image.Point{5, 7}, + WantMouse: widgetapi.MouseScopeGlobal, }, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - lc := New() + lc, err := New(tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } if tc.addSeries != nil { if err := tc.addSeries(lc); err != nil { diff --git a/widgets/linechart/linechartdemo/linechartdemo.go b/widgets/linechart/linechartdemo/linechartdemo.go index 063b894..b7951a3 100644 --- a/widgets/linechart/linechartdemo/linechartdemo.go +++ b/widgets/linechart/linechartdemo/linechartdemo.go @@ -22,11 +22,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/linechart" ) @@ -82,11 +82,14 @@ func main() { const redrawInterval = 250 * time.Millisecond ctx, cancel := context.WithCancel(context.Background()) - lc := linechart.New( + lc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorCyan)), ) + if err != nil { + panic(err) + } go playLineChart(ctx, lc, redrawInterval/3) c, err := container.New( t, diff --git a/widgets/linechart/options.go b/widgets/linechart/options.go index 326cb80..692c428 100644 --- a/widgets/linechart/options.go +++ b/widgets/linechart/options.go @@ -15,8 +15,12 @@ package linechart import ( - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/widgets/linechart/axes" + "fmt" + "math" + + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/widgets/linechart/internal/axes" + "github.com/mum4k/termdash/widgets/linechart/internal/zoom" ) // options.go contains configurable options for LineChart. @@ -29,15 +33,39 @@ type Option interface { // options stores the provided options. type options struct { - axesCellOpts []cell.Option - xLabelCellOpts []cell.Option - yLabelCellOpts []cell.Option - yAxisMode axes.YScaleMode + axesCellOpts []cell.Option + xLabelCellOpts []cell.Option + xLabelOrientation axes.LabelOrientation + yLabelCellOpts []cell.Option + xAxisUnscaled bool + yAxisMode axes.YScaleMode + yAxisCustomScale *customScale + zoomHightlightColor cell.Color + zoomStepPercent int +} + +// validate validates the provided options. +func (o *options) validate() error { + if o.yAxisCustomScale != nil { + if math.IsNaN(o.yAxisCustomScale.min) || math.IsNaN(o.yAxisCustomScale.max) { + return fmt.Errorf("both the min(%v) and the max(%v) provided as custom Y scale must be valid numbers", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + } + if o.yAxisCustomScale.min >= o.yAxisCustomScale.max { + return fmt.Errorf("the min(%v) must be less than the max(%v) provided as custom Y scale", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + } + } + if got, min, max := o.zoomStepPercent, 1, 100; got < min || got > max { + return fmt.Errorf("invalid ZoomStepPercent %d, must be in range %d <= value <= %d", got, min, max) + } + return nil } // newOptions returns a new options instance. func newOptions(opts ...Option) *options { - opt := &options{} + opt := &options{ + zoomHightlightColor: cell.ColorNumber(235), + zoomStepPercent: zoom.DefaultScrollStep, + } for _, o := range opts { o.set(opt) } @@ -66,6 +94,22 @@ func XLabelCellOpts(co ...cell.Option) Option { }) } +// XLabelsVertical makes the labels under the X axis flow vertically. +// Defaults to labels that flow horizontally. +func XLabelsVertical() Option { + return option(func(opts *options) { + opts.xLabelOrientation = axes.LabelOrientationVertical + }) +} + +// XLabelsHorizontal makes the labels under the X axis flow horizontally. +// This is the default option. +func XLabelsHorizontal() Option { + return option(func(opts *options) { + opts.xLabelOrientation = axes.LabelOrientationHorizontal + }) +} + // YLabelCellOpts set the cell options for the labels on the Y axis. func YLabelCellOpts(co ...cell.Option) Option { return option(func(opts *options) { @@ -86,3 +130,67 @@ func YAxisAdaptive() Option { opts.yAxisMode = axes.YScaleModeAdaptive }) } + +// customScale is the custom scale provided via the YAxisCustomScale option. +type customScale struct { + min, max float64 +} + +// YAxisCustomScale when provided, the scale of the Y axis will be based on the +// specified minimum and maximum value instead of determining those from the +// LineChart series. Useful to visually stabilize the Y axis for LineChart +// applications that continuously feed values. +// The default behavior is to continuously determine the minimum and maximum +// value from the series before drawing the LineChart. +// Even when this option is provided, the LineChart would still rescale the Y +// axis if a value is encountered that is outside of the range specified here, +// i.e. smaller than the minimum or larger than the maximum. +// Both the minimum and the maximum must be valid numbers and the minimum must +// be smaller than the maximum. +// +// Providing this option also sets YAxisAdaptive. +func YAxisCustomScale(min, max float64) Option { + return option(func(opts *options) { + opts.yAxisCustomScale = &customScale{ + min: min, + max: max, + } + opts.yAxisMode = axes.YScaleModeAdaptive + }) +} + +// XAxisUnscaled when provided, stops the LineChart from rescaling the X axis +// when it can't fit all the values in the series, instead the LineCharts only +// displays the last n values that fit into its width. This is useful to create +// an impression of values rolling through the linechart right to left. Note +// that this results in hiding of values from the beginning of the series +// that didn't fit completely and might hide some shorter series as this +// effectively makes the X axis start at a non-zero value. +// +// The default behavior is to rescale the X axis to display all the values. +// This option takes no effect if all the values on the series fit into the +// LineChart area. +func XAxisUnscaled() Option { + return option(func(opts *options) { + opts.xAxisUnscaled = true + }) +} + +// ZoomHightlightColor sets the background color of the area that is selected +// with mouse in order to zoom the linechart. +// Defaults to color number 235. +func ZoomHightlightColor(c cell.Color) Option { + return option(func(opts *options) { + opts.zoomHightlightColor = c + }) +} + +// ZoomStepPercent sets the zooming step on each mouse scroll event as the +// percentage of the size of the X axis. +// The value must be in range 0 < value <= 100. +// Defaults to zoom.DefaultScrollStep. +func ZoomStepPercent(perc int) Option { + return option(func(opts *options) { + opts.zoomStepPercent = perc + }) +} diff --git a/widgets/segmentdisplay/options.go b/widgets/segmentdisplay/options.go index a6cb034..4e70ff1 100644 --- a/widgets/segmentdisplay/options.go +++ b/widgets/segmentdisplay/options.go @@ -17,7 +17,7 @@ package segmentdisplay import ( "fmt" - "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/internal/align" ) // options.go contains configurable options for SegmentDisplay. diff --git a/widgets/segmentdisplay/segment_area.go b/widgets/segmentdisplay/segment_area.go index 3ac0492..aaf6e98 100644 --- a/widgets/segmentdisplay/segment_area.go +++ b/widgets/segmentdisplay/segment_area.go @@ -21,7 +21,7 @@ import ( "fmt" "image" - "github.com/mum4k/termdash/draw/segdisp/sixteen" + "github.com/mum4k/termdash/internal/draw/segdisp/sixteen" ) // segArea contains information about the area that will contain the segments. diff --git a/widgets/segmentdisplay/segmentdisplay.go b/widgets/segmentdisplay/segmentdisplay.go index 554312c..7706659 100644 --- a/widgets/segmentdisplay/segmentdisplay.go +++ b/widgets/segmentdisplay/segmentdisplay.go @@ -23,12 +23,12 @@ import ( "image" "sync" - "github.com/mum4k/termdash/align" - "github.com/mum4k/termdash/attrrange" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/draw/segdisp/sixteen" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/align" + "github.com/mum4k/termdash/internal/attrrange" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/draw/segdisp/sixteen" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // SegmentDisplay displays ASCII content by simulating a segment display. @@ -258,7 +258,7 @@ func (sd *SegmentDisplay) Options() widgetapi.Options { return widgetapi.Options{ // The smallest supported size of a display segment. MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/segmentdisplay/segmentdisplay_test.go b/widgets/segmentdisplay/segmentdisplay_test.go index b901fb7..0bade4a 100644 --- a/widgets/segmentdisplay/segmentdisplay_test.go +++ b/widgets/segmentdisplay/segmentdisplay_test.go @@ -19,15 +19,15 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "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/draw/segdisp/sixteen" - "github.com/mum4k/termdash/draw/segdisp/sixteen/testsixteen" - "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" + "github.com/mum4k/termdash/internal/canvas/testcanvas" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw/segdisp/sixteen" + "github.com/mum4k/termdash/internal/draw/segdisp/sixteen/testsixteen" + "github.com/mum4k/termdash/internal/terminal/faketerm" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // mustDrawChar draws the provided character in the area of the canvas or panics. @@ -821,8 +821,8 @@ func TestOptions(t *testing.T) { got := sd.Options() want := widgetapi.Options{ MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } if diff := pretty.Compare(want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) diff --git a/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go b/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go index bc62ec7..a0229cc 100644 --- a/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go +++ b/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go @@ -21,11 +21,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/segmentdisplay" ) diff --git a/widgets/segmentdisplay/write_options.go b/widgets/segmentdisplay/write_options.go index 9252c84..7d8ccb6 100644 --- a/widgets/segmentdisplay/write_options.go +++ b/widgets/segmentdisplay/write_options.go @@ -16,7 +16,7 @@ package segmentdisplay // write_options.go contains options used when writing content to the widget. -import "github.com/mum4k/termdash/cell" +import "github.com/mum4k/termdash/internal/cell" // WriteOption is used to provide options to Write(). type WriteOption interface { diff --git a/widgets/sparkline/options.go b/widgets/sparkline/options.go index a8db45c..27de78e 100644 --- a/widgets/sparkline/options.go +++ b/widgets/sparkline/options.go @@ -16,7 +16,11 @@ package sparkline // options.go contains configurable options for SparkLine. -import "github.com/mum4k/termdash/cell" +import ( + "fmt" + + "github.com/mum4k/termdash/internal/cell" +) // Option is used to provide options. type Option interface { @@ -47,6 +51,14 @@ func newOptions() *options { } } +// validate validates the provided options. +func (o *options) validate() error { + if got, min := o.height, 0; got < min { + return fmt.Errorf("invalid Height %d, must be %d <= Height", got, min) + } + return nil +} + // Label adds a label above the SparkLine. func Label(text string, cOpts ...cell.Option) Option { return option(func(opts *options) { @@ -56,8 +68,8 @@ func Label(text string, cOpts ...cell.Option) Option { } // Height sets a fixed height for the SparkLine. -// If not provided, the SparkLine takes all the available vertical space in the -// container. +// If not provided or set to zero, the SparkLine takes all the available +// vertical space in the container. Must be a positive or zero integer. func Height(h int) Option { return option(func(opts *options) { opts.height = h diff --git a/widgets/sparkline/sparkline.go b/widgets/sparkline/sparkline.go index 419730c..0c55fb7 100644 --- a/widgets/sparkline/sparkline.go +++ b/widgets/sparkline/sparkline.go @@ -21,12 +21,12 @@ import ( "image" "sync" - "github.com/mum4k/termdash/area" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/cell" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/area" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // SparkLine draws a graph showing a series of values as vertical bars. @@ -47,14 +47,18 @@ type SparkLine struct { } // New returns a new SparkLine. -func New(opts ...Option) *SparkLine { +func New(opts ...Option) (*SparkLine, error) { opt := newOptions() for _, o := range opts { o.set(opt) } + if err := opt.validate(); err != nil { + return nil, err + } + return &SparkLine{ opts: opt, - } + }, nil } // Draw draws the SparkLine widget onto the canvas. @@ -226,7 +230,7 @@ func (sl *SparkLine) Options() widgetapi.Options { return widgetapi.Options{ MinimumSize: min, MaximumSize: max, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/sparkline/sparkline_test.go b/widgets/sparkline/sparkline_test.go index aef0ec0..cece593 100644 --- a/widgets/sparkline/sparkline_test.go +++ b/widgets/sparkline/sparkline_test.go @@ -19,28 +19,42 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/canvas" - "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/canvas" + "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" ) func TestSparkLine(t *testing.T) { tests := []struct { desc string - sparkLine *SparkLine + opts []Option update func(*SparkLine) error // update gets called before drawing of the widget. canvas image.Rectangle want func(size image.Point) *faketerm.Terminal + wantErr bool wantUpdateErr bool // whether to expect an error on a call to the update function wantDrawErr bool }{ { - desc: "draws empty for no data points", - sparkLine: New(), + desc: "fails on negative height", + opts: []Option{ + Height(-1), + }, + update: func(sl *SparkLine) error { + return nil + }, + canvas: image.Rect(0, 0, 1, 1), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "draws empty for no data points", update: func(sl *SparkLine) error { return nil }, @@ -50,8 +64,7 @@ func TestSparkLine(t *testing.T) { }, }, { - desc: "fails on negative data points", - sparkLine: New(), + desc: "fails on negative data points", update: func(sl *SparkLine) error { return sl.Add([]int{0, 3, -1, 2}) }, @@ -62,8 +75,7 @@ func TestSparkLine(t *testing.T) { wantUpdateErr: true, }, { - desc: "single height sparkline", - sparkLine: New(), + desc: "single height sparkline", update: func(sl *SparkLine) error { return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}) }, @@ -80,8 +92,7 @@ func TestSparkLine(t *testing.T) { }, }, { - desc: "sparkline can be cleared", - sparkLine: New(), + desc: "sparkline can be cleared", update: func(sl *SparkLine) error { if err := sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}); err != nil { return err @@ -96,9 +107,9 @@ func TestSparkLine(t *testing.T) { }, { desc: "sets sparkline color", - sparkLine: New( + opts: []Option{ Color(cell.ColorMagenta), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}) }, @@ -115,8 +126,7 @@ func TestSparkLine(t *testing.T) { }, }, { - desc: "sets sparkline color on a call to Add", - sparkLine: New(), + desc: "sets sparkline color on a call to Add", update: func(sl *SparkLine) error { return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}, Color(cell.ColorMagenta)) }, @@ -134,8 +144,7 @@ func TestSparkLine(t *testing.T) { }, { - desc: "draws data points from the right", - sparkLine: New(), + desc: "draws data points from the right", update: func(sl *SparkLine) error { return sl.Add([]int{7, 8}) }, @@ -154,9 +163,9 @@ func TestSparkLine(t *testing.T) { }, { desc: "single height sparkline with label", - sparkLine: New( + opts: []Option{ Label("Hello"), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 1, 2, 3, 8, 3, 2, 1, 1}) }, @@ -176,9 +185,9 @@ func TestSparkLine(t *testing.T) { }, { desc: "too long label is trimmed", - sparkLine: New( + opts: []Option{ Label("Hello world"), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{8}) }, @@ -197,8 +206,7 @@ func TestSparkLine(t *testing.T) { }, }, { - desc: "stretches up to the height of the container", - sparkLine: New(), + desc: "stretches up to the height of the container", update: func(sl *SparkLine) error { return sl.Add([]int{0, 100, 50, 85}) }, @@ -232,9 +240,9 @@ func TestSparkLine(t *testing.T) { }, { desc: "stretches up to the height of the container with label", - sparkLine: New( + opts: []Option{ Label("zoo"), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 90, 30, 85}) }, @@ -266,9 +274,9 @@ func TestSparkLine(t *testing.T) { }, { desc: "respects fixed height", - sparkLine: New( + opts: []Option{ Height(2), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 100, 50, 85}) }, @@ -293,9 +301,9 @@ func TestSparkLine(t *testing.T) { }, { desc: "draws resize needed character when canvas is smaller than requested", - sparkLine: New( + opts: []Option{ Height(2), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 100, 50, 85}) }, @@ -311,10 +319,10 @@ func TestSparkLine(t *testing.T) { }, { desc: "respects fixed height with label", - sparkLine: New( + opts: []Option{ Label("zoo"), Height(2), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 100, 50, 0}) }, @@ -339,13 +347,13 @@ func TestSparkLine(t *testing.T) { }, { desc: "sets label color", - sparkLine: New( + opts: []Option{ Label( "Hello", cell.FgColor(cell.ColorBlue), cell.BgColor(cell.ColorYellow), ), - ), + }, update: func(sl *SparkLine) error { return sl.Add([]int{0, 1}) }, @@ -367,8 +375,7 @@ func TestSparkLine(t *testing.T) { }, }, { - desc: "displays only data points that fit the width", - sparkLine: New(), + desc: "displays only data points that fit the width", update: func(sl *SparkLine) error { return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}) }, @@ -386,8 +393,7 @@ func TestSparkLine(t *testing.T) { }, }, { - desc: "data points not visible don't affect the determined max data point", - sparkLine: New(), + desc: "data points not visible don't affect the determined max data point", update: func(sl *SparkLine) error { return sl.Add([]int{10, 4, 8}) }, @@ -408,21 +414,28 @@ func TestSparkLine(t *testing.T) { 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 = tc.update(tc.sparkLine) - if (err != nil) != tc.wantUpdateErr { - t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) - + sp, err := New(tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) } if err != nil { return } - err = tc.sparkLine.Draw(c) + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + err = tc.update(sp) + if (err != nil) != tc.wantUpdateErr { + t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) + } + if err != nil { + return + } + + err = sp.Draw(c) if (err != nil) != tc.wantDrawErr { t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } @@ -448,60 +461,63 @@ func TestSparkLine(t *testing.T) { func TestOptions(t *testing.T) { tests := []struct { - desc string - sparkLine *SparkLine - want widgetapi.Options + desc string + opts []Option + want widgetapi.Options }{ { - desc: "no label and no fixed height", - sparkLine: New(), + desc: "no label and no fixed height", want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "label and no fixed height", - sparkLine: New( + opts: []Option{ Label("foo"), - ), + }, want: widgetapi.Options{ MinimumSize: image.Point{1, 2}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "no label and fixed height", - sparkLine: New( + opts: []Option{ Height(3), - ), + }, want: widgetapi.Options{ MinimumSize: image.Point{1, 3}, MaximumSize: image.Point{1, 3}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, { desc: "label and fixed height", - sparkLine: New( + opts: []Option{ Label("foo"), Height(3), - ), + }, want: widgetapi.Options{ MinimumSize: image.Point{1, 4}, MaximumSize: image.Point{1, 4}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - got := tc.sparkLine.Options() + sp, err := New(tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + got := sp.Options() if diff := pretty.Compare(tc.want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) } diff --git a/widgets/sparkline/sparklinedemo/sparklinedemo.go b/widgets/sparkline/sparklinedemo/sparklinedemo.go index 6d9232f..d4ce883 100644 --- a/widgets/sparkline/sparklinedemo/sparklinedemo.go +++ b/widgets/sparkline/sparklinedemo/sparklinedemo.go @@ -22,11 +22,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/sparkline" ) @@ -59,20 +59,29 @@ func main() { defer t.Close() ctx, cancel := context.WithCancel(context.Background()) - green := sparkline.New( + green, err := sparkline.New( sparkline.Label("Green SparkLine", cell.FgColor(cell.ColorBlue)), sparkline.Color(cell.ColorGreen), ) + if err != nil { + panic(err) + } go playSparkLine(ctx, green, 250*time.Millisecond) - red := sparkline.New( + red, err := sparkline.New( sparkline.Label("Red SparkLine", cell.FgColor(cell.ColorBlue)), sparkline.Color(cell.ColorRed), ) + if err != nil { + panic(err) + } go playSparkLine(ctx, red, 500*time.Millisecond) - yellow := sparkline.New( + yellow, err := sparkline.New( sparkline.Label("Yellow SparkLine", cell.FgColor(cell.ColorGreen)), sparkline.Color(cell.ColorYellow), ) + if err != nil { + panic(err) + } go playSparkLine(ctx, yellow, 1*time.Second) c, err := container.New( diff --git a/widgets/sparkline/sparks.go b/widgets/sparkline/sparks.go index f908163..98c81bb 100644 --- a/widgets/sparkline/sparks.go +++ b/widgets/sparkline/sparks.go @@ -20,8 +20,8 @@ package sparkline import ( "fmt" - runewidth "github.com/mattn/go-runewidth" - "github.com/mum4k/termdash/numbers" + "github.com/mum4k/termdash/internal/cell/runewidth" + "github.com/mum4k/termdash/internal/numbers" ) // sparks are the characters used to draw the SparkLine. diff --git a/widgets/text/line_scanner.go b/widgets/text/line_scanner.go index a2bfee7..19c250b 100644 --- a/widgets/text/line_scanner.go +++ b/widgets/text/line_scanner.go @@ -20,7 +20,7 @@ import ( "strings" "text/scanner" - runewidth "github.com/mattn/go-runewidth" + "github.com/mum4k/termdash/internal/cell/runewidth" ) // wrapNeeded returns true if wrapping is needed for the rune at the horizontal diff --git a/widgets/text/line_trim.go b/widgets/text/line_trim.go index d019a07..fd3e4a7 100644 --- a/widgets/text/line_trim.go +++ b/widgets/text/line_trim.go @@ -18,8 +18,8 @@ import ( "fmt" "image" - runewidth "github.com/mattn/go-runewidth" - "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/cell/runewidth" ) // line_trim.go contains code that trims lines that are too long. diff --git a/widgets/text/line_trim_test.go b/widgets/text/line_trim_test.go index 3a628c6..5719427 100644 --- a/widgets/text/line_trim_test.go +++ b/widgets/text/line_trim_test.go @@ -19,10 +19,10 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/canvas/testcanvas" - "github.com/mum4k/termdash/draw/testdraw" - "github.com/mum4k/termdash/terminal/faketerm" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/canvas/testcanvas" + "github.com/mum4k/termdash/internal/draw/testdraw" + "github.com/mum4k/termdash/internal/terminal/faketerm" ) func TestLineTrim(t *testing.T) { diff --git a/widgets/text/options.go b/widgets/text/options.go index db0fca7..342b5c1 100644 --- a/widgets/text/options.go +++ b/widgets/text/options.go @@ -15,8 +15,10 @@ package text import ( - "github.com/mum4k/termdash/keyboard" - "github.com/mum4k/termdash/mouse" + "fmt" + + "github.com/mum4k/termdash/internal/keyboard" + "github.com/mum4k/termdash/internal/mouse" ) // options.go contains configurable options for Text. @@ -56,6 +58,23 @@ func newOptions(opts ...Option) *options { return opt } +// validate validates the provided options. +func (o *options) validate() error { + keys := map[keyboard.Key]bool{ + o.keyUp: true, + o.keyDown: true, + o.keyPgUp: true, + o.keyPgDown: true, + } + if len(keys) != 4 { + return fmt.Errorf("invalid ScrollKeys(up:%v, down:%v, pageUp:%v, pageDown:%v), the keys must be unique", o.keyUp, o.keyDown, o.keyPgUp, o.keyPgDown) + } + if o.mouseUpButton == o.mouseDownButton { + return fmt.Errorf("invalid ScrollMouseButtons(up:%v, down:%v), the buttons must be unique", o.mouseUpButton, o.mouseDownButton) + } + return nil +} + // option implements Option. type option func(*options) @@ -97,6 +116,8 @@ const ( ) // ScrollMouseButtons configures the mouse buttons that scroll the content. +// The provided buttons must be unique, e.g. the same button cannot be both up +// and down. func ScrollMouseButtons(up, down mouse.Button) Option { return option(func(opts *options) { opts.mouseUpButton = up @@ -113,6 +134,8 @@ const ( ) // ScrollKeys configures the mouse buttons that scroll the content. +// The provided keys must be unique, e.g. the same key cannot be both up and +// down. func ScrollKeys(up, down, pageUp, pageDown keyboard.Key) Option { return option(func(opts *options) { opts.keyUp = up diff --git a/widgets/text/text.go b/widgets/text/text.go index 9f75946..a3802b5 100644 --- a/widgets/text/text.go +++ b/widgets/text/text.go @@ -23,10 +23,10 @@ import ( "sync" "unicode" - "github.com/mum4k/termdash/attrrange" - "github.com/mum4k/termdash/canvas" - "github.com/mum4k/termdash/terminalapi" - "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/internal/attrrange" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/terminalapi" + "github.com/mum4k/termdash/internal/widgetapi" ) // Text displays a block of text. @@ -69,20 +69,27 @@ type Text struct { } // New returns a new text widget. -func New(opts ...Option) *Text { +func New(opts ...Option) (*Text, error) { opt := newOptions(opts...) + if err := opt.validate(); err != nil { + return nil, err + } return &Text{ wOptsTracker: attrrange.NewTracker(), scroll: newScrollTracker(opt), opts: opt, - } + }, nil } // Reset resets the widget back to empty content. func (t *Text) Reset() { t.mu.Lock() defer t.mu.Unlock() + t.reset() +} +// reset implements Reset, caller must hold t.mu. +func (t *Text) reset() { t.buff.Reset() t.givenWOpts = nil t.wOptsTracker = attrrange.NewTracker() @@ -106,8 +113,13 @@ func (t *Text) Write(text string, wOpts ...WriteOption) error { return err } + opts := newWriteOptions(wOpts...) + if opts.replace { + t.reset() + } + pos := t.buff.Len() - t.givenWOpts = append(t.givenWOpts, newWriteOptions(wOpts...)) + t.givenWOpts = append(t.givenWOpts, opts) wOptsIdx := len(t.givenWOpts) - 1 if err := t.wOptsTracker.Add(pos, pos+len(text), wOptsIdx); err != nil { return err @@ -290,11 +302,21 @@ func (t *Text) Mouse(m *terminalapi.Mouse) error { // Options of the widget func (t *Text) Options() widgetapi.Options { + var ks widgetapi.KeyScope + var ms widgetapi.MouseScope + if t.opts.disableScrolling { + ks = widgetapi.KeyScopeNone + ms = widgetapi.MouseScopeNone + } else { + ks = widgetapi.KeyScopeFocused + ms = widgetapi.MouseScopeWidget + } + return widgetapi.Options{ // At least one line with at least one full-width rune. MinimumSize: image.Point{1, 1}, - WantMouse: !t.opts.disableScrolling, - WantKeyboard: !t.opts.disableScrolling, + WantMouse: ms, + WantKeyboard: ks, } } diff --git a/widgets/text/text_test.go b/widgets/text/text_test.go index 5d14fef..93077ff 100644 --- a/widgets/text/text_test.go +++ b/widgets/text/text_test.go @@ -19,16 +19,16 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" - "github.com/mum4k/termdash/canvas" - "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/canvas" + "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/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" ) func TestTextDraws(t *testing.T) { @@ -39,8 +39,31 @@ func TestTextDraws(t *testing.T) { writes func(*Text) error events func(*Text) want func(size image.Point) *faketerm.Terminal + wantErr bool wantWriteErr bool }{ + { + desc: "fails when scroll keys aren't unique", + opts: []Option{ + ScrollKeys('a', 'a', 'a', 'a'), + }, + canvas: image.Rect(0, 0, 1, 1), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "fails when scroll mouse buttons aren't unique", + opts: []Option{ + ScrollMouseButtons(mouse.ButtonLeft, mouse.ButtonLeft), + }, + canvas: image.Rect(0, 0, 1, 1), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, { desc: "empty when no written text", canvas: image.Rect(0, 0, 1, 1), @@ -110,6 +133,24 @@ func TestTextDraws(t *testing.T) { return ft }, }, + { + desc: "multiple writes replace when requested", + canvas: image.Rect(0, 0, 12, 1), + writes: func(widget *Text) error { + if err := widget.Write("hello", WriteReplace()); err != nil { + return err + } + return widget.Write("world", WriteReplace()) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "world", image.Point{0, 0}) + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "reset clears the content", canvas: image.Rect(0, 0, 12, 1), @@ -691,7 +732,14 @@ func TestTextDraws(t *testing.T) { t.Fatalf("canvas.New => unexpected error: %v", err) } - widget := New(tc.opts...) + widget, err := New(tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + if tc.writes != nil { err := tc.writes(widget) if (err != nil) != tc.wantWriteErr { @@ -736,8 +784,8 @@ func TestOptions(t *testing.T) { desc: "minimum size for one character", want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, - WantKeyboard: true, - WantMouse: true, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, }, }, { @@ -747,15 +795,19 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, - WantKeyboard: false, - WantMouse: false, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeNone, }, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - text := New(tc.opts...) + text, err := New(tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + got := text.Options() if diff := pretty.Compare(tc.want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) diff --git a/widgets/text/textdemo/textdemo.go b/widgets/text/textdemo/textdemo.go index 863f8ab..faf1ff6 100644 --- a/widgets/text/textdemo/textdemo.go +++ b/widgets/text/textdemo/textdemo.go @@ -23,11 +23,11 @@ import ( "time" "github.com/mum4k/termdash" - "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" - "github.com/mum4k/termdash/draw" - "github.com/mum4k/termdash/terminal/termbox" - "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/internal/cell" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/terminal/termbox" + "github.com/mum4k/termdash/internal/terminalapi" "github.com/mum4k/termdash/widgets/text" ) @@ -74,22 +74,34 @@ func main() { defer t.Close() ctx, cancel := context.WithCancel(context.Background()) - borderless := text.New() + borderless, err := text.New() + if err != nil { + panic(err) + } if err := borderless.Write("Text without border."); err != nil { panic(err) } - unicode := text.New() + unicode, err := text.New() + if err != nil { + panic(err) + } if err := unicode.Write("你好,世界!"); err != nil { panic(err) } - trimmed := text.New() + trimmed, err := text.New() + if err != nil { + panic(err) + } if err := trimmed.Write("Trims lines that don't fit onto the canvas because they are too long for its width.."); err != nil { panic(err) } - wrapped := text.New(text.WrapAtRunes()) + wrapped, err := text.New(text.WrapAtRunes()) + if err != nil { + panic(err) + } if err := wrapped.Write("Supports", text.WriteCellOpts(cell.FgColor(cell.ColorRed))); err != nil { panic(err) } @@ -100,7 +112,10 @@ func main() { panic(err) } - rolled := text.New(text.RollContent(), text.WrapAtRunes()) + rolled, err := text.New(text.RollContent(), text.WrapAtRunes()) + if err != nil { + panic(err) + } if err := rolled.Write("Rolls the content upwards if RollContent() option is provided.\nSupports keyboard and mouse scrolling.\n\n"); err != nil { panic(err) } diff --git a/widgets/text/write_options.go b/widgets/text/write_options.go index 1c94a04..36e7777 100644 --- a/widgets/text/write_options.go +++ b/widgets/text/write_options.go @@ -17,7 +17,7 @@ package text // write_options.go contains options used when writing content to the Text widget. import ( - "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/cell" ) // WriteOption is used to provide options to Write(). @@ -29,6 +29,7 @@ type WriteOption interface { // writeOptions stores the provided options. type writeOptions struct { cellOpts *cell.Options + replace bool } // newWriteOptions returns new writeOptions instance. @@ -56,3 +57,11 @@ func WriteCellOpts(opts ...cell.Option) WriteOption { wOpts.cellOpts = cell.NewOptions(opts...) }) } + +// WriteReplace instructs the text widget to replace the entire text content on +// this write instead of appending. +func WriteReplace() WriteOption { + return writeOption(func(wOpts *writeOptions) { + wOpts.replace = true + }) +}