InputField is now based on TextArea.

pull/905/head
Oliver 7 months ago
parent 6416d6b23c
commit 8b7bcf9883

@ -58,7 +58,13 @@ func main() {
return
}
entries := make([]string, 0, len(companies))
OuterLoop:
for _, c := range companies {
for _, entry := range entries { // Eliminate duplicates.
if strings.EqualFold(entry, c.Name) {
continue OuterLoop
}
}
entries = append(entries, c.Name)
}
mutex.Lock()

@ -12,7 +12,7 @@ func main() {
SetLabel("Enter a number: ").
SetPlaceholder("E.g. 1234").
SetFieldWidth(10).
//SetAcceptanceFunc(tview.InputFieldInteger).
SetAcceptanceFunc(tview.InputFieldInteger).
SetDoneFunc(func(key tcell.Key) {
app.Stop()
})

@ -42,8 +42,8 @@ func Form(nextSlide func()) (title string, content tview.Primitive) {
img, _ := jpeg.Decode(bytes.NewReader(b))
f := tview.NewForm().
AddImage("Photo:", img, 0, 12, 0).
AddInputField("First name:", "", 20, nil, nil).
AddInputField("Last name:", "", 20, nil, nil).
AddInputField("First name:", "", 0, nil, nil).
AddInputField("Last name:", "", 0, nil, nil).
AddDropDown("Role:", []string{"Engineer", "Manager", "Administration"}, 0, nil).
AddCheckbox("On vacation:", false, nil).
AddPasswordField("Password:", "", 10, '*', nil).

@ -12,6 +12,7 @@ func main() {
app := tview.NewApplication()
textArea := tview.NewTextArea().
SetWrap(false).
SetPlaceholder("Enter text here...")
textArea.SetTitle("Text Area").SetBorder(true)
helpInfo := tview.NewTextView().
@ -34,7 +35,7 @@ func main() {
updateInfos()
mainView := tview.NewGrid().
SetRows(0, 1).
SetRows(3, 0).
AddItem(textArea, 0, 0, 1, 2, 0, 0, true).
AddItem(helpInfo, 1, 0, 1, 1, 0, 0, false).
AddItem(position, 1, 1, 1, 1, 0, 0, false)

@ -1,11 +1,9 @@
package tview
import (
"math"
"regexp"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
"github.com/rivo/uniseg"
@ -21,21 +19,37 @@ const (
// Predefined InputField acceptance functions.
var (
// InputFieldInteger accepts integers.
InputFieldInteger func(text string, ch rune) bool
InputFieldInteger = func(text string, ch rune) bool {
if text == "-" {
return true
}
_, err := strconv.Atoi(text)
return err == nil
}
// InputFieldFloat accepts floating-point numbers.
InputFieldFloat func(text string, ch rune) bool
InputFieldFloat = func(text string, ch rune) bool {
if text == "-" || text == "." || text == "-." {
return true
}
_, err := strconv.ParseFloat(text, 64)
return err == nil
}
// InputFieldMaxLength returns an input field accept handler which accepts
// input strings up to a given length. Use it like this:
//
// inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters.
InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool
InputFieldMaxLength = func(maxLength int) func(text string, ch rune) bool {
return func(text string, ch rune) bool {
return len([]rune(text)) <= maxLength
}
}
)
// InputField is a one-line box (three lines if there is a title) where the
// user can enter text. Use [InputField.SetAcceptanceFunc] to accept or reject
// input, [InputField.SetChangedFunc] to listen for changes, and
// InputField is a one-line box into which the user can enter text. Use
// [InputField.SetAcceptanceFunc] to accept or reject input,
// [InputField.SetChangedFunc] to listen for changes, and
// [InputField.SetMaskCharacter] to hide input from onlookers (e.g. for password
// input).
//
@ -44,62 +58,27 @@ var (
// autocomplete drop-down's behavior, you can also set the
// [InputField.SetAutocompletedFunc].
//
// The following keys can be used for navigation and editing:
// Navigation and editing is the same as for a [TextArea], with the following
// exceptions:
//
// - Tab, BackTab, Enter, Escape: Finish editing.
//
// If autocomplete functionality is configured:
//
// - Left arrow: Move left by one character.
// - Right arrow: Move right by one character.
// - Down arrow: Open the autocomplete drop-down.
// - Tab, Enter: Select the current autocomplete entry.
// - 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
// Whether or not this input field is disabled/read-only.
disabled bool
// 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 text area providing the core functionality of the input field.
textArea *TextArea
// 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.
@ -139,29 +118,31 @@ type InputField struct {
// 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),
Box: NewBox(),
textArea: NewTextArea().SetWrap(false),
}
i.textArea.SetChangedFunc(func() {
if i.changed != nil {
i.changed(i.textArea.GetText())
}
})
i.textArea.textStyle = tcell.StyleDefault.Background(Styles.ContrastBackgroundColor).Foreground(Styles.PrimaryTextColor)
i.textArea.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.
// SetText sets the current text of the input field. This can be undone by the
// user.
func (i *InputField) SetText(text string) *InputField {
i.text = text
i.cursorPos = len(text)
i.textArea.Replace(0, i.textArea.GetTextLength(), text)
if i.changed != nil {
i.changed(text)
}
@ -170,92 +151,92 @@ func (i *InputField) SetText(text string) *InputField {
// GetText returns the current text of the input field.
func (i *InputField) GetText() string {
return i.text
return i.textArea.GetText()
}
// SetLabel sets the text to be displayed before the input area.
func (i *InputField) SetLabel(label string) *InputField {
i.label = label
i.textArea.SetLabel(label)
return i
}
// GetLabel returns the text to be displayed before the input area.
func (i *InputField) GetLabel() string {
return i.label
return i.textArea.GetLabel()
}
// 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
i.textArea.SetLabelWidth(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
i.textArea.SetPlaceholder(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)
i.textArea.SetLabelStyle(i.textArea.GetLabelStyle().Foreground(color))
return i
}
// SetLabelStyle sets the style of the label.
func (i *InputField) SetLabelStyle(style tcell.Style) *InputField {
i.labelStyle = style
i.textArea.SetLabelStyle(style)
return i
}
// GetLabelStyle returns the style of the label.
func (i *InputField) GetLabelStyle() tcell.Style {
return i.labelStyle
return i.textArea.GetLabelStyle()
}
// SetFieldBackgroundColor sets the background color of the input area.
func (i *InputField) SetFieldBackgroundColor(color tcell.Color) *InputField {
i.fieldStyle = i.fieldStyle.Background(color)
i.textArea.SetTextStyle(i.textArea.GetTextStyle().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)
i.textArea.SetTextStyle(i.textArea.GetTextStyle().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
i.textArea.SetTextStyle(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
return i.textArea.GetTextStyle()
}
// SetPlaceholderTextColor sets the text color of placeholder text.
func (i *InputField) SetPlaceholderTextColor(color tcell.Color) *InputField {
i.placeholderStyle = i.placeholderStyle.Foreground(color)
i.textArea.SetPlaceholderStyle(i.textArea.GetPlaceholderStyle().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
i.textArea.SetPlaceholderStyle(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
return i.textArea.GetPlaceholderStyle()
}
// SetAutocompleteStyles sets the colors and style of the autocomplete entries.
@ -270,11 +251,7 @@ func (i *InputField) SetAutocompleteStyles(background tcell.Color, main, selecte
// 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)
i.textArea.SetFormAttributes(labelWidth, labelColor, bgColor, fieldTextColor, fieldBgColor)
return i
}
@ -297,7 +274,7 @@ func (i *InputField) GetFieldHeight() int {
// SetDisabled sets whether or not the item is disabled / read-only.
func (i *InputField) SetDisabled(disabled bool) FormItem {
i.disabled = disabled
i.textArea.SetDisabled(disabled)
if i.finished != nil {
i.finished(-1)
}
@ -307,7 +284,15 @@ func (i *InputField) SetDisabled(disabled bool) FormItem {
// 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
if mask == 0 {
i.textArea.setTransform(nil)
return i
}
maskStr := string(mask)
maskWidth := uniseg.StringWidth(maskStr)
i.textArea.setTransform(func(cluster, rest string, boundaries int) (newCluster string, newBoundaries int) {
return maskStr, maskWidth << uniseg.ShiftWidth
})
return i
}
@ -315,8 +300,8 @@ func (i *InputField) SetMaskCharacter(mask rune) *InputField {
// 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.
// [InputField.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()
@ -326,7 +311,7 @@ func (i *InputField) SetAutocompleteFunc(callback func(currentText string) (entr
// SetAutocompletedFunc sets a callback function which is invoked when the user
// selects an entry from the autocomplete drop-down list. The function is passed
// the text of the selected entry (stripped of any style tags), the index of the
// entry, and the user action that caused the selection, e.g.
// entry, and the user action that caused the selection, for example
// [AutocompletedNavigate]. It returns true if the autocomplete drop-down should
// be closed after the callback returns or false if it should remain open, in
// which case [InputField.Autocomplete] is called to update the drop-down's
@ -341,10 +326,10 @@ func (i *InputField) SetAutocompletedFunc(autocompleted func(text string, index
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.
// Autocomplete invokes the autocomplete callback (if there is one, see
// [InputField.SetAutocompleteFunc]). 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
@ -357,7 +342,8 @@ func (i *InputField) Autocomplete() *InputField {
}
// Do we have any autocomplete entries?
entries := i.autocomplete(i.text)
text := i.textArea.GetText()
entries := i.autocomplete(text)
if len(entries) == 0 {
// No entries, no list.
i.autocompleteList = nil
@ -380,9 +366,9 @@ func (i *InputField) Autocomplete() *InputField {
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 {
if strings.HasPrefix(entry, text) && len(entry)-len(text) < suffixLength {
currentEntry = index
suffixLength = len(i.text) - len(entry)
suffixLength = len(text) - len(entry)
}
}
@ -395,10 +381,14 @@ func (i *InputField) Autocomplete() *InputField {
}
// SetAcceptanceFunc sets a handler which may reject the last character that was
// entered (by returning false).
// entered, by returning false. The handler receives the text as it would be
// after the change and the last character entered. If the handler is nil, all
// input is accepted. The function is only called when a single rune is inserted
// at the current cursor position.
//
// This package defines a number of variables prefixed with InputField which may
// be used for common input (e.g. numbers, maximum text length).
// be used for common input (e.g. numbers, maximum text length). See for example
// [InputFieldInteger].
func (i *InputField) SetAcceptanceFunc(handler func(textToCheck string, lastChar rune) bool) *InputField {
i.accept = handler
return i
@ -434,7 +424,7 @@ func (i *InputField) SetFinishedFunc(handler func(key tcell.Key)) FormItem {
func (i *InputField) Focus(delegate func(p Primitive)) {
// If we're part of a form and this item is disabled, there's nothing the
// user can do here so we're finished.
if i.finished != nil && i.disabled {
if i.finished != nil && i.textArea.GetDisabled() {
i.finished(-1)
return
}
@ -442,8 +432,14 @@ func (i *InputField) Focus(delegate func(p Primitive)) {
i.Box.Focus(delegate)
}
// HasFocus returns whether or not this primitive has focus.
func (i *InputField) HasFocus() bool {
return i.textArea.HasFocus() || i.Box.HasFocus()
}
// Blur is called when this primitive loses focus.
func (i *InputField) Blur() {
i.textArea.Blur()
i.Box.Blur()
i.autocompleteList = nil // Hide the autocomplete drop-down.
}
@ -454,117 +450,25 @@ func (i *InputField) Draw(screen tcell.Screen) {
// Prepare
x, y, width, height := i.GetInnerRect()
rightLimit := x + width
if height < 1 || rightLimit <= x {
if height < 1 || width < 1 {
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
// Resize text area.
labelWidth := i.textArea.GetLabelWidth()
if labelWidth == 0 {
labelWidth = TaggedStringWidth(i.textArea.GetLabel())
}
// 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 i.disabled {
inputStyle = inputStyle.Background(i.backgroundColor)
}
if inputBg != tcell.ColorDefault {
for index := 0; index < fieldWidth; index++ {
screen.SetContent(x+index, y, ' ', nil, inputStyle)
}
fieldWidth = width - labelWidth
}
i.textArea.SetRect(x, y, labelWidth+fieldWidth, 1)
i.textArea.setMinCursorPadding(fieldWidth-1, 1)
// 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
// Find cursor position.
var (
state *stepState
textPos int
)
str := text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
textPos += state.GrossLength()
if textPos > i.cursorPos {
break
}
cursorScreenPos += state.Width()
}
} 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
var (
state *stepState
textPos int
)
str := text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
if textPos >= currentOffset {
if shiftLeft > 0 {
i.offset = textPos + state.GrossLength()
shiftLeft -= state.Width()
} else {
if textPos+state.GrossLength() > i.cursorPos {
break
}
cursorScreenPos += state.Width()
}
}
textPos += state.GrossLength()
}
printWithStyle(screen, Escape(text[i.offset:]), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true)
}
}
// Draw text area.
i.textArea.hasFocus = i.HasFocus() // Force cursor positioning.
i.textArea.Draw(screen)
// Draw autocomplete list.
i.autocompleteListMutex.Lock()
@ -582,7 +486,7 @@ func (i *InputField) Draw(screen tcell.Screen) {
}
// We prefer to drop down but if there is no space, maybe drop up?
lx := x
lx := x + labelWidth
ly := y + 1
_, sheight := screen.Size()
if ly+lheight >= sheight && ly-2 > lheight-ly {
@ -597,95 +501,45 @@ func (i *InputField) Draw(screen tcell.Screen) {
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)) {
if i.disabled {
if i.textArea.GetDisabled() {
return
}
// Trigger changed events.
currentText := i.text
var skipAutocomplete bool
currentText := i.textArea.GetText()
defer func() {
if i.text != currentText {
i.Autocomplete()
newText := i.textArea.GetText()
if newText != currentText {
if !skipAutocomplete {
i.Autocomplete()
}
if i.changed != nil {
i.changed(i.text)
i.changed(newText)
}
}
}()
// Movement functions.
home := func() { i.cursorPos = 0 }
end := func() { i.cursorPos = len(i.text) }
moveLeft := func() {
var state *stepState
str := i.text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
if len(str) <= len(i.text)-i.cursorPos {
i.cursorPos -= state.GrossLength()
if i.cursorPos < 0 {
i.cursorPos = 0
}
break
}
}
}
moveRight := func() {
_, _, state := step(i.text[i.cursorPos:], nil, stepOptionsNone)
i.cursorPos += state.GrossLength()
}
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
}
// Finish up.
finish := func(key tcell.Key) {
if i.done != nil {
i.done(key)
}
if i.finished != nil {
i.finished(key)
}
}
// If we have an autocomplete list, there are certain keys we will
// forward to it.
i.autocompleteListMutex.Lock()
defer i.autocompleteListMutex.Unlock()
if i.autocompleteList != nil {
i.autocompleteList.SetChangedFunc(nil)
i.autocompleteList.SetSelectedFunc(nil)
switch key := event.Key(); key {
case tcell.KeyEscape: // Close the list.
i.autocompleteList = nil
return
case tcell.KeyEnter, tcell.KeyTab: // Intentional selection.
index := i.autocompleteList.GetCurrentItem()
text, _ := i.autocompleteList.GetItemText(index)
if i.autocompleted != nil {
index := i.autocompleteList.GetCurrentItem()
text, _ := i.autocompleteList.GetItemText(index)
source := AutocompletedEnter
if key == tcell.KeyTab {
source = AutocompletedTab
@ -695,6 +549,8 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
currentText = i.GetText()
}
} else {
i.SetText(text)
skipAutocomplete = true
i.autocompleteList = nil
}
return
@ -716,86 +572,36 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
}
}
// Process key event for the input field.
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.
var state *stepState
str := i.text
for len(str) > 0 && i.cursorPos > 0 {
_, str, state = step(str, state, stepOptionsNone)
if len(str) <= len(i.text)-i.cursorPos {
i.cursorPos -= state.GrossLength()
i.text = i.text[:i.cursorPos] + i.text[i.cursorPos+state.GrossLength():]
break
}
}
if i.offset >= i.cursorPos {
i.offset = 0
}
case tcell.KeyDelete, tcell.KeyCtrlD: // Delete character after the cursor.
if len(i.text) > i.cursorPos {
_, rest, _ := step(i.text[i.cursorPos:], nil, stepOptionsNone)
i.text = i.text[:i.cursorPos] + rest
}
case tcell.KeyLeft:
if event.Modifiers()&tcell.ModAlt > 0 {
moveWordLeft()
} else {
moveLeft()
// Finish up.
finish := func(key tcell.Key) {
if i.done != nil {
i.done(key)
}
case tcell.KeyCtrlB:
moveLeft()
case tcell.KeyRight:
if event.Modifiers()&tcell.ModAlt > 0 {
moveWordRight()
} else {
moveRight()
if i.finished != nil {
i.finished(key)
}
case tcell.KeyCtrlF:
moveRight()
case tcell.KeyHome, tcell.KeyCtrlA:
home()
case tcell.KeyEnd, tcell.KeyCtrlE:
end()
}
// Process special key events for the input field.
switch key := event.Key(); key {
case tcell.KeyDown:
i.autocompleteListMutex.Unlock() // We're still holding a lock.
i.Autocomplete()
i.autocompleteListMutex.Lock()
case tcell.KeyEnter, tcell.KeyEscape, tcell.KeyTab, tcell.KeyBacktab:
finish(key)
case tcell.KeyRune:
if event.Modifiers()&tcell.ModAlt == 0 && i.accept != nil {
// Check if this rune is accepted.
r := event.Rune()
if !i.accept(i.textArea.getTextBeforeCursor()+string(r)+i.textArea.getTextAfterCursor(), r) {
return
}
}
fallthrough
default:
// Forward other key events to the text area.
i.textArea.InputHandler()(event, setFocus)
}
})
}
@ -803,16 +609,20 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
// 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) {
if i.disabled {
if i.textArea.GetDisabled() {
return false, nil
}
var skipAutocomplete bool
currentText := i.GetText()
defer func() {
if i.GetText() != currentText {
i.Autocomplete()
newText := i.GetText()
if newText != currentText {
if !skipAutocomplete {
i.Autocomplete()
}
if i.changed != nil {
i.changed(i.text)
i.changed(newText)
}
}
}()
@ -821,7 +631,8 @@ func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventM
i.autocompleteListMutex.Lock()
defer i.autocompleteListMutex.Unlock()
if i.autocompleteList != nil {
i.autocompleteList.SetChangedFunc(func(index int, text, secondaryText string, shortcut rune) {
i.autocompleteList.SetChangedFunc(nil)
i.autocompleteList.SetSelectedFunc(func(index int, text, secondaryText string, shortcut rune) {
text = stripTags(text)
if i.autocompleted != nil {
if i.autocompleted(text, index, AutocompletedClick) {
@ -831,6 +642,7 @@ func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventM
return
}
i.SetText(text)
skipAutocomplete = true
i.autocompleteList = nil
})
if consumed, _ = i.autocompleteList.MouseHandler()(action, event, setFocus); consumed {
@ -841,37 +653,12 @@ func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventM
// Is mouse event within the input field?
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 {
var (
state *stepState
screenPos int
str = i.text[i.offset:]
)
i.cursorPos = i.offset
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
screenPos += state.Width()
if screenPos > x-i.fieldX {
break
}
i.cursorPos += state.GrossLength()
}
}
consumed = true
}
}
// Forward mouse event to the text area.
consumed, capture = i.textArea.MouseHandler()(action, event, setFocus)
return
})

@ -20,11 +20,11 @@ const (
// could be longer but it would be highly unusual.
maxGraphemeClusterSize = 40
// The minimum width of text (if available) to be shown left of the cursor.
minCursorPrefix = 5
// The default value for the [TextArea.minCursorPrefix] variable.
minCursorPrefixDefault = 5
// The minimum width of text (if available) to be shown right of the cursor.
minCursorSuffix = 3
// The default value for the [TextArea.minCursorSuffix] variable.
minCursorSuffixDefault = 3
)
// Types of user actions on a text area.
@ -245,6 +245,10 @@ type TextArea struct {
// deleted from this slice.
spans []textAreaSpan
// An optional function which transforms grapheme clusters. This can be used
// to hide characters from the screen while preserving the original text.
transform func(cluster, rest string, boundaries int) (newCluster string, newBoundaries int)
// Display, navigation, and cursor related fields:
// If set to true, lines that are longer than the available width are
@ -275,9 +279,9 @@ type TextArea struct {
lineStarts [][3]int
// The cursor always points to the next position where a new character would
// be placed. The selection start is the same as cursor as long as there is
// no selection. When there is one, the selection is between selectionStart
// and cursor.
// be placed. The selection start is the same as the cursor as long as there
// is no selection. When there is one, the selection is between
// selectionStart and cursor.
cursor, selectionStart struct {
// The row and column in screen space but relative to the start of the
// text which may be outside the text area's box. The column value may
@ -291,6 +295,12 @@ type TextArea struct {
pos [3]int
}
// The minimum width of text (if available) to be shown left of the cursor.
minCursorPrefix int
// The minimum width of text (if available) to be shown right of the cursor.
minCursorSuffix int
// Set to true when the mouse is dragging to select text.
dragging bool
@ -349,6 +359,8 @@ func NewTextArea() *TextArea {
selectedStyle: tcell.StyleDefault.Background(Styles.PrimaryTextColor).Foreground(Styles.PrimitiveBackgroundColor),
spans: make([]textAreaSpan, 2, pieceChainMinCap), // We reserve some space to avoid reallocations right when editing starts.
lastAction: taActionOther,
minCursorPrefix: minCursorPrefixDefault,
minCursorSuffix: minCursorSuffixDefault,
}
t.editText.Grow(editBufferMinCap)
t.spans[0] = textAreaSpan{previous: -1, next: 1}
@ -444,6 +456,63 @@ func (t *TextArea) GetText() string {
return text.String()
}
// getTextBeforeCursor returns the text of the text area up until the cursor.
// Note that this will result in a new allocation for the returned text.
func (t *TextArea) getTextBeforeCursor() string {
if t.length == 0 || t.cursor.pos[0] == t.spans[0].next && t.cursor.pos[1] == 0 {
return ""
}
var text strings.Builder
spanIndex := t.spans[0].next
for spanIndex != 1 {
span := &t.spans[spanIndex]
length := span.length
if length < 0 {
if t.cursor.pos[0] == spanIndex {
length = -t.cursor.pos[1]
}
text.WriteString(t.initialText[span.offset : span.offset-length])
} else {
if t.cursor.pos[0] == spanIndex {
length = t.cursor.pos[1]
}
text.WriteString(t.editText.String()[span.offset : span.offset+length])
}
if t.cursor.pos[0] == spanIndex {
break
}
spanIndex = t.spans[spanIndex].next
}
return text.String()
}
// getTextAfterCursor returns the text of the text area after the cursor. Note
// that this will result in a new allocation for the returned text.
func (t *TextArea) getTextAfterCursor() string {
if t.length == 0 || t.cursor.pos[0] == 1 {
return ""
}
var text strings.Builder
spanIndex := t.cursor.pos[0]
cursorOffset := t.cursor.pos[1]
for spanIndex != 1 {
span := &t.spans[spanIndex]
length := span.length
if length < 0 {
text.WriteString(t.initialText[span.offset+cursorOffset : span.offset-length])
} else {
text.WriteString(t.editText.String()[span.offset+cursorOffset : span.offset+length])
}
spanIndex = t.spans[spanIndex].next
cursorOffset = 0
}
return text.String()
}
// HasSelection returns whether the selected text is non-empty.
func (t *TextArea) HasSelection() bool {
return t.selectionStart != t.cursor
@ -733,6 +802,11 @@ func (t *TextArea) SetLabelWidth(width int) *TextArea {
return t
}
// GetLabelWidth returns the screen width of the label.
func (t *TextArea) GetLabelWidth() int {
return t.labelWidth
}
// SetSize sets the screen size of the input element of the text area. The input
// element is always located next to the label which is always located in the
// top left corner. If any of the values are 0 or larger than the available
@ -762,6 +836,11 @@ func (t *TextArea) SetDisabled(disabled bool) FormItem {
return t
}
// GetDisabled returns whether or not the item is disabled / read-only.
func (t *TextArea) GetDisabled() bool {
return t.disabled
}
// SetMaxLength sets the maximum number of bytes allowed in the text area. A
// value of 0 means there is no limit. If the text area currently contains more
// bytes than this, it may violate this constraint.
@ -770,6 +849,14 @@ func (t *TextArea) SetMaxLength(maxLength int) *TextArea {
return t
}
// setMinCursorPadding sets a minimum width to be reserved left and right of the
// cursor. This is ignored if wrapping is enabled.
func (t *TextArea) setMinCursorPadding(prefix, suffix int) *TextArea {
t.minCursorPrefix = prefix
t.minCursorSuffix = suffix
return t
}
// SetLabelStyle sets the style of the label.
func (t *TextArea) SetLabelStyle(style tcell.Style) *TextArea {
t.labelStyle = style
@ -781,13 +868,17 @@ func (t *TextArea) GetLabelStyle() tcell.Style {
return t.labelStyle
}
// SetTextStyle sets the style of the text. Background colors different from the
// Box's background color may lead to unwanted artefacts.
// SetTextStyle sets the style of the text.
func (t *TextArea) SetTextStyle(style tcell.Style) *TextArea {
t.textStyle = style
return t
}
// GetTextStyle returns the style of the text.
func (t *TextArea) GetTextStyle() tcell.Style {
return t.textStyle
}
// SetSelectedStyle sets the style of the selected text.
func (t *TextArea) SetSelectedStyle(style tcell.Style) *TextArea {
t.selectedStyle = style
@ -800,6 +891,11 @@ func (t *TextArea) SetPlaceholderStyle(style tcell.Style) *TextArea {
return t
}
// GetPlaceholderStyle returns the style of the placeholder text.
func (t *TextArea) GetPlaceholderStyle() tcell.Style {
return t.placeholderStyle
}
// GetOffset returns the text's offset, that is, the number of rows and columns
// skipped during drawing at the top or on the left, respectively. Note that the
// column offset is ignored if wrapping is enabled.
@ -1124,6 +1220,7 @@ func (t *TextArea) Draw(screen tcell.Screen) {
// Placeholder.
if t.length == 0 && len(t.placeholder) > 0 {
t.drawPlaceholder(screen, x, y, width, height)
t.lastHeight, t.lastWidth = height, width
return // We're done already.
}
@ -1206,8 +1303,7 @@ func (t *TextArea) drawPlaceholder(screen tcell.Screen, x, y, width, height int)
textView := NewTextView().
SetText(t.placeholder).
SetTextStyle(t.placeholderStyle)
textView.SetBackgroundColor(t.backgroundColor).
SetRect(x, y, width, height)
textView.SetRect(x, y, width, height)
textView.Draw(screen)
}
@ -1373,15 +1469,15 @@ func (t *TextArea) findCursor(clamp bool, startRow int) {
}
}
if !t.wrap {
if t.cursor.actualColumn < t.columnOffset+minCursorPrefix {
if t.cursor.actualColumn < t.columnOffset+t.minCursorPrefix {
// We're left of the viewport.
t.columnOffset = t.cursor.actualColumn - minCursorPrefix
t.columnOffset = t.cursor.actualColumn - t.minCursorPrefix
if t.columnOffset < 0 {
t.columnOffset = 0
}
} else if t.cursor.actualColumn >= t.columnOffset+t.lastWidth-minCursorSuffix {
} else if t.cursor.actualColumn >= t.columnOffset+t.lastWidth-t.minCursorSuffix {
// We're right of the viewport.
t.columnOffset = t.cursor.actualColumn - t.lastWidth + minCursorSuffix
t.columnOffset = t.cursor.actualColumn - t.lastWidth + t.minCursorSuffix
if t.columnOffset >= t.widestLine {
t.columnOffset = t.widestLine - 1
if t.columnOffset < 0 {
@ -1475,9 +1571,22 @@ RowLoop:
}
}
// setTransform sets the transform function to be used when drawing the text.
// This function is called for each grapheme cluster and can be used to modify
// the cluster, the cluster's screen width, and the cluster's boundaries. The
// function is called with the original cluster, the rest of the text, the
// original cluster's width, and the original cluster's boundaries. The function
// must return the new cluster, the new width, and the new boundaries. This only
// affects the drawing of the text, not the text content itself. The boundaries
// values correspond to the values returned by
// [github.com/rivo/uniseg.StepString].
func (t *TextArea) setTransform(transform func(cluster, rest string, boundaries int) (newCluster string, newBoundaries int)) {
t.transform = transform
}
// step is similar to [github.com/rivo/uniseg.StepString] but it iterates over
// the piece chain, starting with "pos", a span position plus state (which may
// be -1 for the start of the text). The returned "boundaries" value is same
// be -1 for the start of the text). The returned "boundaries" value is the same
// value returned by [github.com/rivo/uniseg.StepString], "width" is the screen
// width of the grapheme. The "pos" and "endPos" positions refer to the start
// and the end of the "text" string, respectively. For the first call, text may
@ -1537,6 +1646,10 @@ func (t *TextArea) step(text string, pos, endPos [3]int) (cluster, rest string,
span = t.spans[pos[0]]
}
if t.transform != nil {
cluster, boundaries = t.transform(cluster, text, boundaries)
}
if cluster == "\t" {
width = TabSize
} else {

@ -4,7 +4,6 @@ import (
"math"
"os"
"regexp"
"strconv"
"github.com/gdamore/tcell/v2"
)
@ -28,27 +27,6 @@ var (
// Package initialization.
func init() {
// Initialize the predefined input field handlers.
InputFieldInteger = func(text string, ch rune) bool {
if text == "-" {
return true
}
_, err := strconv.Atoi(text)
return err == nil
}
InputFieldFloat = func(text string, ch rune) bool {
if text == "-" || text == "." || text == "-." {
return true
}
_, err := strconv.ParseFloat(text, 64)
return err == nil
}
InputFieldMaxLength = func(maxLength int) func(text string, ch rune) bool {
return func(text string, ch rune) bool {
return len([]rune(text)) <= maxLength
}
}
// Determine the number of colors available in the terminal.
info, err := tcell.LookupTerminfo(os.Getenv("TERM"))
if err == nil {

Loading…
Cancel
Save