gomuks/ui/widget/advanced-inputfield.go

466 lines
14 KiB
Go

// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package widget
import (
"math"
"regexp"
"strings"
"unicode/utf8"
"github.com/gdamore/tcell"
"github.com/mattn/go-runewidth"
"github.com/zyedidia/clipboard"
"maunium.net/go/tview"
)
// AdvancedInputField is a multi-line user-editable text area.
//
// Use SetMaskCharacter() to hide input from onlookers (e.g. for password
// input).
type AdvancedInputField struct {
*tview.Box
// Cursor position
cursorOffset int
viewOffset int
// 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 color.
labelColor tcell.Color
// The background color of the input area.
fieldBackgroundColor tcell.Color
// The text color of the input area.
fieldTextColor tcell.Color
// The text color of the placeholder.
placeholderTextColor tcell.Color
// 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
vimBindings bool
// 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 (enter, tab, backtab or escape).
done func(tcell.Key)
// An optional function which is called when the user presses tab.
tabComplete func(text string, cursorOffset int) string
}
// NewAdvancedInputField returns a new input field.
func NewAdvancedInputField() *AdvancedInputField {
return &AdvancedInputField{
Box: tview.NewBox(),
labelColor: tview.Styles.SecondaryTextColor,
fieldBackgroundColor: tview.Styles.ContrastBackgroundColor,
fieldTextColor: tview.Styles.PrimaryTextColor,
placeholderTextColor: tview.Styles.ContrastSecondaryTextColor,
}
}
// SetText sets the current text of the input field.
func (field *AdvancedInputField) SetText(text string) *AdvancedInputField {
field.text = text
if field.changed != nil {
field.changed(text)
}
return field
}
// GetText returns the current text of the input field.
func (field *AdvancedInputField) GetText() string {
return field.text
}
// SetLabel sets the text to be displayed before the input area.
func (field *AdvancedInputField) SetLabel(label string) *AdvancedInputField {
field.label = label
return field
}
// GetLabel returns the text to be displayed before the input area.
func (field *AdvancedInputField) GetLabel() string {
return field.label
}
// SetPlaceholder sets the text to be displayed when the input text is empty.
func (field *AdvancedInputField) SetPlaceholder(text string) *AdvancedInputField {
field.placeholder = text
return field
}
// SetLabelColor sets the color of the label.
func (field *AdvancedInputField) SetLabelColor(color tcell.Color) *AdvancedInputField {
field.labelColor = color
return field
}
// SetFieldBackgroundColor sets the background color of the input area.
func (field *AdvancedInputField) SetFieldBackgroundColor(color tcell.Color) *AdvancedInputField {
field.fieldBackgroundColor = color
return field
}
// SetFieldTextColor sets the text color of the input area.
func (field *AdvancedInputField) SetFieldTextColor(color tcell.Color) *AdvancedInputField {
field.fieldTextColor = color
return field
}
// SetPlaceholderExtColor sets the text color of placeholder text.
func (field *AdvancedInputField) SetPlaceholderExtColor(color tcell.Color) *AdvancedInputField {
field.placeholderTextColor = color
return field
}
// SetFormAttributes sets attributes shared by all form items.
func (field *AdvancedInputField) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem {
field.label = label
field.labelColor = labelColor
field.SetBackgroundColor(bgColor)
field.fieldTextColor = fieldTextColor
field.fieldBackgroundColor = fieldBgColor
return field
}
// SetFieldWidth sets the screen width of the input area. A value of 0 means
// extend as much as possible.
func (field *AdvancedInputField) SetFieldWidth(width int) *AdvancedInputField {
field.fieldWidth = width
return field
}
// GetFieldWidth returns this primitive's field width.
func (field *AdvancedInputField) GetFieldWidth() int {
return field.fieldWidth
}
// SetMaskCharacter sets a character that masks user input on a screen. A value
// of 0 disables masking.
func (field *AdvancedInputField) SetMaskCharacter(mask rune) *AdvancedInputField {
field.maskCharacter = mask
return field
}
// 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 AdvancedInputField which may
// be used for common input (e.g. numbers, maximum text length).
func (field *AdvancedInputField) SetAcceptanceFunc(handler func(textToCheck string, lastChar rune) bool) *AdvancedInputField {
field.accept = handler
return field
}
// 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 (field *AdvancedInputField) SetChangedFunc(handler func(text string)) *AdvancedInputField {
field.changed = handler
return field
}
// 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: Tab
// - KeyBacktab: Shift + Tab
func (field *AdvancedInputField) SetDoneFunc(handler func(key tcell.Key)) *AdvancedInputField {
field.done = handler
return field
}
func (field *AdvancedInputField) SetTabCompleteFunc(handler func(text string, cursorOffset int) string) *AdvancedInputField {
field.tabComplete = handler
return field
}
// SetFinishedFunc calls SetDoneFunc().
func (field *AdvancedInputField) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
return field.SetDoneFunc(handler)
}
// Draw draws this primitive onto the screen.
func (field *AdvancedInputField) Draw(screen tcell.Screen) {
field.Box.Draw(screen)
// Prepare
x, y, width, height := field.GetInnerRect()
rightLimit := x + width
if height < 1 || rightLimit <= x {
return
}
// Draw label.
_, drawnWidth := tview.Print(screen, field.label, x, y, rightLimit-x, tview.AlignLeft, field.labelColor)
x += drawnWidth
// Draw input area.
fieldWidth := field.fieldWidth
if fieldWidth == 0 {
fieldWidth = math.MaxInt32
}
if rightLimit-x < fieldWidth {
fieldWidth = rightLimit - x
}
fieldStyle := tcell.StyleDefault.Background(field.fieldBackgroundColor)
for index := 0; index < fieldWidth; index++ {
screen.SetContent(x+index, y, ' ', nil, fieldStyle)
}
text := field.text
if text == "" && field.placeholder != "" {
tview.Print(screen, field.placeholder, x, y, fieldWidth, tview.AlignLeft, field.placeholderTextColor)
}
// Draw entered text.
if field.maskCharacter > 0 {
text = strings.Repeat(string(field.maskCharacter), utf8.RuneCountInString(field.text))
}
textWidth := runewidth.StringWidth(text)
if field.cursorOffset >= textWidth {
fieldWidth--
}
// Recalculate view offset
if field.cursorOffset < field.viewOffset {
field.viewOffset = field.cursorOffset
} else if field.cursorOffset > field.viewOffset+fieldWidth {
field.viewOffset = field.cursorOffset - fieldWidth
} else if textWidth-field.viewOffset < fieldWidth {
field.viewOffset = textWidth - fieldWidth
}
// Make sure view offset didn't become negative
if field.viewOffset < 0 {
field.viewOffset = 0
}
// Draw entered text.
runes := []rune(text)
relPos := 0
for pos := field.viewOffset; pos <= fieldWidth+field.viewOffset && pos < len(runes); pos++ {
ch := runes[pos]
w := runewidth.RuneWidth(ch)
_, _, style, _ := screen.GetContent(x+relPos, y)
style = style.Foreground(field.fieldTextColor)
for w > 0 {
screen.SetContent(x+relPos, y, ch, nil, style)
relPos++
w--
}
}
// Set cursor.
if field.GetFocusable().HasFocus() {
field.setCursor(screen)
}
}
func (field *AdvancedInputField) GetCursorOffset() int {
return field.cursorOffset
}
func (field *AdvancedInputField) SetCursorOffset(offset int) *AdvancedInputField {
if offset < 0 {
offset = 0
} else {
width := runewidth.StringWidth(field.text)
if offset >= width {
offset = width
}
}
field.cursorOffset = offset
return field
}
// setCursor sets the cursor position.
func (field *AdvancedInputField) setCursor(screen tcell.Screen) {
x, y, width, _ := field.GetRect()
origX, origY := x, y
rightLimit := x + width
if field.HasBorder() {
x++
y++
rightLimit -= 2
}
fieldWidth := runewidth.StringWidth(field.text)
if field.fieldWidth > 0 && fieldWidth > field.fieldWidth-1 {
fieldWidth = field.fieldWidth - 1
}
x = x + tview.StringWidth(field.label) + field.cursorOffset - field.viewOffset
if x >= rightLimit {
x = rightLimit - 1
} else if x < origX {
x = origY
}
screen.ShowCursor(x, y)
}
var (
lastWord = regexp.MustCompile(`\S+\s*$`)
firstWord = regexp.MustCompile(`^\s*\S+`)
)
func SubstringBefore(s string, w int) string {
return runewidth.Truncate(s, w, "")
}
// InputHandler returns the handler for this primitive.
func (field *AdvancedInputField) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return field.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
currentText := field.text
defer func() {
// Trigger changed events.
if field.text != currentText && field.changed != nil {
field.changed(field.text)
}
// Make sure cursor offset is valid
if field.cursorOffset < 0 {
field.cursorOffset = 0
}
width := runewidth.StringWidth(field.text)
if field.cursorOffset > width {
field.cursorOffset = width
}
}()
// Process key event.
switch key := event.Key(); key {
case tcell.KeyRune: // Regular character.
leftPart := SubstringBefore(field.text, field.cursorOffset)
newText := leftPart + string(event.Rune()) + field.text[len(leftPart):]
if field.accept != nil {
if !field.accept(newText, event.Rune()) {
break
}
}
field.text = newText
field.cursorOffset += runewidth.RuneWidth(event.Rune())
case tcell.KeyCtrlV:
clip, _ := clipboard.ReadAll("clipboard")
leftPart := SubstringBefore(field.text, field.cursorOffset)
field.text = leftPart + clip + field.text[len(leftPart):]
field.cursorOffset += runewidth.StringWidth(clip)
case tcell.KeyLeft: // Move cursor left.
before := SubstringBefore(field.text, field.cursorOffset)
if event.Modifiers() == tcell.ModCtrl {
found := lastWord.FindString(before)
field.cursorOffset -= runewidth.StringWidth(found)
} else if len(before) > 0 {
beforeRunes := []rune(before)
char := beforeRunes[len(beforeRunes)-1]
field.cursorOffset -= runewidth.RuneWidth(char)
}
case tcell.KeyRight: // Move cursor right.
before := SubstringBefore(field.text, field.cursorOffset)
after := field.text[len(before):]
if event.Modifiers() == tcell.ModCtrl {
found := firstWord.FindString(after)
field.cursorOffset += runewidth.StringWidth(found)
} else if len(after) > 0 {
char := []rune(after)[0]
field.cursorOffset += runewidth.RuneWidth(char)
}
case tcell.KeyDelete: // Delete next character.
if field.cursorOffset >= runewidth.StringWidth(field.text) {
break
}
leftPart := SubstringBefore(field.text, field.cursorOffset)
// Take everything after the left part minus the first character.
rightPart := string([]rune(field.text[len(leftPart):])[1:])
field.text = leftPart + rightPart
case tcell.KeyCtrlU:
if !field.vimBindings {
break
}
field.text = ""
field.cursorOffset = 0
case tcell.KeyCtrlW:
if !field.vimBindings {
break
}
fallthrough
case tcell.KeyBackspace: // Delete last word
leftPart := SubstringBefore(field.text, field.cursorOffset)
rightPart := field.text[len(leftPart):]
replacement := lastWord.ReplaceAllString(leftPart, "")
field.text = replacement + rightPart
field.cursorOffset -= runewidth.StringWidth(leftPart) - runewidth.StringWidth(replacement)
case tcell.KeyBackspace2: // Delete last character
if field.cursorOffset == 0 {
break
}
leftPart := SubstringBefore(field.text, field.cursorOffset)
rightPart := field.text[len(leftPart):]
// Take everything before the right part minus the last character.
leftPartRunes := []rune(leftPart)
leftPartRunes = leftPartRunes[0 : len(leftPartRunes)-1]
leftPart = string(leftPartRunes)
// Figure out what character was removed to correctly decrease cursorOffset.
removedChar := field.text[len(leftPart) : len(field.text)-len(rightPart)]
field.text = leftPart + rightPart
field.cursorOffset -= runewidth.StringWidth(removedChar)
case tcell.KeyTab: // Tab-completion
if field.tabComplete != nil {
oldWidth := runewidth.StringWidth(field.text)
field.text = field.tabComplete(field.text, field.cursorOffset)
newWidth := runewidth.StringWidth(field.text)
if oldWidth != newWidth {
field.cursorOffset += newWidth - oldWidth
}
break
}
fallthrough
case tcell.KeyEnter, tcell.KeyEscape, tcell.KeyBacktab: // We're done.
if field.done != nil {
field.done(key)
}
}
})
}