mirror of https://github.com/mum4k/termdash.git
372 lines
8.9 KiB
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()
|
|
}
|