diff --git a/.travis.yml b/.travis.yml index 307e0c4..69f0462 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ language: go go: - - 1.10.x - 1.11.x - 1.12.x + - 1.13.x - stable script: - go get -t ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d23fc..cc2be75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 7-Mar-2020 + +#### Breaking API changes + +- Termdash now requires at least Go version 1.11. + +### Added + +- New [`tcell`](https://github.com/gdamore/tcell) based terminal implementation + which implements the `terminalapi.Terminal` interface. +- tcell implementation supports two initialization `Option`s: + - `ColorMode` the terminal color output mode (defaults to 256 color mode) + - `ClearStyle` the foreground and background color style to use when clearing + the screen (defaults to the global ColorDefault for both foreground and + background) + +### Fixed + +- Improved test coverage of the `Gauge` widget. + ## [0.10.0] - 5-Jun-2019 ### Added @@ -295,7 +315,8 @@ identifiers shouldn't be used externally. - The Gauge widget. - The Text widget. -[unreleased]: https://github.com/mum4k/termdash/compare/v0.10.0...devel +[unreleased]: https://github.com/mum4k/termdash/compare/v0.11.0...devel +[0.11.0]: https://github.com/mum4k/termdash/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/mum4k/termdash/compare/v0.9.1...v0.10.0 [0.9.1]: https://github.com/mum4k/termdash/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/mum4k/termdash/compare/v0.8.0...v0.9.0 diff --git a/internal/numbers/numbers_test.go b/internal/numbers/numbers_test.go index 99b6d85..2dcc474 100644 --- a/internal/numbers/numbers_test.go +++ b/internal/numbers/numbers_test.go @@ -83,8 +83,7 @@ func TestZeroBeforeDecimal(t *testing.T) { } } -// Copied from the math package of Go 1.10 for backwards compatibility with Go -// 1.8 where the math.Round function doesn't exist yet. +// Copied from the Go's math package, file all_test.go. func tolerance(a, b, e float64) bool { // Multiplying by e here can underflow denormal values to zero. // Check a==b so that at least if a and b are small and identical @@ -107,18 +106,7 @@ func tolerance(a, b, e float64) bool { } return d < e } -func close(a, b float64) bool { return tolerance(a, b, 1e-14) } -func veryclose(a, b float64) bool { return tolerance(a, b, 4e-16) } -func soclose(a, b, e float64) bool { return tolerance(a, b, e) } -func alike(a, b float64) bool { - switch { - case math.IsNaN(a) && math.IsNaN(b): - return true - case a == b: - return math.Signbit(a) == math.Signbit(b) - } - return false -} +func veryclose(a, b float64) bool { return tolerance(a, b, 4e-16) } func TestMinMax(t *testing.T) { tests := []struct { diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go index f2b6ab2..647ab05 100644 --- a/termdashdemo/termdashdemo.go +++ b/termdashdemo/termdashdemo.go @@ -18,7 +18,9 @@ package main import ( "context" + "flag" "fmt" + "log" "math" "math/rand" "sync" @@ -31,6 +33,7 @@ import ( "github.com/mum4k/termdash/container/grid" "github.com/mum4k/termdash/keyboard" "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/terminal/tcell" "github.com/mum4k/termdash/terminal/termbox" "github.com/mum4k/termdash/terminal/terminalapi" "github.com/mum4k/termdash/widgets/barchart" @@ -466,8 +469,30 @@ func contLayout(w *widgets) ([]container.Option, error) { // rootID is the ID assigned to the root container. const rootID = "root" +// Terminal implementations +const ( + termboxTerminal = "termbox" + tcellTerminal = "tcell" +) + func main() { - t, err := termbox.New(termbox.ColorMode(terminalapi.ColorMode256)) + terminalPtr := flag.String("terminal", + "termbox", + "The terminal implementation to use. Available implementations are 'termbox' and 'tcell' (default = termbox).") + flag.Parse() + + var t terminalapi.Terminal + var err error + switch terminal := *terminalPtr; terminal { + case termboxTerminal: + t, err = termbox.New(termbox.ColorMode(terminalapi.ColorMode256)) + case tcellTerminal: + t, err = tcell.New(tcell.ColorMode(terminalapi.ColorMode256)) + default: + log.Fatalf("Unknown terminal implementation '%s' specified. Please choose between 'termbox' and 'tcell'.", terminal) + return + } + if err != nil { panic(err) } diff --git a/terminal/tcell/cell_options.go b/terminal/tcell/cell_options.go new file mode 100644 index 0000000..e0357a9 --- /dev/null +++ b/terminal/tcell/cell_options.go @@ -0,0 +1,62 @@ +// Copyright 2020 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 tcell + +import ( + "github.com/gdamore/tcell" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +// cellColor converts termdash cell color to the tcell format. +func cellColor(c cell.Color) tcell.Color { + return tcell.Color(c&0x1ff) - 1 +} + +// fixColor converts the target color for the current color mode +func fixColor(c tcell.Color, colorMode terminalapi.ColorMode) tcell.Color { + if c == tcell.ColorDefault { + return c + } + switch colorMode { + case terminalapi.ColorModeNormal: + c %= tcell.Color(16) + case terminalapi.ColorMode256: + c %= tcell.Color(256) + case terminalapi.ColorMode216: + c %= tcell.Color(216) + c += tcell.Color(16) + case terminalapi.ColorModeGrayscale: + c %= tcell.Color(24) + c += tcell.Color(232) + default: + c = tcell.ColorDefault + } + return c +} + +// cellOptsToStyle converts termdash cell color to the tcell format. +func cellOptsToStyle(opts *cell.Options, colorMode terminalapi.ColorMode) tcell.Style { + st := tcell.StyleDefault + + fg := cellColor(opts.FgColor) + bg := cellColor(opts.BgColor) + + fg = fixColor(fg, colorMode) + bg = fixColor(bg, colorMode) + + st = st.Foreground(fg).Background(bg) + return st +} diff --git a/terminal/tcell/cell_options_test.go b/terminal/tcell/cell_options_test.go new file mode 100644 index 0000000..b3df570 --- /dev/null +++ b/terminal/tcell/cell_options_test.go @@ -0,0 +1,157 @@ +// Copyright 2020 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 tcell + +import ( + "testing" + + "github.com/gdamore/tcell" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +func TestCellColor(t *testing.T) { + tests := []struct { + color cell.Color + want tcell.Color + }{ + {cell.ColorDefault, tcell.ColorDefault}, + {cell.ColorBlack, tcell.ColorBlack}, + {cell.ColorRed, tcell.ColorMaroon}, + {cell.ColorGreen, tcell.ColorGreen}, + {cell.ColorYellow, tcell.ColorOlive}, + {cell.ColorBlue, tcell.ColorNavy}, + {cell.ColorMagenta, tcell.ColorPurple}, + {cell.ColorCyan, tcell.ColorTeal}, + {cell.ColorWhite, tcell.ColorSilver}, + {cell.ColorNumber(42), tcell.Color(42)}, + } + + for _, tc := range tests { + t.Run(tc.color.String(), func(t *testing.T) { + got := cellColor(tc.color) + if got != tc.want { + t.Errorf("cellColor(%v) => got %v, want %v", tc.color, got, tc.want) + } + }) + } +} + +func TestFixColor(t *testing.T) { + tests := []struct { + colorMode terminalapi.ColorMode + color cell.Color + want tcell.Color + }{ + // See https://jonasjacek.github.io/colors/ for a good reference of all 256 xterm colors + // All 256 colors + {terminalapi.ColorMode256, cell.ColorDefault, tcell.ColorDefault}, + {terminalapi.ColorMode256, cell.ColorBlack, tcell.ColorBlack}, + {terminalapi.ColorMode256, cell.ColorRed, tcell.ColorMaroon}, + {terminalapi.ColorMode256, cell.ColorGreen, tcell.ColorGreen}, + {terminalapi.ColorMode256, cell.ColorYellow, tcell.ColorOlive}, + {terminalapi.ColorMode256, cell.ColorBlue, tcell.ColorNavy}, + {terminalapi.ColorMode256, cell.ColorMagenta, tcell.ColorPurple}, + {terminalapi.ColorMode256, cell.ColorCyan, tcell.ColorTeal}, + {terminalapi.ColorMode256, cell.ColorWhite, tcell.ColorSilver}, + {terminalapi.ColorMode256, cell.ColorNumber(42), tcell.Color(42)}, + // 8 system colors + {terminalapi.ColorModeNormal, cell.ColorDefault, tcell.ColorDefault}, + {terminalapi.ColorModeNormal, cell.ColorBlack, tcell.ColorBlack}, + {terminalapi.ColorModeNormal, cell.ColorRed, tcell.ColorMaroon}, + {terminalapi.ColorModeNormal, cell.ColorGreen, tcell.ColorGreen}, + {terminalapi.ColorModeNormal, cell.ColorYellow, tcell.ColorOlive}, + {terminalapi.ColorModeNormal, cell.ColorBlue, tcell.ColorNavy}, + {terminalapi.ColorModeNormal, cell.ColorMagenta, tcell.ColorPurple}, + {terminalapi.ColorModeNormal, cell.ColorCyan, tcell.ColorTeal}, + {terminalapi.ColorModeNormal, cell.ColorWhite, tcell.ColorSilver}, + {terminalapi.ColorModeNormal, cell.ColorNumber(42), tcell.Color(10)}, + // Grayscale colors (all the grey colours from 231 to 255) + {terminalapi.ColorModeGrayscale, cell.ColorDefault, tcell.ColorDefault}, + {terminalapi.ColorModeGrayscale, cell.ColorBlack, tcell.Color232}, + {terminalapi.ColorModeGrayscale, cell.ColorRed, tcell.Color233}, + {terminalapi.ColorModeGrayscale, cell.ColorGreen, tcell.Color234}, + {terminalapi.ColorModeGrayscale, cell.ColorYellow, tcell.Color235}, + {terminalapi.ColorModeGrayscale, cell.ColorBlue, tcell.Color236}, + {terminalapi.ColorModeGrayscale, cell.ColorMagenta, tcell.Color237}, + {terminalapi.ColorModeGrayscale, cell.ColorCyan, tcell.Color238}, + {terminalapi.ColorModeGrayscale, cell.ColorWhite, tcell.Color239}, + {terminalapi.ColorModeGrayscale, cell.ColorNumber(42), tcell.Color(250)}, + // 216 colors (16 to 231) + {terminalapi.ColorMode216, cell.ColorDefault, tcell.ColorDefault}, + {terminalapi.ColorMode216, cell.ColorBlack, tcell.Color16}, + {terminalapi.ColorMode216, cell.ColorRed, tcell.Color17}, + {terminalapi.ColorMode216, cell.ColorGreen, tcell.Color18}, + {terminalapi.ColorMode216, cell.ColorYellow, tcell.Color19}, + {terminalapi.ColorMode216, cell.ColorBlue, tcell.Color20}, + {terminalapi.ColorMode216, cell.ColorMagenta, tcell.Color21}, + {terminalapi.ColorMode216, cell.ColorCyan, tcell.Color22}, + {terminalapi.ColorMode216, cell.ColorWhite, tcell.Color23}, + {terminalapi.ColorMode216, cell.ColorNumber(42), tcell.Color(58)}, + // Unknown color mode + {-1, cell.ColorRed, tcell.ColorDefault}, + } + + for _, tc := range tests { + t.Run(tc.colorMode.String()+"_"+tc.color.String(), func(t *testing.T) { + color := cellColor(tc.color) + got := fixColor(color, tc.colorMode) + if got != tc.want { + t.Errorf("fixColor(%v_%v), => got %v, want %v", tc.colorMode, tc.color, got, tc.want) + } + }) + } +} + +func TestCellOptsToStyle(t *testing.T) { + tests := []struct { + colorMode terminalapi.ColorMode + opts cell.Options + want tcell.Style + }{ + { + colorMode: terminalapi.ColorMode256, + opts: cell.Options{FgColor: cell.ColorWhite, BgColor: cell.ColorBlack}, + want: tcell.StyleDefault.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + }, + { + colorMode: terminalapi.ColorModeNormal, + opts: cell.Options{FgColor: cell.ColorWhite, BgColor: cell.ColorBlack}, + want: tcell.StyleDefault.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + }, + { + colorMode: terminalapi.ColorModeGrayscale, + opts: cell.Options{FgColor: cell.ColorWhite, BgColor: cell.ColorBlack}, + want: tcell.StyleDefault.Foreground(tcell.Color239).Background(tcell.Color232), + }, + { + colorMode: terminalapi.ColorMode216, + opts: cell.Options{FgColor: cell.ColorWhite, BgColor: cell.ColorBlack}, + want: tcell.StyleDefault.Foreground(tcell.Color23).Background(tcell.Color16), + }, + } + + for _, tc := range tests { + t.Run(tc.opts.FgColor.String()+"+"+tc.opts.BgColor.String(), func(t *testing.T) { + got := cellOptsToStyle(&tc.opts, tc.colorMode) + if got != tc.want { + fg, bg, _ := got.Decompose() + wantFg, wantBg, _ := tc.want.Decompose() + t.Errorf("cellOptsToStyle(%v, fg=%v, bg=%v) => got (fg=%X, bg=%X), want (fg=%X, bg=%X)", + tc.colorMode, tc.opts.FgColor, tc.opts.BgColor, fg, bg, wantFg, wantBg) + } + }) + } +} diff --git a/terminal/tcell/event.go b/terminal/tcell/event.go new file mode 100644 index 0000000..a778dfe --- /dev/null +++ b/terminal/tcell/event.go @@ -0,0 +1,199 @@ +// Copyright 2020 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 tcell + +import ( + "image" + + "github.com/gdamore/tcell" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +// tcell representation of the space key +var tcellSpaceKey = tcell.Key(' ') + +// tcellToTd maps tcell key values to the termdash format. +var tcellToTd = map[tcell.Key]keyboard.Key{ + tcellSpaceKey: keyboard.KeySpace, + tcell.KeyF1: keyboard.KeyF1, + tcell.KeyF2: keyboard.KeyF2, + tcell.KeyF3: keyboard.KeyF3, + tcell.KeyF4: keyboard.KeyF4, + tcell.KeyF5: keyboard.KeyF5, + tcell.KeyF6: keyboard.KeyF6, + tcell.KeyF7: keyboard.KeyF7, + tcell.KeyF8: keyboard.KeyF8, + tcell.KeyF9: keyboard.KeyF9, + tcell.KeyF10: keyboard.KeyF10, + tcell.KeyF11: keyboard.KeyF11, + tcell.KeyF12: keyboard.KeyF12, + tcell.KeyInsert: keyboard.KeyInsert, + tcell.KeyDelete: keyboard.KeyDelete, + tcell.KeyHome: keyboard.KeyHome, + tcell.KeyEnd: keyboard.KeyEnd, + tcell.KeyPgUp: keyboard.KeyPgUp, + tcell.KeyPgDn: keyboard.KeyPgDn, + tcell.KeyUp: keyboard.KeyArrowUp, + tcell.KeyDown: keyboard.KeyArrowDown, + tcell.KeyLeft: keyboard.KeyArrowLeft, + tcell.KeyRight: keyboard.KeyArrowRight, + tcell.KeyEnter: keyboard.KeyEnter, + tcell.KeyCtrlA: keyboard.KeyCtrlA, + tcell.KeyCtrlB: keyboard.KeyCtrlB, + tcell.KeyCtrlC: keyboard.KeyCtrlC, + tcell.KeyCtrlD: keyboard.KeyCtrlD, + tcell.KeyCtrlE: keyboard.KeyCtrlE, + tcell.KeyCtrlF: keyboard.KeyCtrlF, + tcell.KeyCtrlG: keyboard.KeyCtrlG, + tcell.KeyCtrlJ: keyboard.KeyCtrlJ, + tcell.KeyCtrlK: keyboard.KeyCtrlK, + tcell.KeyCtrlL: keyboard.KeyCtrlL, + tcell.KeyCtrlN: keyboard.KeyCtrlN, + tcell.KeyCtrlO: keyboard.KeyCtrlO, + tcell.KeyCtrlP: keyboard.KeyCtrlP, + tcell.KeyCtrlQ: keyboard.KeyCtrlQ, + tcell.KeyCtrlR: keyboard.KeyCtrlR, + tcell.KeyCtrlS: keyboard.KeyCtrlS, + tcell.KeyCtrlT: keyboard.KeyCtrlT, + tcell.KeyCtrlU: keyboard.KeyCtrlU, + tcell.KeyCtrlV: keyboard.KeyCtrlV, + tcell.KeyCtrlW: keyboard.KeyCtrlW, + tcell.KeyCtrlX: keyboard.KeyCtrlX, + tcell.KeyCtrlY: keyboard.KeyCtrlY, + tcell.KeyCtrlZ: keyboard.KeyCtrlZ, + tcell.KeyBackspace: keyboard.KeyBackspace, + tcell.KeyTab: keyboard.KeyTab, + tcell.KeyEscape: keyboard.KeyEsc, + tcell.KeyCtrlBackslash: keyboard.KeyCtrlBackslash, + tcell.KeyCtrlRightSq: keyboard.KeyCtrlRsqBracket, + tcell.KeyCtrlUnderscore: keyboard.KeyCtrlUnderscore, + tcell.KeyBackspace2: keyboard.KeyBackspace2, + tcell.KeyCtrlSpace: keyboard.KeyCtrlSpace, +} + +// convKey converts a tcell keyboard event to the termdash format. +func convKey(event *tcell.EventKey) terminalapi.Event { + tcellKey := event.Key() + + if tcellKey == tcell.KeyRune { + ch := event.Rune() + return &terminalapi.Keyboard{ + Key: keyboard.Key(ch), + } + } + + k, ok := tcellToTd[tcellKey] + if !ok { + return terminalapi.NewErrorf("unknown keyboard key '%v' in a keyboard event %v", tcellKey, event.Name()) + } + + return &terminalapi.Keyboard{ + Key: k, + } +} + +// convMouse converts a tcell mouse event to the termdash format. +// Since tcell supports many combinations of mouse events, such as multiple mouse buttons pressed at the same time, +// this function returns nil if the event is unsupported by termdash. +func convMouse(event *tcell.EventMouse) terminalapi.Event { + var button mouse.Button + x, y := event.Position() + + tcellBtn := event.Buttons() + + // tcell uses signed int16 for button masks, and negative values are invalid + if tcellBtn < 0 { + return terminalapi.NewErrorf("unknown mouse key %v in a mouse event", tcellBtn) + } + + // Get wheel events + if tcellBtn&tcell.WheelUp != 0 { + button = mouse.ButtonWheelUp + } else if tcellBtn&tcell.WheelDown != 0 { + button = mouse.ButtonWheelDown + } + + // Return wheel event if found + if button > 0 { + return &terminalapi.Mouse{ + Position: image.Point{X: x, Y: y}, + Button: button, + } + } + + // Get only button events, not wheel events + tcellBtn &= tcell.ButtonMask(0xff) + switch tcellBtn = event.Buttons(); tcellBtn { + case tcell.ButtonNone: + button = mouse.ButtonRelease + case tcell.Button1: + button = mouse.ButtonLeft + case tcell.Button2: + button = mouse.ButtonRight + case tcell.Button3: + button = mouse.ButtonMiddle + default: + // Unknown event to termdash + return nil + } + + return &terminalapi.Mouse{ + Position: image.Point{X: x, Y: y}, + Button: button, + } +} + +// convResize converts a tcell resize event to the termdash format. +func convResize(event *tcell.EventResize) terminalapi.Event { + w, h := event.Size() + size := image.Point{X: w, Y: h} + if size.X < 0 || size.Y < 0 { + return terminalapi.NewErrorf("terminal resized to negative size: %v", size) + } + return &terminalapi.Resize{ + Size: size, + } +} + +// toTermdashEvents converts a tcell event to the termdash event format. +// This function returns nil if the event is unsupported by termdash. +func toTermdashEvents(event tcell.Event) []terminalapi.Event { + switch event := event.(type) { + case *tcell.EventInterrupt: + return []terminalapi.Event{ + terminalapi.NewError("event type EventInterrupt isn't supported"), + } + case *tcell.EventKey: + return []terminalapi.Event{convKey(event)} + case *tcell.EventMouse: + mouseEvent := convMouse(event) + if mouseEvent != nil { + return []terminalapi.Event{mouseEvent} + } + return nil + case *tcell.EventResize: + return []terminalapi.Event{convResize(event)} + case *tcell.EventError: + return []terminalapi.Event{ + terminalapi.NewErrorf("encountered tcell error event: %v", event), + } + default: + return []terminalapi.Event{ + terminalapi.NewErrorf("unknown tcell event type: %v", event), + } + } +} diff --git a/terminal/tcell/event_test.go b/terminal/tcell/event_test.go new file mode 100644 index 0000000..4d7e6c5 --- /dev/null +++ b/terminal/tcell/event_test.go @@ -0,0 +1,281 @@ +// Copyright 2020 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 tcell + +import ( + "errors" + "fmt" + "image" + "testing" + "time" + + "github.com/gdamore/tcell" + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +type mockUnknownEvent struct { +} + +func (m *mockUnknownEvent) When() time.Time { + return time.Now() +} + +func TestToTermdashEvents(t *testing.T) { + tests := []struct { + desc string + event tcell.Event + want []terminalapi.Event + }{ + { + desc: "unknown event type", + event: &mockUnknownEvent{}, + want: []terminalapi.Event{ + terminalapi.NewError("unknown tcell event type: &{}"), + }, + }, + { + desc: "interrupts aren't supported", + event: tcell.NewEventInterrupt(nil), + want: []terminalapi.Event{ + terminalapi.NewError("event type EventInterrupt isn't supported"), + }, + }, + { + desc: "error event", + event: tcell.NewEventError(errors.New("error event")), + want: []terminalapi.Event{ + terminalapi.NewError("encountered tcell error event: error event"), + }, + }, + { + desc: "resize event", + event: tcell.NewEventResize(640, 480), + want: []terminalapi.Event{ + &terminalapi.Resize{ + Size: image.Point{X: 640, Y: 480}, + }, + }, + }, + { + desc: "resize event to a negative size", + event: tcell.NewEventResize(-1, -1), + want: []terminalapi.Event{ + terminalapi.NewError("terminal resized to negative size: (-1,-1)"), + }, + }, + { + desc: "mouse event", + event: tcell.NewEventMouse(100, 200, tcell.Button1, tcell.ModNone), + want: []terminalapi.Event{ + &terminalapi.Mouse{ + Position: image.Point{X: 100, Y: 200}, + Button: mouse.ButtonLeft, + }, + }, + }, + { + desc: "keyboard event", + event: tcell.NewEventKey(tcell.KeyF1, 0, tcell.ModNone), + want: []terminalapi.Event{ + &terminalapi.Keyboard{ + Key: keyboard.KeyF1, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := toTermdashEvents(tc.event) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("toTermdashEvents => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestMouseButtons(t *testing.T) { + tests := []struct { + btnMask tcell.ButtonMask + want []mouse.Button + wantErr bool + }{ + {btnMask: -1, want: []mouse.Button{mouse.Button(-1)}, wantErr: true}, + {btnMask: tcell.Button1, want: []mouse.Button{mouse.ButtonLeft}}, + {btnMask: tcell.Button3, want: []mouse.Button{mouse.ButtonMiddle}}, + {btnMask: tcell.Button2, want: []mouse.Button{mouse.ButtonRight}}, + {btnMask: tcell.ButtonNone, want: []mouse.Button{mouse.ButtonRelease}}, + {btnMask: tcell.WheelUp, want: []mouse.Button{mouse.ButtonWheelUp}}, + {btnMask: tcell.WheelDown, want: []mouse.Button{mouse.ButtonWheelDown}}, + {btnMask: tcell.Button1 | tcell.Button2, want: nil}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("key:%v want:%v", tc.btnMask, tc.want), func(t *testing.T) { + + evs := toTermdashEvents(tcell.NewEventMouse(0, 0, tc.btnMask, tcell.ModNone)) + if got, want := len(evs), len(tc.want); got != want { + t.Fatalf("toTermdashEvents => got %d events, want %d", got, want) + } + + switch count := len(tc.want); count { + case 0: + // Events that may exist for the terminal implementation but are not supported by termdash will be nil + return + case 1: + // Proceed with test + default: + t.Fatalf("toTermdashEvents test case specified %d expected events, only one is supported", count) + } + if len(tc.want) == 0 { + return + } + + ev := evs[0] + if err, ok := ev.(*terminalapi.Error); ok != tc.wantErr { + t.Fatalf("toTermdashEvents => unexpected error:%v, wantErr: %v", err, tc.wantErr) + } + if _, ok := ev.(*terminalapi.Error); ok { + return + } + + switch e := ev.(type) { + case *terminalapi.Mouse: + if got := e.Button; got != tc.want[0] { + t.Errorf("toTermdashEvents => got %v, want %v", got, tc.want) + } + + default: + t.Fatalf("toTermdashEvents => unexpected event type %T", e) + } + }) + } +} + +func TestKeyboardKeys(t *testing.T) { + tests := []struct { + key tcell.Key + ch rune + want keyboard.Key + wantErr bool + }{ + {key: 2000, wantErr: true}, + {key: tcell.KeyRune, ch: 'a', want: 'a'}, + {key: tcell.KeyRune, ch: 'A', want: 'A'}, + {key: tcell.KeyRune, ch: 'z', want: 'z'}, + {key: tcell.KeyRune, ch: 'Z', want: 'Z'}, + {key: tcell.KeyRune, ch: '0', want: '0'}, + {key: tcell.KeyRune, ch: '9', want: '9'}, + {key: tcell.KeyRune, ch: '!', want: '!'}, + {key: tcell.KeyRune, ch: ')', want: ')'}, + {key: tcellSpaceKey, want: keyboard.KeySpace}, + {key: tcell.KeyF1, want: keyboard.KeyF1}, + {key: tcell.KeyF2, want: keyboard.KeyF2}, + {key: tcell.KeyF3, want: keyboard.KeyF3}, + {key: tcell.KeyF4, want: keyboard.KeyF4}, + {key: tcell.KeyF5, want: keyboard.KeyF5}, + {key: tcell.KeyF6, want: keyboard.KeyF6}, + {key: tcell.KeyF7, want: keyboard.KeyF7}, + {key: tcell.KeyF8, want: keyboard.KeyF8}, + {key: tcell.KeyF9, want: keyboard.KeyF9}, + {key: tcell.KeyF10, want: keyboard.KeyF10}, + {key: tcell.KeyF11, want: keyboard.KeyF11}, + {key: tcell.KeyF12, want: keyboard.KeyF12}, + {key: tcell.KeyInsert, want: keyboard.KeyInsert}, + {key: tcell.KeyDelete, want: keyboard.KeyDelete}, + {key: tcell.KeyHome, want: keyboard.KeyHome}, + {key: tcell.KeyEnd, want: keyboard.KeyEnd}, + {key: tcell.KeyPgUp, want: keyboard.KeyPgUp}, + {key: tcell.KeyPgDn, want: keyboard.KeyPgDn}, + {key: tcell.KeyUp, want: keyboard.KeyArrowUp}, + {key: tcell.KeyDown, want: keyboard.KeyArrowDown}, + {key: tcell.KeyLeft, want: keyboard.KeyArrowLeft}, + {key: tcell.KeyRight, want: keyboard.KeyArrowRight}, + {key: tcell.KeyCtrlSpace, want: keyboard.KeyCtrlTilde}, + {key: tcell.KeyCtrlA, want: keyboard.KeyCtrlA}, + {key: tcell.KeyCtrlB, want: keyboard.KeyCtrlB}, + {key: tcell.KeyCtrlC, want: keyboard.KeyCtrlC}, + {key: tcell.KeyCtrlD, want: keyboard.KeyCtrlD}, + {key: tcell.KeyCtrlE, want: keyboard.KeyCtrlE}, + {key: tcell.KeyCtrlF, want: keyboard.KeyCtrlF}, + {key: tcell.KeyCtrlG, want: keyboard.KeyCtrlG}, + {key: tcell.KeyBackspace, want: keyboard.KeyBackspace}, + {key: tcell.KeyBackspace, want: keyboard.KeyCtrlH}, + {key: tcell.KeyCtrlH, want: keyboard.KeyBackspace}, + {key: tcell.KeyTab, want: keyboard.KeyTab}, + {key: tcell.KeyTab, want: keyboard.KeyCtrlI}, + {key: tcell.KeyCtrlI, want: keyboard.KeyTab}, + {key: tcell.KeyCtrlJ, want: keyboard.KeyCtrlJ}, + {key: tcell.KeyCtrlK, want: keyboard.KeyCtrlK}, + {key: tcell.KeyCtrlL, want: keyboard.KeyCtrlL}, + {key: tcell.KeyEnter, want: keyboard.KeyEnter}, + {key: tcell.KeyEnter, want: keyboard.KeyCtrlM}, + {key: tcell.KeyCtrlM, want: keyboard.KeyEnter}, + {key: tcell.KeyCtrlN, want: keyboard.KeyCtrlN}, + {key: tcell.KeyCtrlO, want: keyboard.KeyCtrlO}, + {key: tcell.KeyCtrlP, want: keyboard.KeyCtrlP}, + {key: tcell.KeyCtrlQ, want: keyboard.KeyCtrlQ}, + {key: tcell.KeyCtrlR, want: keyboard.KeyCtrlR}, + {key: tcell.KeyCtrlS, want: keyboard.KeyCtrlS}, + {key: tcell.KeyCtrlT, want: keyboard.KeyCtrlT}, + {key: tcell.KeyCtrlU, want: keyboard.KeyCtrlU}, + {key: tcell.KeyCtrlV, want: keyboard.KeyCtrlV}, + {key: tcell.KeyCtrlW, want: keyboard.KeyCtrlW}, + {key: tcell.KeyCtrlX, want: keyboard.KeyCtrlX}, + {key: tcell.KeyCtrlY, want: keyboard.KeyCtrlY}, + {key: tcell.KeyCtrlZ, want: keyboard.KeyCtrlZ}, + {key: tcell.KeyEsc, want: keyboard.KeyEsc}, + {key: tcell.KeyEsc, want: keyboard.KeyCtrlLsqBracket}, + {key: tcell.KeyEsc, want: keyboard.KeyCtrl3}, + {key: tcell.KeyCtrlLeftSq, want: keyboard.KeyEsc}, + {key: tcell.KeyCtrlBackslash, want: keyboard.KeyCtrl4}, + {key: tcell.KeyCtrlRightSq, want: keyboard.KeyCtrl5}, + {key: tcell.KeyCtrlUnderscore, want: keyboard.KeyCtrlUnderscore}, + {key: tcell.KeyBackspace2, want: keyboard.KeyBackspace2}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("key:%v and ch:%v want:%v", tc.key, tc.ch, tc.want), func(t *testing.T) { + evs := toTermdashEvents(tcell.NewEventKey(tc.key, tc.ch, tcell.ModNone)) + + gotCount := len(evs) + wantCount := 1 + if gotCount != wantCount { + t.Fatalf("toTermdashEvents => got %d events, want %d, events were:\n%v", gotCount, wantCount, pretty.Sprint(evs)) + } + ev := evs[0] + + if err, ok := ev.(*terminalapi.Error); ok != tc.wantErr { + t.Fatalf("toTermdashEvents => unexpected error:%v, wantErr: %v", err, tc.wantErr) + } + if _, ok := ev.(*terminalapi.Error); ok { + return + } + + switch e := ev.(type) { + case *terminalapi.Keyboard: + if got, want := e.Key, tc.want; got != want { + t.Errorf("toTermdashEvents => got key %v, want %v", got, want) + } + + default: + t.Fatalf("toTermdashEvents => unexpected event type %T", e) + } + }) + } +} diff --git a/terminal/tcell/tcell.go b/terminal/tcell/tcell.go new file mode 100644 index 0000000..6177202 --- /dev/null +++ b/terminal/tcell/tcell.go @@ -0,0 +1,200 @@ +// Copyright 2020 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 tcell + +import ( + "context" + "image" + + "github.com/gdamore/tcell" + "github.com/gdamore/tcell/encoding" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/event/eventqueue" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +// Option is used to provide options. +type Option interface { + // set sets the provided option. + set(*Terminal) +} + +// option implements Option. +type option func(*Terminal) + +// set implements Option.set. +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 + }) +} + +// ClearStyle sets the style to use for tcell when clearing the screen. +// Defaults to ColorDefault for foreground and background. +func ClearStyle(fg, bg cell.Color) Option { + return option(func(t *Terminal) { + t.clearStyle = &cell.Options{ + FgColor: fg, + BgColor: bg, + } + }) +} + +// Terminal provides input and output to a real terminal. Wraps the +// gdamore/tcell terminal implementation. This object is not thread-safe. +// Implements terminalapi.Terminal. +type Terminal struct { + // events is a queue of input events. + events *eventqueue.Unbound + + // done gets closed when Close() is called. + done chan struct{} + + // the tcell terminal window + screen tcell.Screen + + // Options. + colorMode terminalapi.ColorMode + clearStyle *cell.Options +} + +// newTerminal creates the terminal and applies the options. +func newTerminal(opts ...Option) (*Terminal, error) { + screen, err := tcell.NewScreen() + if err != nil { + return nil, err + } + + t := &Terminal{ + events: eventqueue.New(), + done: make(chan struct{}), + colorMode: DefaultColorMode, + clearStyle: &cell.Options{ + FgColor: cell.ColorDefault, + BgColor: cell.ColorDefault, + }, + screen: screen, + } + for _, opt := range opts { + opt.set(t) + } + + return t, nil +} + +// New returns a new tcell based Terminal. +// Call Close() when the terminal isn't required anymore. +func New(opts ...Option) (*Terminal, error) { + // Enable full character set support for tcell + encoding.Register() + + t, err := newTerminal(opts...) + if err != nil { + return nil, err + } + if err = t.screen.Init(); err != nil { + return nil, err + } + + clearStyle := cellOptsToStyle(t.clearStyle, t.colorMode) + t.screen.EnableMouse() + t.screen.SetStyle(clearStyle) + + go t.pollEvents() // Stops when Close() is called. + return t, nil +} + +// Size implements terminalapi.Terminal.Size. +func (t *Terminal) Size() image.Point { + w, h := t.screen.Size() + return image.Point{ + X: w, + Y: h, + } +} + +// Clear implements terminalapi.Terminal.Clear. +func (t *Terminal) Clear(opts ...cell.Option) error { + o := cell.NewOptions(opts...) + st := cellOptsToStyle(o, t.colorMode) + t.screen.Fill(' ', st) + return nil +} + +// Flush implements terminalapi.Terminal.Flush. +func (t *Terminal) Flush() error { + t.screen.Show() + return nil +} + +// SetCursor implements terminalapi.Terminal.SetCursor. +func (t *Terminal) SetCursor(p image.Point) { + t.screen.ShowCursor(p.X, p.Y) +} + +// HideCursor implements terminalapi.Terminal.HideCursor. +func (t *Terminal) HideCursor() { + t.screen.HideCursor() +} + +// SetCell implements terminalapi.Terminal.SetCell. +func (t *Terminal) SetCell(p image.Point, r rune, opts ...cell.Option) error { + o := cell.NewOptions(opts...) + st := cellOptsToStyle(o, t.colorMode) + t.screen.SetContent(p.X, p.Y, r, nil, st) + return nil +} + +// pollEvents polls and enqueues the input events. +func (t *Terminal) pollEvents() { + for { + select { + case <-t.done: + return + default: + } + + events := toTermdashEvents(t.screen.PollEvent()) + for _, ev := range events { + t.events.Push(ev) + } + } +} + +// Event implements terminalapi.Terminal.Event. +func (t *Terminal) Event(ctx context.Context) terminalapi.Event { + ev := t.events.Pull(ctx) + if ev == nil { + return nil + } + return ev +} + +// Close closes the terminal, should be called when the terminal isn't required +// anymore to return the screen to a sane state. +// Implements terminalapi.Terminal.Close. +func (t *Terminal) Close() { + close(t.done) + t.screen.Fini() +} diff --git a/terminal/tcell/tcell_test.go b/terminal/tcell/tcell_test.go new file mode 100644 index 0000000..6b8d79d --- /dev/null +++ b/terminal/tcell/tcell_test.go @@ -0,0 +1,116 @@ +// Copyright 2020 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 tcell + +import ( + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +func TestNewTerminalColorMode(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, err := newTerminal(tc.opts...) + if err != nil { + t.Errorf("newTerminal => unexpected error:\n%v", err) + } + + // Ignore these fields. + got.screen = nil + got.events = nil + got.done = nil + got.clearStyle = nil + + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("newTerminal => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestNewTerminalClearStyle(t *testing.T) { + tests := []struct { + desc string + opts []Option + want *Terminal + }{ + { + desc: "default options", + want: &Terminal{ + colorMode: terminalapi.ColorMode256, + clearStyle: &cell.Options{ + FgColor: cell.ColorDefault, + BgColor: cell.ColorDefault, + }, + }, + }, + { + desc: "sets clear style", + opts: []Option{ + ClearStyle(cell.ColorRed, cell.ColorBlue), + }, + want: &Terminal{ + colorMode: terminalapi.ColorMode256, + clearStyle: &cell.Options{ + FgColor: cell.ColorRed, + BgColor: cell.ColorBlue, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := newTerminal(tc.opts...) + if err != nil { + t.Errorf("newTerminal => unexpected error:\n%v", err) + } + + // Ignore these fields. + got.screen = nil + 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/terminal/terminalapi/terminalapi.go b/terminal/terminalapi/terminalapi.go index d45935a..831abc1 100644 --- a/terminal/terminalapi/terminalapi.go +++ b/terminal/terminalapi/terminalapi.go @@ -49,4 +49,8 @@ type Terminal interface { // This call blocks until the next event or cancellation of the context. // Returns nil when the context gets canceled. Event(ctx context.Context) Event + + // Close closes the underlying terminal implementation and should be called when + // the terminal isn't required anymore to return the screen to a sane state. + Close() } diff --git a/widgets/gauge/gauge.go b/widgets/gauge/gauge.go index feca8fb..bc3e92a 100644 --- a/widgets/gauge/gauge.go +++ b/widgets/gauge/gauge.go @@ -163,14 +163,10 @@ func (g *Gauge) progressText() string { return "" } - switch g.pt { - case progressTypePercent: + if g.pt == progressTypePercent { return fmt.Sprintf("%d%%", g.current) - case progressTypeAbsolute: - return fmt.Sprintf("%d/%d", g.current, g.total) - default: - return "" } + return fmt.Sprintf("%d/%d", g.current, g.total) } // gaugeText returns full text to be displayed within the gauge, i.e. the diff --git a/widgets/gauge/gauge_test.go b/widgets/gauge/gauge_test.go index f05a13f..ec80ca5 100644 --- a/widgets/gauge/gauge_test.go +++ b/widgets/gauge/gauge_test.go @@ -15,6 +15,7 @@ package gauge import ( + "fmt" "image" "testing" @@ -27,6 +28,7 @@ import ( "github.com/mum4k/termdash/internal/draw/testdraw" "github.com/mum4k/termdash/internal/faketerm" "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/terminal/terminalapi" "github.com/mum4k/termdash/widgetapi" ) @@ -67,6 +69,58 @@ func TestGauge(t *testing.T) { }, wantErr: true, }, + { + 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 without progress text", + opts: []Option{ + Char('o'), + HideTextProgress(), + }, + percent: &percentCall{p: 35}, + canvas: image.Rect(0, 0, 10, 3), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustRectangle(c, image.Rect(0, 0, 3, 3), + draw.RectChar('o'), + draw.RectCellOpts(cell.BgColor(cell.ColorGreen)), + ) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "sets gauge color", + opts: []Option{ + Char('o'), + HideTextProgress(), + Color(cell.ColorBlue), + }, + percent: &percentCall{p: 35}, + canvas: image.Rect(0, 0, 10, 3), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustRectangle(c, image.Rect(0, 0, 3, 3), + draw.RectChar('o'), + draw.RectCellOpts(cell.BgColor(cell.ColorBlue)), + ) + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "gauge showing percentage", opts: []Option{ @@ -87,6 +141,24 @@ func TestGauge(t *testing.T) { return ft }, }, + { + desc: "fails when Percent is less than zero", + opts: []Option{ + Char('o'), + }, + percent: &percentCall{p: -1}, + canvas: image.Rect(0, 0, 10, 3), + wantUpdateErr: true, + }, + { + desc: "fails when Percent is more than 100", + opts: []Option{ + Char('o'), + }, + percent: &percentCall{p: 101}, + canvas: image.Rect(0, 0, 10, 3), + wantUpdateErr: true, + }, { desc: "draws resize needed character when canvas is smaller than requested", opts: []Option{ @@ -315,6 +387,33 @@ func TestGauge(t *testing.T) { return ft }, }, + { + desc: "fails when Absolute done is negative", + opts: []Option{ + Char('o'), + }, + absolute: &absoluteCall{done: -1, total: 100}, + canvas: image.Rect(0, 0, 10, 3), + wantUpdateErr: true, + }, + { + desc: "fails when Absolute total is zero", + opts: []Option{ + Char('o'), + }, + absolute: &absoluteCall{done: 0, total: 0}, + canvas: image.Rect(0, 0, 10, 3), + wantUpdateErr: true, + }, + { + desc: "fails when Absolute total is less than done", + opts: []Option{ + Char('o'), + }, + absolute: &absoluteCall{done: 10, total: 5}, + canvas: image.Rect(0, 0, 10, 3), + wantUpdateErr: true, + }, { desc: "gauge without text progress", opts: []Option{ @@ -804,6 +903,46 @@ func TestGauge(t *testing.T) { } } +func TestKeyboard(t *testing.T) { + g, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + if err := g.Keyboard(&terminalapi.Keyboard{}); err == nil { + t.Errorf("Keyboard => got nil err, wanted one") + } +} + +func TestMouse(t *testing.T) { + g, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + if err := g.Mouse(&terminalapi.Mouse{}); err == nil { + t.Errorf("Mouse => got nil err, wanted one") + } +} + +func TestProgressTypeString(t *testing.T) { + tests := []struct { + pt progressType + want string + }{ + {progressType(-1), "progressTypeUnknown"}, + {progressTypePercent, "progressTypePercent"}, + {progressTypeAbsolute, "progressTypeAbsolute"}, + } + + for i, tc := range tests { + t.Run(fmt.Sprintf("case(%d)", i), func(t *testing.T) { + got := tc.pt.String() + if tc.want != got { + t.Errorf("String => %q, want %q", got, tc.want) + } + }) + } +} + func TestOptions(t *testing.T) { tests := []struct { desc string