1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Working proof-of-concept of the textinput field.

This commit is contained in:
Jakub Sobon 2019-04-20 17:33:23 -04:00
parent bda6223690
commit c0c9727c80
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
5 changed files with 342 additions and 80 deletions

View File

@ -350,8 +350,21 @@ func (fe *fieldEditor) viewFor(width int) (string, int, error) {
return fe.data.runesIn(fe.visible), cur, 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) {
if runewidth.RuneWidth(r) == 0 {
return
}
fe.data.insertAt(fe.curDataPos, r)
fe.curDataPos++
}

View File

@ -17,6 +17,8 @@ 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/linestyle"
@ -45,7 +47,8 @@ type options struct {
border linestyle.LineStyle
borderColor cell.Color
textWidthPerc *int
widthPerc *int
maxWidthCells *int
label string
labelCellOpts []cell.Option
labelAlign align.Horizontal
@ -59,6 +62,12 @@ type options struct {
// 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)
}
return nil
}
@ -104,7 +113,7 @@ func HighlightedColor(c cell.Color) Option {
// DefaultCursorColorNumber is the default color number for the CursorColor
// option.
const DefaultCursorColorNumber = 235
const DefaultCursorColorNumber = 250
// CursorColor sets the color of the cursor.
// Defaults to DefaultCursorColorNumber.
@ -129,12 +138,23 @@ func BorderColor(c cell.Color) Option {
})
}
// TextWidthPerc 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.
// 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 TextWidthPerc(perc int) Option {
func WidthPerc(perc int) Option {
return option(func(opts *options) {
opts.textWidthPerc = &perc
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
})
}

View File

@ -18,10 +18,17 @@ package textinput
import (
"image"
"sync"
"unicode"
"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/terminal/terminalapi"
"github.com/mum4k/termdash/widgetapi"
@ -52,6 +59,9 @@ type TextInput struct {
// mu protects the widget.
mu sync.Mutex
// editor tracks the edits and the state of the text input field.
editor *fieldEditor
// opts are the provided options.
opts *options
}
@ -66,7 +76,8 @@ func New(opts ...Option) (*TextInput, error) {
return nil, err
}
return &TextInput{
opts: opt,
editor: newFieldEditor(),
opts: opt,
}, nil
}
@ -81,13 +92,97 @@ var (
cursorRune rune = 0
)
// 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
}
// 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()
// Ensure 4 available for text field.
labelAr, textAr, err := split(cvs.Area(), ti.opts.label, ti.opts.widthPerc)
if err != nil {
return err
}
var forField image.Rectangle
if ti.opts.border != linestyle.None {
forField = area.ExcludeBorder(textAr)
} else {
forField = textAr
}
if forField.Dx() < minFieldWidth {
return draw.ResizeNeeded(cvs)
}
if !labelAr.Eq(image.ZR) {
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
}
}
if ti.opts.border != linestyle.None {
if err := draw.Border(cvs, textAr, draw.BorderCellOpts(cell.FgColor(ti.opts.borderColor))); err != nil {
return err
}
}
if err := cvs.SetAreaCellOpts(forField, cell.BgColor(ti.opts.fillColor)); err != nil {
return err
}
text, curPos, err := ti.editor.viewFor(forField.Dx())
if err != nil {
return err
}
if err := draw.Text(
cvs, text, forField.Min,
draw.TextMaxX(forField.Max.X),
draw.TextCellOpts(cell.FgColor(ti.opts.textColor)),
); err != nil {
return err
}
if meta.Focused {
p := image.Point{
curPos + forField.Min.X,
forField.Min.Y,
}
if err := cvs.SetCellOpts(
p,
cell.FgColor(ti.opts.highlightedColor),
cell.BgColor(ti.opts.cursorColor),
); err != nil {
return err
}
}
return nil
}
@ -97,6 +192,36 @@ 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()
default:
if err := wrap.ValidText(string(k.Key)); err != nil {
// Ignore unsupported runes.
return nil
}
if !unicode.IsPrint(rune(k.Key)) {
return nil
}
ti.editor.insert(rune(k.Key))
}
return nil
}
@ -124,13 +249,20 @@ func (ti *TextInput) Options() widgetapi.Options {
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{
0, // Any width.
maxWidth,
needHeight,
},
WantKeyboard: widgetapi.KeyScopeFocused,
@ -141,10 +273,10 @@ func (ti *TextInput) Options() widgetapi.Options {
// 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, textWidthPerc *int) (labelAr, textAr image.Rectangle, err error) {
func split(cvsAr image.Rectangle, label string, widthPerc *int) (labelAr, textAr image.Rectangle, err error) {
switch {
case textWidthPerc != nil:
splitP := 100 - *textWidthPerc
case widthPerc != nil:
splitP := 100 - *widthPerc
labelAr, textAr, err := area.VSplit(cvsAr, splitP)
if err != nil {
return image.ZR, image.ZR, err

View File

@ -60,7 +60,6 @@ func (ct *callbackTracker) submit(text string) error {
func TestTextInput(t *testing.T) {
tests := []struct {
desc string
text string
callback *callbackTracker
opts []Option
events []terminalapi.Event
@ -71,9 +70,32 @@ func TestTextInput(t *testing.T) {
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,
},
}
textFieldRune = 'x'
textFieldRune = '_'
cursorRune = '█'
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
gotCallback := tc.callback
@ -171,6 +193,18 @@ func TestOptions(t *testing.T) {
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{
@ -183,6 +217,19 @@ func TestOptions(t *testing.T) {
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{
@ -195,6 +242,19 @@ func TestOptions(t *testing.T) {
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{
@ -220,6 +280,20 @@ func TestOptions(t *testing.T) {
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 {
@ -239,33 +313,33 @@ func TestOptions(t *testing.T) {
func TestSplit(t *testing.T) {
tests := []struct {
desc string
cvsAr image.Rectangle
label string
textWidthPerc *int
wantLabelAr image.Rectangle
wantTextAr image.Rectangle
wantErr bool
desc string
cvsAr image.Rectangle
label string
widthPerc *int
wantLabelAr image.Rectangle
wantTextAr image.Rectangle
wantErr bool
}{
{
desc: "fails on invalid textWidthPerc",
desc: "fails on invalid widthPerc",
cvsAr: image.Rect(0, 0, 10, 1),
textWidthPerc: func() *int {
widthPerc: func() *int {
i := -1
return &i
}(),
wantErr: true,
},
{
desc: "no label and no textWidthPerc, full area for text input field",
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: "textWidthPerc set, splits canvas area",
desc: "widthPerc set, splits canvas area",
cvsAr: image.Rect(0, 0, 10, 1),
textWidthPerc: func() *int {
widthPerc: func() *int {
i := 30
return &i
}(),
@ -273,9 +347,9 @@ func TestSplit(t *testing.T) {
wantTextAr: image.Rect(7, 0, 10, 1),
},
{
desc: "textWidthPerc and label set",
desc: "widthPerc and label set",
cvsAr: image.Rect(0, 0, 10, 1),
textWidthPerc: func() *int {
widthPerc: func() *int {
i := 30
return &i
}(),
@ -285,9 +359,9 @@ func TestSplit(t *testing.T) {
},
{
desc: "textWidthPerc set to 100, splits canvas area",
desc: "widthPerc set to 100, splits canvas area",
cvsAr: image.Rect(0, 0, 10, 1),
textWidthPerc: func() *int {
widthPerc: func() *int {
i := 100
return &i
}(),
@ -295,9 +369,9 @@ func TestSplit(t *testing.T) {
wantTextAr: image.Rect(0, 0, 10, 1),
},
{
desc: "textWidthPerc set to 1, splits canvas area",
desc: "widthPerc set to 1, splits canvas area",
cvsAr: image.Rect(0, 0, 10, 1),
textWidthPerc: func() *int {
widthPerc: func() *int {
i := 1
return &i
}(),
@ -329,7 +403,7 @@ func TestSplit(t *testing.T) {
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
gotLabelAr, gotTextAr, err := split(tc.cvsAr, tc.label, tc.textWidthPerc)
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)
}

View File

@ -23,10 +23,10 @@ import (
"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/terminal/terminalapi"
"github.com/mum4k/termdash/widgets/button"
"github.com/mum4k/termdash/widgets/segmentdisplay"
"github.com/mum4k/termdash/widgets/textinput"
@ -126,72 +126,95 @@ func main() {
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),
)
if err != nil {
panic(err)
}
submitB, err := button.New("Submit", func() error {
updateText <- "Hello World"
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)),
)
input, err := textinput.New()
if err != nil {
panic(err)
}
c, err := container.New(
t,
container.Border(linestyle.Light),
container.BorderTitle("PRESS Q TO QUIT"),
container.SplitHorizontal(
container.Top(
container.PlaceWidget(rollingSD),
builder := grid.New()
builder.Add(
grid.RowHeightPerc(40,
grid.Widget(
rollingSD,
),
container.Bottom(
container.SplitHorizontal(
container.Top(
container.PlaceWidget(input),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.AlignVertical(align.VerticalTop),
container.AlignHorizontal(align.HorizontalRight),
container.PaddingRight(1),
container.PlaceWidget(submitB),
),
container.Right(
container.AlignVertical(align.VerticalTop),
container.AlignHorizontal(align.HorizontalLeft),
container.PaddingLeft(1),
container.PlaceWidget(clearB),
),
),
),
container.SplitPercent(30),
),
),
container.SplitPercent(40),
),
)
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)
}
quitter := func(k *terminalapi.Keyboard) {
if k.Key == 'q' || k.Key == 'Q' {
cancel()
}
}
if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(500*time.Millisecond)); err != nil {
if err := termdash.Run(ctx, t, c, termdash.RedrawInterval(500*time.Millisecond)); err != nil {
panic(err)
}
}