From 886f970586eb0a91df8d27d53a2cbf9c03f52df9 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Thu, 25 Apr 2019 23:44:14 -0400 Subject: [PATCH] Completing test coverage and most of the functionality. Mouse support is outstanding. --- widgets/textinput/options.go | 44 +- widgets/textinput/textinput.go | 55 +- widgets/textinput/textinput_test.go | 845 +++++++++++++++++- .../textinput/textinputdemo/textinputdemo.go | 3 +- 4 files changed, 918 insertions(+), 29 deletions(-) diff --git a/widgets/textinput/options.go b/widgets/textinput/options.go index 124b2b1..b170b24 100644 --- a/widgets/textinput/options.go +++ b/widgets/textinput/options.go @@ -21,6 +21,8 @@ import ( "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" ) @@ -57,8 +59,9 @@ type options struct { placeHolder string hideTextWith rune - filter FilterFn - onSubmit SubmitFn + filter FilterFn + onSubmit SubmitFn + clearOnSubmit bool } // validate validates the provided options. @@ -69,6 +72,14 @@ func (o *options) validate() error { 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 } @@ -77,6 +88,7 @@ func newOptions() *options { return &options{ fillColor: cell.ColorNumber(DefaultFillColorNumber), placeHolderColor: cell.ColorNumber(DefaultPlaceHolderColorNumber), + highlightedColor: cell.ColorNumber(DefaultHighlightedColorNumber), cursorColor: cell.ColorNumber(DefaultCursorColorNumber), labelAlign: DefaultLabelAlign, } @@ -191,7 +203,7 @@ func PlaceHolder(text string) Option { // DefaultPlaceHolderColorNumber is the default color number for the // PlaceHolderColor option. -const DefaultPlaceHolderColorNumber = 190 +const DefaultPlaceHolderColorNumber = 194 // PlaceHolderColor sets the color of the placeholder text. // Defaults to DefaultPlaceHolderColorNumber. @@ -204,12 +216,18 @@ func PlaceHolderColor(c cell.Color) Option { // 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 { @@ -218,10 +236,30 @@ func Filter(fn FilterFn) Option { }) } +// 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 index b327e19..cccedf0 100644 --- a/widgets/textinput/textinput.go +++ b/widgets/textinput/textinput.go @@ -17,6 +17,7 @@ package textinput import ( "image" + "strings" "sync" "github.com/mum4k/termdash/align" @@ -33,19 +34,6 @@ import ( "github.com/mum4k/termdash/widgetapi" ) -// 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 - -// 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 - // TextInput accepts text input from the user. // // Displays an input field where the user can edit text and an optional label. @@ -160,6 +148,11 @@ func (ti *TextInput) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error { if err != nil { return err } + + if ti.opts.hideTextWith != 0 { + text = hideText(text, ti.opts.hideTextWith) + } + if err := draw.Text( cvs, text, forField.Min, draw.TextMaxX(forField.Max.X), @@ -223,11 +216,24 @@ func (ti *TextInput) Keyboard(k *terminalapi.Keyboard) error { 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)) } @@ -311,3 +317,26 @@ func split(cvsAr image.Rectangle, label string, widthPerc *int) (labelAr, textAr 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 index 6c568e9..f475830 100644 --- a/widgets/textinput/textinput_test.go +++ b/widgets/textinput/textinput_test.go @@ -28,6 +28,7 @@ import ( "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/terminal/terminalapi" "github.com/mum4k/termdash/widgetapi" @@ -101,6 +102,20 @@ func TestTextInput(t *testing.T) { }, 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), @@ -206,6 +221,7 @@ func TestTextInput(t *testing.T) { image.Point{0, 0}, cursorRune, cell.BgColor(cell.ColorNumber(DefaultCursorColorNumber)), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), ) testcanvas.MustApply(cvs, ft) return ft @@ -294,6 +310,7 @@ func TestTextInput(t *testing.T) { image.Point{0, 0}, cursorRune, cell.BgColor(cell.ColorRed), + cell.FgColor(cell.ColorNumber(DefaultHighlightedColorNumber)), ) testcanvas.MustApply(cvs, ft) return ft @@ -541,6 +558,729 @@ func TestTextInput(t *testing.T) { 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 + }, + }, } for _, tc := range tests { @@ -558,24 +1298,38 @@ func TestTextInput(t *testing.T) { return } - for _, ev := range tc.events { + for i, ev := range tc.events { switch e := ev.(type) { case *terminalapi.Mouse: err := ti.Mouse(e) - if (err != nil) != tc.wantEventErr { - t.Errorf("Mouse => unexpected error: %v, wantEventErr: %v", err, tc.wantEventErr) - } - if err != nil { - return + // 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) - if (err != nil) != tc.wantEventErr { - t.Errorf("Keyboard => unexpected error: %v, wantEventErr: %v", err, tc.wantEventErr) - } - if err != nil { - return + // 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: @@ -625,6 +1379,75 @@ func TestTextInput(t *testing.T) { } } +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 diff --git a/widgets/textinput/textinputdemo/textinputdemo.go b/widgets/textinput/textinputdemo/textinputdemo.go index 79e2eb7..8e7c8be 100644 --- a/widgets/textinput/textinputdemo/textinputdemo.go +++ b/widgets/textinput/textinputdemo/textinputdemo.go @@ -25,7 +25,6 @@ import ( "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" @@ -129,7 +128,7 @@ func main() { input, err := textinput.New( textinput.Label("New text:", cell.FgColor(cell.ColorBlue)), textinput.MaxWidthCells(20), - textinput.Border(linestyle.Light), + //textinput.Border(linestyle.Light), textinput.PlaceHolder("Enter any text"), ) if err != nil {