diff --git a/CHANGELOG.md b/CHANGELOG.md index 518dff7..fc25301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `TextInput` widget, an input field allowing interactive text input. + ### Changed - Widgets now get information whether their container is focused when Draw is @@ -26,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Termdash now requires at least Go version 1.10, which allows us to utilize `math.Round` instead of our own implementation and `strings.Builder` instead of `bytes.Buffer`. +- Terminal shortcuts like `Ctrl-A` no longer come as two separate events, + Termdash now mirrors termbox-go and sends these as one event. ## [0.8.0] - 30-Mar-2019 diff --git a/README.md b/README.md index 6ba7ada..4ae90a3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Termdash is a cross-platform customizable terminal based dashboard. -[termdashdemo](termdashdemo/termdashdemo.go) +[termdashdemo](termdashdemo/termdashdemo.go) The feature set is inspired by the [gizak/termui](http://github.com/gizak/termui) project, which in turn was @@ -86,6 +86,18 @@ go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go [buttondemo](widgets/button/buttondemo/buttondemo.go) +## The TextInput + +Allows users to interact with the application by entering, editing and +submitting text data. Run the +[textinputdemo](widgets/textinput/textinputdemo/textinputdemo.go). + +```go +go run github.com/mum4k/termdash/widgets/textinput/textinputdemo/textinputdemo.go +``` + +[textinputdemo](widgets/textinput/textinputdemo/textinputdemo.go) + ## The Gauge Displays the progress of an operation. Run the diff --git a/doc/images/termdashdemo_0_7_0.gif b/doc/images/termdashdemo_0_7_0.gif deleted file mode 100644 index b49214b..0000000 Binary files a/doc/images/termdashdemo_0_7_0.gif and /dev/null differ diff --git a/doc/images/termdashdemo_0_9_0.gif b/doc/images/termdashdemo_0_9_0.gif new file mode 100644 index 0000000..28e8a5b Binary files /dev/null and b/doc/images/termdashdemo_0_9_0.gif differ diff --git a/doc/images/textinputdemo.gif b/doc/images/textinputdemo.gif new file mode 100644 index 0000000..298dffb Binary files /dev/null and b/doc/images/textinputdemo.gif differ diff --git a/internal/area/area.go b/internal/area/area.go index c0b3a30..236b9b7 100644 --- a/internal/area/area.go +++ b/internal/area/area.go @@ -78,6 +78,29 @@ func VSplit(area image.Rectangle, widthPerc int) (left image.Rectangle, right im return left, right, nil } +// VSplitCells returns two new areas created by splitting the provided area +// after the specified amount of cells of its width. The number of cells must +// be a zero or a positive integer. Providing a zero returns left=image.ZR, +// right=area. Providing a number equal or larger to area's width returns +// left=area, right=image.ZR. +func VSplitCells(area image.Rectangle, cells int) (left image.Rectangle, right image.Rectangle, err error) { + if min := 0; cells < min { + return image.ZR, image.ZR, fmt.Errorf("invalid cells %d, must be a positive integer", cells) + } + if cells == 0 { + return image.ZR, area, nil + } + + width := area.Dx() + if cells >= width { + return area, image.ZR, nil + } + + left = image.Rect(area.Min.X, area.Min.Y, area.Min.X+cells, area.Max.Y) + right = image.Rect(area.Min.X+cells, area.Min.Y, area.Max.X, area.Max.Y) + return left, right, nil +} + // ExcludeBorder returns a new area created by subtracting a border around the // provided area. Return the zero area if there isn't enough space to exclude // the border. diff --git a/internal/area/area_test.go b/internal/area/area_test.go index cd92df9..16630cd 100644 --- a/internal/area/area_test.go +++ b/internal/area/area_test.go @@ -282,6 +282,91 @@ func TestVSplit(t *testing.T) { } } +func TestVSplitCells(t *testing.T) { + tests := []struct { + desc string + area image.Rectangle + cells int + wantLeft image.Rectangle + wantRight image.Rectangle + wantErr bool + }{ + { + desc: "fails on negative cells", + area: image.Rect(1, 1, 2, 2), + cells: -1, + wantErr: true, + }, + { + desc: "returns area as left on cells too large", + area: image.Rect(1, 1, 2, 2), + cells: 2, + wantLeft: image.Rect(1, 1, 2, 2), + wantRight: image.ZR, + }, + { + desc: "returns area as left on cells equal area width", + area: image.Rect(1, 1, 2, 2), + cells: 1, + wantLeft: image.Rect(1, 1, 2, 2), + wantRight: image.ZR, + }, + { + desc: "returns area as right on zero cells", + area: image.Rect(1, 1, 2, 2), + cells: 0, + wantRight: image.Rect(1, 1, 2, 2), + wantLeft: image.ZR, + }, + { + desc: "zero area to begin with", + area: image.ZR, + cells: 0, + wantLeft: image.ZR, + wantRight: image.ZR, + }, + { + desc: "splits area with even width", + area: image.Rect(1, 1, 3, 3), + cells: 1, + wantLeft: image.Rect(1, 1, 2, 3), + wantRight: image.Rect(2, 1, 3, 3), + }, + { + desc: "splits area with odd width", + area: image.Rect(1, 1, 4, 4), + cells: 1, + wantLeft: image.Rect(1, 1, 2, 4), + wantRight: image.Rect(2, 1, 4, 4), + }, + { + desc: "splits to unequal areas", + area: image.Rect(0, 0, 4, 4), + cells: 3, + wantLeft: image.Rect(0, 0, 3, 4), + wantRight: image.Rect(3, 0, 4, 4), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotLeft, gotRight, err := VSplitCells(tc.area, tc.cells) + if (err != nil) != tc.wantErr { + t.Errorf("VSplitCells => unexpected error:%v, wantErr:%v", err, tc.wantErr) + } + if err != nil { + return + } + if diff := pretty.Compare(tc.wantLeft, gotLeft); diff != "" { + t.Errorf("VSplitCells => left value unexpected diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.wantRight, gotRight); diff != "" { + t.Errorf("VSplitCells => right value unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + func TestExcludeBorder(t *testing.T) { tests := []struct { desc string diff --git a/internal/canvas/buffer/buffer.go b/internal/canvas/buffer/buffer.go index 0256bec..ba8c26b 100644 --- a/internal/canvas/buffer/buffer.go +++ b/internal/canvas/buffer/buffer.go @@ -115,6 +115,11 @@ func (b Buffer) SetCell(p image.Point, r rune, opts ...cell.Option) (int, error) return -1, err } rw := runewidth.RuneWidth(r) + if rw == 0 { + // Even if the rune is invisible, like the zero-value rune, it still + // occupies at least the target cell. + rw = 1 + } if rw > remW { return -1, fmt.Errorf("cannot set rune %q of width %d at point %v, only have %d remaining cells at this line", r, rw, p, remW) } diff --git a/internal/canvas/buffer/buffer_test.go b/internal/canvas/buffer/buffer_test.go index d59f3c6..f0c94da 100644 --- a/internal/canvas/buffer/buffer_test.go +++ b/internal/canvas/buffer/buffer_test.go @@ -411,6 +411,18 @@ func TestSetCell(t *testing.T) { return b }(), }, + { + desc: "sets zero-value rune in a cell", + buffer: mustNew(image.Point{3, 3}), + point: image.Point{1, 2}, + r: 0, + wantCells: 1, + want: func() Buffer { + b := mustNew(size) + b[1][2].Rune = 0 + return b + }(), + }, { desc: "sets cell options", buffer: mustNew(image.Point{3, 3}), diff --git a/internal/runewidth/runewidth_test.go b/internal/runewidth/runewidth_test.go index ce3ee6b..06a342c 100644 --- a/internal/runewidth/runewidth_test.go +++ b/internal/runewidth/runewidth_test.go @@ -66,12 +66,12 @@ func TestRuneWidth(t *testing.T) { }, { desc: "termdash special runes", - runes: []rune{'⇄', '…', '⇧', '⇩'}, + runes: []rune{'⇄', '…', '⇧', '⇩', '⇦', '⇨'}, want: 1, }, { desc: "termdash special runes in eastAsian", - runes: []rune{'⇄', '…', '⇧', '⇩'}, + runes: []rune{'⇄', '…', '⇧', '⇩', '⇦', '⇨'}, eastAsian: true, want: 1, }, diff --git a/keyboard/keyboard.go b/keyboard/keyboard.go index e8bc655..3a852b3 100644 --- a/keyboard/keyboard.go +++ b/keyboard/keyboard.go @@ -55,11 +55,40 @@ var buttonNames = map[Key]string{ KeyArrowDown: "KeyArrowDown", KeyArrowLeft: "KeyArrowLeft", KeyArrowRight: "KeyArrowRight", + KeyCtrlTilde: "KeyCtrlTilde", + KeyCtrlA: "KeyCtrlA", + KeyCtrlB: "KeyCtrlB", + KeyCtrlC: "KeyCtrlC", + KeyCtrlD: "KeyCtrlD", + KeyCtrlE: "KeyCtrlE", + KeyCtrlF: "KeyCtrlF", + KeyCtrlG: "KeyCtrlG", KeyBackspace: "KeyBackspace", KeyTab: "KeyTab", + KeyCtrlJ: "KeyCtrlJ", + KeyCtrlK: "KeyCtrlK", + KeyCtrlL: "KeyCtrlL", KeyEnter: "KeyEnter", + KeyCtrlN: "KeyCtrlN", + KeyCtrlO: "KeyCtrlO", + KeyCtrlP: "KeyCtrlP", + KeyCtrlQ: "KeyCtrlQ", + KeyCtrlR: "KeyCtrlR", + KeyCtrlS: "KeyCtrlS", + KeyCtrlT: "KeyCtrlT", + KeyCtrlU: "KeyCtrlU", + KeyCtrlV: "KeyCtrlV", + KeyCtrlW: "KeyCtrlW", + KeyCtrlX: "KeyCtrlX", + KeyCtrlY: "KeyCtrlY", + KeyCtrlZ: "KeyCtrlZ", KeyEsc: "KeyEsc", - KeyCtrl: "KeyCtrl", + KeyCtrl4: "KeyCtrl4", + KeyCtrl5: "KeyCtrl5", + KeyCtrl6: "KeyCtrl6", + KeyCtrl7: "KeyCtrl7", + KeySpace: "KeySpace", + KeyBackspace2: "KeyBackspace2", } // Printable characters, but worth having constants for them. @@ -91,9 +120,53 @@ const ( KeyArrowDown KeyArrowLeft KeyArrowRight + KeyCtrlTilde + KeyCtrlA + KeyCtrlB + KeyCtrlC + KeyCtrlD + KeyCtrlE + KeyCtrlF + KeyCtrlG KeyBackspace KeyTab + KeyCtrlJ + KeyCtrlK + KeyCtrlL KeyEnter + KeyCtrlN + KeyCtrlO + KeyCtrlP + KeyCtrlQ + KeyCtrlR + KeyCtrlS + KeyCtrlT + KeyCtrlU + KeyCtrlV + KeyCtrlW + KeyCtrlX + KeyCtrlY + KeyCtrlZ KeyEsc - KeyCtrl + KeyCtrl4 + KeyCtrl5 + KeyCtrl6 + KeyCtrl7 + KeyBackspace2 +) + +// Keys declared as duplicates by termbox. +const ( + KeyCtrl2 Key = KeyCtrlTilde + KeyCtrlSpace Key = KeyCtrlTilde + KeyCtrlH Key = KeyBackspace + KeyCtrlI Key = KeyTab + KeyCtrlM Key = KeyEnter + KeyCtrlLsqBracket Key = KeyEsc + KeyCtrl3 Key = KeyEsc + KeyCtrlBackslash Key = KeyCtrl4 + KeyCtrlRsqBracket Key = KeyCtrl5 + KeyCtrlSlash Key = KeyCtrl7 + KeyCtrlUnderscore Key = KeyCtrl7 + KeyCtrl8 Key = KeyBackspace2 ) diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go index 768057f..a37dd75 100644 --- a/termdashdemo/termdashdemo.go +++ b/termdashdemo/termdashdemo.go @@ -29,6 +29,7 @@ import ( "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" "github.com/mum4k/termdash/container/grid" + "github.com/mum4k/termdash/keyboard" "github.com/mum4k/termdash/linestyle" "github.com/mum4k/termdash/terminal/termbox" "github.com/mum4k/termdash/terminal/terminalapi" @@ -40,6 +41,7 @@ import ( "github.com/mum4k/termdash/widgets/segmentdisplay" "github.com/mum4k/termdash/widgets/sparkline" "github.com/mum4k/termdash/widgets/text" + "github.com/mum4k/termdash/widgets/textinput" ) // redrawInterval is how often termdash redraws the screen. @@ -48,6 +50,7 @@ const redrawInterval = 250 * time.Millisecond // widgets holds the widgets used by this demo. type widgets struct { segDist *segmentdisplay.SegmentDisplay + input *textinput.TextInput rollT *text.Text spGreen *sparkline.SparkLine spRed *sparkline.SparkLine @@ -64,10 +67,17 @@ type widgets struct { // newWidgets creates all widgets used by this demo. func newWidgets(ctx context.Context, c *container.Container) (*widgets, error) { - sd, err := newSegmentDisplay(ctx) + updateText := make(chan string) + sd, err := newSegmentDisplay(ctx, updateText) if err != nil { return nil, err } + + input, err := newTextInput(updateText) + if err != nil { + return nil, err + } + rollT, err := newRollText(ctx) if err != nil { return nil, err @@ -102,6 +112,7 @@ func newWidgets(ctx context.Context, c *container.Container) (*widgets, error) { } return &widgets{ segDist: sd, + input: input, rollT: rollT, spGreen: spGreen, spRed: spRed, @@ -138,9 +149,13 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) { grid.RowHeightPerc(25, grid.Widget(w.segDist, container.Border(linestyle.Light), - container.BorderTitle("Press Q to quit"), + container.BorderTitle("Press Esc to quit"), ), ), + grid.RowHeightPerc(5, + grid.Widget(w.input), + ), + grid.RowHeightPerc(5, grid.ColWidthPerc(25, grid.Widget(w.buttons.allB), @@ -159,7 +174,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) { switch lt { case layoutAll: leftRows = append(leftRows, - grid.RowHeightPerc(23, + grid.RowHeightPerc(20, grid.ColWidthPerc(50, grid.Widget(w.rollT, container.Border(linestyle.Light), @@ -188,7 +203,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) { container.BorderColor(cell.ColorNumber(39)), ), ), - grid.RowHeightPerc(35, + grid.RowHeightPerc(38, grid.Widget(w.heartLC, container.Border(linestyle.Light), container.BorderTitle("A LineChart"), @@ -315,40 +330,49 @@ func contLayout(w *widgets) ([]container.Option, error) { ), } - segmentTextSpark := []container.Option{ + textAndSparks := []container.Option{ + container.SplitVertical( + container.Left( + container.Border(linestyle.Light), + container.BorderTitle("A rolling text"), + container.PlaceWidget(w.rollT), + ), + container.Right( + container.SplitHorizontal( + container.Top( + container.Border(linestyle.Light), + container.BorderTitle("Green SparkLine"), + container.PlaceWidget(w.spGreen), + ), + container.Bottom( + container.Border(linestyle.Light), + container.BorderTitle("Red SparkLine"), + container.PlaceWidget(w.spRed), + ), + ), + ), + ), + } + + segmentTextInputSparks := []container.Option{ container.SplitHorizontal( container.Top( container.Border(linestyle.Light), - container.BorderTitle("Press Q to quit"), + container.BorderTitle("Press Esc to quit"), container.PlaceWidget(w.segDist), ), container.Bottom( container.SplitHorizontal( - container.Top(buttonRow...), - container.Bottom( - container.SplitVertical( - container.Left( - container.Border(linestyle.Light), - container.BorderTitle("A rolling text"), - container.PlaceWidget(w.rollT), - ), - container.Right( - container.SplitHorizontal( - container.Top( - container.Border(linestyle.Light), - container.BorderTitle("Green SparkLine"), - container.PlaceWidget(w.spGreen), - ), - container.Bottom( - container.Border(linestyle.Light), - container.BorderTitle("Red SparkLine"), - container.PlaceWidget(w.spRed), - ), - ), + container.Top( + container.SplitHorizontal( + container.Top( + container.PlaceWidget(w.input), ), + container.Bottom(buttonRow...), ), ), - container.SplitPercent(20), + container.Bottom(textAndSparks...), + container.SplitPercent(40), ), ), container.SplitPercent(50), @@ -374,7 +398,7 @@ func contLayout(w *widgets) ([]container.Option, error) { leftSide := []container.Option{ container.SplitHorizontal( - container.Top(segmentTextSpark...), + container.Top(segmentTextInputSparks...), container.Bottom(gaugeAndHeartbeat...), container.SplitPercent(50), ), @@ -475,7 +499,7 @@ func main() { } quitter := func(k *terminalapi.Keyboard) { - if k.Key == 'q' || k.Key == 'Q' { + if k.Key == keyboard.KeyEsc || k.Key == keyboard.KeyCtrlC { cancel() } } @@ -501,44 +525,99 @@ func periodic(ctx context.Context, interval time.Duration, fn func() error) { } } -// newSegmentDisplay creates a new SegmentDisplay that shows the Termdash name. -func newSegmentDisplay(ctx context.Context) (*segmentdisplay.SegmentDisplay, error) { +// textState creates a rotated state for the text we are displaying. +func textState(text string, capacity, step int) []rune { + if capacity == 0 { + return nil + } + + var state []rune + for i := 0; i < capacity; i++ { + state = append(state, ' ') + } + state = append(state, []rune(text)...) + step = step % len(state) + return rotateRunes(state, step) +} + +// newTextInput creates a new TextInput field that changes the text on the +// SegmentDisplay. +func newTextInput(updateText chan<- string) (*textinput.TextInput, error) { + input, err := textinput.New( + textinput.Label("Change text to: ", cell.FgColor(cell.ColorBlue)), + textinput.MaxWidthCells(20), + textinput.PlaceHolder("enter any text"), + textinput.OnSubmit(func(text string) error { + updateText <- text + return nil + }), + textinput.ClearOnSubmit(), + ) + if err != nil { + return nil, err + } + return input, err +} + +// newSegmentDisplay creates a new SegmentDisplay that initially shows the +// Termdash name. Shows any text that is sent over the channel. +func newSegmentDisplay(ctx context.Context, updateText <-chan string) (*segmentdisplay.SegmentDisplay, error) { sd, err := segmentdisplay.New() if err != nil { return nil, err } - const text = "Termdash" - colors := map[rune]cell.Color{ - 'T': cell.ColorBlue, - 'e': cell.ColorRed, - 'r': cell.ColorYellow, - 'm': cell.ColorBlue, - 'd': cell.ColorGreen, - 'a': cell.ColorRed, - 's': cell.ColorGreen, - 'h': cell.ColorRed, + colors := []cell.Color{ + cell.ColorBlue, + cell.ColorRed, + cell.ColorYellow, + cell.ColorBlue, + cell.ColorGreen, + cell.ColorRed, + cell.ColorGreen, + cell.ColorRed, } - var state []rune - for i := 0; i < len(text); i++ { - state = append(state, ' ') - } - state = append(state, []rune(text)...) - go periodic(ctx, 500*time.Millisecond, func() error { - var chunks []*segmentdisplay.TextChunk - for i := 0; i < len(text); i++ { - chunks = append(chunks, segmentdisplay.NewChunk( - string(state[i]), - segmentdisplay.WriteCellOpts(cell.FgColor(colors[state[i]])), - )) + text := "Termdash" + step := 0 + + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + state := textState(text, sd.Capacity(), step) + var chunks []*segmentdisplay.TextChunk + for i := 0; i < sd.Capacity(); i++ { + if i >= len(state) { + break + } + + color := colors[i%len(colors)] + chunks = append(chunks, segmentdisplay.NewChunk( + string(state[i]), + segmentdisplay.WriteCellOpts(cell.FgColor(color)), + )) + } + if len(chunks) == 0 { + continue + } + if err := sd.Write(chunks); err != nil { + panic(err) + } + step++ + + case t := <-updateText: + text = t + sd.Reset() + step = 0 + + case <-ctx.Done(): + return + } } - if err := sd.Write(chunks); err != nil { - return err - } - state = rotateRunes(state, 1) - return nil - }) + }() return sd, nil } diff --git a/terminal/termbox/event.go b/terminal/termbox/event.go index d4deae7..c26d88c 100644 --- a/terminal/termbox/event.go +++ b/terminal/termbox/event.go @@ -25,146 +25,84 @@ import ( tbx "github.com/nsf/termbox-go" ) -// newKeyboard creates a new termdash keyboard events with the provided keys. -func newKeyboard(keys ...keyboard.Key) []terminalapi.Event { - var evs []terminalapi.Event - for _, k := range keys { - evs = append(evs, &terminalapi.Keyboard{Key: k}) - } - return evs +// tbxToTd maps termbox key values to the termdash format. +var tbxToTd = map[tbx.Key]keyboard.Key{ + tbx.KeySpace: keyboard.KeySpace, + tbx.KeyF1: keyboard.KeyF1, + tbx.KeyF2: keyboard.KeyF2, + tbx.KeyF3: keyboard.KeyF3, + tbx.KeyF4: keyboard.KeyF4, + tbx.KeyF5: keyboard.KeyF5, + tbx.KeyF6: keyboard.KeyF6, + tbx.KeyF7: keyboard.KeyF7, + tbx.KeyF8: keyboard.KeyF8, + tbx.KeyF9: keyboard.KeyF9, + tbx.KeyF10: keyboard.KeyF10, + tbx.KeyF11: keyboard.KeyF11, + tbx.KeyF12: keyboard.KeyF12, + tbx.KeyInsert: keyboard.KeyInsert, + tbx.KeyDelete: keyboard.KeyDelete, + tbx.KeyHome: keyboard.KeyHome, + tbx.KeyEnd: keyboard.KeyEnd, + tbx.KeyPgup: keyboard.KeyPgUp, + tbx.KeyPgdn: keyboard.KeyPgDn, + tbx.KeyArrowUp: keyboard.KeyArrowUp, + tbx.KeyArrowDown: keyboard.KeyArrowDown, + tbx.KeyArrowLeft: keyboard.KeyArrowLeft, + tbx.KeyArrowRight: keyboard.KeyArrowRight, + tbx.KeyCtrlTilde: keyboard.KeyCtrlTilde, + tbx.KeyCtrlA: keyboard.KeyCtrlA, + tbx.KeyCtrlB: keyboard.KeyCtrlB, + tbx.KeyCtrlC: keyboard.KeyCtrlC, + tbx.KeyCtrlD: keyboard.KeyCtrlD, + tbx.KeyCtrlE: keyboard.KeyCtrlE, + tbx.KeyCtrlF: keyboard.KeyCtrlF, + tbx.KeyCtrlG: keyboard.KeyCtrlG, + tbx.KeyBackspace: keyboard.KeyBackspace, + tbx.KeyTab: keyboard.KeyTab, + tbx.KeyCtrlJ: keyboard.KeyCtrlJ, + tbx.KeyCtrlK: keyboard.KeyCtrlK, + tbx.KeyCtrlL: keyboard.KeyCtrlL, + tbx.KeyEnter: keyboard.KeyEnter, + tbx.KeyCtrlN: keyboard.KeyCtrlN, + tbx.KeyCtrlO: keyboard.KeyCtrlO, + tbx.KeyCtrlP: keyboard.KeyCtrlP, + tbx.KeyCtrlQ: keyboard.KeyCtrlQ, + tbx.KeyCtrlR: keyboard.KeyCtrlR, + tbx.KeyCtrlS: keyboard.KeyCtrlS, + tbx.KeyCtrlT: keyboard.KeyCtrlT, + tbx.KeyCtrlU: keyboard.KeyCtrlU, + tbx.KeyCtrlV: keyboard.KeyCtrlV, + tbx.KeyCtrlW: keyboard.KeyCtrlW, + tbx.KeyCtrlX: keyboard.KeyCtrlX, + tbx.KeyCtrlY: keyboard.KeyCtrlY, + tbx.KeyCtrlZ: keyboard.KeyCtrlZ, + tbx.KeyEsc: keyboard.KeyEsc, + tbx.KeyCtrl4: keyboard.KeyCtrl4, + tbx.KeyCtrl5: keyboard.KeyCtrl5, + tbx.KeyCtrl6: keyboard.KeyCtrl6, + tbx.KeyCtrl7: keyboard.KeyCtrl7, + tbx.KeyBackspace2: keyboard.KeyBackspace2, } // convKey converts a termbox keyboard event to the termdash format. -func convKey(tbxEv tbx.Event) []terminalapi.Event { +func convKey(tbxEv tbx.Event) terminalapi.Event { if tbxEv.Key != 0 && tbxEv.Ch != 0 { - return []terminalapi.Event{ - terminalapi.NewErrorf("the key event contain both a key(%v) and a character(%v)", tbxEv.Key, tbxEv.Ch), - } + return terminalapi.NewErrorf("the key event contain both a key(%v) and a character(%v)", tbxEv.Key, tbxEv.Ch) } if tbxEv.Ch != 0 { - return []terminalapi.Event{&terminalapi.Keyboard{ + return &terminalapi.Keyboard{ Key: keyboard.Key(tbxEv.Ch), - }} + } } - switch k := tbxEv.Key; k { - case tbx.KeySpace: - return newKeyboard(keyboard.KeySpace) - case tbx.KeyF1: - return newKeyboard(keyboard.KeyF1) - case tbx.KeyF2: - return newKeyboard(keyboard.KeyF2) - case tbx.KeyF3: - return newKeyboard(keyboard.KeyF3) - case tbx.KeyF4: - return newKeyboard(keyboard.KeyF4) - case tbx.KeyF5: - return newKeyboard(keyboard.KeyF5) - case tbx.KeyF6: - return newKeyboard(keyboard.KeyF6) - case tbx.KeyF7: - return newKeyboard(keyboard.KeyF7) - case tbx.KeyF8: - return newKeyboard(keyboard.KeyF8) - case tbx.KeyF9: - return newKeyboard(keyboard.KeyF9) - case tbx.KeyF10: - return newKeyboard(keyboard.KeyF10) - case tbx.KeyF11: - return newKeyboard(keyboard.KeyF11) - case tbx.KeyF12: - return newKeyboard(keyboard.KeyF12) - case tbx.KeyInsert: - return newKeyboard(keyboard.KeyInsert) - case tbx.KeyDelete: - return newKeyboard(keyboard.KeyDelete) - case tbx.KeyHome: - return newKeyboard(keyboard.KeyHome) - case tbx.KeyEnd: - return newKeyboard(keyboard.KeyEnd) - case tbx.KeyPgup: - return newKeyboard(keyboard.KeyPgUp) - case tbx.KeyPgdn: - return newKeyboard(keyboard.KeyPgDn) - case tbx.KeyArrowUp: - return newKeyboard(keyboard.KeyArrowUp) - case tbx.KeyArrowDown: - return newKeyboard(keyboard.KeyArrowDown) - case tbx.KeyArrowLeft: - return newKeyboard(keyboard.KeyArrowLeft) - case tbx.KeyArrowRight: - return newKeyboard(keyboard.KeyArrowRight) - case tbx.KeyBackspace /*, tbx.KeyCtrlH */ : - return newKeyboard(keyboard.KeyBackspace) - case tbx.KeyTab /*, tbx.KeyCtrlI */ : - return newKeyboard(keyboard.KeyTab) - case tbx.KeyEnter /*, tbx.KeyCtrlM*/ : - return newKeyboard(keyboard.KeyEnter) - case tbx.KeyEsc /*, tbx.KeyCtrlLsqBracket, tbx.KeyCtrl3 */ : - return newKeyboard(keyboard.KeyEsc) - case tbx.KeyCtrl2 /*, tbx.KeyCtrlTilde, tbx.KeyCtrlSpace */ : - return newKeyboard(keyboard.KeyCtrl, '2') - case tbx.KeyCtrl4 /*, tbx.KeyCtrlBackslash */ : - return newKeyboard(keyboard.KeyCtrl, '4') - case tbx.KeyCtrl5 /*, tbx.KeyCtrlRsqBracket */ : - return newKeyboard(keyboard.KeyCtrl, '5') - case tbx.KeyCtrl6: - return newKeyboard(keyboard.KeyCtrl, '6') - case tbx.KeyCtrl7 /*, tbx.KeyCtrlSlash, tbx.KeyCtrlUnderscore */ : - return newKeyboard(keyboard.KeyCtrl, '7') - case tbx.KeyCtrl8: - return newKeyboard(keyboard.KeyCtrl, '8') - case tbx.KeyCtrlA: - return newKeyboard(keyboard.KeyCtrl, 'a') - case tbx.KeyCtrlB: - return newKeyboard(keyboard.KeyCtrl, 'b') - case tbx.KeyCtrlC: - return newKeyboard(keyboard.KeyCtrl, 'c') - case tbx.KeyCtrlD: - return newKeyboard(keyboard.KeyCtrl, 'd') - case tbx.KeyCtrlE: - return newKeyboard(keyboard.KeyCtrl, 'e') - case tbx.KeyCtrlF: - return newKeyboard(keyboard.KeyCtrl, 'f') - case tbx.KeyCtrlG: - return newKeyboard(keyboard.KeyCtrl, 'g') - case tbx.KeyCtrlJ: - return newKeyboard(keyboard.KeyCtrl, 'j') - case tbx.KeyCtrlK: - return newKeyboard(keyboard.KeyCtrl, 'k') - case tbx.KeyCtrlL: - return newKeyboard(keyboard.KeyCtrl, 'l') - case tbx.KeyCtrlN: - return newKeyboard(keyboard.KeyCtrl, 'n') - case tbx.KeyCtrlO: - return newKeyboard(keyboard.KeyCtrl, 'o') - case tbx.KeyCtrlP: - return newKeyboard(keyboard.KeyCtrl, 'p') - case tbx.KeyCtrlQ: - return newKeyboard(keyboard.KeyCtrl, 'q') - case tbx.KeyCtrlR: - return newKeyboard(keyboard.KeyCtrl, 'r') - case tbx.KeyCtrlS: - return newKeyboard(keyboard.KeyCtrl, 's') - case tbx.KeyCtrlT: - return newKeyboard(keyboard.KeyCtrl, 't') - case tbx.KeyCtrlU: - return newKeyboard(keyboard.KeyCtrl, 'u') - case tbx.KeyCtrlV: - return newKeyboard(keyboard.KeyCtrl, 'v') - case tbx.KeyCtrlW: - return newKeyboard(keyboard.KeyCtrl, 'w') - case tbx.KeyCtrlX: - return newKeyboard(keyboard.KeyCtrl, 'x') - case tbx.KeyCtrlY: - return newKeyboard(keyboard.KeyCtrl, 'y') - case tbx.KeyCtrlZ: - return newKeyboard(keyboard.KeyCtrl, 'z') - default: - return []terminalapi.Event{ - terminalapi.NewErrorf("unknown keyboard key %v in a keyboard event", k), - } + k, ok := tbxToTd[tbxEv.Key] + if !ok { + return terminalapi.NewErrorf("unknown keyboard key '%v' in a keyboard event", k) + } + return &terminalapi.Keyboard{ + Key: k, } } @@ -230,7 +168,9 @@ func toTermdashEvents(tbxEv tbx.Event) []terminalapi.Event { case tbx.EventMouse: return []terminalapi.Event{convMouse(tbxEv)} case tbx.EventKey: - return convKey(tbxEv) + return []terminalapi.Event{ + convKey(tbxEv), + } default: return []terminalapi.Event{ terminalapi.NewErrorf("unknown termbox event type: %v", t), diff --git a/terminal/termbox/event_test.go b/terminal/termbox/event_test.go index 7746520..6ae2a4b 100644 --- a/terminal/termbox/event_test.go +++ b/terminal/termbox/event_test.go @@ -193,86 +193,99 @@ func TestKeyboardKeys(t *testing.T) { tests := []struct { key tbx.Key ch rune - want []keyboard.Key + want keyboard.Key wantErr bool }{ {key: tbx.KeyF1, ch: 'a', wantErr: true}, {key: 2000, wantErr: true}, - {ch: 'a', want: []keyboard.Key{'a'}}, - {ch: 'A', want: []keyboard.Key{'A'}}, - {ch: 'z', want: []keyboard.Key{'z'}}, - {ch: 'Z', want: []keyboard.Key{'Z'}}, - {ch: '0', want: []keyboard.Key{'0'}}, - {ch: '9', want: []keyboard.Key{'9'}}, - {ch: '!', want: []keyboard.Key{'!'}}, - {ch: ')', want: []keyboard.Key{')'}}, - {key: tbx.KeySpace, want: []keyboard.Key{keyboard.KeySpace}}, - {key: tbx.KeyF1, want: []keyboard.Key{keyboard.KeyF1}}, - {key: tbx.KeyF2, want: []keyboard.Key{keyboard.KeyF2}}, - {key: tbx.KeyF3, want: []keyboard.Key{keyboard.KeyF3}}, - {key: tbx.KeyF4, want: []keyboard.Key{keyboard.KeyF4}}, - {key: tbx.KeyF5, want: []keyboard.Key{keyboard.KeyF5}}, - {key: tbx.KeyF6, want: []keyboard.Key{keyboard.KeyF6}}, - {key: tbx.KeyF7, want: []keyboard.Key{keyboard.KeyF7}}, - {key: tbx.KeyF8, want: []keyboard.Key{keyboard.KeyF8}}, - {key: tbx.KeyF9, want: []keyboard.Key{keyboard.KeyF9}}, - {key: tbx.KeyF10, want: []keyboard.Key{keyboard.KeyF10}}, - {key: tbx.KeyF11, want: []keyboard.Key{keyboard.KeyF11}}, - {key: tbx.KeyF12, want: []keyboard.Key{keyboard.KeyF12}}, - {key: tbx.KeyInsert, want: []keyboard.Key{keyboard.KeyInsert}}, - {key: tbx.KeyDelete, want: []keyboard.Key{keyboard.KeyDelete}}, - {key: tbx.KeyHome, want: []keyboard.Key{keyboard.KeyHome}}, - {key: tbx.KeyEnd, want: []keyboard.Key{keyboard.KeyEnd}}, - {key: tbx.KeyPgup, want: []keyboard.Key{keyboard.KeyPgUp}}, - {key: tbx.KeyPgdn, want: []keyboard.Key{keyboard.KeyPgDn}}, - {key: tbx.KeyArrowUp, want: []keyboard.Key{keyboard.KeyArrowUp}}, - {key: tbx.KeyArrowDown, want: []keyboard.Key{keyboard.KeyArrowDown}}, - {key: tbx.KeyArrowLeft, want: []keyboard.Key{keyboard.KeyArrowLeft}}, - {key: tbx.KeyArrowRight, want: []keyboard.Key{keyboard.KeyArrowRight}}, - {key: tbx.KeyBackspace, want: []keyboard.Key{keyboard.KeyBackspace}}, - {key: tbx.KeyCtrlH, want: []keyboard.Key{keyboard.KeyBackspace}}, - {key: tbx.KeyTab, want: []keyboard.Key{keyboard.KeyTab}}, - {key: tbx.KeyCtrlI, want: []keyboard.Key{keyboard.KeyTab}}, - {key: tbx.KeyEnter, want: []keyboard.Key{keyboard.KeyEnter}}, - {key: tbx.KeyCtrlM, want: []keyboard.Key{keyboard.KeyEnter}}, - {key: tbx.KeyEsc, want: []keyboard.Key{keyboard.KeyEsc}}, - {key: tbx.KeyCtrlLsqBracket, want: []keyboard.Key{keyboard.KeyEsc}}, - {key: tbx.KeyCtrl3, want: []keyboard.Key{keyboard.KeyEsc}}, - {key: tbx.KeyCtrl2, want: []keyboard.Key{keyboard.KeyCtrl, '2'}}, - {key: tbx.KeyCtrlTilde, want: []keyboard.Key{keyboard.KeyCtrl, '2'}}, - {key: tbx.KeyCtrlSpace, want: []keyboard.Key{keyboard.KeyCtrl, '2'}}, - {key: tbx.KeyCtrl4, want: []keyboard.Key{keyboard.KeyCtrl, '4'}}, - {key: tbx.KeyCtrlBackslash, want: []keyboard.Key{keyboard.KeyCtrl, '4'}}, - {key: tbx.KeyCtrl5, want: []keyboard.Key{keyboard.KeyCtrl, '5'}}, - {key: tbx.KeyCtrlRsqBracket, want: []keyboard.Key{keyboard.KeyCtrl, '5'}}, - {key: tbx.KeyCtrl6, want: []keyboard.Key{keyboard.KeyCtrl, '6'}}, - {key: tbx.KeyCtrl7, want: []keyboard.Key{keyboard.KeyCtrl, '7'}}, - {key: tbx.KeyCtrlSlash, want: []keyboard.Key{keyboard.KeyCtrl, '7'}}, - {key: tbx.KeyCtrlUnderscore, want: []keyboard.Key{keyboard.KeyCtrl, '7'}}, - {key: tbx.KeyCtrl8, want: []keyboard.Key{keyboard.KeyCtrl, '8'}}, - {key: tbx.KeyCtrlA, want: []keyboard.Key{keyboard.KeyCtrl, 'a'}}, - {key: tbx.KeyCtrlB, want: []keyboard.Key{keyboard.KeyCtrl, 'b'}}, - {key: tbx.KeyCtrlC, want: []keyboard.Key{keyboard.KeyCtrl, 'c'}}, - {key: tbx.KeyCtrlD, want: []keyboard.Key{keyboard.KeyCtrl, 'd'}}, - {key: tbx.KeyCtrlE, want: []keyboard.Key{keyboard.KeyCtrl, 'e'}}, - {key: tbx.KeyCtrlF, want: []keyboard.Key{keyboard.KeyCtrl, 'f'}}, - {key: tbx.KeyCtrlG, want: []keyboard.Key{keyboard.KeyCtrl, 'g'}}, - {key: tbx.KeyCtrlJ, want: []keyboard.Key{keyboard.KeyCtrl, 'j'}}, - {key: tbx.KeyCtrlK, want: []keyboard.Key{keyboard.KeyCtrl, 'k'}}, - {key: tbx.KeyCtrlL, want: []keyboard.Key{keyboard.KeyCtrl, 'l'}}, - {key: tbx.KeyCtrlN, want: []keyboard.Key{keyboard.KeyCtrl, 'n'}}, - {key: tbx.KeyCtrlO, want: []keyboard.Key{keyboard.KeyCtrl, 'o'}}, - {key: tbx.KeyCtrlP, want: []keyboard.Key{keyboard.KeyCtrl, 'p'}}, - {key: tbx.KeyCtrlQ, want: []keyboard.Key{keyboard.KeyCtrl, 'q'}}, - {key: tbx.KeyCtrlR, want: []keyboard.Key{keyboard.KeyCtrl, 'r'}}, - {key: tbx.KeyCtrlS, want: []keyboard.Key{keyboard.KeyCtrl, 's'}}, - {key: tbx.KeyCtrlT, want: []keyboard.Key{keyboard.KeyCtrl, 't'}}, - {key: tbx.KeyCtrlU, want: []keyboard.Key{keyboard.KeyCtrl, 'u'}}, - {key: tbx.KeyCtrlV, want: []keyboard.Key{keyboard.KeyCtrl, 'v'}}, - {key: tbx.KeyCtrlW, want: []keyboard.Key{keyboard.KeyCtrl, 'w'}}, - {key: tbx.KeyCtrlX, want: []keyboard.Key{keyboard.KeyCtrl, 'x'}}, - {key: tbx.KeyCtrlY, want: []keyboard.Key{keyboard.KeyCtrl, 'y'}}, - {key: tbx.KeyCtrlZ, want: []keyboard.Key{keyboard.KeyCtrl, 'z'}}, + {ch: 'a', want: 'a'}, + {ch: 'A', want: 'A'}, + {ch: 'z', want: 'z'}, + {ch: 'Z', want: 'Z'}, + {ch: '0', want: '0'}, + {ch: '9', want: '9'}, + {ch: '!', want: '!'}, + {ch: ')', want: ')'}, + {key: tbx.KeySpace, want: keyboard.KeySpace}, + {key: tbx.KeyF1, want: keyboard.KeyF1}, + {key: tbx.KeyF2, want: keyboard.KeyF2}, + {key: tbx.KeyF3, want: keyboard.KeyF3}, + {key: tbx.KeyF4, want: keyboard.KeyF4}, + {key: tbx.KeyF5, want: keyboard.KeyF5}, + {key: tbx.KeyF6, want: keyboard.KeyF6}, + {key: tbx.KeyF7, want: keyboard.KeyF7}, + {key: tbx.KeyF8, want: keyboard.KeyF8}, + {key: tbx.KeyF9, want: keyboard.KeyF9}, + {key: tbx.KeyF10, want: keyboard.KeyF10}, + {key: tbx.KeyF11, want: keyboard.KeyF11}, + {key: tbx.KeyF12, want: keyboard.KeyF12}, + {key: tbx.KeyInsert, want: keyboard.KeyInsert}, + {key: tbx.KeyDelete, want: keyboard.KeyDelete}, + {key: tbx.KeyHome, want: keyboard.KeyHome}, + {key: tbx.KeyEnd, want: keyboard.KeyEnd}, + {key: tbx.KeyPgup, want: keyboard.KeyPgUp}, + {key: tbx.KeyPgdn, want: keyboard.KeyPgDn}, + {key: tbx.KeyArrowUp, want: keyboard.KeyArrowUp}, + {key: tbx.KeyArrowDown, want: keyboard.KeyArrowDown}, + {key: tbx.KeyArrowLeft, want: keyboard.KeyArrowLeft}, + {key: tbx.KeyArrowRight, want: keyboard.KeyArrowRight}, + {key: tbx.KeyCtrlTilde, want: keyboard.KeyCtrlTilde}, + {key: tbx.KeyCtrlTilde, want: keyboard.KeyCtrl2}, + {key: tbx.KeyCtrlTilde, want: keyboard.KeyCtrlSpace}, + {key: tbx.KeyCtrl2, want: keyboard.KeyCtrlTilde}, + {key: tbx.KeyCtrlSpace, want: keyboard.KeyCtrlTilde}, + {key: tbx.KeyCtrlA, want: keyboard.KeyCtrlA}, + {key: tbx.KeyCtrlB, want: keyboard.KeyCtrlB}, + {key: tbx.KeyCtrlC, want: keyboard.KeyCtrlC}, + {key: tbx.KeyCtrlD, want: keyboard.KeyCtrlD}, + {key: tbx.KeyCtrlE, want: keyboard.KeyCtrlE}, + {key: tbx.KeyCtrlF, want: keyboard.KeyCtrlF}, + {key: tbx.KeyCtrlG, want: keyboard.KeyCtrlG}, + {key: tbx.KeyBackspace, want: keyboard.KeyBackspace}, + {key: tbx.KeyBackspace, want: keyboard.KeyCtrlH}, + {key: tbx.KeyCtrlH, want: keyboard.KeyBackspace}, + {key: tbx.KeyTab, want: keyboard.KeyTab}, + {key: tbx.KeyTab, want: keyboard.KeyCtrlI}, + {key: tbx.KeyCtrlI, want: keyboard.KeyTab}, + {key: tbx.KeyCtrlJ, want: keyboard.KeyCtrlJ}, + {key: tbx.KeyCtrlK, want: keyboard.KeyCtrlK}, + {key: tbx.KeyCtrlL, want: keyboard.KeyCtrlL}, + {key: tbx.KeyEnter, want: keyboard.KeyEnter}, + {key: tbx.KeyEnter, want: keyboard.KeyCtrlM}, + {key: tbx.KeyCtrlM, want: keyboard.KeyEnter}, + {key: tbx.KeyCtrlN, want: keyboard.KeyCtrlN}, + {key: tbx.KeyCtrlO, want: keyboard.KeyCtrlO}, + {key: tbx.KeyCtrlP, want: keyboard.KeyCtrlP}, + {key: tbx.KeyCtrlQ, want: keyboard.KeyCtrlQ}, + {key: tbx.KeyCtrlR, want: keyboard.KeyCtrlR}, + {key: tbx.KeyCtrlS, want: keyboard.KeyCtrlS}, + {key: tbx.KeyCtrlT, want: keyboard.KeyCtrlT}, + {key: tbx.KeyCtrlU, want: keyboard.KeyCtrlU}, + {key: tbx.KeyCtrlV, want: keyboard.KeyCtrlV}, + {key: tbx.KeyCtrlW, want: keyboard.KeyCtrlW}, + {key: tbx.KeyCtrlX, want: keyboard.KeyCtrlX}, + {key: tbx.KeyCtrlY, want: keyboard.KeyCtrlY}, + {key: tbx.KeyCtrlZ, want: keyboard.KeyCtrlZ}, + {key: tbx.KeyEsc, want: keyboard.KeyEsc}, + {key: tbx.KeyEsc, want: keyboard.KeyCtrlLsqBracket}, + {key: tbx.KeyEsc, want: keyboard.KeyCtrl3}, + {key: tbx.KeyCtrlLsqBracket, want: keyboard.KeyEsc}, + {key: tbx.KeyCtrl3, want: keyboard.KeyEsc}, + {key: tbx.KeyCtrl4, want: keyboard.KeyCtrl4}, + {key: tbx.KeyCtrl4, want: keyboard.KeyCtrlBackslash}, + {key: tbx.KeyCtrlBackslash, want: keyboard.KeyCtrl4}, + {key: tbx.KeyCtrl5, want: keyboard.KeyCtrl5}, + {key: tbx.KeyCtrl5, want: keyboard.KeyCtrlRsqBracket}, + {key: tbx.KeyCtrlRsqBracket, want: keyboard.KeyCtrl5}, + {key: tbx.KeyCtrl6, want: keyboard.KeyCtrl6}, + {key: tbx.KeyCtrl7, want: keyboard.KeyCtrl7}, + {key: tbx.KeyCtrl7, want: keyboard.KeyCtrlSlash}, + {key: tbx.KeyCtrl7, want: keyboard.KeyCtrlUnderscore}, + {key: tbx.KeyCtrlSlash, want: keyboard.KeyCtrl7}, + {key: tbx.KeyCtrlUnderscore, want: keyboard.KeyCtrl7}, + {key: tbx.KeyBackspace2, want: keyboard.KeyBackspace2}, + {key: tbx.KeyBackspace2, want: keyboard.KeyCtrl8}, + {key: tbx.KeyCtrl8, want: keyboard.KeyBackspace2}, } for _, tc := range tests { @@ -284,34 +297,27 @@ func TestKeyboardKeys(t *testing.T) { }) gotCount := len(evs) - var wantCount int - if tc.wantErr { - wantCount = 1 - } else { - wantCount = len(tc.want) - } - + 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] - for i, ev := range evs { - 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 + 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) } - switch e := ev.(type) { - case *terminalapi.Keyboard: - if got, want := e.Key, tc.want[i]; got != want { - t.Errorf("toTermdashEvents => got key[%d] %v, want %v", got, i, want) - } - - default: - t.Fatalf("toTermdashEvents => unexpected event type %T", e) - } + default: + t.Fatalf("toTermdashEvents => unexpected event type %T", e) } }) } diff --git a/widgets/textinput/editor.go b/widgets/textinput/editor.go new file mode 100644 index 0000000..2977561 --- /dev/null +++ b/widgets/textinput/editor.go @@ -0,0 +1,417 @@ +// 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 textinput + +// editor.go contains data types that edit the content of the text input field. + +import ( + "fmt" + "strings" + + "github.com/mum4k/termdash/internal/numbers" + "github.com/mum4k/termdash/internal/runewidth" +) + +// fieldData are the data currently present inside the text input field. +type fieldData []rune + +// String implements fmt.Stringer. +func (fd fieldData) String() string { + var b strings.Builder + for _, r := range fd { + b.WriteRune(r) + } + return fmt.Sprintf("%q", b.String()) +} + +// insertAt inserts rune at the specified index. +func (fd *fieldData) insertAt(idx int, r rune) { + *fd = append( + (*fd)[:idx], + append(fieldData{r}, (*fd)[idx:]...)..., + ) +} + +// deleteAt deletes rune at the specified index. +func (fd *fieldData) deleteAt(idx int) { + *fd = append((*fd)[:idx], (*fd)[idx+1:]...) +} + +// cellsBefore given an endIdx calculates startIdx that results in range that +// will take at most the provided number of cells to print on the screen. +func (fd *fieldData) cellsBefore(cells, endIdx int) int { + if endIdx == 0 { + return 0 + } + + usedCells := 0 + for i := endIdx; i > 0; i-- { + prev := (*fd)[i-1] + width := runewidth.RuneWidth(prev) + + if usedCells+width > cells { + return i + } + usedCells += width + } + return 0 +} + +// cellsAfter given a startIdx calculates endIdx that results in range that +// will take at most the provided number of cells to print on the screen. +func (fd *fieldData) cellsAfter(cells, startIdx int) int { + if startIdx >= len(*fd) || cells == 0 { + return startIdx + } + + first := (*fd)[startIdx] + usedCells := runewidth.RuneWidth(first) + for i := startIdx + 1; i < len(*fd); i++ { + r := (*fd)[i] + width := runewidth.RuneWidth(r) + if usedCells+width > cells { + return i + } + usedCells += width + } + return len(*fd) +} + +// minForArrows is the smallest number of cells in the window where we can +// indicate hidden text with left and right arrow. +const minForArrows = 3 + +// curMinIdx returns the lowest acceptable index for cursor position that is +// still within the visible range. +func curMinIdx(start, cells int) int { + if start == 0 || cells < minForArrows { + // The very first rune is visible, so the cursor can go all the way to + // the start. + return start + } + + // When the first rune isn't visible, the cursor cannot go on the first + // cell in the visible range since it contains the left arrow. + return start + 1 +} + +// curMaxIdx returns the highest acceptable index for cursor position that is +// still within the visible range given the number of runes in data. +func curMaxIdx(start, end, cells, runeCount int) int { + if end == runeCount+1 || cells < minForArrows { + // The last rune is visible, so the cursor can go all the way to the + // end. + return end - 1 + } + + // When the last rune isn't visible, the cursor cannot go on the last cell + // in the window that is reserved for appending text, since it contains the + // right arrow. + return end - 2 +} + +// shiftLeft shifts the visible range left so that it again contains the +// cursor. +// The visible range includes all fieldData indexes +// in range start <= idx < end. +func (fd *fieldData) shiftLeft(start, cells, curDataPos int) (int, int) { + var startIdx int + switch { + case curDataPos == 0 || cells < minForArrows: + startIdx = curDataPos + + default: + startIdx = curDataPos - 1 + } + forRunes := cells - 1 + endIdx := fd.cellsAfter(forRunes, startIdx) + endIdx++ // Space for the cursor. + + return startIdx, endIdx +} + +// shiftRight shifts the visible range right so that it again contains the +// cursor. +// The visible range includes all fieldData indexes +// in range start <= idx < end. +func (fd *fieldData) shiftRight(start, cells, curDataPos int) (int, int) { + var endIdx int + switch dataLen := len(*fd); { + case curDataPos == dataLen: + // Cursor is in the empty space after the data. + // Print all runes until the end of data. + endIdx = dataLen + + default: + // Cursor is within the data, print all runes including the one the + // cursor is on. + endIdx = curDataPos + 1 + } + + forRunes := cells - 1 + startIdx := fd.cellsBefore(forRunes, endIdx) + + // Invariant, if counting form the back ends in the middle of a full-width + // rune, cellsAfter doesn't include the full-width rune. This means that we + // might have recovered space for one half-with rune at the end if there is + // one. + endIdx = fd.cellsAfter(forRunes, startIdx) + endIdx++ // Space for the cursor. + + return startIdx, endIdx +} + +// lastVisible given an end index of visible range asserts whether the last +// rune in the data is visible. +// The visible range includes all fieldData indexes +// in range start <= idx < end. +func (fd *fieldData) lastVisible(end int) bool { + return end-1 >= len(*fd) +} + +// runesIn returns all the runes in the visible range. +// The visible range includes all fieldData indexes +// in range start <= idx < end. +func (fd *fieldData) runesIn(start, end int) []rune { + var runes []rune + for i, r := range (*fd)[start:] { + if i+start > end-2 { // One last space is for the cursor after the text. + break + } + runes = append(runes, r) + } + return runes +} + +// fitRunes starting from the firstRune index returns runes that take at most +// the specified number of cells. The last cell is reserved for a cursor +// position used for appending new runes. +// This might return smaller number of runes than the size of the range, +// depending on the width of the individual runes. +// Returns the text and the start and end positions within the data. +func (fd *fieldData) fitRunes(firstRune, curPos, cells int) (string, int, int) { + forRunes := cells - 1 // One cell reserved for the cursor when appending. + + // Determine how many runes fit from the start. + start := firstRune + end := fd.cellsAfter(forRunes, start) + end++ + + if start > 0 && fd.lastVisible(end) { + // Start is in the middle, end is visible. + // Fit runes from the end. + end = len(*fd) + start = fd.cellsBefore(forRunes, end) + end++ // Space for the cursor within the visible range. + } + + // The fitting of runes might have resulted in a visible range that no + // longer contains the cursor (it became shorter) or the cursor was outside + // to begin with (due to cursorLeft() or cursorRight() calls). + // Shift the range so the cursor is again inside. + if curPos < curMinIdx(start, cells) { + start, end = fd.shiftLeft(start, cells, curPos) + } else if curPos > curMaxIdx(start, end, cells, len(*fd)) { + start, end = fd.shiftRight(start, cells, curPos) + } + + runes := fd.runesIn(start, end) + useArrows := cells >= minForArrows + var b strings.Builder + for i, r := range runes { + switch { + case useArrows && i == 0 && start > 0: + // Indicate that start is hidden by replacing the first visible + // rune with an arrow. + b.WriteRune('⇦') + if rw := runewidth.RuneWidth(r); rw == 2 { + // If the replaced rune was a full-width rune, place two arrows + // to keep the same space allocation as pre-calculated. + b.WriteRune('⇦') + } + + default: + b.WriteRune(r) + } + } + + if useArrows && !fd.lastVisible(end) { + // Indicate that end is hidden by placing an arrow at the end. + // THis has no impact on space allocation, since the last cell is + // always reserved for the cursor or the arrow. + b.WriteRune('⇨') + } + return b.String(), start, end +} + +// fieldEditor maintains the cursor position and allows editing of the data in +// the text input field. +// This object isn't thread-safe. +type fieldEditor struct { + // data are the data currently present in the text input field. + data fieldData + + // curDataPos is the current position of the cursor within the data. + // The cursor is allowed to go one cell beyond the data so appending is + // possible. + curDataPos int + + // firstRune is the index of the first displayed rune in the text input + // field. + firstRune int + + // width is the width of the text input field last time viewFor was called. + width int +} + +// newFieldEditor returns a new fieldEditor instance. +func newFieldEditor() *fieldEditor { + return &fieldEditor{} +} + +// minFieldWidth is the minimum supported width of the text input field. +const minFieldWidth = 4 + +// curCell returns the index of the cell the cursor is in within the text input field. +func (fe *fieldEditor) curCell(width int) int { + if width == 0 { + return 0 + } + // The index of rune within the visible range the cursor is at. + runeNum := fe.curDataPos - fe.firstRune + + cellNum := 0 + rn := 0 + for i, r := range fe.data { + if i < fe.firstRune { + continue + } + if rn >= runeNum { + break + } + rn++ + cellNum += runewidth.RuneWidth(r) + } + return cellNum +} + +// viewFor returns the currently visible data inside a text field with the +// specified width and the cursor position within the field. +func (fe *fieldEditor) viewFor(width int) (string, int, error) { + if min := minFieldWidth; width < min { // One for left arrow, two for one full-width rune and one for the cursor. + return "", -1, fmt.Errorf("width %d is too small, the minimum is %d", width, min) + } + runes, start, _ := fe.data.fitRunes(fe.firstRune, fe.curDataPos, width) + fe.firstRune = start + fe.width = width + return runes, fe.curCell(width), nil +} + +// content returns the string content in the field editor. +func (fe *fieldEditor) content() string { + return string(fe.data) +} + +// reset resets the content back to zero. +func (fe *fieldEditor) reset() { + *fe = *newFieldEditor() +} + +// insert inserts the rune at the current position of the cursor. +func (fe *fieldEditor) insert(r rune) { + rw := runewidth.RuneWidth(r) + if rw == 0 { + // Don't insert invisible runes. + return + } + fe.data.insertAt(fe.curDataPos, r) + fe.curDataPos++ +} + +// delete deletes the rune at the current position of the cursor. +func (fe *fieldEditor) delete() { + if fe.curDataPos >= len(fe.data) { + // Cursor not on a rune, nothing to do. + return + } + fe.data.deleteAt(fe.curDataPos) +} + +// deleteBefore deletes the rune that is immediately to the left of the cursor. +func (fe *fieldEditor) deleteBefore() { + if fe.curDataPos == 0 { + // Cursor at the beginning, nothing to do. + return + } + fe.cursorLeft() + fe.delete() +} + +// cursorRight moves the cursor one position to the right. +func (fe *fieldEditor) cursorRight() { + fe.curDataPos, _ = numbers.MinMaxInts([]int{fe.curDataPos + 1, len(fe.data)}) +} + +// cursorLeft moves the cursor one position to the left. +func (fe *fieldEditor) cursorLeft() { + _, fe.curDataPos = numbers.MinMaxInts([]int{fe.curDataPos - 1, 0}) +} + +// cursorStart moves the cursor to the beginning of the data. +func (fe *fieldEditor) cursorStart() { + fe.curDataPos = 0 +} + +// cursorEnd moves the cursor to the end of the data. +func (fe *fieldEditor) cursorEnd() { + fe.curDataPos = len(fe.data) +} + +// cursorRelCell sets the cursor onto the cell index within the visible +// area. +// If the index falls before the window, the cursor is moved onto the first +// visible position. +// If the pos falls after the end of data, the cursor is moved onto the last +// visible position. +func (fe *fieldEditor) cursorRelCell(cellIdx int) { + runes, start, end := fe.data.fitRunes(fe.firstRune, fe.curDataPos, fe.width) + minDataIdx := curMinIdx(start, fe.width) + maxDataIdx := curMaxIdx(start, end, fe.width, len(fe.data)) + + // Index of the rune we should move the cursor to relative to the visible + // range. + var relRuneIdx int + var cell int + for _, r := range runes { + cell += runewidth.RuneWidth(r) + if cell > cellIdx { + break + } + relRuneIdx++ + } + + // Absolute index of the rune we should move the cursor to. + dataIdx := fe.firstRune + relRuneIdx + switch { + case dataIdx < minDataIdx: + fe.curDataPos = minDataIdx + + case dataIdx > maxDataIdx: + fe.curDataPos = maxDataIdx + + default: + fe.curDataPos = dataIdx + } +} diff --git a/widgets/textinput/editor_test.go b/widgets/textinput/editor_test.go new file mode 100644 index 0000000..9656277 --- /dev/null +++ b/widgets/textinput/editor_test.go @@ -0,0 +1,1813 @@ +// 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 textinput + +import ( + "fmt" + "testing" + + "github.com/kylelemons/godebug/pretty" +) + +func TestData(t *testing.T) { + tests := []struct { + desc string + data fieldData + ops func(*fieldData) + want fieldData + }{ + { + desc: "appends to empty data", + ops: func(fd *fieldData) { + fd.insertAt(0, 'a') + }, + want: fieldData{'a'}, + }, + { + desc: "appends at the end of non-empty data", + data: fieldData{'a'}, + ops: func(fd *fieldData) { + fd.insertAt(1, 'b') + fd.insertAt(2, 'c') + }, + want: fieldData{'a', 'b', 'c'}, + }, + { + desc: "appends at the beginning of non-empty data", + data: fieldData{'a'}, + ops: func(fd *fieldData) { + fd.insertAt(0, 'b') + fd.insertAt(0, 'c') + }, + want: fieldData{'c', 'b', 'a'}, + }, + { + desc: "deletes the last rune, result in empty", + data: fieldData{'a'}, + ops: func(fd *fieldData) { + fd.deleteAt(0) + }, + want: fieldData{}, + }, + { + desc: "deletes the last rune, result in non-empty", + data: fieldData{'a', 'b'}, + ops: func(fd *fieldData) { + fd.deleteAt(1) + }, + want: fieldData{'a'}, + }, + { + desc: "deletes runes in the middle", + data: fieldData{'a', 'b', 'c', 'd'}, + ops: func(fd *fieldData) { + fd.deleteAt(1) + fd.deleteAt(1) + }, + want: fieldData{'a', 'd'}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := tc.data + if tc.ops != nil { + tc.ops(&got) + } + t.Logf(fmt.Sprintf("got: %s", got)) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("fieldData => unexpected diff (-want, +got):\n%s\n got: %q\nwant: %q", diff, got, tc.want) + } + }) + } +} + +func TestCellsBefore(t *testing.T) { + tests := []struct { + desc string + data fieldData + cells int + endIdx int + want int + }{ + { + desc: "empty data and range", + cells: 1, + endIdx: 0, + want: 0, + }, + { + desc: "requesting zero cells", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 0, + endIdx: 1, + want: 1, + }, + { + desc: "data only has one rune", + data: fieldData{'a'}, + cells: 1, + endIdx: 1, + want: 0, + }, + { + desc: "non-empty data and empty range", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 1, + endIdx: 0, + want: 0, + }, + { + desc: "more cells than runes from endIdx", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 10, + endIdx: 1, + want: 0, + }, + { + desc: "less cells than runes from endIdx, stops on half-width rune", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 1, + endIdx: 2, + want: 1, + }, + { + desc: "less cells than runes from endIdx, stops on full-width rune", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 2, + endIdx: 3, + want: 2, + }, + { + desc: "less cells than runes from endIdx, full-width rune doesn't fit, no space for arrows", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 2, + endIdx: 4, + want: 3, + }, + { + desc: "full-width runes only", + data: fieldData{'你', '好', '世', '界'}, + cells: 7, + endIdx: 4, + want: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := tc.data.cellsBefore(tc.cells, tc.endIdx) + if got != tc.want { + t.Errorf("cellsBefore => %d, want %d", got, tc.want) + } + }) + } +} + +func TestCellsAfter(t *testing.T) { + tests := []struct { + desc string + data fieldData + cells int + startIdx int + want int + }{ + { + desc: "empty data and range", + cells: 1, + startIdx: 0, + want: 0, + }, + { + desc: "empty data and range, non-zero start", + cells: 1, + startIdx: 1, + want: 1, + }, + { + desc: "data only has one rune", + data: fieldData{'a'}, + cells: 1, + startIdx: 0, + want: 1, + }, + { + desc: "non-empty data and empty range", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 0, + startIdx: 1, + want: 1, + }, + { + desc: "more cells than runes from startIdx", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 10, + startIdx: 1, + want: 4, + }, + { + desc: "less cells than runes from startIdx, stops on half-width rune", + data: fieldData{'a', 'b', '世', 'd', 'e', 'f'}, + cells: 2, + startIdx: 3, + want: 5, + }, + { + desc: "less cells than runes from startIdx, stops on full-width rune", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 3, + startIdx: 1, + want: 3, + }, + { + desc: "less cells than runes from startIdx, full-width rune doesn't fit", + data: fieldData{'a', 'b', '世', 'd'}, + cells: 3, + startIdx: 0, + want: 2, + }, + { + desc: "full-width runes only", + data: fieldData{'你', '好', '世', '界'}, + cells: 7, + startIdx: 0, + want: 3, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := tc.data.cellsAfter(tc.cells, tc.startIdx) + if got != tc.want { + t.Errorf("cellsAfter => %d, want %d", got, tc.want) + } + }) + } +} + +func TestCurCell(t *testing.T) { + tests := []struct { + desc string + data fieldData + firstRune int + curDataPos int + width int + want int + wantErr bool + }{ + { + desc: "empty data", + data: fieldData{}, + curDataPos: 0, + want: 0, + }, + { + desc: "cursor within the first page of data", + data: fieldData{'a', 'b', 'c', 'd'}, + firstRune: 1, + curDataPos: 2, + width: 3, + want: 1, + }, + { + desc: "cursor within the first page of data, after full-width rune", + data: fieldData{'a', '世', 'c', 'd'}, + firstRune: 1, + curDataPos: 2, + width: 3, + want: 2, + }, + { + desc: "cursor within the second page of data", + data: fieldData{'a', 'b', 'c', 'd', 'e', 'f'}, + firstRune: 3, + curDataPos: 4, + width: 3, + want: 1, + }, + { + desc: "cursor within the second page of data, after full-width rune", + data: fieldData{'a', 'b', 'c', '世', 'e', 'f'}, + firstRune: 3, + curDataPos: 4, + width: 3, + want: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + fe := newFieldEditor() + fe.data = tc.data + fe.firstRune = tc.firstRune + fe.curDataPos = tc.curDataPos + got := fe.curCell(tc.width) + if got != tc.want { + t.Errorf("curCell => %d, want %d", got, tc.want) + } + }) + } +} + +func TestFieldEditor(t *testing.T) { + tests := []struct { + desc string + width int + ops func(*fieldEditor) error + wantView string + wantContent string + wantCurIdx int + wantErr bool + }{ + { + desc: "fails for width too small", + width: 3, + wantErr: true, + }, + { + desc: "no data", + width: 4, + wantView: "", + wantContent: "", + wantCurIdx: 0, + }, + { + desc: "data and cursor fit exactly", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 3, + }, + { + desc: "longer data than the width, cursor at the end", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + return nil + }, + wantView: "⇦cd", + wantContent: "abcd", + wantCurIdx: 3, + }, + { + desc: "longer data than the width, cursor at the end, has full-width runes", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('世') + return nil + }, + wantView: "⇦世", + wantContent: "abc世", + wantCurIdx: 3, + }, + { + desc: "width decreased, adjusts cursor and shifts data", + width: 4, + ops: func(fe *fieldEditor) error { + if _, _, err := fe.viewFor(5); err != nil { + return err + } + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + return nil + }, + wantView: "⇦cd", + wantContent: "abcd", + wantCurIdx: 3, + }, + { + desc: "cursor won't go right beyond the end of the data", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦cd", + wantContent: "abcd", + wantCurIdx: 3, + }, + { + desc: "moves cursor to the left", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + return nil + }, + wantView: "⇦cd", + wantContent: "abcd", + wantCurIdx: 2, + }, + { + desc: "scrolls content to the left, start becomes visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "abc⇨", + wantContent: "abcd", + wantCurIdx: 1, + }, + { + desc: "scrolls content to the left, both ends invisible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦cd⇨", + wantContent: "abcde", + wantCurIdx: 1, + }, + { + desc: "scrolls left, then back right to make end visible again", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦de", + wantContent: "abcde", + wantCurIdx: 3, + }, + { + desc: "scrolls left, won't go beyond the start of data", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "abc⇨", + wantContent: "abcde", + wantCurIdx: 0, + }, + { + desc: "scrolls left, then back right won't go beyond the end of data", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦de", + wantContent: "abcde", + wantCurIdx: 3, + }, + { + desc: "have less data than width, all fits", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 3, + }, + { + desc: "moves cursor to the start", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + return nil + }, + wantView: "abc⇨", + wantContent: "abcde", + wantCurIdx: 0, + }, + { + desc: "moves cursor to the end", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorEnd() + return nil + }, + wantView: "⇦de", + wantContent: "abcde", + wantCurIdx: 3, + }, + { + desc: "deletesBefore when cursor after the data", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.deleteBefore() + return nil + }, + wantView: "⇦cd", + wantContent: "abcd", + wantCurIdx: 3, + }, + { + desc: "deletesBefore when cursor after the data, text has full-width rune", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('世') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.deleteBefore() + return nil + }, + wantView: "⇦世", + wantContent: "abc世", + wantCurIdx: 3, + }, + { + desc: "deletesBefore when cursor in the middle", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.deleteBefore() + return nil + }, + wantView: "acd⇨", + wantContent: "acde", + wantCurIdx: 1, + }, + { + desc: "deletesBefore when cursor in the middle, full-width runes", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('世') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.deleteBefore() + return nil + }, + wantView: "世c⇨", + wantContent: "世cde", + wantCurIdx: 2, + }, + { + desc: "deletesBefore does nothing when cursor at the start", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.deleteBefore() + return nil + }, + wantView: "abc⇨", + wantContent: "abcde", + wantCurIdx: 0, + }, + { + desc: "delete does nothing when cursor at the end", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.delete() + return nil + }, + wantView: "⇦de", + wantContent: "abcde", + wantCurIdx: 3, + }, + { + desc: "delete in the middle, last rune remains hidden", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.delete() + return nil + }, + wantView: "acd⇨", + wantContent: "acde", + wantCurIdx: 1, + }, + { + desc: "delete in the middle, last rune becomes visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.delete() + fe.delete() + return nil + }, + wantView: "ade", + wantContent: "ade", + wantCurIdx: 1, + }, + { + desc: "delete in the middle, last full-width rune would be invisible, shifts to keep cursor in window", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('世') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.delete() + fe.delete() + return nil + }, + wantView: "⇦世", + wantContent: "ab世", + wantCurIdx: 1, + }, + { + desc: "delete in the middle, last rune was and is visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.delete() + return nil + }, + wantView: "ac", + wantContent: "ac", + wantCurIdx: 1, + }, + { + desc: "delete in the middle, last full-width rune was and is visible", + width: 5, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('世') + if _, _, err := fe.viewFor(5); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(5); err != nil { + return err + } + fe.cursorRight() + fe.delete() + return nil + }, + wantView: "a世", + wantContent: "a世", + wantCurIdx: 1, + }, + { + desc: "delete last rune, contains full-width runes", + width: 5, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('世') + if _, _, err := fe.viewFor(5); err != nil { + return err + } + fe.cursorStart() + if _, _, err := fe.viewFor(5); err != nil { + return err + } + fe.delete() + fe.delete() + fe.delete() + return nil + }, + wantView: "", + wantContent: "", + wantCurIdx: 0, + }, + { + desc: "half-width runes only, exact fit", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 3, + }, + { + desc: "full-width runes only, exact fit", + width: 7, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + if _, _, err := fe.viewFor(7); err != nil { + return err + } + return nil + }, + wantView: "你好世", + wantContent: "你好世", + wantCurIdx: 6, + }, + { + desc: "half-width runes only, both ends hidden", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦cd⇨", + wantContent: "abcde", + wantCurIdx: 1, + }, + { + desc: "half-width runes only, both ends invisible, scrolls to make start visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "abc⇨", + wantContent: "abcde", + wantCurIdx: 1, + }, + { + desc: "half-width runes only, both ends invisible, deletes to make start visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.deleteBefore() + return nil + }, + wantView: "acd⇨", + wantContent: "acde", + wantCurIdx: 1, + }, + { + desc: "half-width runes only, deletion on second page refills the field", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + fe.insert('f') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.delete() + return nil + }, + wantView: "⇦df", + wantContent: "abcdf", + wantCurIdx: 2, + }, + { + desc: "half-width runes only, both ends invisible, scrolls to make end visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦de", + wantContent: "abcde", + wantCurIdx: 2, + }, + { + desc: "half-width runes only, both ends invisible, deletes to make end visible", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.delete() + return nil + }, + wantView: "⇦de", + wantContent: "abde", + wantCurIdx: 1, + }, + { + desc: "full-width runes only, both ends invisible", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦⇦世⇨", + wantContent: "你好世界", + wantCurIdx: 2, + }, + { + desc: "full-width runes only, both ends invisible, scrolls to make start visible", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + return nil + }, + wantView: "你好⇨", + wantContent: "你好世界", + wantCurIdx: 2, + }, + { + desc: "full-width runes only, both ends invisible, deletes to make start visible", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.deleteBefore() + return nil + }, + wantView: "你世⇨", + wantContent: "你世界", + wantCurIdx: 2, + }, + { + desc: "full-width runes only, both ends invisible, scrolls to make end visible", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRight() + return nil + }, + wantView: "⇦⇦界", + wantContent: "你好世界", + wantCurIdx: 2, + }, + { + desc: "full-width runes only, both ends invisible, deletes to make end visible", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.delete() + return nil + }, + wantView: "⇦⇦界", + wantContent: "你好界", + wantCurIdx: 2, + }, + { + desc: "scrolls to make full-width rune appear at the beginning", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('b') + fe.insert('c') + fe.insert('d') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "你b⇨", + wantContent: "你bcd", + wantCurIdx: 2, + }, + { + desc: "scrolls to make full-width rune appear at the end", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('你') + fe.cursorStart() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦你", + wantContent: "abc你", + wantCurIdx: 1, + }, + { + desc: "inserts after last full width rune, first is half-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('你') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.insert('e') + return nil + }, + wantView: "⇦c你e", + wantContent: "abc你e", + wantCurIdx: 5, + }, + { + desc: "inserts after last full width rune, first is half-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('世') + fe.insert('b') + fe.insert('你') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.insert('d') + return nil + }, + wantView: "⇦你d", + wantContent: "世b你d", + wantCurIdx: 4, + }, + { + desc: "inserts after last full width rune, hidden rune is full-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('世') + fe.insert('你') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.insert('c') + fe.insert('d') + return nil + }, + wantView: "⇦⇦cd", + wantContent: "世你cd", + wantCurIdx: 4, + }, + { + desc: "scrolls right, first is full-width, last are half-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('你') + fe.insert('世') + fe.insert('d') + fe.insert('e') + fe.insert('f') + fe.insert('g') + fe.insert('h') + fe.cursorStart() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦⇦def⇨", + wantContent: "a你世defgh", + wantCurIdx: 3, + }, + { + desc: "scrolls right, first is half-width, last is full-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('你') + fe.insert('世') + fe.insert('f') + fe.insert('g') + fe.insert('h') + fe.cursorStart() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦你世⇨", + wantContent: "abc你世fgh", + wantCurIdx: 3, + }, + { + desc: "scrolls right, first and last are full-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + fe.cursorStart() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦⇦世⇨", + wantContent: "你好世界", + wantCurIdx: 2, + }, + { + desc: "scrolls right, first and last are half-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + fe.insert('f') + fe.insert('g') + fe.cursorStart() + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + fe.cursorRight() + return nil + }, + wantView: "⇦cdef⇨", + wantContent: "abcdefg", + wantCurIdx: 4, + }, + { + desc: "scrolls left, first is full-width, last are half-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('你') + fe.insert('世') + fe.insert('d') + fe.insert('e') + fe.insert('f') + fe.insert('g') + fe.insert('h') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦⇦def⇨", + wantContent: "a你世defgh", + wantCurIdx: 2, + }, + { + desc: "scrolls left, first is half-width, last is full-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('你') + fe.insert('世') + fe.insert('f') + fe.insert('g') + fe.insert('h') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦你世⇨", + wantContent: "abc你世fgh", + wantCurIdx: 1, + }, + { + desc: "scrolls left, first and last are full-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦⇦世⇨", + wantContent: "你好世界", + wantCurIdx: 2, + }, + { + desc: "scrolls left, first and last are half-width", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + fe.insert('f') + fe.insert('g') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + return nil + }, + wantView: "⇦cdef⇨", + wantContent: "abcdefg", + wantCurIdx: 1, + }, + { + desc: "resets the field editor", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.reset() + return nil + }, + wantView: "", + wantContent: "", + wantCurIdx: 0, + }, + { + desc: "doesn't insert runes with rune width of zero", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('\x08') + fe.insert('c') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + return nil + }, + wantView: "ac", + wantContent: "ac", + wantCurIdx: 2, + }, + { + desc: "all text visible, moves cursor to position zero", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRelCell(0) + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 0, + }, + { + desc: "all text visible, moves cursor to position in the middle", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorRelCell(1) + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 1, + }, + { + desc: "all text visible, moves cursor back to the last character", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorStart() + fe.cursorRelCell(2) + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 2, + }, + { + desc: "all text visible, moves cursor to the appending space", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorStart() + fe.cursorRelCell(3) + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 3, + }, + { + desc: "all text visible, moves cursor before the beginning of data", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorStart() + fe.cursorRelCell(-1) + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 0, + }, + { + desc: "all text visible, moves cursor after the appending space", + width: 6, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + if _, _, err := fe.viewFor(6); err != nil { + return err + } + fe.cursorStart() + fe.cursorRelCell(10) + return nil + }, + wantView: "abc", + wantContent: "abc", + wantCurIdx: 3, + }, + { + desc: "moves cursor when there is no text", + width: 6, + ops: func(fe *fieldEditor) error { + fe.cursorRelCell(10) + return nil + }, + wantView: "", + wantContent: "", + wantCurIdx: 0, + }, + { + desc: "both ends hidden, moves cursor onto the left arrow", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRelCell(0) + return nil + }, + wantView: "⇦cd⇨", + wantContent: "abcde", + wantCurIdx: 1, + }, + { + desc: "both ends hidden, moves cursor onto the first character", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRight() + fe.cursorRelCell(1) + return nil + }, + wantView: "⇦cd⇨", + wantContent: "abcde", + wantCurIdx: 1, + }, + { + desc: "both ends hidden, moves cursor onto the right arrow", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRelCell(3) + return nil + }, + wantView: "⇦cd⇨", + wantContent: "abcde", + wantCurIdx: 2, + }, + { + desc: "both ends hidden, moves cursor onto the last character", + width: 4, + ops: func(fe *fieldEditor) error { + fe.insert('a') + fe.insert('b') + fe.insert('c') + fe.insert('d') + fe.insert('e') + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(4); err != nil { + return err + } + fe.cursorRelCell(2) + return nil + }, + wantView: "⇦cd⇨", + wantContent: "abcde", + wantCurIdx: 2, + }, + { + desc: "moves cursor onto the first cell containing a full-width rune", + width: 8, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + fe.insert('你') + if _, _, err := fe.viewFor(8); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(8); err != nil { + return err + } + fe.cursorRelCell(4) + return nil + }, + wantView: "⇦⇦世界⇨", + wantContent: "你好世界你", + wantCurIdx: 4, + }, + { + desc: "moves cursor onto the second cell containing a full-width rune", + width: 8, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + fe.insert('你') + if _, _, err := fe.viewFor(8); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(8); err != nil { + return err + } + fe.cursorRelCell(5) + return nil + }, + wantView: "⇦⇦世界⇨", + wantContent: "你好世界你", + wantCurIdx: 4, + }, + { + desc: "moves cursor onto the second right arrow", + width: 8, + ops: func(fe *fieldEditor) error { + fe.insert('你') + fe.insert('好') + fe.insert('世') + fe.insert('界') + fe.insert('你') + if _, _, err := fe.viewFor(8); err != nil { + return err + } + fe.cursorLeft() + fe.cursorLeft() + fe.cursorLeft() + if _, _, err := fe.viewFor(8); err != nil { + return err + } + fe.cursorRelCell(1) + return nil + }, + wantView: "⇦⇦世界⇨", + wantContent: "你好世界你", + wantCurIdx: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + fe := newFieldEditor() + if tc.ops != nil { + if err := tc.ops(fe); err != nil { + t.Fatalf("ops => unexpected error: %v", err) + } + } + + gotView, gotCurIdx, err := fe.viewFor(tc.width) + if (err != nil) != tc.wantErr { + t.Errorf("viewFor(%d) => unexpected error: %v, wantErr: %v", tc.width, err, tc.wantErr) + } + if err != nil { + return + } + + if gotView != tc.wantView || gotCurIdx != tc.wantCurIdx { + t.Errorf("viewFor(%d) => (%q, %d), want (%q, %d)", tc.width, gotView, gotCurIdx, tc.wantView, tc.wantCurIdx) + } + + gotContent := fe.content() + if gotContent != tc.wantContent { + t.Errorf("content -> %q, want %q", gotContent, tc.wantContent) + } + }) + } +} diff --git a/widgets/textinput/options.go b/widgets/textinput/options.go new file mode 100644 index 0000000..b170b24 --- /dev/null +++ b/widgets/textinput/options.go @@ -0,0 +1,265 @@ +// 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 textinput + +// options.go contains configurable options for TextInput. + +import ( + "fmt" + + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/runewidth" + "github.com/mum4k/termdash/internal/wrap" + "github.com/mum4k/termdash/linestyle" +) + +// 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 + placeHolderColor cell.Color + highlightedColor cell.Color + cursorColor cell.Color + border linestyle.LineStyle + borderColor cell.Color + + widthPerc *int + maxWidthCells *int + label string + labelCellOpts []cell.Option + labelAlign align.Horizontal + + placeHolder string + hideTextWith rune + + filter FilterFn + onSubmit SubmitFn + clearOnSubmit bool +} + +// validate validates the provided options. +func (o *options) validate() error { + if min, max, perc := 0, 100, o.widthPerc; perc != nil && (*perc <= min || *perc > max) { + return fmt.Errorf("invalid WidthPerc(%d), must be value in range %d < value <= %d", *perc, min, max) + } + if min, cells := 4, o.maxWidthCells; cells != nil && *cells < min { + return fmt.Errorf("invalid MaxWidthCells(%d), must be value in range %d <= value", *cells, min) + } + if r := o.hideTextWith; r != 0 { + if err := wrap.ValidText(string(r)); err != nil { + return fmt.Errorf("invalid HideTextWidth rune %c(%d): %v", r, r, err) + } + if got, want := runewidth.RuneWidth(r), 1; got != want { + return fmt.Errorf("invalid HideTextWidth rune %c(%d), has rune width of %d cells, only runes with width of %d are accepted", r, r, got, want) + } + } + return nil +} + +// newOptions returns options with the default values set. +func newOptions() *options { + return &options{ + fillColor: cell.ColorNumber(DefaultFillColorNumber), + placeHolderColor: cell.ColorNumber(DefaultPlaceHolderColorNumber), + highlightedColor: cell.ColorNumber(DefaultHighlightedColorNumber), + cursorColor: cell.ColorNumber(DefaultCursorColorNumber), + labelAlign: DefaultLabelAlign, + } +} + +// DefaultFillColorNumber is the default color number for the FillColor option. +const DefaultFillColorNumber = 33 + +// FillColor sets the fill color for the text input field. +// Defaults to DefaultFillColorNumber. +func FillColor(c cell.Color) Option { + return option(func(opts *options) { + opts.fillColor = c + }) +} + +// TextColor sets the color of the text in the input field. +// Defaults to the default terminal color. +func TextColor(c cell.Color) Option { + return option(func(opts *options) { + opts.textColor = c + }) +} + +// DefaultHighlightedColorNumber is the default color number for the +// HighlightedColor option. +const DefaultHighlightedColorNumber = 0 + +// HighlightedColor sets the color of the text rune directly under the cursor. +// Defaults to the default terminal color. +func HighlightedColor(c cell.Color) Option { + return option(func(opts *options) { + opts.highlightedColor = c + }) +} + +// DefaultCursorColorNumber is the default color number for the CursorColor +// option. +const DefaultCursorColorNumber = 250 + +// CursorColor sets the color of the cursor. +// Defaults to DefaultCursorColorNumber. +func CursorColor(c cell.Color) Option { + return option(func(opts *options) { + opts.cursorColor = c + }) +} + +// Border adds a border around the text input field. +func Border(ls linestyle.LineStyle) Option { + return option(func(opts *options) { + opts.border = ls + }) +} + +// BorderColor sets the color of the border. +// Defaults to the default terminal color. +func BorderColor(c cell.Color) Option { + return option(func(opts *options) { + opts.borderColor = c + }) +} + +// WidthPerc sets the width for the text input field as a percentage of the +// container width. Must be a value in the range 0 < perc <= 100. +// Defaults to the width adjusted automatically base on the label length. +func WidthPerc(perc int) Option { + return option(func(opts *options) { + opts.widthPerc = &perc + }) +} + +// MaxWidthCells sets the maximum width of the text input field as an absolute value +// in cells. Must be a value in the range 4 <= cells. +// This doesn't limit the text that the user can input, if the text overflows +// the width of the input field, it scrolls to the left. +// Defaults to using all available width in the container. +func MaxWidthCells(cells int) Option { + return option(func(opts *options) { + opts.maxWidthCells = &cells + }) +} + +// Label adds a text label to the left of the input field. +func Label(label string, cOpts ...cell.Option) Option { + return option(func(opts *options) { + opts.label = label + opts.labelCellOpts = cOpts + }) +} + +// DefaultLabelAlign is the default value for the LabelAlign option. +const DefaultLabelAlign = align.HorizontalLeft + +// LabelAlign sets the alignment of the label within its area. +// The label is placed to the left of the input field. The width of this area +// can be specified using the LabelWidthPerc option. +// Defaults to DefaultLabelAlign. +func LabelAlign(la align.Horizontal) Option { + return option(func(opts *options) { + opts.labelAlign = la + }) +} + +// PlaceHolder sets text to be displayed in the input field when it is empty. +// This text disappears when the text input field becomes focused. +func PlaceHolder(text string) Option { + return option(func(opts *options) { + opts.placeHolder = text + }) +} + +// DefaultPlaceHolderColorNumber is the default color number for the +// PlaceHolderColor option. +const DefaultPlaceHolderColorNumber = 194 + +// PlaceHolderColor sets the color of the placeholder text. +// Defaults to DefaultPlaceHolderColorNumber. +func PlaceHolderColor(c cell.Color) Option { + return option(func(opts *options) { + opts.placeHolderColor = c + }) +} + +// HideTextWith sets the rune that should be displayed instead of displaying +// the text. Useful for fields that accept sensitive information like +// passwords. +// The rune must be a printable rune with cell width of one. +func HideTextWith(r rune) Option { + return option(func(opts *options) { + opts.hideTextWith = r + }) +} + +// FilterFn if provided can be used to filter runes that are allowed in the +// text input field. Any rune for which this function returns false will be +// rejected. +type FilterFn func(rune) bool + +// Filter sets a function that will be used to filter characters the user can +// input. +func Filter(fn FilterFn) Option { + return option(func(opts *options) { + opts.filter = fn + }) +} + +// SubmitFn if provided is called when the user submits the content of the text +// input field, the argument text contains all the text in the field. +// Submitting the input field clears its content. +// +// The callback function must be thread-safe as the keyboard event that +// triggers the submission comes from a separate goroutine. +type SubmitFn func(text string) error + +// OnSubmit sets a function that will be called with the text typed by the user +// when they submit the content by pressing the Enter key. +// The SubmitFn must not attempt to read from or modify the TextInput instance +// in any way as while the SubmitFn is executing, the TextInput is mutex +// locked. If the intention is to clear the content on submission, use the +// ClearOnSubmit() option. +func OnSubmit(fn SubmitFn) Option { + return option(func(opts *options) { + opts.onSubmit = fn + }) +} + +// ClearOnSubmit sets the text input to be cleared when a submit of the content +// is triggered by the user pressing the Enter key. +func ClearOnSubmit() Option { + return option(func(opts *options) { + opts.clearOnSubmit = true + }) +} diff --git a/widgets/textinput/textinput.go b/widgets/textinput/textinput.go new file mode 100644 index 0000000..cc2de60 --- /dev/null +++ b/widgets/textinput/textinput.go @@ -0,0 +1,377 @@ +// 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 textinput implements a widget that accepts text input. +package textinput + +import ( + "image" + "strings" + "sync" + + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/alignfor" + "github.com/mum4k/termdash/internal/area" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/runewidth" + "github.com/mum4k/termdash/internal/wrap" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/terminal/terminalapi" + "github.com/mum4k/termdash/widgetapi" +) + +// TextInput accepts text input from the user. +// +// Displays an input field and an optional text label. The input field allows +// the user to edit and submit text. +// +// The text can be submitted by pressing enter or read at any time by calling +// Read. The text input field can be navigated using arrows, the Home and End +// button and using mouse. +// +// Implements widgetapi.Widget. This object is thread-safe. +type TextInput struct { + // mu protects the widget. + mu sync.Mutex + + // editor tracks the edits and the state of the text input field. + editor *fieldEditor + + // forField is the area that was occupied by the text input field last + // time Draw() was called. + forField image.Rectangle + + // opts are the provided options. + opts *options +} + +// New returns a new TextInput. +func New(opts ...Option) (*TextInput, error) { + opt := newOptions() + for _, o := range opts { + o.set(opt) + } + if err := opt.validate(); err != nil { + return nil, err + } + return &TextInput{ + editor: newFieldEditor(), + opts: opt, + }, nil +} + +// Vars to be replaced from tests. +var ( + // textFieldRune is the rune used in cells reserved for the text input + // field if no text is present. + // Changed from tests to provide readable test failures. + textFieldRune rune + + // cursorRune is rune that represents the cursor position. + cursorRune rune +) + +// Read reads the content of the text input field. +func (ti *TextInput) Read() string { + ti.mu.Lock() + defer ti.mu.Unlock() + + return ti.editor.content() +} + +// ReadAndClear reads the content of the text input field and clears it. +func (ti *TextInput) ReadAndClear() string { + ti.mu.Lock() + defer ti.mu.Unlock() + + c := ti.editor.content() + ti.editor.reset() + return c +} + +// drawLabel draws the text label in the area. +func (ti *TextInput) drawLabel(cvs *canvas.Canvas, labelAr image.Rectangle) error { + start, err := alignfor.Text(labelAr, ti.opts.label, ti.opts.labelAlign, align.VerticalMiddle) + if err != nil { + return err + } + if err := draw.Text( + cvs, ti.opts.label, start, + draw.TextOverrunMode(draw.OverrunModeThreeDot), + draw.TextMaxX(labelAr.Max.X), + draw.TextCellOpts(ti.opts.labelCellOpts...), + ); err != nil { + return err + } + return nil +} + +// drawField draws the text input field. +func (ti *TextInput) drawField(cvs *canvas.Canvas, text string) error { + if err := cvs.SetAreaCells(ti.forField, textFieldRune, cell.BgColor(ti.opts.fillColor)); err != nil { + return err + } + + if ti.opts.hideTextWith != 0 { + text = hideText(text, ti.opts.hideTextWith) + } + + if err := draw.Text( + cvs, text, ti.forField.Min, + draw.TextMaxX(ti.forField.Max.X), + draw.TextCellOpts(cell.FgColor(ti.opts.textColor)), + ); err != nil { + return err + } + return nil +} + +// drawCursor draws the cursor within the text input field. +func (ti *TextInput) drawCursor(cvs *canvas.Canvas, curPos int) error { + p := image.Point{ + curPos + ti.forField.Min.X, + ti.forField.Min.Y, + } + if err := cvs.SetCellOpts( + p, + cell.FgColor(ti.opts.highlightedColor), + cell.BgColor(ti.opts.cursorColor), + ); err != nil { + return err + } + if cursorRune != 0 { + if _, err := cvs.SetCell(p, cursorRune); err != nil { + return err + } + } + return nil +} + +// Draw draws the TextInput widget onto the canvas. +// Implements widgetapi.Widget.Draw. +func (ti *TextInput) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error { + ti.mu.Lock() + defer ti.mu.Unlock() + + labelAr, textAr, err := split(cvs.Area(), ti.opts.label, ti.opts.widthPerc) + if err != nil { + return err + } + + if ti.opts.border != linestyle.None { + ti.forField = area.ExcludeBorder(textAr) + } else { + ti.forField = textAr + } + + if ti.forField.Dx() < minFieldWidth || ti.forField.Dy() < minFieldHeight { + return draw.ResizeNeeded(cvs) + } + + if !labelAr.Eq(image.ZR) { + if err := ti.drawLabel(cvs, labelAr); err != nil { + return err + } + } + + if ti.opts.border != linestyle.None { + if err := draw.Border(cvs, textAr, draw.BorderCellOpts(cell.FgColor(ti.opts.borderColor))); err != nil { + return err + } + } + + text, curPos, err := ti.editor.viewFor(ti.forField.Dx()) + if err != nil { + return err + } + + if err := ti.drawField(cvs, text); err != nil { + return err + } + + if meta.Focused { + if err := ti.drawCursor(cvs, curPos); err != nil { + return err + } + } else if ti.opts.placeHolder != "" && text == "" { + if err := draw.Text( + cvs, ti.opts.placeHolder, ti.forField.Min, + draw.TextMaxX(ti.forField.Max.X), + draw.TextCellOpts(cell.FgColor(ti.opts.placeHolderColor)), + ); err != nil { + return err + } + } + return nil +} + +// Keyboard processes keyboard events. +// Implements widgetapi.Widget.Keyboard. +func (ti *TextInput) Keyboard(k *terminalapi.Keyboard) error { + ti.mu.Lock() + defer ti.mu.Unlock() + + switch k.Key { + case keyboard.KeyBackspace, keyboard.KeyBackspace2: + ti.editor.deleteBefore() + + case keyboard.KeyDelete: + ti.editor.delete() + + case keyboard.KeyArrowLeft: + ti.editor.cursorLeft() + + case keyboard.KeyArrowRight: + ti.editor.cursorRight() + + case keyboard.KeyHome, keyboard.KeyCtrlA: + ti.editor.cursorStart() + + case keyboard.KeyEnd, keyboard.KeyCtrlE: + ti.editor.cursorEnd() + + case keyboard.KeyEnter: + text := ti.editor.content() + if ti.opts.clearOnSubmit { + ti.editor.reset() + } + if ti.opts.onSubmit != nil { + return ti.opts.onSubmit(text) + } + + default: + if err := wrap.ValidText(string(k.Key)); err != nil { + // Ignore unsupported runes. + return nil + } + if ti.opts.filter != nil && !ti.opts.filter(rune(k.Key)) { + // Ignore filtered runes. + return nil + } + ti.editor.insert(rune(k.Key)) + } + + return nil +} + +// Mouse processes mouse events. +// Implements widgetapi.Widget.Mouse. +func (ti *TextInput) Mouse(m *terminalapi.Mouse) error { + ti.mu.Lock() + defer ti.mu.Unlock() + + if m.Button != mouse.ButtonLeft || !m.Position.In(ti.forField) { + return nil + } + + cellIdx := m.Position.X - ti.forField.Min.X + ti.editor.cursorRelCell(cellIdx) + return nil +} + +// minFieldHeight is the minimum height in cells needed for the text input field. +const minFieldHeight = 1 + +// Options implements widgetapi.Widget.Options. +func (ti *TextInput) Options() widgetapi.Options { + ti.mu.Lock() + defer ti.mu.Unlock() + + needWidth := minFieldWidth + if lw := runewidth.StringWidth(ti.opts.label); lw > 0 { + needWidth += lw + } + + needHeight := minFieldHeight + if ti.opts.border != linestyle.None { + needWidth += 2 + needHeight += 2 + } + + maxWidth := 0 + if ti.opts.maxWidthCells != nil { + additional := *ti.opts.maxWidthCells - minFieldWidth + maxWidth = needWidth + additional + } + + return widgetapi.Options{ + MinimumSize: image.Point{ + needWidth, + needHeight, + }, + MaximumSize: image.Point{ + maxWidth, + needHeight, + }, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + } +} + +// split splits the available area into label and text input areas according to +// configuration. The returned labelAr might be image.ZR if no label was +// configured. +func split(cvsAr image.Rectangle, label string, widthPerc *int) (labelAr, textAr image.Rectangle, err error) { + switch { + case widthPerc != nil: + splitP := 100 - *widthPerc + labelAr, textAr, err := area.VSplit(cvsAr, splitP) + if err != nil { + return image.ZR, image.ZR, err + } + if len(label) == 0 { + labelAr = image.ZR + } + return labelAr, textAr, nil + + case len(label) > 0: + cells := runewidth.StringWidth(label) + labelAr, textAr, err := area.VSplitCells(cvsAr, cells) + if err != nil { + return image.ZR, image.ZR, err + } + return labelAr, textAr, nil + + default: + // Neither a label nor width percentage specified. + return image.ZR, cvsAr, nil + } +} + +// hideText returns the text with all runes replaced with hr. +func hideText(text string, hr rune) string { + var b strings.Builder + + i := 0 + sw := runewidth.StringWidth(text) + for _, r := range text { + rw := runewidth.RuneWidth(r) + switch { + case i == 0 && r == '⇦': + b.WriteRune(r) + + case i == sw-1 && r == '⇨': + b.WriteRune(r) + + default: + b.WriteString(strings.Repeat(string(hr), rw)) + } + i++ + } + return b.String() +} diff --git a/widgets/textinput/textinput_test.go b/widgets/textinput/textinput_test.go new file mode 100644 index 0000000..2eb9124 --- /dev/null +++ b/widgets/textinput/textinput_test.go @@ -0,0 +1,1832 @@ +// 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 textinput + +import ( + "errors" + "image" + "sync" + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/internal/canvas/testcanvas" + "github.com/mum4k/termdash/internal/draw" + "github.com/mum4k/termdash/internal/draw/testdraw" + "github.com/mum4k/termdash/internal/faketerm" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/terminal/terminalapi" + "github.com/mum4k/termdash/widgetapi" +) + +// callbackTracker tracks whether callback was called. +type callbackTracker struct { + // wantErr when set to true, makes callback return an error. + wantErr bool + + // text is the text received OnSubmit. + text string + + // count is the number of times the callback was called. + count int + + // mu protects the tracker. + mu sync.Mutex +} + +// submit is the callback function called OnSubmit. +func (ct *callbackTracker) submit(text string) error { + ct.mu.Lock() + defer ct.mu.Unlock() + + if ct.wantErr { + return errors.New("ct.wantErr set to true") + } + + ct.count++ + ct.text = text + return nil +} + +func TestTextInput(t *testing.T) { + // Makes the empty text input field visible and cursor in test outputs. + textFieldRune = '_' + cursorRune = '█' + + tests := []struct { + desc string + callback *callbackTracker + opts []Option + events []terminalapi.Event + canvas image.Rectangle + meta *widgetapi.Meta + want func(size image.Point) *faketerm.Terminal + wantCallback *callbackTracker + wantNewErr bool + wantDrawErr bool + wantEventErr bool + }{ + { + desc: "fails on WidthPerc too low", + opts: []Option{ + WidthPerc(0), + }, + wantNewErr: true, + }, + { + desc: "fails on WidthPerc too high", + opts: []Option{ + WidthPerc(101), + }, + wantNewErr: true, + }, + { + desc: "fails on MaxWidthCells too low", + opts: []Option{ + MaxWidthCells(3), + }, + wantNewErr: true, + }, + { + desc: "fails on HideTextWith control rune", + opts: []Option{ + HideTextWith(0x007f), + }, + wantNewErr: true, + }, + { + desc: "fails on HideTextWith full-width rune", + opts: []Option{ + HideTextWith('世'), + }, + wantNewErr: true, + }, + { + desc: "takes all space without label", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + cvs.Area(), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "text field with border", + opts: []Option{ + Border(linestyle.Light), + }, + canvas: image.Rect(0, 0, 10, 3), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustBorder(cvs, cvs.Area()) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(1, 1, 9, 2), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets custom border color", + opts: []Option{ + Border(linestyle.Light), + BorderColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 10, 3), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustBorder(cvs, cvs.Area(), draw.BorderCellOpts(cell.FgColor(cell.ColorRed))) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(1, 1, 9, 2), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets custom fill color", + opts: []Option{ + FillColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + cvs.Area(), + textFieldRune, + cell.BgColor(cell.ColorRed), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws cursor when focused", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + cvs.Area(), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustSetCell( + cvs, + image.Point{0, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws place holder text when empty and not focused", + opts: []Option{ + PlaceHolder("holder"), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: false, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + cvs.Area(), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "holder", + image.Point{0, 0}, + draw.TextCellOpts(cell.FgColor(cell.ColorNumber(DefaultPlaceHolderColorNumber))), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets custom place holder text color", + opts: []Option{ + PlaceHolder("holder"), + PlaceHolderColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: false, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + cvs.Area(), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "holder", + image.Point{0, 0}, + draw.TextCellOpts(cell.FgColor(cell.ColorRed)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets custom cursor color", + opts: []Option{ + CursorColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + cvs.Area(), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustSetCell( + cvs, + image.Point{0, 0}, + cursorRune, + cell.BgColor(cell.ColorRed), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets width percentage, results in area too small", + opts: []Option{ + WidthPerc(10), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustResizeNeeded(cvs) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets width percentage, field aligns right", + opts: []Option{ + WidthPerc(50), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(5, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "automatically adjusts space for label, rest for text field", + opts: []Option{ + Label("hi:"), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(cvs, "hi:", image.Point{0, 0}) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(3, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "has label and border, not enough remaining height", + opts: []Option{ + Label("hi:"), + Border(linestyle.Light), + }, + canvas: image.Rect(0, 0, 10, 2), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustResizeNeeded(cvs) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws label and border", + opts: []Option{ + Label("hi:"), + Border(linestyle.Light), + }, + canvas: image.Rect(0, 0, 10, 3), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(cvs, "hi:", image.Point{0, 1}) + testdraw.MustBorder(cvs, image.Rect(3, 0, 10, 3)) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(4, 1, 9, 2), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws resize needed if label makes text field too narrow", + opts: []Option{ + Label("hello world:"), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustResizeNeeded(cvs) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets width percentage for text field, label gets the rest, aligns left by default", + opts: []Option{ + Label("hi:"), + WidthPerc(50), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(cvs, "hi:", image.Point{0, 0}) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(5, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets width percentage for text field, label gets the rest, aligns left with option", + opts: []Option{ + Label("hi:"), + WidthPerc(50), + LabelAlign(align.HorizontalLeft), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(cvs, "hi:", image.Point{0, 0}) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(5, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets width percentage for text field, label gets the rest, aligns center with option", + opts: []Option{ + Label("hi:"), + WidthPerc(50), + LabelAlign(align.HorizontalCenter), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(cvs, "hi:", image.Point{1, 0}) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(5, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets width percentage for text field, label gets the rest, aligns right with option", + opts: []Option{ + Label("hi:"), + WidthPerc(50), + LabelAlign(align.HorizontalRight), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(cvs, "hi:", image.Point{2, 0}) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(5, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets label cell options", + opts: []Option{ + Label( + "hi:", + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + ), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testdraw.MustText( + cvs, + "hi:", + image.Point{0, 0}, + draw.TextCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + ), + ) + testcanvas.MustSetAreaCells( + cvs, + image.Rect(3, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "displays written text", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "submits written text on enter", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + callback: &callbackTracker{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + text: "abc", + count: 1, + }, + }, + { + desc: "forwards error returned by SubmitFn", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + callback: &callbackTracker{ + wantErr: true, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + text: "abc", + count: 1, + }, + wantEventErr: true, + }, + { + desc: "submits written text on enter and clears the text input field", + opts: []Option{ + ClearOnSubmit(), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + callback: &callbackTracker{}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + text: "abc", + count: 1, + }, + }, + { + desc: "clears the text input field when enter is pressed and ClearOnSubmit option given", + opts: []Option{ + ClearOnSubmit(), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "write ignores control or unsupported space runes", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: '\t'}, + &terminalapi.Keyboard{Key: 0x007f}, + &terminalapi.Keyboard{Key: 'b'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "ab", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "write filters runes with the provided FilterFn", + opts: []Option{ + Filter(func(r rune) bool { + return r != 'b' && r != 'c' + }), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: 'd'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "ad", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + + { + desc: "displays written text with full-width runes", + canvas: image.Rect(0, 0, 4, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: '世'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 4, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "⇦世", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "hides text when requested", + opts: []Option{ + HideTextWith('*'), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "***", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "hides text, but doesn't hide scrolling arrows", + opts: []Option{ + HideTextWith('*'), + }, + canvas: image.Rect(0, 0, 4, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: 'd'}, + &terminalapi.Keyboard{Key: 'e'}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowLeft}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 4, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "⇦**⇨", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "hides text hides scrolling arrows that are part of the text", + opts: []Option{ + HideTextWith('*'), + }, + canvas: image.Rect(0, 0, 4, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: '⇦'}, + &terminalapi.Keyboard{Key: '⇨'}, + &terminalapi.Keyboard{Key: 'e'}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowLeft}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 4, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "⇦**⇨", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "hides full-width runes with two hide runes", + opts: []Option{ + HideTextWith('*'), + }, + canvas: image.Rect(0, 0, 4, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: '世'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 4, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "⇦**", + image.Point{0, 0}, + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets custom text color", + opts: []Option{ + TextColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{}, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + draw.TextCellOpts(cell.FgColor(cell.ColorRed)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "displays written text and cursor when focused", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{3, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "moves cursor left", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{2, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets custom highlight color", + opts: []Option{ + HighlightedColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{2, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorRed), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "moves cursor to start", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyHome}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{0, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "moves cursor right", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyHome}, + &terminalapi.Keyboard{Key: keyboard.KeyArrowRight}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{1, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "moves cursor to end", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyHome}, + &terminalapi.Keyboard{Key: keyboard.KeyEnd}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{3, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "deletes rune the cursor is on", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyHome}, + &terminalapi.Keyboard{Key: keyboard.KeyDelete}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "bc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{0, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "deletes rune just before the cursor", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Keyboard{Key: keyboard.KeyBackspace}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "ab", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{2, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "left mouse button moves the cursor", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Mouse{ + Button: mouse.ButtonLeft, + Position: image.Point{1, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{1, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "ignores other mouse buttons", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Mouse{ + Button: mouse.ButtonRight, + Position: image.Point{1, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{3, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "ignores mouse events outside of the text field", + canvas: image.Rect(0, 0, 10, 1), + meta: &widgetapi.Meta{ + Focused: true, + }, + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + &terminalapi.Mouse{ + Button: mouse.ButtonLeft, + Position: image.Point{5, 15}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetAreaCells( + cvs, + image.Rect(0, 0, 10, 1), + textFieldRune, + cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)), + ) + testdraw.MustText( + cvs, + "abc", + image.Point{0, 0}, + ) + testcanvas.MustSetCell( + cvs, + image.Point{3, 0}, + cursorRune, + cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), + ) + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotCallback := tc.callback + if gotCallback != nil { + tc.opts = append(tc.opts, OnSubmit(gotCallback.submit)) + } + + ti, err := New(tc.opts...) + if (err != nil) != tc.wantNewErr { + t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) + } + if err != nil { + return + } + + { + // Draw once so mouse events are acceptable. + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + err = ti.Draw(c, tc.meta) + 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 := ti.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.wantEventErr { + t.Errorf("Mouse => unexpected error: %v, wantEventErr: %v", err, tc.wantEventErr) + } + if err != nil { + return + } + } else { + if err != nil { + t.Fatalf("Mouse => unexpected error: %v", err) + } + } + + case *terminalapi.Keyboard: + err := ti.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.wantEventErr { + t.Errorf("Keyboard => unexpected error: %v, wantEventErr: %v", err, tc.wantEventErr) + } + 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 = ti.Draw(c, tc.meta) + 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 TestTextInputRead(t *testing.T) { + tests := []struct { + desc string + events []terminalapi.Event + want string + }{ + { + desc: "reads empty without events", + events: []terminalapi.Event{}, + want: "", + }, + { + desc: "reads written text", + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: 'a'}, + &terminalapi.Keyboard{Key: 'b'}, + &terminalapi.Keyboard{Key: 'c'}, + }, + want: "abc", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + ti, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + + for _, ev := range tc.events { + switch e := ev.(type) { + case *terminalapi.Keyboard: + err := ti.Keyboard(e) + if err != nil { + t.Fatalf("Keyboard => unexpected error: %v", err) + } + + default: + t.Fatalf("unsupported event type: %T", ev) + } + } + + got := ti.Read() + if got != tc.want { + t.Errorf("Read => %q, want %q", got, tc.want) + } + + gotRC := ti.ReadAndClear() + if gotRC != tc.want { + t.Errorf("ReadAndClear after clearing => %q, want %q", gotRC, tc.want) + } + + // Both should now return empty content. + { + want := "" + got := ti.Read() + if got != want { + t.Errorf("Read after clearing => %q, want %q", got, want) + } + + gotRC := ti.ReadAndClear() + if gotRC != want { + t.Errorf("ReadAndClear after clearing => %q, want %q", gotRC, want) + } + } + }) + } +} + +func TestOptions(t *testing.T) { + tests := []struct { + desc string + opts []Option + want widgetapi.Options + }{ + { + desc: "no label and no border", + want: widgetapi.Options{ + MinimumSize: image.Point{4, 1}, + MaximumSize: image.Point{0, 1}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "no label and no border, max width specified", + opts: []Option{ + MaxWidthCells(5), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{4, 1}, + MaximumSize: image.Point{5, 1}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "no label, has border", + opts: []Option{ + Border(linestyle.Light), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{6, 3}, + MaximumSize: image.Point{0, 3}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "no label, has border, max width specified", + opts: []Option{ + Border(linestyle.Light), + MaxWidthCells(5), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{6, 3}, + MaximumSize: image.Point{7, 3}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "has label and no border", + opts: []Option{ + Label("hello"), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{9, 1}, + MaximumSize: image.Point{0, 1}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "has label and no border, max width specified", + opts: []Option{ + Label("hello"), + MaxWidthCells(5), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{9, 1}, + MaximumSize: image.Point{10, 1}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "has label with full-width runes and no border", + opts: []Option{ + Label("hello世"), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{11, 1}, + MaximumSize: image.Point{0, 1}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "has label and border", + opts: []Option{ + Label("hello"), + Border(linestyle.Light), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{11, 3}, + MaximumSize: image.Point{0, 3}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + { + desc: "has label and border, max width specified", + opts: []Option{ + Label("hello"), + Border(linestyle.Light), + MaxWidthCells(5), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{11, 3}, + MaximumSize: image.Point{12, 3}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeWidget, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + ti, err := New(tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + + got := ti.Options() + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestSplit(t *testing.T) { + tests := []struct { + desc string + cvsAr image.Rectangle + label string + widthPerc *int + wantLabelAr image.Rectangle + wantTextAr image.Rectangle + wantErr bool + }{ + { + desc: "fails on invalid widthPerc", + cvsAr: image.Rect(0, 0, 10, 1), + widthPerc: func() *int { + i := -1 + return &i + }(), + wantErr: true, + }, + { + desc: "no label and no widthPerc, full area for text input field", + cvsAr: image.Rect(0, 0, 5, 1), + wantLabelAr: image.ZR, + wantTextAr: image.Rect(0, 0, 5, 1), + }, + { + desc: "widthPerc set, splits canvas area", + cvsAr: image.Rect(0, 0, 10, 1), + widthPerc: func() *int { + i := 30 + return &i + }(), + wantLabelAr: image.ZR, + wantTextAr: image.Rect(7, 0, 10, 1), + }, + { + desc: "widthPerc and label set", + cvsAr: image.Rect(0, 0, 10, 1), + widthPerc: func() *int { + i := 30 + return &i + }(), + label: "hello", + wantLabelAr: image.Rect(0, 0, 7, 1), + wantTextAr: image.Rect(7, 0, 10, 1), + }, + + { + desc: "widthPerc set to 100, splits canvas area", + cvsAr: image.Rect(0, 0, 10, 1), + widthPerc: func() *int { + i := 100 + return &i + }(), + wantLabelAr: image.ZR, + wantTextAr: image.Rect(0, 0, 10, 1), + }, + { + desc: "widthPerc set to 1, splits canvas area", + cvsAr: image.Rect(0, 0, 10, 1), + widthPerc: func() *int { + i := 1 + return &i + }(), + wantLabelAr: image.ZR, + wantTextAr: image.Rect(9, 0, 10, 1), + }, + { + desc: "label set, half-width runes only", + cvsAr: image.Rect(0, 0, 10, 1), + label: "hello", + wantLabelAr: image.Rect(0, 0, 5, 1), + wantTextAr: image.Rect(5, 0, 10, 1), + }, + { + desc: "label set, full-width runes", + cvsAr: image.Rect(0, 0, 10, 1), + label: "hello世", + wantLabelAr: image.Rect(0, 0, 7, 1), + wantTextAr: image.Rect(7, 0, 10, 1), + }, + { + desc: "label longer than canvas width", + cvsAr: image.Rect(0, 0, 10, 1), + label: "helloworld1", + wantLabelAr: image.Rect(0, 0, 10, 1), + wantTextAr: image.ZR, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotLabelAr, gotTextAr, err := split(tc.cvsAr, tc.label, tc.widthPerc) + if (err != nil) != tc.wantErr { + t.Errorf("split => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if diff := pretty.Compare(tc.wantLabelAr, gotLabelAr); diff != "" { + t.Errorf("split => unexpected labelAr, diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.wantTextAr, gotTextAr); diff != "" { + t.Errorf("split => unexpected labelAr, diff (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/widgets/textinput/textinputdemo/textinputdemo.go b/widgets/textinput/textinputdemo/textinputdemo.go new file mode 100644 index 0000000..79e2eb7 --- /dev/null +++ b/widgets/textinput/textinputdemo/textinputdemo.go @@ -0,0 +1,221 @@ +// 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 textinputdemo shows the functionality of a text input field. +package main + +import ( + "context" + "time" + + "github.com/mum4k/termdash" + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/container/grid" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/terminal/termbox" + "github.com/mum4k/termdash/widgets/button" + "github.com/mum4k/termdash/widgets/segmentdisplay" + "github.com/mum4k/termdash/widgets/textinput" +) + +// rotate returns a new slice with inputs rotated by step. +// I.e. for a step of one: +// inputs[0] -> inputs[len(inputs)-1] +// inputs[1] -> inputs[0] +// And so on. +func rotate(inputs []rune, step int) []rune { + return append(inputs[step:], inputs[:step]...) +} + +// textState creates a rotated state for the text we are displaying. +func textState(text string, capacity, step int) []rune { + if capacity == 0 { + return nil + } + + var state []rune + for i := 0; i < capacity; i++ { + state = append(state, ' ') + } + state = append(state, []rune(text)...) + step = step % len(state) + return rotate(state, step) +} + +// rollText rolls a text across the segment display. +// Exists when the context expires. +func rollText(ctx context.Context, sd *segmentdisplay.SegmentDisplay, updateText <-chan string) { + colors := []cell.Color{ + cell.ColorBlue, + cell.ColorRed, + cell.ColorYellow, + cell.ColorBlue, + cell.ColorGreen, + cell.ColorRed, + cell.ColorGreen, + cell.ColorRed, + } + + text := "Termdash" + step := 0 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + state := textState(text, sd.Capacity(), step) + var chunks []*segmentdisplay.TextChunk + for i := 0; i < sd.Capacity(); i++ { + if i >= len(state) { + break + } + + color := colors[i%len(colors)] + chunks = append(chunks, segmentdisplay.NewChunk( + string(state[i]), + segmentdisplay.WriteCellOpts(cell.FgColor(color)), + )) + } + if len(chunks) == 0 { + continue + } + if err := sd.Write(chunks); err != nil { + panic(err) + } + step++ + + case t := <-updateText: + text = t + sd.Reset() + step = 0 + + case <-ctx.Done(): + return + } + } +} + +func main() { + t, err := termbox.New() + if err != nil { + panic(err) + } + defer t.Close() + + ctx, cancel := context.WithCancel(context.Background()) + rollingSD, err := segmentdisplay.New( + segmentdisplay.MaximizeSegmentHeight(), + ) + if err != nil { + panic(err) + } + updateText := make(chan string) + go rollText(ctx, rollingSD, updateText) + + input, err := textinput.New( + textinput.Label("New text:", cell.FgColor(cell.ColorBlue)), + textinput.MaxWidthCells(20), + textinput.Border(linestyle.Light), + textinput.PlaceHolder("Enter any text"), + ) + if err != nil { + panic(err) + } + + submitB, err := button.New("Submit", func() error { + updateText <- input.ReadAndClear() + return nil + }, + button.GlobalKey(keyboard.KeyEnter), + button.FillColor(cell.ColorNumber(220)), + ) + clearB, err := button.New("Clear", func() error { + input.ReadAndClear() + updateText <- "" + return nil + }, + button.WidthFor("Submit"), + button.FillColor(cell.ColorNumber(220)), + ) + quitB, err := button.New("Quit", func() error { + cancel() + return nil + }, + button.WidthFor("Submit"), + button.FillColor(cell.ColorNumber(196)), + ) + + builder := grid.New() + builder.Add( + grid.RowHeightPerc(40, + grid.Widget( + rollingSD, + ), + ), + ) + builder.Add( + grid.RowHeightPerc(20, + grid.Widget( + input, + container.AlignHorizontal(align.HorizontalCenter), + container.AlignVertical(align.VerticalBottom), + container.MarginBottom(1), + ), + ), + ) + + builder.Add( + grid.RowHeightPerc(40, + grid.ColWidthPerc(20), + grid.ColWidthPerc(20, + grid.Widget( + submitB, + container.AlignVertical(align.VerticalTop), + container.AlignHorizontal(align.HorizontalRight), + ), + ), + grid.ColWidthPerc(20, + grid.Widget( + clearB, + container.AlignVertical(align.VerticalTop), + container.AlignHorizontal(align.HorizontalCenter), + ), + ), + grid.ColWidthPerc(20, + grid.Widget( + quitB, + container.AlignVertical(align.VerticalTop), + container.AlignHorizontal(align.HorizontalLeft), + ), + ), + grid.ColWidthPerc(20), + ), + ) + + gridOpts, err := builder.Build() + if err != nil { + panic(err) + } + c, err := container.New(t, gridOpts...) + if err != nil { + panic(err) + } + + if err := termdash.Run(ctx, t, c, termdash.RedrawInterval(500*time.Millisecond)); err != nil { + panic(err) + } +}