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

Skeleton of the TextInput widget.

This commit is contained in:
Jakub Sobon 2019-04-07 00:41:09 -04:00
parent ea2e0b7855
commit bf72b5ddc2
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
4 changed files with 654 additions and 0 deletions

View File

@ -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
})
}

View File

@ -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{}
}

View File

@ -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)
}
})
}
}

View File

@ -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)
}
}