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.go)
+[
](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
[
](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
+```
+
+[
](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)
+ }
+}