diff --git a/widgets/textinput/options.go b/widgets/textinput/options.go new file mode 100644 index 0000000..987d4ba --- /dev/null +++ b/widgets/textinput/options.go @@ -0,0 +1,171 @@ +// 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 ( + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/cell" + "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 + cursorColor cell.Color + border linestyle.LineStyle + + textWidthPerc *int + label string + labelCellOpts []cell.Option + labelAlign align.Horizontal + + placeHolder string + hideTextWith rune + + filter FilterFn + onSubmit SubmitFn +} + +// validate validates the provided options. +func (o *options) validate() error { + return nil +} + +// newOptions returns options with the default values set. +func newOptions() *options { + return &options{ + fillColor: cell.ColorNumber(DefaultFillColorNumber), + 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 + }) +} + +// DefaultCursorColorNumber is the default color number for the CursorColor +// option. +const DefaultCursorColorNumber = 235 + +// 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 + }) +} + +// 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. +// Defaults to the width adjusted automatically base on the label length. +func TextWidthPerc(perc int) Option { + return option(func(opts *options) { + opts.textWidthPerc = &perc + }) +} + +// 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 + }) +} + +// HideTextWith sets the rune that should be displayed instead of displaying +// the text. Useful for fields that accept sensitive information like +// passwords. +func HideTextWith(r rune) Option { + return option(func(opts *options) { + opts.hideTextWith = r + }) +} + +// 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 + }) +} + +// 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. +func OnSubmit(fn SubmitFn) Option { + return option(func(opts *options) { + opts.onSubmit = fn + }) +} diff --git a/widgets/textinput/textinput.go b/widgets/textinput/textinput.go new file mode 100644 index 0000000..5c235ee --- /dev/null +++ b/widgets/textinput/textinput.go @@ -0,0 +1,107 @@ +// 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 ( + "sync" + + "github.com/mum4k/termdash/internal/canvas" + "github.com/mum4k/termdash/terminal/terminalapi" + "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. +// +// The text can be submitted by pressing enter or read at any time by calling +// Read. +// +// Implements widgetapi.Widget. This object is thread-safe. +type TextInput struct { + // mu protects the widget. + mu sync.Mutex + + // 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{ + opts: opt, + }, nil +} + +// Vars to be replaced from tests. +var ( + // Runes to use in cells that contain are reserved for the text input + // field if no text is present. + // Changed from tests to provide readable test failures. + textFieldRune = ' ' +) + +// 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() + + 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() + + 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() + + return nil +} + +// Options implements widgetapi.Widget.Options. +func (ti *TextInput) Options() widgetapi.Options { + return widgetapi.Options{} +} diff --git a/widgets/textinput/textinput_test.go b/widgets/textinput/textinput_test.go new file mode 100644 index 0000000..636deef --- /dev/null +++ b/widgets/textinput/textinput_test.go @@ -0,0 +1,179 @@ +// 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/internal/canvas" + "github.com/mum4k/termdash/internal/faketerm" + "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) { + tests := []struct { + desc string + text 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 + }{} + + textFieldRune = 'x' + 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 + } + + for _, 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 + } + + 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 + } + + 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 TestOptions(t *testing.T) { + tests := []struct { + desc string + text string + opts []Option + want widgetapi.Options + }{} + + 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) + } + }) + } +} diff --git a/widgets/textinput/textinputdemo/textinputdemo.go b/widgets/textinput/textinputdemo/textinputdemo.go new file mode 100644 index 0000000..dd04211 --- /dev/null +++ b/widgets/textinput/textinputdemo/textinputdemo.go @@ -0,0 +1,197 @@ +// 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/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" +) + +// 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) + + submitB, err := button.New("Submit", func() error { + updateText <- "Hello World" + return nil + }, + button.GlobalKey(keyboard.KeyEnter), + button.FillColor(cell.ColorNumber(220)), + ) + clearB, err := button.New("Clear", func() error { + updateText <- "" + 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), + ), + 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), + ), + ) + 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 { + panic(err) + } +}