tview/inputfield.go

718 lines
21 KiB
Go

package tview
import (
"math"
"regexp"
"strings"
"sync"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
"github.com/rivo/uniseg"
)
// InputField is a one-line box (three lines if there is a title) where the
// user can enter text. Use SetAcceptanceFunc() to accept or reject input,
// SetChangedFunc() to listen for changes, and SetMaskCharacter() to hide input
// from onlookers (e.g. for password input).
//
// The following keys can be used for navigation and editing:
//
// - Left arrow: Move left by one character.
// - Right arrow: Move right by one character.
// - Home, Ctrl-A, Alt-a: Move to the beginning of the line.
// - End, Ctrl-E, Alt-e: Move to the end of the line.
// - Alt-left, Alt-b: Move left by one word.
// - Alt-right, Alt-f: Move right by one word.
// - Backspace: Delete the character before the cursor.
// - Delete: Delete the character after the cursor.
// - Ctrl-K: Delete from the cursor to the end of the line.
// - Ctrl-W: Delete the last word before the cursor.
// - Ctrl-U: Delete the entire line.
//
// See https://github.com/rivo/tview/wiki/InputField for an example.
type InputField struct {
*Box
// The text that was entered.
text string
// The text to be displayed before the input area.
label string
// The text to be displayed in the input area when "text" is empty.
placeholder string
// The label style.
labelStyle tcell.Style
// The style of the input area with input text.
fieldStyle tcell.Style
// The style of the input area with placeholder text.
placeholderStyle tcell.Style
// The screen width of the label area. A value of 0 means use the width of
// the label text.
labelWidth int
// The screen width of the input area. A value of 0 means extend as much as
// possible.
fieldWidth int
// A character to mask entered text (useful for password fields). A value of 0
// disables masking.
maskCharacter rune
// The cursor position as a byte index into the text string.
cursorPos int
// An optional autocomplete function which receives the current text of the
// input field and returns a slice of strings to be displayed in a drop-down
// selection.
autocomplete func(text string) []string
// The List object which shows the selectable autocomplete entries. If not
// nil, the list's main texts represent the current autocomplete entries.
autocompleteList *List
autocompleteListMutex sync.Mutex
// The styles of the autocomplete entries.
autocompleteStyles struct {
main tcell.Style
selected tcell.Style
background tcell.Color
}
// An optional function which may reject the last character that was entered.
accept func(text string, ch rune) bool
// An optional function which is called when the input has changed.
changed func(text string)
// An optional function which is called when the user indicated that they
// are done entering text. The key which was pressed is provided (tab,
// shift-tab, enter, or escape).
done func(tcell.Key)
// A callback function set by the Form class and called when the user leaves
// this form item.
finished func(tcell.Key)
fieldX int // The x-coordinate of the input field as determined during the last call to Draw().
offset int // The number of bytes of the text string skipped ahead while drawing.
}
// NewInputField returns a new input field.
func NewInputField() *InputField {
i := &InputField{
Box: NewBox(),
labelStyle: tcell.StyleDefault.Foreground(Styles.SecondaryTextColor),
fieldStyle: tcell.StyleDefault.Background(Styles.ContrastBackgroundColor).Foreground(Styles.PrimaryTextColor),
placeholderStyle: tcell.StyleDefault.Background(Styles.ContrastBackgroundColor).Foreground(Styles.ContrastSecondaryTextColor),
}
i.autocompleteStyles.main = tcell.StyleDefault.Foreground(Styles.PrimitiveBackgroundColor)
i.autocompleteStyles.selected = tcell.StyleDefault.Background(Styles.PrimaryTextColor).Foreground(Styles.PrimitiveBackgroundColor)
i.autocompleteStyles.background = Styles.MoreContrastBackgroundColor
return i
}
// SetText sets the current text of the input field.
func (i *InputField) SetText(text string) *InputField {
i.text = text
i.cursorPos = len(text)
if i.changed != nil {
i.changed(text)
}
return i
}
// GetText returns the current text of the input field.
func (i *InputField) GetText() string {
return i.text
}
// SetLabel sets the text to be displayed before the input area.
func (i *InputField) SetLabel(label string) *InputField {
i.label = label
return i
}
// GetLabel returns the text to be displayed before the input area.
func (i *InputField) GetLabel() string {
return i.label
}
// SetLabelWidth sets the screen width of the label. A value of 0 will cause the
// primitive to use the width of the label string.
func (i *InputField) SetLabelWidth(width int) *InputField {
i.labelWidth = width
return i
}
// SetPlaceholder sets the text to be displayed when the input text is empty.
func (i *InputField) SetPlaceholder(text string) *InputField {
i.placeholder = text
return i
}
// SetLabelColor sets the text color of the label.
func (i *InputField) SetLabelColor(color tcell.Color) *InputField {
i.labelStyle = i.labelStyle.Foreground(color)
return i
}
// SetLabelStyle sets the style of the label.
func (i *InputField) SetLabelStyle(style tcell.Style) *InputField {
i.labelStyle = style
return i
}
// GetLabelStyle returns the style of the label.
func (i *InputField) GetLabelStyle() tcell.Style {
return i.labelStyle
}
// SetFieldBackgroundColor sets the background color of the input area.
func (i *InputField) SetFieldBackgroundColor(color tcell.Color) *InputField {
i.fieldStyle = i.fieldStyle.Background(color)
return i
}
// SetFieldTextColor sets the text color of the input area.
func (i *InputField) SetFieldTextColor(color tcell.Color) *InputField {
i.fieldStyle = i.fieldStyle.Foreground(color)
return i
}
// SetFieldStyle sets the style of the input area (when no placeholder is
// shown).
func (i *InputField) SetFieldStyle(style tcell.Style) *InputField {
i.fieldStyle = style
return i
}
// GetFieldStyle returns the style of the input area (when no placeholder is
// shown).
func (i *InputField) GetFieldStyle() tcell.Style {
return i.fieldStyle
}
// SetPlaceholderTextColor sets the text color of placeholder text.
func (i *InputField) SetPlaceholderTextColor(color tcell.Color) *InputField {
i.placeholderStyle = i.placeholderStyle.Foreground(color)
return i
}
// SetPlaceholderStyle sets the style of the input area (when a placeholder is
// shown).
func (i *InputField) SetPlaceholderStyle(style tcell.Style) *InputField {
i.placeholderStyle = style
return i
}
// GetPlaceholderStyle returns the style of the input area (when a placeholder
// is shown).
func (i *InputField) GetPlaceholderStyle() tcell.Style {
return i.placeholderStyle
}
// SetAutocompleteStyles sets the colors and style of the autocomplete entries.
// For details, see List.SetMainTextStyle(), List.SetSelectedStyle(), and
// Box.SetBackgroundColor().
func (i *InputField) SetAutocompleteStyles(background tcell.Color, main, selected tcell.Style) *InputField {
i.autocompleteStyles.background = background
i.autocompleteStyles.main = main
i.autocompleteStyles.selected = selected
return i
}
// SetFormAttributes sets attributes shared by all form items.
func (i *InputField) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) FormItem {
i.labelWidth = labelWidth
i.backgroundColor = bgColor
i.SetLabelColor(labelColor).
SetFieldTextColor(fieldTextColor).
SetFieldBackgroundColor(fieldBgColor)
return i
}
// SetFieldWidth sets the screen width of the input area. A value of 0 means
// extend as much as possible.
func (i *InputField) SetFieldWidth(width int) *InputField {
i.fieldWidth = width
return i
}
// GetFieldWidth returns this primitive's field width.
func (i *InputField) GetFieldWidth() int {
return i.fieldWidth
}
// GetFieldHeight returns this primitive's field height.
func (i *InputField) GetFieldHeight() int {
return 1
}
// SetMaskCharacter sets a character that masks user input on a screen. A value
// of 0 disables masking.
func (i *InputField) SetMaskCharacter(mask rune) *InputField {
i.maskCharacter = mask
return i
}
// SetAutocompleteFunc sets an autocomplete callback function which may return
// strings to be selected from a drop-down based on the current text of the
// input field. The drop-down appears only if len(entries) > 0. The callback is
// invoked in this function and whenever the current text changes or when
// Autocomplete() is called. Entries are cleared when the user selects an entry
// or presses Escape.
func (i *InputField) SetAutocompleteFunc(callback func(currentText string) (entries []string)) *InputField {
i.autocomplete = callback
i.Autocomplete()
return i
}
// Autocomplete invokes the autocomplete callback (if there is one). If the
// length of the returned autocomplete entries slice is greater than 0, the
// input field will present the user with a corresponding drop-down list the
// next time the input field is drawn.
//
// It is safe to call this function from any goroutine. Note that the input
// field is not redrawn automatically unless called from the main goroutine
// (e.g. in response to events).
func (i *InputField) Autocomplete() *InputField {
i.autocompleteListMutex.Lock()
defer i.autocompleteListMutex.Unlock()
if i.autocomplete == nil {
return i
}
// Do we have any autocomplete entries?
entries := i.autocomplete(i.text)
if len(entries) == 0 {
// No entries, no list.
i.autocompleteList = nil
return i
}
// Make a list if we have none.
if i.autocompleteList == nil {
i.autocompleteList = NewList()
i.autocompleteList.ShowSecondaryText(false).
SetMainTextStyle(i.autocompleteStyles.main).
SetSelectedStyle(i.autocompleteStyles.selected).
SetHighlightFullLine(true).
SetBackgroundColor(i.autocompleteStyles.background)
}
// Fill it with the entries.
currentEntry := -1
suffixLength := 9999 // I'm just waiting for the day somebody opens an issue with this number being too small.
i.autocompleteList.Clear()
for index, entry := range entries {
i.autocompleteList.AddItem(entry, "", 0, nil)
if strings.HasPrefix(entry, i.text) && len(entry)-len(i.text) < suffixLength {
currentEntry = index
suffixLength = len(i.text) - len(entry)
}
}
// Set the selection if we have one.
if currentEntry >= 0 {
i.autocompleteList.SetCurrentItem(currentEntry)
}
return i
}
// SetAcceptanceFunc sets a handler which may reject the last character that was
// entered (by returning false).
//
// This package defines a number of variables prefixed with InputField which may
// be used for common input (e.g. numbers, maximum text length).
func (i *InputField) SetAcceptanceFunc(handler func(textToCheck string, lastChar rune) bool) *InputField {
i.accept = handler
return i
}
// SetChangedFunc sets a handler which is called whenever the text of the input
// field has changed. It receives the current text (after the change).
func (i *InputField) SetChangedFunc(handler func(text string)) *InputField {
i.changed = handler
return i
}
// SetDoneFunc sets a handler which is called when the user is done entering
// text. The callback function is provided with the key that was pressed, which
// is one of the following:
//
// - KeyEnter: Done entering text.
// - KeyEscape: Abort text input.
// - KeyTab: Move to the next field.
// - KeyBacktab: Move to the previous field.
func (i *InputField) SetDoneFunc(handler func(key tcell.Key)) *InputField {
i.done = handler
return i
}
// SetFinishedFunc sets a callback invoked when the user leaves this form item.
func (i *InputField) SetFinishedFunc(handler func(key tcell.Key)) FormItem {
i.finished = handler
return i
}
// Draw draws this primitive onto the screen.
func (i *InputField) Draw(screen tcell.Screen) {
i.Box.DrawForSubclass(screen, i)
// Prepare
x, y, width, height := i.GetInnerRect()
rightLimit := x + width
if height < 1 || rightLimit <= x {
return
}
// Draw label.
_, labelBg, _ := i.labelStyle.Decompose()
if i.labelWidth > 0 {
labelWidth := i.labelWidth
if labelWidth > width {
labelWidth = width
}
printWithStyle(screen, i.label, x, y, 0, labelWidth, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
x += labelWidth
} else {
_, drawnWidth, _, _ := printWithStyle(screen, i.label, x, y, 0, width, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
x += drawnWidth
}
// Draw input area.
i.fieldX = x
fieldWidth := i.fieldWidth
text := i.text
inputStyle := i.fieldStyle
placeholder := text == "" && i.placeholder != ""
if placeholder {
inputStyle = i.placeholderStyle
}
_, inputBg, _ := inputStyle.Decompose()
if fieldWidth == 0 {
fieldWidth = math.MaxInt32
}
if rightLimit-x < fieldWidth {
fieldWidth = rightLimit - x
}
if inputBg != tcell.ColorDefault {
for index := 0; index < fieldWidth; index++ {
screen.SetContent(x+index, y, ' ', nil, inputStyle)
}
}
// Text.
var cursorScreenPos int
if placeholder {
// Draw placeholder text.
printWithStyle(screen, Escape(i.placeholder), x, y, 0, fieldWidth, AlignLeft, i.placeholderStyle, true)
i.offset = 0
} else {
// Draw entered text.
if i.maskCharacter > 0 {
text = strings.Repeat(string(i.maskCharacter), utf8.RuneCountInString(i.text))
}
if fieldWidth >= uniseg.StringWidth(text) {
// We have enough space for the full text.
printWithStyle(screen, Escape(text), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true)
i.offset = 0
iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if textPos >= i.cursorPos {
return true
}
cursorScreenPos += screenWidth
return false
})
} else {
// The text doesn't fit. Where is the cursor?
if i.cursorPos < 0 {
i.cursorPos = 0
} else if i.cursorPos > len(text) {
i.cursorPos = len(text)
}
// Shift the text so the cursor is inside the field.
var shiftLeft int
if i.offset > i.cursorPos {
i.offset = i.cursorPos
} else if subWidth := uniseg.StringWidth(text[i.offset:i.cursorPos]); subWidth > fieldWidth-1 {
shiftLeft = subWidth - fieldWidth + 1
}
currentOffset := i.offset
iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if textPos >= currentOffset {
if shiftLeft > 0 {
i.offset = textPos + textWidth
shiftLeft -= screenWidth
} else {
if textPos+textWidth > i.cursorPos {
return true
}
cursorScreenPos += screenWidth
}
}
return false
})
printWithStyle(screen, Escape(text[i.offset:]), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true)
}
}
// Draw autocomplete list.
i.autocompleteListMutex.Lock()
defer i.autocompleteListMutex.Unlock()
if i.autocompleteList != nil {
// How much space do we need?
lheight := i.autocompleteList.GetItemCount()
lwidth := 0
for index := 0; index < lheight; index++ {
entry, _ := i.autocompleteList.GetItemText(index)
width := TaggedStringWidth(entry)
if width > lwidth {
lwidth = width
}
}
// We prefer to drop down but if there is no space, maybe drop up?
lx := x
ly := y + 1
_, sheight := screen.Size()
if ly+lheight >= sheight && ly-2 > lheight-ly {
ly = y - lheight
if ly < 0 {
ly = 0
}
}
if ly+lheight >= sheight {
lheight = sheight - ly
}
i.autocompleteList.SetRect(lx, ly, lwidth, lheight)
i.autocompleteList.Draw(screen)
}
// Set cursor.
if i.HasFocus() {
screen.ShowCursor(x+cursorScreenPos, y)
}
}
// InputHandler returns the handler for this primitive.
func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
return i.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
// Trigger changed events.
currentText := i.text
defer func() {
if i.text != currentText {
i.Autocomplete()
if i.changed != nil {
i.changed(i.text)
}
}
}()
// Movement functions.
home := func() { i.cursorPos = 0 }
end := func() { i.cursorPos = len(i.text) }
moveLeft := func() {
iterateStringReverse(i.text[:i.cursorPos], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
i.cursorPos -= textWidth
return true
})
}
moveRight := func() {
iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
i.cursorPos += textWidth
return true
})
}
moveWordLeft := func() {
i.cursorPos = len(regexp.MustCompile(`\S+\s*$`).ReplaceAllString(i.text[:i.cursorPos], ""))
}
moveWordRight := func() {
i.cursorPos = len(i.text) - len(regexp.MustCompile(`^\s*\S+\s*`).ReplaceAllString(i.text[i.cursorPos:], ""))
}
// Add character function. Returns whether or not the rune character is
// accepted.
add := func(r rune) bool {
newText := i.text[:i.cursorPos] + string(r) + i.text[i.cursorPos:]
if i.accept != nil && !i.accept(newText, r) {
return false
}
i.text = newText
i.cursorPos += len(string(r))
return true
}
// Change the autocomplete selection.
autocompleteSelect := func(offset int) {
count := i.autocompleteList.GetItemCount()
newEntry := i.autocompleteList.GetCurrentItem() + offset
if newEntry >= count {
newEntry = 0
} else if newEntry < 0 {
newEntry = count - 1
}
i.autocompleteList.SetCurrentItem(newEntry)
currentText, _ = i.autocompleteList.GetItemText(newEntry) // Don't trigger changed function twice.
currentText = stripTags(currentText)
i.SetText(currentText)
}
// Finish up.
finish := func(key tcell.Key) {
if i.done != nil {
i.done(key)
}
if i.finished != nil {
i.finished(key)
}
}
// Process key event.
i.autocompleteListMutex.Lock()
defer i.autocompleteListMutex.Unlock()
switch key := event.Key(); key {
case tcell.KeyRune: // Regular character.
if event.Modifiers()&tcell.ModAlt > 0 {
// We accept some Alt- key combinations.
switch event.Rune() {
case 'a': // Home.
home()
case 'e': // End.
end()
case 'b': // Move word left.
moveWordLeft()
case 'f': // Move word right.
moveWordRight()
default:
if !add(event.Rune()) {
return
}
}
} else {
// Other keys are simply accepted as regular characters.
if !add(event.Rune()) {
return
}
}
case tcell.KeyCtrlU: // Delete all.
i.text = ""
i.cursorPos = 0
case tcell.KeyCtrlK: // Delete until the end of the line.
i.text = i.text[:i.cursorPos]
case tcell.KeyCtrlW: // Delete last word.
lastWord := regexp.MustCompile(`\S+\s*$`)
newText := lastWord.ReplaceAllString(i.text[:i.cursorPos], "") + i.text[i.cursorPos:]
i.cursorPos -= len(i.text) - len(newText)
i.text = newText
case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete character before the cursor.
iterateStringReverse(i.text[:i.cursorPos], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
i.text = i.text[:textPos] + i.text[textPos+textWidth:]
i.cursorPos -= textWidth
return true
})
if i.offset >= i.cursorPos {
i.offset = 0
}
case tcell.KeyDelete, tcell.KeyCtrlD: // Delete character after the cursor.
iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
i.text = i.text[:i.cursorPos] + i.text[i.cursorPos+textWidth:]
return true
})
case tcell.KeyLeft:
if event.Modifiers()&tcell.ModAlt > 0 {
moveWordLeft()
} else {
moveLeft()
}
case tcell.KeyCtrlB:
moveLeft()
case tcell.KeyRight:
if event.Modifiers()&tcell.ModAlt > 0 {
moveWordRight()
} else {
moveRight()
}
case tcell.KeyCtrlF:
moveRight()
case tcell.KeyHome, tcell.KeyCtrlA:
home()
case tcell.KeyEnd, tcell.KeyCtrlE:
end()
case tcell.KeyEnter:
if i.autocompleteList != nil {
autocompleteSelect(0)
i.autocompleteList = nil
} else {
finish(key)
}
case tcell.KeyEscape:
if i.autocompleteList != nil {
i.autocompleteList = nil
} else {
finish(key)
}
case tcell.KeyTab:
if i.autocompleteList != nil {
autocompleteSelect(0)
} else {
finish(key)
}
case tcell.KeyDown:
if i.autocompleteList != nil {
autocompleteSelect(1)
} else {
finish(key)
}
case tcell.KeyUp, tcell.KeyBacktab: // Autocomplete selection.
if i.autocompleteList != nil {
autocompleteSelect(-1)
} else {
finish(key)
}
}
})
}
// MouseHandler returns the mouse handler for this primitive.
func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) {
return i.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) {
x, y := event.Position()
_, rectY, _, _ := i.GetInnerRect()
if !i.InRect(x, y) {
return false, nil
}
// Process mouse event.
if y == rectY {
if action == MouseLeftDown {
setFocus(i)
consumed = true
} else if action == MouseLeftClick {
// Determine where to place the cursor.
if x >= i.fieldX {
if !iterateString(i.text[i.offset:], func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth, boundaries int) bool {
if x-i.fieldX < screenPos+screenWidth {
i.cursorPos = textPos + i.offset
return true
}
return false
}) {
i.cursorPos = len(i.text)
}
}
consumed = true
}
}
return
})
}