termdash/widgets/textinput/textinput.go

372 lines
8.9 KiB
Go

// 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
}
return draw.Text(
cvs, ti.opts.label, start,
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextMaxX(labelAr.Max.X),
draw.TextCellOpts(ti.opts.labelCellOpts...),
)
}
// 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)
}
return draw.Text(
cvs, text, ti.forField.Min,
draw.TextMaxX(ti.forField.Max.X),
draw.TextCellOpts(cell.FgColor(ti.opts.textColor)),
)
}
// 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()
}