mirror of https://github.com/rivo/tview.git
2328 lines
74 KiB
Go
2328 lines
74 KiB
Go
package tview
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/uniseg"
|
|
)
|
|
|
|
const (
|
|
// The minimum capacity of the text area's piece chain slice.
|
|
pieceChainMinCap = 10
|
|
|
|
// The minimum capacity of the text area's edit buffer.
|
|
editBufferMinCap = 200
|
|
|
|
// The maximum number of bytes making up a grapheme cluster. In theory, this
|
|
// 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 minimum width of text (if available) to be shown right of the cursor.
|
|
minCursorSuffix = 3
|
|
)
|
|
|
|
// Types of user actions on a text area.
|
|
type taAction int
|
|
|
|
const (
|
|
taActionOther taAction = iota
|
|
taActionTypeSpace // Typing a space character.
|
|
taActionTypeNonSpace // Typing a non-space character.
|
|
taActionBackspace // Deleting the previous character.
|
|
taActionDelete // Deleting the next character.
|
|
)
|
|
|
|
// NewLine is the string sequence to be inserted when hitting the Enter key in a
|
|
// TextArea. The default is "\n" but you may change it to "\r\n" if required.
|
|
var NewLine = "\n"
|
|
|
|
// textAreaSpan represents a range of text in a text area. The text area widget
|
|
// roughly follows the concept of Piece Chains outlined in
|
|
// http://www.catch22.net/tuts/neatpad/piece-chains with some modifications.
|
|
// This type represents a "span" (or "piece") and thus refers to a subset of the
|
|
// text in the editor as part of a doubly-linked list.
|
|
//
|
|
// In most places where we reference a position in the text, we use a
|
|
// three-element int array. The first element is the index of the referenced
|
|
// span in the piece chain. The second element is the offset into the span's
|
|
// referenced text (relative to the span's start), its value is always >= 0 and
|
|
// < span.length. The third element is the state of the text parser at that
|
|
// position.
|
|
//
|
|
// A range of text is represented by a span range which is a starting position
|
|
// (3-int array) and an ending position (3-int array). The starting position
|
|
// references the first character of the range, the ending position references
|
|
// the position after the last character of the range. The end of the text is
|
|
// therefore always [3]int{1, 0, 0}, position 0 of the ending sentinel.
|
|
//
|
|
// Sentinel spans are dummy spans not referring to any text. There are always
|
|
// two sentinel spans: the starting span at index 0 of the [TextArea.spans]
|
|
// slice and the ending span at index 1.
|
|
type textAreaSpan struct {
|
|
// Links to the previous and next textAreaSpan objects as indices into the
|
|
// [TextArea.spans] slice. The sentinel spans (index 0 and 1) have -1 as
|
|
// their previous or next links, respectively.
|
|
previous, next int
|
|
|
|
// The start index and the length of the text segment this span represents.
|
|
// If "length" is negative, the span represents a substring of
|
|
// [TextArea.initialText] and the actual length is its absolute value. If it
|
|
// is positive, the span represents a substring of [TextArea.editText]. For
|
|
// the sentinel spans (index 0 and 1), both values will be 0. Others will
|
|
// never have a zero length.
|
|
offset, length int
|
|
}
|
|
|
|
// textAreaUndoItem represents an undoable edit to the text area. It describes
|
|
// the two spans wrapping a text change.
|
|
type textAreaUndoItem struct {
|
|
before, after int // The index of the copied "before" and "after" spans into the "spans" slice.
|
|
originalBefore, originalAfter int // The original indices of the "before" and "after" spans.
|
|
pos [3]int // The cursor position to be assumed after applying an undo.
|
|
length int // The total text length at the time the undo item was created.
|
|
continuation bool // If true, this item is a continuation of the previous undo item. It is handled together with all other undo items in the same continuation sequence.
|
|
}
|
|
|
|
// TextArea implements a simple text editor for multi-line text. Multi-color
|
|
// text is not supported. Word-wrapping is enabled by default but can be turned
|
|
// off or be changed to character-wrapping.
|
|
//
|
|
// At this point, a text area cannot be added to a [Form]. This will be added in
|
|
// the future.
|
|
//
|
|
// # Navigation and Editing
|
|
//
|
|
// A text area is always in editing mode and no other mode exists. The following
|
|
// keys can be used to move the cursor (subject to what the user's terminal
|
|
// supports and how it is configured):
|
|
//
|
|
// - Left arrow: Move left.
|
|
// - Right arrow: Move right.
|
|
// - Down arrow: Move down.
|
|
// - Up arrow: Move up.
|
|
// - Ctrl-A, Home: Move to the beginning of the current line.
|
|
// - Ctrl-E, End: Move to the end of the current line.
|
|
// - Ctrl-F, page down: Move down by one page.
|
|
// - Ctrl-B, page up: Move up by one page.
|
|
// - Alt-Up arrow: Scroll the page up, leaving the cursor in its position.
|
|
// - Alt-Down arrow: Scroll the page down, leaving the cursor in its position.
|
|
// - Alt-Left arrow: Scroll the page to the left, leaving the cursor in its
|
|
// position. Ignored if wrapping is enabled.
|
|
// - Alt-Right arrow: Scroll the page to the right, leaving the cursor in its
|
|
// position. Ignored if wrapping is enabled.
|
|
// - Alt-B, Ctrl-Left arrow: Jump to the beginning of the current or previous
|
|
// word.
|
|
// - Alt-F, Ctrl-Right arrow: Jump to the end of the current or next word.
|
|
//
|
|
// Words are defined according to [Unicode Standard Annex #29]. We skip any
|
|
// words that contain only spaces or punctuation.
|
|
//
|
|
// Entering a character will insert it at the current cursor location.
|
|
// Subsequent characters are shifted accordingly. If the cursor is outside the
|
|
// visible area, any changes to the text will move it into the visible area. The
|
|
// following keys can also be used to modify the text:
|
|
//
|
|
// - Enter: Insert a newline character (see [NewLine]).
|
|
// - Tab: Insert a tab character (\t). It will be rendered like [TabSize]
|
|
// spaces. (This may eventually be changed to behave like regular tabs.)
|
|
// - Ctrl-H, Backspace: Delete one character to the left of the cursor.
|
|
// - Ctrl-D, Delete: Delete the character under the cursor (or the first
|
|
// character on the next line if the cursor is at the end of a line).
|
|
// - Alt-Backspace: Delete the word to the left of the cursor.
|
|
// - Ctrl-K: Delete everything under and to the right of the cursor until the
|
|
// next newline character.
|
|
// - Ctrl-W: Delete from the start of the current word to the left of the
|
|
// cursor.
|
|
// - Ctrl-U: Delete the current line, i.e. everything after the last newline
|
|
// character before the cursor up until the next newline character. This may
|
|
// span multiple visible rows if wrapping is enabled.
|
|
//
|
|
// Text can be selected by moving the cursor while holding the Shift key, to the
|
|
// extent that this is supported by the user's terminal. The Ctrl-L key can be
|
|
// used to select the entire text. (Ctrl-A already binds to the "Home" key.)
|
|
//
|
|
// When text is selected:
|
|
//
|
|
// - Entering a character will replace the selected text with the new
|
|
// character.
|
|
// - Backspace, delete, Ctrl-H, Ctrl-D: Delete the selected text.
|
|
// - Ctrl-Q: Copy the selected text into the clipboard, unselect the text.
|
|
// - Ctrl-X: Copy the selected text into the clipboard and delete it.
|
|
// - Ctrl-V: Replace the selected text with the clipboard text. If no text is
|
|
// selected, the clipboard text will be inserted at the cursor location.
|
|
//
|
|
// The Ctrl-Q key was chosen for the "copy" function because the Ctrl-C key is
|
|
// the default key to stop the application. If your application frees up the
|
|
// global Ctrl-C key and you want to bind it to the "copy to clipboard"
|
|
// function, you may use [Box.SetInputCapture] to override the Ctrl-Q key to
|
|
// implement copying to the clipboard. Note that using your terminal's /
|
|
// operating system's key bindings for copy+paste functionality may not have the
|
|
// expected effect as tview will not be able to handle these keys. Pasting text
|
|
// using your operating system's or terminal's own methods may be very slow as
|
|
// each character will be pasted individually.
|
|
//
|
|
// The default clipboard is an internal text buffer, i.e. the operating system's
|
|
// clipboard is not used. If you want to implement your own clipboard (or make
|
|
// use of your operating system's clipboard), you can use
|
|
// [TextArea.SetClipboard] which provides all the functionality needed to
|
|
// implement your own clipboard.
|
|
//
|
|
// The text area also supports Undo:
|
|
//
|
|
// - Ctrl-Z: Undo the last change.
|
|
// - Ctrl-Y: Redo the last Undo change.
|
|
//
|
|
// Undo does not affect the clipboard.
|
|
//
|
|
// If the mouse is enabled, the following actions are available:
|
|
//
|
|
// - Left click: Move the cursor to the clicked position or to the end of the
|
|
// line if past the last character.
|
|
// - Left double-click: Select the word under the cursor.
|
|
// - Left click while holding the Shift key: Select text.
|
|
// - Scroll wheel: Scroll the text.
|
|
//
|
|
// [Unicode Standard Annex #29]: https://unicode.org/reports/tr29/
|
|
type TextArea struct {
|
|
*Box
|
|
|
|
// Whether or not this text area is disabled/read-only.
|
|
disabled bool
|
|
|
|
// The size of the text area. If set to 0, the text area will use the entire
|
|
// available space.
|
|
width, height int
|
|
|
|
// The text to be shown in the text area when it is empty.
|
|
placeholder string
|
|
|
|
// The label text shown, usually when part of a form.
|
|
label string
|
|
|
|
// The width of the text area's label.
|
|
labelWidth int
|
|
|
|
// Styles:
|
|
|
|
// The label style.
|
|
labelStyle tcell.Style
|
|
|
|
// The style of the text. Background colors different from the Box's
|
|
// background color may lead to unwanted artefacts.
|
|
textStyle tcell.Style
|
|
|
|
// The style of the selected text.
|
|
selectedStyle tcell.Style
|
|
|
|
// The style of the placeholder text.
|
|
placeholderStyle tcell.Style
|
|
|
|
// Text manipulation related fields:
|
|
|
|
// The text area's text prior to any editing. It is referenced by spans with
|
|
// a negative length.
|
|
initialText string
|
|
|
|
// Any text that's been added by the user at some point. We only ever append
|
|
// to this buffer. It is referenced by spans with a positive length.
|
|
editText strings.Builder
|
|
|
|
// The total length of all text in the text area.
|
|
length int
|
|
|
|
// The maximum number of bytes allowed in the text area. If 0, there is no
|
|
// limit.
|
|
maxLength int
|
|
|
|
// The piece chain. The first two spans are sentinel spans which don't
|
|
// reference anything and always remain in the same place. Spans are never
|
|
// deleted from this slice.
|
|
spans []textAreaSpan
|
|
|
|
// Display, navigation, and cursor related fields:
|
|
|
|
// If set to true, lines that are longer than the available width are
|
|
// wrapped onto the next line. If set to false, any characters beyond the
|
|
// available width are discarded.
|
|
wrap bool
|
|
|
|
// If set to true and if wrap is also true, lines are split at spaces or
|
|
// after punctuation characters.
|
|
wordWrap bool
|
|
|
|
// The index of the first line shown in the text area.
|
|
rowOffset int
|
|
|
|
// The number of cells to be skipped on each line (not used in wrap mode).
|
|
columnOffset int
|
|
|
|
// The inner height and width of the text area the last time it was drawn.
|
|
lastHeight, lastWidth int
|
|
|
|
// The width of the currently known widest line, as determined by
|
|
// [TextArea.extendLines].
|
|
widestLine int
|
|
|
|
// Text positions and states of the start of lines. Each element is a span
|
|
// position (see [textAreaSpan]). Not all lines of the text may be contained
|
|
// at any time, extend as needed with the [TextArea.extendLines] function.
|
|
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.
|
|
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
|
|
// be larger than where the cursor actually is if the line the cursor
|
|
// is on is shorter. The actualColumn is the position as it is seen on
|
|
// screen. These three values may not be determined yet, in which case
|
|
// the row is negative.
|
|
row, column, actualColumn int
|
|
|
|
// The textAreaSpan position with state for the actual next character.
|
|
pos [3]int
|
|
}
|
|
|
|
// Set to true when the mouse is dragging to select text.
|
|
dragging bool
|
|
|
|
// Clipboard related fields:
|
|
|
|
// The internal clipboard.
|
|
clipboard string
|
|
|
|
// The function to call when the user copies/cuts a text selection to the
|
|
// clipboard.
|
|
copyToClipboard func(string)
|
|
|
|
// The function to call when the user pastes text from the clipboard.
|
|
pasteFromClipboard func() string
|
|
|
|
// Undo/redo related fields:
|
|
|
|
// The last action performed by the user.
|
|
lastAction taAction
|
|
|
|
// The undo stack's items. Each item is a copy of the span before the
|
|
// modified span range and a copy of the span after the modified span range.
|
|
// To undo an action, the two referenced spans are put back into their
|
|
// original place. Undos and redos decrease or increase the nextUndo value.
|
|
// Thus, the next undo action is not always the last item.
|
|
undoStack []textAreaUndoItem
|
|
|
|
// The current undo/redo position on the undo stack. If no undo or redo has
|
|
// been performed yet, this is the same as len(undoStack).
|
|
nextUndo int
|
|
|
|
// Event handlers:
|
|
|
|
// An optional function which is called when the input has changed.
|
|
changed func()
|
|
|
|
// An optional function which is called when the position of the cursor or
|
|
// the selection has changed.
|
|
moved func()
|
|
|
|
// A callback function set by the Form class and called when the user leaves
|
|
// this form item.
|
|
finished func(tcell.Key)
|
|
}
|
|
|
|
// NewTextArea returns a new text area. Use [TextArea.SetText] to set the
|
|
// initial text.
|
|
func NewTextArea() *TextArea {
|
|
t := &TextArea{
|
|
Box: NewBox(),
|
|
wrap: true,
|
|
wordWrap: true,
|
|
placeholderStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.TertiaryTextColor),
|
|
labelStyle: tcell.StyleDefault.Foreground(Styles.SecondaryTextColor),
|
|
textStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.PrimaryTextColor),
|
|
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,
|
|
}
|
|
t.editText.Grow(editBufferMinCap)
|
|
t.spans[0] = textAreaSpan{previous: -1, next: 1}
|
|
t.spans[1] = textAreaSpan{previous: 0, next: -1}
|
|
t.cursor.pos = [3]int{1, 0, -1}
|
|
t.selectionStart = t.cursor
|
|
t.SetClipboard(nil, nil)
|
|
|
|
return t
|
|
}
|
|
|
|
// SetText sets the text of the text area. All existing text is deleted and
|
|
// replaced with the new text. Any edits are discarded, no undos are available.
|
|
// This function is typically only used to initialize the text area with a text
|
|
// after it has been created. To clear the text area's text (again, no undos),
|
|
// provide an empty string.
|
|
//
|
|
// If cursorAtTheEnd is false, the cursor is placed at the start of the text. If
|
|
// it is true, it is placed at the end of the text. For very long texts, placing
|
|
// the cursor at the end can be an expensive operation because the entire text
|
|
// needs to be parsed and laid out.
|
|
//
|
|
// If you want to set text and preserve undo functionality, use
|
|
// [TextArea.Replace] instead.
|
|
func (t *TextArea) SetText(text string, cursorAtTheEnd bool) *TextArea {
|
|
t.spans = t.spans[:2]
|
|
t.initialText = text
|
|
t.editText.Reset()
|
|
t.lineStarts = nil
|
|
t.length = len(text)
|
|
t.rowOffset = 0
|
|
t.columnOffset = 0
|
|
t.reset()
|
|
t.cursor.row, t.cursor.actualColumn, t.cursor.column = 0, 0, 0
|
|
t.cursor.pos = [3]int{1, 0, -1}
|
|
t.undoStack = t.undoStack[:0]
|
|
|
|
if len(text) > 0 {
|
|
t.spans = append(t.spans, textAreaSpan{
|
|
previous: 0,
|
|
next: 1,
|
|
offset: 0,
|
|
length: -len(text),
|
|
})
|
|
t.spans[0].next = 2
|
|
t.spans[1].previous = 2
|
|
if cursorAtTheEnd {
|
|
t.cursor.row = -1
|
|
if t.lastWidth > 0 {
|
|
t.findCursor(true, 0)
|
|
}
|
|
} else {
|
|
t.cursor.pos = [3]int{2, 0, -1}
|
|
}
|
|
} else {
|
|
t.spans[0].next = 1
|
|
t.spans[1].previous = 0
|
|
}
|
|
t.selectionStart = t.cursor
|
|
|
|
if t.changed != nil {
|
|
t.changed()
|
|
}
|
|
|
|
if t.lastWidth > 0 && t.moved != nil {
|
|
t.moved()
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// GetText returns the entire text of the text area. Note that this will newly
|
|
// allocate the entire text.
|
|
func (t *TextArea) GetText() string {
|
|
if t.length == 0 {
|
|
return ""
|
|
}
|
|
|
|
var text strings.Builder
|
|
text.Grow(t.length)
|
|
spanIndex := t.spans[0].next
|
|
for spanIndex != 1 {
|
|
span := &t.spans[spanIndex]
|
|
if span.length < 0 {
|
|
text.WriteString(t.initialText[span.offset : span.offset-span.length])
|
|
} else {
|
|
text.WriteString(t.editText.String()[span.offset : span.offset+span.length])
|
|
}
|
|
spanIndex = t.spans[spanIndex].next
|
|
}
|
|
|
|
return text.String()
|
|
}
|
|
|
|
// HasSelection returns whether the selected text is non-empty.
|
|
func (t *TextArea) HasSelection() bool {
|
|
return t.selectionStart != t.cursor
|
|
}
|
|
|
|
// GetSelection returns the currently selected text and its start and end
|
|
// positions within the entire text as a half-open interval. If the returned
|
|
// text is an empty string, the start and end positions are the same and can be
|
|
// interpreted as the cursor position.
|
|
//
|
|
// Calling this function will result in string allocations as well as a search
|
|
// for text positions. This is expensive if the text has been edited extensively
|
|
// already. Use [TextArea.HasSelection] first if you are only interested in
|
|
// selected text.
|
|
func (t *TextArea) GetSelection() (text string, start int, end int) {
|
|
from, to := t.selectionStart.pos, t.cursor.pos
|
|
if t.cursor.row < t.selectionStart.row || (t.cursor.row == t.selectionStart.row && t.cursor.actualColumn < t.selectionStart.actualColumn) {
|
|
from, to = to, from
|
|
}
|
|
|
|
if from[0] == 1 {
|
|
start = t.length
|
|
}
|
|
if to[0] == 1 {
|
|
end = t.length
|
|
}
|
|
|
|
var (
|
|
index int
|
|
selection strings.Builder
|
|
inside bool
|
|
)
|
|
for span := t.spans[0].next; span != 1; span = t.spans[span].next {
|
|
var spanText string
|
|
length := t.spans[span].length
|
|
if length < 0 {
|
|
length = -length
|
|
spanText = t.initialText
|
|
} else {
|
|
spanText = t.editText.String()
|
|
}
|
|
spanText = spanText[t.spans[span].offset : t.spans[span].offset+length]
|
|
|
|
if from[0] == span && to[0] == span {
|
|
if from != to {
|
|
selection.WriteString(spanText[from[1]:to[1]])
|
|
}
|
|
start = index + from[1]
|
|
end = index + to[1]
|
|
break
|
|
} else if from[0] == span {
|
|
if from != to {
|
|
selection.WriteString(spanText[from[1]:])
|
|
}
|
|
start = index + from[1]
|
|
inside = true
|
|
} else if to[0] == span {
|
|
if from != to {
|
|
selection.WriteString(spanText[:to[1]])
|
|
}
|
|
end = index + to[1]
|
|
break
|
|
} else if inside && from != to {
|
|
selection.WriteString(spanText)
|
|
}
|
|
|
|
index += length
|
|
}
|
|
|
|
if selection.Len() != 0 {
|
|
text = selection.String()
|
|
}
|
|
return
|
|
}
|
|
|
|
// GetCursor returns the current cursor position where the first character of
|
|
// the entire text is in row 0, column 0. If the user has selected text, the
|
|
// "from" values will refer to the beginning of the selection and the "to"
|
|
// values to the end of the selection (exclusive). They are the same if there
|
|
// is no selection.
|
|
func (t *TextArea) GetCursor() (fromRow, fromColumn, toRow, toColumn int) {
|
|
fromRow, fromColumn = t.selectionStart.row, t.selectionStart.actualColumn
|
|
toRow, toColumn = t.cursor.row, t.cursor.actualColumn
|
|
if toRow < fromRow || (toRow == fromRow && toColumn < fromColumn) {
|
|
fromRow, fromColumn, toRow, toColumn = toRow, toColumn, fromRow, fromColumn
|
|
}
|
|
if t.length > 0 && t.wrap && fromColumn >= t.lastWidth { // This happens when a row has text all the way until the end, pushing the cursor outside the viewport.
|
|
fromRow++
|
|
fromColumn = 0
|
|
}
|
|
if t.length > 0 && t.wrap && toColumn >= t.lastWidth {
|
|
toRow++
|
|
toColumn = 0
|
|
}
|
|
return
|
|
}
|
|
|
|
// GetTextLength returns the string length of the text in the text area.
|
|
func (t *TextArea) GetTextLength() int {
|
|
return t.length
|
|
}
|
|
|
|
// Replace replaces a section of the text with new text. The start and end
|
|
// positions refer to index positions within the entire text string (as a
|
|
// half-open interval). They may be the same, in which case text is inserted at
|
|
// the given position. If the text is an empty string, text between start and
|
|
// end is deleted. Index positions will be shifted to line up with character
|
|
// boundaries.
|
|
//
|
|
// Previous selections are cleared. The cursor will be located at the end of the
|
|
// replaced text. Scroll offsets will not be changed.
|
|
//
|
|
// The effects of this function can be undone (and redone) by the user.
|
|
func (t *TextArea) Replace(start, end int, text string) *TextArea {
|
|
t.Select(start, end)
|
|
row := t.selectionStart.row
|
|
t.cursor.pos = t.replace(t.selectionStart.pos, t.cursor.pos, text, false)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(false, row)
|
|
t.selectionStart = t.cursor
|
|
if t.changed != nil {
|
|
t.changed()
|
|
}
|
|
if t.moved != nil {
|
|
t.moved()
|
|
}
|
|
return t
|
|
}
|
|
|
|
// Select selects a section of the text. The start and end positions refer to
|
|
// index positions within the entire text string (as a half-open interval). They
|
|
// may be the same, in which case the cursor is placed at the given position.
|
|
// Any previous selection is removed. Scroll offsets will be preserved.
|
|
//
|
|
// Index positions will be shifted to line up with character boundaries.
|
|
func (t *TextArea) Select(start, end int) *TextArea {
|
|
oldFrom, oldTo := t.selectionStart, t.cursor
|
|
defer func() {
|
|
if (oldFrom != t.selectionStart || oldTo != t.cursor) && t.moved != nil {
|
|
t.moved()
|
|
}
|
|
}()
|
|
|
|
// Clamp input values.
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start > t.length {
|
|
start = t.length
|
|
}
|
|
if end < 0 {
|
|
end = 0
|
|
}
|
|
if end > t.length {
|
|
end = t.length
|
|
}
|
|
if end < start {
|
|
start, end = end, start
|
|
}
|
|
|
|
// Find the cursor positions.
|
|
var row, index int
|
|
t.cursor.row, t.cursor.pos = -1, [3]int{1, 0, -1}
|
|
t.selectionStart = t.cursor
|
|
RowLoop:
|
|
for {
|
|
if row >= len(t.lineStarts) {
|
|
t.extendLines(t.lastWidth, row)
|
|
if row >= len(t.lineStarts) {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check the spans of this row.
|
|
pos := t.lineStarts[row]
|
|
var (
|
|
next [3]int
|
|
lineIndex int
|
|
)
|
|
if row+1 < len(t.lineStarts) {
|
|
next = t.lineStarts[row+1]
|
|
} else {
|
|
next = [3]int{1, 0, -1}
|
|
}
|
|
for {
|
|
if pos[0] == next[0] {
|
|
if start >= index+lineIndex && start < index+lineIndex+next[1]-pos[1] ||
|
|
end >= index+lineIndex && end < index+lineIndex+next[1]-pos[1] {
|
|
break
|
|
}
|
|
index += lineIndex + next[1] - pos[1]
|
|
row++
|
|
continue RowLoop // Move on to the next row.
|
|
} else {
|
|
length := t.spans[pos[0]].length
|
|
if length < 0 {
|
|
length = -length
|
|
}
|
|
if start >= index+lineIndex && start < index+lineIndex+length-pos[1] ||
|
|
end >= index+lineIndex && end < index+lineIndex+length-pos[1] {
|
|
break
|
|
}
|
|
lineIndex += length - pos[1]
|
|
pos[0], pos[1] = t.spans[pos[0]].next, 0
|
|
}
|
|
}
|
|
|
|
// One of the indices is in this row. Step through it.
|
|
pos = t.lineStarts[row]
|
|
endPos := pos
|
|
var (
|
|
cluster, text string
|
|
column, width int
|
|
)
|
|
for pos != next {
|
|
if t.selectionStart.row < 0 && start <= index {
|
|
t.selectionStart.row, t.selectionStart.column, t.selectionStart.actualColumn = row, column, column
|
|
t.selectionStart.pos = pos
|
|
}
|
|
if t.cursor.row < 0 && end <= index {
|
|
t.cursor.row, t.cursor.column, t.cursor.actualColumn = row, column, column
|
|
t.cursor.pos = pos
|
|
break RowLoop
|
|
}
|
|
cluster, text, _, width, pos, endPos = t.step(text, pos, endPos)
|
|
index += len(cluster)
|
|
column += width
|
|
}
|
|
}
|
|
|
|
if t.cursor.row < 0 {
|
|
t.findCursor(false, 0) // This only happens if we couldn't find the locations above.
|
|
t.selectionStart = t.cursor
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// SetWrap sets the flag that, if true, leads to lines that are longer than the
|
|
// available width being wrapped onto the next line. If false, any characters
|
|
// beyond the available width are not displayed.
|
|
func (t *TextArea) SetWrap(wrap bool) *TextArea {
|
|
if t.wrap != wrap {
|
|
t.wrap = wrap
|
|
t.reset()
|
|
}
|
|
return t
|
|
}
|
|
|
|
// SetWordWrap sets the flag that causes lines that are longer than the
|
|
// available width to be wrapped onto the next line at spaces or after
|
|
// punctuation marks (according to [Unicode Standard Annex #14]). This flag is
|
|
// ignored if the flag set with [TextArea.SetWrap] is false. The text area's
|
|
// default is word-wrapping.
|
|
//
|
|
// [Unicode Standard Annex #14]: https://www.unicode.org/reports/tr14/
|
|
func (t *TextArea) SetWordWrap(wrapOnWords bool) *TextArea {
|
|
if t.wordWrap != wrapOnWords {
|
|
t.wordWrap = wrapOnWords
|
|
t.reset()
|
|
}
|
|
return t
|
|
}
|
|
|
|
// SetPlaceholder sets the text to be displayed when the text area is empty.
|
|
func (t *TextArea) SetPlaceholder(placeholder string) *TextArea {
|
|
t.placeholder = placeholder
|
|
return t
|
|
}
|
|
|
|
// SetLabel sets the text to be displayed before the text area.
|
|
func (t *TextArea) SetLabel(label string) *TextArea {
|
|
t.label = label
|
|
return t
|
|
}
|
|
|
|
// GetLabel returns the text to be displayed before the text area.
|
|
func (t *TextArea) GetLabel() string {
|
|
return t.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 (t *TextArea) SetLabelWidth(width int) *TextArea {
|
|
t.labelWidth = width
|
|
return t
|
|
}
|
|
|
|
// 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
|
|
// space, the available space will be used.
|
|
func (t *TextArea) SetSize(rows, columns int) *TextArea {
|
|
t.width = columns
|
|
t.height = rows
|
|
return t
|
|
}
|
|
|
|
// GetFieldWidth returns this primitive's field width.
|
|
func (t *TextArea) GetFieldWidth() int {
|
|
return t.width
|
|
}
|
|
|
|
// GetFieldHeight returns this primitive's field height.
|
|
func (t *TextArea) GetFieldHeight() int {
|
|
return t.height
|
|
}
|
|
|
|
// SetDisabled sets whether or not the item is disabled / read-only.
|
|
func (t *TextArea) SetDisabled(disabled bool) FormItem {
|
|
t.disabled = disabled
|
|
if t.finished != nil {
|
|
t.finished(-1)
|
|
}
|
|
return t
|
|
}
|
|
|
|
// 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.
|
|
func (t *TextArea) SetMaxLength(maxLength int) *TextArea {
|
|
t.maxLength = maxLength
|
|
return t
|
|
}
|
|
|
|
// SetLabelStyle sets the style of the label.
|
|
func (t *TextArea) SetLabelStyle(style tcell.Style) *TextArea {
|
|
t.labelStyle = style
|
|
return t
|
|
}
|
|
|
|
// GetLabelStyle returns the style of the label.
|
|
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.
|
|
func (t *TextArea) SetTextStyle(style tcell.Style) *TextArea {
|
|
t.textStyle = style
|
|
return t
|
|
}
|
|
|
|
// SetSelectedStyle sets the style of the selected text.
|
|
func (t *TextArea) SetSelectedStyle(style tcell.Style) *TextArea {
|
|
t.selectedStyle = style
|
|
return t
|
|
}
|
|
|
|
// SetPlaceholderStyle sets the style of the placeholder text.
|
|
func (t *TextArea) SetPlaceholderStyle(style tcell.Style) *TextArea {
|
|
t.placeholderStyle = style
|
|
return t
|
|
}
|
|
|
|
// 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.
|
|
func (t *TextArea) GetOffset() (row, column int) {
|
|
return t.rowOffset, t.columnOffset
|
|
}
|
|
|
|
// SetOffset sets the text's offset, that is, the number of rows and columns
|
|
// skipped during drawing at the top or on the left, respectively. If wrapping
|
|
// is enabled, the column offset is ignored. These values may get adjusted
|
|
// automatically to ensure that some text is always visible.
|
|
func (t *TextArea) SetOffset(row, column int) *TextArea {
|
|
t.rowOffset, t.columnOffset = row, column
|
|
return t
|
|
}
|
|
|
|
// SetClipboard allows you to implement your own clipboard by providing a
|
|
// function that is called when the user wishes to store text in the clipboard
|
|
// (copyToClipboard) and a function that is called when the user wishes to
|
|
// retrieve text from the clipboard (pasteFromClipboard).
|
|
//
|
|
// Providing nil values will cause the default clipboard implementation to be
|
|
// used.
|
|
func (t *TextArea) SetClipboard(copyToClipboard func(string), pasteFromClipboard func() string) *TextArea {
|
|
t.copyToClipboard = copyToClipboard
|
|
if t.copyToClipboard == nil {
|
|
t.copyToClipboard = func(text string) {
|
|
t.clipboard = text
|
|
}
|
|
}
|
|
|
|
t.pasteFromClipboard = pasteFromClipboard
|
|
if t.pasteFromClipboard == nil {
|
|
t.pasteFromClipboard = func() string {
|
|
return t.clipboard
|
|
}
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// SetChangedFunc sets a handler which is called whenever the text of the text
|
|
// area has changed.
|
|
func (t *TextArea) SetChangedFunc(handler func()) *TextArea {
|
|
t.changed = handler
|
|
return t
|
|
}
|
|
|
|
// SetMovedFunc sets a handler which is called whenever the cursor position or
|
|
// the text selection has changed.
|
|
func (t *TextArea) SetMovedFunc(handler func()) *TextArea {
|
|
t.moved = handler
|
|
return t
|
|
}
|
|
|
|
// SetFinishedFunc sets a callback invoked when the user leaves this form item.
|
|
func (t *TextArea) SetFinishedFunc(handler func(key tcell.Key)) FormItem {
|
|
t.finished = handler
|
|
return t
|
|
}
|
|
|
|
// Focus is called when this primitive receives focus.
|
|
func (t *TextArea) 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 t.finished != nil && t.disabled {
|
|
t.finished(-1)
|
|
return
|
|
}
|
|
|
|
t.Box.Focus(delegate)
|
|
}
|
|
|
|
// SetFormAttributes sets attributes shared by all form items.
|
|
func (t *TextArea) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) FormItem {
|
|
t.labelWidth = labelWidth
|
|
t.backgroundColor = bgColor
|
|
t.labelStyle = t.labelStyle.Foreground(labelColor)
|
|
t.textStyle = tcell.StyleDefault.Foreground(fieldTextColor).Background(fieldBgColor)
|
|
return t
|
|
}
|
|
|
|
// replace deletes a range of text and inserts the given text at that position.
|
|
// If the resulting text would exceed the maximum length, the function does not
|
|
// do anything. The function returns the end position of the deleted/inserted
|
|
// range. The provided row is the row of the deleted range start.
|
|
//
|
|
// The function can hang if "deleteStart" is located after "deleteEnd".
|
|
//
|
|
// Undo events are always generated unless continuation is true and text is
|
|
// either appended to the end of a span or a span is shortened at the beginning
|
|
// or the end (and nothing else).
|
|
//
|
|
// This function does not modify [TextArea.lineStarts].
|
|
func (t *TextArea) replace(deleteStart, deleteEnd [3]int, insert string, continuation bool) [3]int {
|
|
// Maybe nothing needs to be done?
|
|
if deleteStart == deleteEnd && insert == "" || t.maxLength > 0 && len(insert) > 0 && t.length+len(insert) >= t.maxLength {
|
|
return deleteEnd
|
|
}
|
|
|
|
// Notify at the end.
|
|
if t.changed != nil {
|
|
defer t.changed()
|
|
}
|
|
|
|
// Handle a few cases where we don't put anything onto the undo stack for
|
|
// increased efficiency.
|
|
if continuation {
|
|
// Same action as the one before. An undo item was already generated for
|
|
// this block of (same) actions. We're also only changing one character.
|
|
switch {
|
|
case insert == "" && deleteStart[1] != 0 && deleteEnd[1] == 0:
|
|
// Simple backspace. Just shorten this span.
|
|
length := t.spans[deleteStart[0]].length
|
|
if length < 0 {
|
|
t.length -= -length - deleteStart[1]
|
|
length = -deleteStart[1]
|
|
} else {
|
|
t.length -= length - deleteStart[1]
|
|
length = deleteStart[1]
|
|
}
|
|
t.spans[deleteStart[0]].length = length
|
|
return deleteEnd
|
|
case insert == "" && deleteStart[1] == 0 && deleteEnd[1] != 0:
|
|
// Simple delete. Just clip the beginning of this span.
|
|
t.spans[deleteEnd[0]].offset += deleteEnd[1]
|
|
if t.spans[deleteEnd[0]].length < 0 {
|
|
t.spans[deleteEnd[0]].length += deleteEnd[1]
|
|
} else {
|
|
t.spans[deleteEnd[0]].length -= deleteEnd[1]
|
|
}
|
|
t.length -= deleteEnd[1]
|
|
deleteEnd[1] = 0
|
|
return deleteEnd
|
|
case insert != "" && deleteStart == deleteEnd && deleteEnd[1] == 0:
|
|
previous := t.spans[deleteStart[0]].previous
|
|
bufferSpan := t.spans[previous]
|
|
if bufferSpan.length > 0 && bufferSpan.offset+bufferSpan.length == t.editText.Len() {
|
|
// Typing individual characters. Simply extend the edit buffer.
|
|
length, _ := t.editText.WriteString(insert)
|
|
t.spans[previous].length += length
|
|
t.length += length
|
|
return deleteEnd
|
|
}
|
|
}
|
|
}
|
|
|
|
// All other cases generate an undo item.
|
|
before := t.spans[deleteStart[0]].previous
|
|
after := deleteEnd[0]
|
|
if deleteEnd[1] > 0 {
|
|
after = t.spans[deleteEnd[0]].next
|
|
}
|
|
t.undoStack = t.undoStack[:t.nextUndo]
|
|
t.undoStack = append(t.undoStack, textAreaUndoItem{
|
|
before: len(t.spans),
|
|
after: len(t.spans) + 1,
|
|
originalBefore: before,
|
|
originalAfter: after,
|
|
length: t.length,
|
|
pos: t.cursor.pos,
|
|
continuation: continuation,
|
|
})
|
|
t.spans = append(t.spans, t.spans[before])
|
|
t.spans = append(t.spans, t.spans[after])
|
|
t.nextUndo++
|
|
|
|
// Adjust total text length by subtracting everything between "before" and
|
|
// "after". Inserted spans will be added back.
|
|
for index := deleteStart[0]; index != after; index = t.spans[index].next {
|
|
if t.spans[index].length < 0 {
|
|
t.length += t.spans[index].length
|
|
} else {
|
|
t.length -= t.spans[index].length
|
|
}
|
|
}
|
|
t.spans[before].next = after
|
|
t.spans[after].previous = before
|
|
|
|
// We go from left to right, connecting new spans as needed. We update
|
|
// "before" as the span to connect new spans to.
|
|
|
|
// If we start deleting in the middle of a span, connect a partial span.
|
|
if deleteStart[1] != 0 {
|
|
span := textAreaSpan{
|
|
previous: before,
|
|
next: after,
|
|
offset: t.spans[deleteStart[0]].offset,
|
|
length: deleteStart[1],
|
|
}
|
|
if t.spans[deleteStart[0]].length < 0 {
|
|
span.length = -span.length
|
|
}
|
|
t.length += deleteStart[1] // This was previously subtracted.
|
|
t.spans[before].next = len(t.spans)
|
|
t.spans[after].previous = len(t.spans)
|
|
before = len(t.spans)
|
|
for row, lineStart := range t.lineStarts { // Also redirect line starts until the end of this new span.
|
|
if lineStart[0] == deleteStart[0] {
|
|
if lineStart[1] >= deleteStart[1] {
|
|
t.lineStarts = t.lineStarts[:row] // Everything else is unknown at this point.
|
|
break
|
|
}
|
|
t.lineStarts[row][0] = len(t.spans)
|
|
}
|
|
}
|
|
t.spans = append(t.spans, span)
|
|
}
|
|
|
|
// If we insert text, connect a new span.
|
|
if insert != "" {
|
|
span := textAreaSpan{
|
|
previous: before,
|
|
next: after,
|
|
offset: t.editText.Len(),
|
|
}
|
|
span.length, _ = t.editText.WriteString(insert)
|
|
t.length += span.length
|
|
t.spans[before].next = len(t.spans)
|
|
t.spans[after].previous = len(t.spans)
|
|
before = len(t.spans)
|
|
t.spans = append(t.spans, span)
|
|
}
|
|
|
|
// If we stop deleting in the middle of a span, connect a partial span.
|
|
if deleteEnd[1] != 0 {
|
|
span := textAreaSpan{
|
|
previous: before,
|
|
next: after,
|
|
offset: t.spans[deleteEnd[0]].offset + deleteEnd[1],
|
|
}
|
|
length := t.spans[deleteEnd[0]].length
|
|
if length < 0 {
|
|
span.length = length + deleteEnd[1]
|
|
t.length -= span.length // This was previously subtracted.
|
|
} else {
|
|
span.length = length - deleteEnd[1]
|
|
t.length += span.length // This was previously subtracted.
|
|
}
|
|
t.spans[before].next = len(t.spans)
|
|
t.spans[after].previous = len(t.spans)
|
|
deleteEnd[0], deleteEnd[1] = len(t.spans), 0
|
|
t.spans = append(t.spans, span)
|
|
}
|
|
|
|
return deleteEnd
|
|
}
|
|
|
|
// Draw draws this primitive onto the screen.
|
|
func (t *TextArea) Draw(screen tcell.Screen) {
|
|
t.Box.DrawForSubclass(screen, t)
|
|
|
|
// Prepare
|
|
x, y, width, height := t.GetInnerRect()
|
|
if width <= 0 || height <= 0 {
|
|
return // We have no space for anything.
|
|
}
|
|
columnOffset := t.columnOffset
|
|
if t.wrap {
|
|
columnOffset = 0
|
|
}
|
|
|
|
// Draw label.
|
|
_, labelBg, _ := t.labelStyle.Decompose()
|
|
if t.labelWidth > 0 {
|
|
labelWidth := t.labelWidth
|
|
if labelWidth > width {
|
|
labelWidth = width
|
|
}
|
|
printWithStyle(screen, t.label, x, y, 0, labelWidth, AlignLeft, t.labelStyle, labelBg == tcell.ColorDefault)
|
|
x += labelWidth
|
|
width -= labelWidth
|
|
} else {
|
|
_, drawnWidth, _, _ := printWithStyle(screen, t.label, x, y, 0, width, AlignLeft, t.labelStyle, labelBg == tcell.ColorDefault)
|
|
x += drawnWidth
|
|
width -= drawnWidth
|
|
}
|
|
|
|
// What's the space for the input element?
|
|
if t.width > 0 && t.width < width {
|
|
width = t.width
|
|
}
|
|
if t.height > 0 && t.height < height {
|
|
height = t.height
|
|
}
|
|
if width <= 0 {
|
|
return // No space left for the text area.
|
|
}
|
|
|
|
// Draw the input element if necessary.
|
|
_, bg, _ := t.textStyle.Decompose()
|
|
if t.disabled {
|
|
bg = t.backgroundColor
|
|
}
|
|
if bg != t.backgroundColor {
|
|
for row := 0; row < height; row++ {
|
|
for column := 0; column < width; column++ {
|
|
screen.SetContent(x+column, y+row, ' ', nil, t.textStyle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show/hide the cursor at the end.
|
|
defer func() {
|
|
if t.HasFocus() {
|
|
row, column := t.cursor.row, t.cursor.actualColumn
|
|
if t.length > 0 && t.wrap && column >= t.lastWidth { // This happens when a row has text all the way until the end, pushing the cursor outside the viewport.
|
|
row++
|
|
column = 0
|
|
}
|
|
if row >= 0 &&
|
|
row-t.rowOffset >= 0 && row-t.rowOffset < height &&
|
|
column-columnOffset >= 0 && column-columnOffset < width {
|
|
screen.ShowCursor(x+column-columnOffset, y+row-t.rowOffset)
|
|
} else {
|
|
screen.HideCursor()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Placeholder.
|
|
if t.length == 0 && len(t.placeholder) > 0 {
|
|
t.drawPlaceholder(screen, x, y, width, height)
|
|
return // We're done already.
|
|
}
|
|
|
|
// Make sure the visible lines are broken over.
|
|
firstDrawing := t.lastWidth == 0
|
|
if t.lastWidth != width && t.lineStarts != nil {
|
|
t.reset()
|
|
}
|
|
t.lastHeight, t.lastWidth = height, width
|
|
t.extendLines(width, t.rowOffset+height)
|
|
if len(t.lineStarts) <= t.rowOffset {
|
|
return // It's scrolled out of view.
|
|
}
|
|
|
|
// If the cursor position is unknown, find it. This usually only happens
|
|
// before the screen is drawn for the first time.
|
|
if t.cursor.row < 0 {
|
|
t.findCursor(true, 0)
|
|
if t.selectionStart.row < 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
if firstDrawing && t.moved != nil {
|
|
t.moved()
|
|
}
|
|
}
|
|
|
|
// Print the text.
|
|
var cluster, text string
|
|
line := t.rowOffset
|
|
pos := t.lineStarts[line]
|
|
endPos := pos
|
|
posX, posY := 0, 0
|
|
for pos[0] != 1 {
|
|
var clusterWidth int
|
|
cluster, text, _, clusterWidth, pos, endPos = t.step(text, pos, endPos)
|
|
|
|
// Prepare drawing.
|
|
runes := []rune(cluster)
|
|
style := t.selectedStyle
|
|
fromRow, fromColumn := t.cursor.row, t.cursor.actualColumn
|
|
toRow, toColumn := t.selectionStart.row, t.selectionStart.actualColumn
|
|
if fromRow > toRow || fromRow == toRow && fromColumn > toColumn {
|
|
fromRow, fromColumn, toRow, toColumn = toRow, toColumn, fromRow, fromColumn
|
|
}
|
|
if toRow < line ||
|
|
toRow == line && toColumn <= posX ||
|
|
fromRow > line ||
|
|
fromRow == line && fromColumn > posX {
|
|
style = t.textStyle
|
|
if t.disabled {
|
|
style = style.Background(t.backgroundColor)
|
|
}
|
|
}
|
|
|
|
// Draw character.
|
|
if posX+clusterWidth-columnOffset <= width && posX-columnOffset >= 0 && clusterWidth > 0 {
|
|
screen.SetContent(x+posX-columnOffset, y+posY, runes[0], runes[1:], style)
|
|
}
|
|
|
|
// Advance.
|
|
posX += clusterWidth
|
|
if line+1 < len(t.lineStarts) && t.lineStarts[line+1] == pos {
|
|
// We must break over.
|
|
posY++
|
|
if posY >= height {
|
|
break // Done.
|
|
}
|
|
posX = 0
|
|
line++
|
|
}
|
|
}
|
|
}
|
|
|
|
// drawPlaceholder draws the placeholder text into the given rectangle. It does
|
|
// not do anything if the text area already contains text or if there is no
|
|
// placeholder text.
|
|
func (t *TextArea) drawPlaceholder(screen tcell.Screen, x, y, width, height int) {
|
|
posX, posY := x, y
|
|
lastLineBreak, lastGraphemeBreak := x, x // Screen positions of the last possible line/grapheme break.
|
|
iterateString(t.placeholder, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
|
|
if posX+screenWidth > x+width {
|
|
// This character doesn't fit. Break over to the next line.
|
|
// Perform word wrapping first by copying the last word over to
|
|
// the next line.
|
|
clearX := lastLineBreak
|
|
if lastLineBreak == x {
|
|
clearX = lastGraphemeBreak
|
|
}
|
|
posY++
|
|
if posY >= y+height {
|
|
return true
|
|
}
|
|
newPosX := x
|
|
for clearX < posX {
|
|
main, comb, _, _ := screen.GetContent(clearX, posY-1)
|
|
screen.SetContent(clearX, posY-1, ' ', nil, tcell.StyleDefault.Background(t.backgroundColor))
|
|
screen.SetContent(newPosX, posY, main, comb, t.placeholderStyle)
|
|
clearX++
|
|
newPosX++
|
|
}
|
|
lastLineBreak, lastGraphemeBreak, posX = x, x, newPosX
|
|
}
|
|
|
|
// Draw this character.
|
|
screen.SetContent(posX, posY, main, comb, t.placeholderStyle)
|
|
posX += screenWidth
|
|
switch boundaries & uniseg.MaskLine {
|
|
case uniseg.LineMustBreak:
|
|
posY++
|
|
if posY >= y+height {
|
|
return true
|
|
}
|
|
posX = x
|
|
case uniseg.LineCanBreak:
|
|
lastLineBreak = posX
|
|
}
|
|
lastGraphemeBreak = posX
|
|
|
|
return false
|
|
})
|
|
}
|
|
|
|
// reset resets many of the local variables of the text area because they cannot
|
|
// be used anymore and must be recalculated, typically after the text area's
|
|
// size has changed.
|
|
func (t *TextArea) reset() {
|
|
t.truncateLines(0)
|
|
if t.wrap {
|
|
t.cursor.row = -1
|
|
t.selectionStart.row = -1
|
|
}
|
|
t.widestLine = 0
|
|
}
|
|
|
|
// extendLines traverses the current text and extends [TextArea.lineStarts] such
|
|
// that it describes at least maxLines+1 lines (or less if the text is shorter).
|
|
// Text is laid out for the given width while respecting the wrapping settings.
|
|
// It is assumed that if [TextArea.lineStarts] already has entries, they obey
|
|
// the same rules.
|
|
//
|
|
// If width is 0, nothing happens.
|
|
func (t *TextArea) extendLines(width, maxLines int) {
|
|
if width <= 0 {
|
|
return
|
|
}
|
|
|
|
// Start with the first span.
|
|
if len(t.lineStarts) == 0 {
|
|
if len(t.spans) > 2 {
|
|
t.lineStarts = append(t.lineStarts, [3]int{t.spans[0].next, 0, -1})
|
|
} else {
|
|
return // No text.
|
|
}
|
|
}
|
|
|
|
// Determine starting positions and starting spans.
|
|
pos := t.lineStarts[len(t.lineStarts)-1] // The starting position is the last known line.
|
|
endPos := pos
|
|
var (
|
|
cluster, text string
|
|
lineWidth, clusterWidth, boundaries int
|
|
lastGraphemeBreak, lastLineBreak [3]int
|
|
widthSinceLineBreak int
|
|
)
|
|
for pos[0] != 1 {
|
|
// Get the next grapheme cluster.
|
|
cluster, text, boundaries, clusterWidth, pos, endPos = t.step(text, pos, endPos)
|
|
lineWidth += clusterWidth
|
|
widthSinceLineBreak += clusterWidth
|
|
|
|
// Any line breaks?
|
|
if !t.wrap || lineWidth <= width {
|
|
if boundaries&uniseg.MaskLine == uniseg.LineMustBreak && (len(text) > 0 || uniseg.HasTrailingLineBreakInString(cluster)) {
|
|
// We must break over.
|
|
t.lineStarts = append(t.lineStarts, pos)
|
|
if lineWidth > t.widestLine {
|
|
t.widestLine = lineWidth
|
|
}
|
|
lineWidth = 0
|
|
lastGraphemeBreak = [3]int{}
|
|
lastLineBreak = [3]int{}
|
|
widthSinceLineBreak = 0
|
|
if len(t.lineStarts) > maxLines {
|
|
break // We have enough lines, we can stop.
|
|
}
|
|
continue
|
|
}
|
|
} else { // t.wrap && lineWidth > width
|
|
if !t.wordWrap || lastLineBreak == [3]int{} {
|
|
if lastGraphemeBreak != [3]int{} { // We have at least one character on each line.
|
|
// Break after last grapheme.
|
|
t.lineStarts = append(t.lineStarts, lastGraphemeBreak)
|
|
if lineWidth > t.widestLine {
|
|
t.widestLine = lineWidth
|
|
}
|
|
lineWidth = clusterWidth
|
|
lastLineBreak = [3]int{}
|
|
}
|
|
} else { // t.wordWrap && lastLineBreak != [3]int{}
|
|
// Break after last line break opportunity.
|
|
t.lineStarts = append(t.lineStarts, lastLineBreak)
|
|
if lineWidth > t.widestLine {
|
|
t.widestLine = lineWidth
|
|
}
|
|
lineWidth = widthSinceLineBreak
|
|
lastLineBreak = [3]int{}
|
|
}
|
|
}
|
|
|
|
// Analyze break opportunities.
|
|
if boundaries&uniseg.MaskLine == uniseg.LineCanBreak {
|
|
lastLineBreak = pos
|
|
widthSinceLineBreak = 0
|
|
}
|
|
lastGraphemeBreak = pos
|
|
|
|
// Can we stop?
|
|
if len(t.lineStarts) > maxLines {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// truncateLines truncates the trailing lines of the [TextArea.lineStarts]
|
|
// slice such that len(lineStarts) <= fromLine. If fromLine is negative, a value
|
|
// of 0 is assumed. If it is greater than the length of lineStarts, nothing
|
|
// happens.
|
|
func (t *TextArea) truncateLines(fromLine int) {
|
|
if fromLine < 0 {
|
|
fromLine = 0
|
|
}
|
|
if fromLine < len(t.lineStarts) {
|
|
t.lineStarts = t.lineStarts[:fromLine]
|
|
}
|
|
}
|
|
|
|
// findCursor determines the cursor position if its "row" value is < 0
|
|
// (=unknown) but only its span position ("pos" value) is known. If the cursor
|
|
// position is already known (row >= 0), it can also be used to modify row and
|
|
// column offsets such that the cursor is visible during the next call to
|
|
// [TextArea.Draw], by setting "clamp" to true.
|
|
//
|
|
// To determine the cursor position, "startRow" helps reduce processing time by
|
|
// indicating the lowest row in which searching should start. Set this to 0 if
|
|
// you don't have any information where the cursor might be (but know that this
|
|
// is expensive for long texts).
|
|
//
|
|
// The cursor's desired column will be set to its actual column.
|
|
func (t *TextArea) findCursor(clamp bool, startRow int) {
|
|
defer func() {
|
|
t.cursor.column = t.cursor.actualColumn
|
|
}()
|
|
|
|
if !clamp && t.cursor.row >= 0 {
|
|
return // Nothing to do.
|
|
}
|
|
|
|
// Clamp to viewport.
|
|
if clamp && t.cursor.row >= 0 {
|
|
cursorRow := t.cursor.row
|
|
if t.wrap && t.cursor.actualColumn >= t.lastWidth {
|
|
cursorRow++ // A row can push the cursor just outside the viewport. It will wrap onto the next line.
|
|
}
|
|
if cursorRow < t.rowOffset {
|
|
// We're above the viewport.
|
|
t.rowOffset = cursorRow
|
|
} else if cursorRow >= t.rowOffset+t.lastHeight {
|
|
// We're below the viewport.
|
|
t.rowOffset = cursorRow - t.lastHeight + 1
|
|
if t.rowOffset >= len(t.lineStarts) {
|
|
t.extendLines(t.lastWidth, t.rowOffset)
|
|
if t.rowOffset >= len(t.lineStarts) {
|
|
t.rowOffset = len(t.lineStarts) - 1
|
|
if t.rowOffset < 0 {
|
|
t.rowOffset = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !t.wrap {
|
|
if t.cursor.actualColumn < t.columnOffset+minCursorPrefix {
|
|
// We're left of the viewport.
|
|
t.columnOffset = t.cursor.actualColumn - minCursorPrefix
|
|
if t.columnOffset < 0 {
|
|
t.columnOffset = 0
|
|
}
|
|
} else if t.cursor.actualColumn >= t.columnOffset+t.lastWidth-minCursorSuffix {
|
|
// We're right of the viewport.
|
|
t.columnOffset = t.cursor.actualColumn - t.lastWidth + minCursorSuffix
|
|
if t.columnOffset >= t.widestLine {
|
|
t.columnOffset = t.widestLine - 1
|
|
if t.columnOffset < 0 {
|
|
t.columnOffset = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// The screen position of the cursor is unknown. Find it. This can be
|
|
// expensive. First, find the row.
|
|
row := startRow
|
|
if row < 0 {
|
|
row = 0
|
|
}
|
|
RowLoop:
|
|
for {
|
|
// Examine the current row.
|
|
if row+1 >= len(t.lineStarts) {
|
|
t.extendLines(t.lastWidth, row+1)
|
|
}
|
|
if row >= len(t.lineStarts) {
|
|
t.cursor.row, t.cursor.actualColumn, t.cursor.pos = row, 0, [3]int{1, 0, -1}
|
|
break // It's the end of the text.
|
|
}
|
|
|
|
// Check this row's spans to see if the cursor is in this row.
|
|
pos := t.lineStarts[row]
|
|
for pos[0] != 1 {
|
|
if row+1 >= len(t.lineStarts) {
|
|
break // It's the last row so the cursor must be in this row.
|
|
}
|
|
if t.cursor.pos[0] == pos[0] {
|
|
// The cursor is in this span.
|
|
if t.lineStarts[row+1][0] == pos[0] {
|
|
// The next row starts with the same span.
|
|
if t.cursor.pos[1] >= t.lineStarts[row+1][1] {
|
|
// The cursor is not in this row.
|
|
row++
|
|
continue RowLoop
|
|
} else {
|
|
// The cursor is in this row.
|
|
break
|
|
}
|
|
} else {
|
|
// The next row starts with a different span. The cursor
|
|
// must be in this row.
|
|
break
|
|
}
|
|
} else {
|
|
// The cursor is in a different span.
|
|
if t.lineStarts[row+1][0] == pos[0] {
|
|
// The next row starts with the same span. This row is
|
|
// irrelevant.
|
|
row++
|
|
continue RowLoop
|
|
} else {
|
|
// The next row starts with a different span. Move towards it.
|
|
pos = [3]int{t.spans[pos[0]].next, 0, -1}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to find the screen position in this row.
|
|
pos = t.lineStarts[row]
|
|
endPos := pos
|
|
column := 0
|
|
var text string
|
|
for {
|
|
if pos[0] == 1 || t.cursor.pos[0] == pos[0] && t.cursor.pos[1] == pos[1] {
|
|
// We found the position. We're done.
|
|
t.cursor.row, t.cursor.actualColumn, t.cursor.pos = row, column, pos
|
|
break RowLoop
|
|
}
|
|
var clusterWidth int
|
|
_, text, _, clusterWidth, pos, endPos = t.step(text, pos, endPos)
|
|
if row+1 < len(t.lineStarts) && t.lineStarts[row+1] == pos {
|
|
// We reached the end of the line. Go to the next one.
|
|
row++
|
|
continue RowLoop
|
|
}
|
|
column += clusterWidth
|
|
}
|
|
}
|
|
|
|
if clamp && t.cursor.row >= 0 {
|
|
// We know the position now. Adapt offsets.
|
|
t.findCursor(true, startRow)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
// be empty and pos/endPos may be the same. For consecutive calls, provide
|
|
// "rest" as the text and "newPos" and "newEndPos" as the new positions/states.
|
|
// An empty "rest" string indicates the end of the text. The "endPos" state is
|
|
// irrelevant.
|
|
func (t *TextArea) step(text string, pos, endPos [3]int) (cluster, rest string, boundaries, width int, newPos, newEndPos [3]int) {
|
|
if pos[0] == 1 {
|
|
return // We're already past the end.
|
|
}
|
|
|
|
// We want to make sure we have a text at least the size of a grapheme
|
|
// cluster.
|
|
span := t.spans[pos[0]]
|
|
if len(text) < maxGraphemeClusterSize &&
|
|
(span.length < 0 && -span.length-pos[1] >= maxGraphemeClusterSize ||
|
|
span.length > 0 && t.spans[pos[0]].length-pos[1] >= maxGraphemeClusterSize) {
|
|
// We can use a substring of one span.
|
|
if span.length < 0 {
|
|
text = t.initialText[span.offset+pos[1] : span.offset-span.length]
|
|
} else {
|
|
text = t.editText.String()[span.offset+pos[1] : span.offset+span.length]
|
|
}
|
|
endPos = [3]int{span.next, 0, -1}
|
|
} else {
|
|
// We have to compose the text from multiple spans.
|
|
for len(text) < maxGraphemeClusterSize && endPos[0] != 1 {
|
|
endSpan := t.spans[endPos[0]]
|
|
var moreText string
|
|
if endSpan.length < 0 {
|
|
moreText = t.initialText[endSpan.offset+endPos[1] : endSpan.offset-endSpan.length]
|
|
} else {
|
|
moreText = t.editText.String()[endSpan.offset+endPos[1] : endSpan.offset+endSpan.length]
|
|
}
|
|
if len(moreText) > maxGraphemeClusterSize {
|
|
moreText = moreText[:maxGraphemeClusterSize]
|
|
}
|
|
text += moreText
|
|
endPos[1] += len(moreText)
|
|
if endPos[1] >= endSpan.length {
|
|
endPos[0], endPos[1] = endSpan.next, 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run the grapheme cluster iterator.
|
|
cluster, text, boundaries, pos[2] = uniseg.StepString(text, pos[2])
|
|
pos[1] += len(cluster)
|
|
for pos[0] != 1 && (span.length < 0 && pos[1] >= -span.length || span.length >= 0 && pos[1] >= span.length) {
|
|
pos[0] = span.next
|
|
if span.length < 0 {
|
|
pos[1] += span.length
|
|
} else {
|
|
pos[1] -= span.length
|
|
}
|
|
span = t.spans[pos[0]]
|
|
}
|
|
|
|
if cluster == "\t" {
|
|
width = TabSize
|
|
} else {
|
|
width = boundaries >> uniseg.ShiftWidth
|
|
}
|
|
|
|
return cluster, text, boundaries, width, pos, endPos
|
|
}
|
|
|
|
// moveCursor sets the cursor's screen position and span position for the given
|
|
// row and column which are screen space coordinates relative to the top-left
|
|
// corner of the text area's full text (visible or not). The column value may be
|
|
// negative, in which case, the cursor will be placed at the end of the line.
|
|
// The cursor's actual position will be aligned with a grapheme cluster
|
|
// boundary. The next call to [TextArea.Draw] will attempt to keep the cursor in
|
|
// the viewport.
|
|
func (t *TextArea) moveCursor(row, column int) {
|
|
// Are we within the range of rows?
|
|
if len(t.lineStarts) <= row {
|
|
// No. Extent the line buffer.
|
|
t.extendLines(t.lastWidth, row)
|
|
}
|
|
if len(t.lineStarts) == 0 {
|
|
return // No lines. Nothing to do.
|
|
}
|
|
if row < 0 {
|
|
// We're at the start of the text.
|
|
row = 0
|
|
column = 0
|
|
} else if row >= len(t.lineStarts) {
|
|
// We're already past the end.
|
|
row = len(t.lineStarts) - 1
|
|
column = -1
|
|
}
|
|
|
|
// Iterate through this row until we find the position.
|
|
t.cursor.row, t.cursor.actualColumn = row, 0
|
|
if t.wrap {
|
|
t.cursor.actualColumn = 0
|
|
}
|
|
pos := t.lineStarts[row]
|
|
endPos := pos
|
|
var text string
|
|
for pos[0] != 1 {
|
|
var clusterWidth int
|
|
oldPos := pos // We may have to revert to this position.
|
|
_, text, _, clusterWidth, pos, endPos = t.step(text, pos, endPos)
|
|
if len(t.lineStarts) > row+1 && pos == t.lineStarts[row+1] || // We've reached the end of the line.
|
|
column >= 0 && t.cursor.actualColumn+clusterWidth > column { // We're past the requested column.
|
|
pos = oldPos
|
|
break
|
|
}
|
|
t.cursor.actualColumn += clusterWidth
|
|
}
|
|
|
|
if column < 0 {
|
|
t.cursor.column = t.cursor.actualColumn
|
|
} else {
|
|
t.cursor.column = column
|
|
}
|
|
t.cursor.pos = pos
|
|
t.findCursor(true, row)
|
|
}
|
|
|
|
// moveWordRight moves the cursor to the end of the current or next word. If
|
|
// after is set to true, the cursor will be placed after the word. If false, the
|
|
// cursor will be placed on the last character of the word. If clamp is set to
|
|
// true, the cursor will be visible during the next call to [TextArea.Draw].
|
|
func (t *TextArea) moveWordRight(after, clamp bool) {
|
|
// Because we rely on clampToCursor to calculate the new screen position,
|
|
// this is an expensive operation for large texts.
|
|
pos := t.cursor.pos
|
|
endPos := pos
|
|
var (
|
|
cluster, text string
|
|
inWord bool
|
|
)
|
|
for pos[0] != 0 {
|
|
var boundaries int
|
|
oldPos := pos
|
|
cluster, text, boundaries, _, pos, endPos = t.step(text, pos, endPos)
|
|
if oldPos == t.cursor.pos {
|
|
continue // Skip the first character.
|
|
}
|
|
firstRune, _ := utf8.DecodeRuneInString(cluster)
|
|
if !unicode.IsSpace(firstRune) && !unicode.IsPunct(firstRune) {
|
|
inWord = true
|
|
}
|
|
if inWord && boundaries&uniseg.MaskWord != 0 {
|
|
if !after {
|
|
pos = oldPos
|
|
}
|
|
break
|
|
}
|
|
}
|
|
startRow := t.cursor.row
|
|
t.cursor.row, t.cursor.column, t.cursor.actualColumn = -1, 0, 0
|
|
t.cursor.pos = pos
|
|
t.findCursor(clamp, startRow)
|
|
}
|
|
|
|
// moveWordLeft moves the cursor to the beginning of the current or previous
|
|
// word. If clamp is true, the cursor will be visible during the next call to
|
|
// [TextArea.Draw].
|
|
func (t *TextArea) moveWordLeft(clamp bool) {
|
|
// We go back row by row, trying to find the last word boundary before the
|
|
// cursor.
|
|
row := t.cursor.row
|
|
if row+1 < len(t.lineStarts) {
|
|
t.extendLines(t.lastWidth, row+1)
|
|
}
|
|
if row >= len(t.lineStarts) {
|
|
row = len(t.lineStarts) - 1
|
|
}
|
|
for row >= 0 {
|
|
pos := t.lineStarts[row]
|
|
endPos := pos
|
|
var lastWordBoundary [3]int
|
|
var (
|
|
cluster, text string
|
|
inWord bool
|
|
boundaries int
|
|
)
|
|
for pos[0] != 1 && pos != t.cursor.pos {
|
|
oldBoundaries := boundaries
|
|
oldPos := pos
|
|
cluster, text, boundaries, _, pos, endPos = t.step(text, pos, endPos)
|
|
firstRune, _ := utf8.DecodeRuneInString(cluster)
|
|
wordRune := !unicode.IsSpace(firstRune) && !unicode.IsPunct(firstRune)
|
|
if oldBoundaries&uniseg.MaskWord != 0 {
|
|
if pos != t.cursor.pos && !inWord && wordRune {
|
|
// A boundary transitioning from a space/punctuation word to
|
|
// a letter word.
|
|
lastWordBoundary = oldPos
|
|
}
|
|
inWord = false
|
|
}
|
|
if wordRune {
|
|
inWord = true
|
|
}
|
|
}
|
|
if lastWordBoundary[0] != 0 {
|
|
// We found something.
|
|
t.cursor.pos = lastWordBoundary
|
|
break
|
|
}
|
|
row--
|
|
}
|
|
if row < 0 {
|
|
// We didn't find anything. We're at the start of the text.
|
|
t.cursor.pos = [3]int{t.spans[0].next, 0, -1}
|
|
row = 0
|
|
}
|
|
t.cursor.row, t.cursor.column, t.cursor.actualColumn = -1, 0, 0
|
|
t.findCursor(clamp, row)
|
|
}
|
|
|
|
// deleteLine deletes all characters between the last newline before the cursor
|
|
// and the next newline after the cursor (inclusive).
|
|
func (t *TextArea) deleteLine() {
|
|
// We go back row by row, trying to find the last mandatory line break
|
|
// before the cursor.
|
|
startRow := t.cursor.row
|
|
if t.cursor.actualColumn == 0 && t.cursor.pos[0] == 1 {
|
|
startRow-- // If we're at the very end, delete the row before.
|
|
}
|
|
if startRow+1 < len(t.lineStarts) {
|
|
t.extendLines(t.lastWidth, startRow+1)
|
|
}
|
|
if len(t.lineStarts) == 0 {
|
|
return // Nothing to delete.
|
|
}
|
|
if startRow >= len(t.lineStarts) {
|
|
startRow = len(t.lineStarts) - 1
|
|
}
|
|
for startRow >= 0 {
|
|
// What's the last rune before the start of the line?
|
|
pos := t.lineStarts[startRow]
|
|
span := t.spans[pos[0]]
|
|
var text string
|
|
if pos[1] > 0 {
|
|
// Extract text from this span.
|
|
if span.length < 0 {
|
|
text = t.initialText
|
|
} else {
|
|
text = t.editText.String()
|
|
}
|
|
text = text[:span.offset+pos[1]]
|
|
} else {
|
|
// Extract text from the previous span.
|
|
if span.previous != 0 {
|
|
span = t.spans[span.previous]
|
|
if span.length < 0 {
|
|
text = t.initialText[:span.offset-span.length]
|
|
} else {
|
|
text = t.editText.String()[:span.offset+span.length]
|
|
}
|
|
}
|
|
}
|
|
if uniseg.HasTrailingLineBreakInString(text) {
|
|
// The row before this one ends with a mandatory line break. This is
|
|
// the first line we will delete.
|
|
break
|
|
}
|
|
startRow--
|
|
}
|
|
if startRow < 0 {
|
|
// We didn't find anything. It'll be the first line.
|
|
startRow = 0
|
|
}
|
|
|
|
// Find the next line break after the cursor.
|
|
pos := t.cursor.pos
|
|
endPos := pos
|
|
var cluster, text string
|
|
for pos[0] != 1 {
|
|
cluster, text, _, _, pos, endPos = t.step(text, pos, endPos)
|
|
if uniseg.HasTrailingLineBreakInString(cluster) {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Delete the text.
|
|
t.cursor.pos = t.replace(t.lineStarts[startRow], pos, "", false)
|
|
t.cursor.row = -1
|
|
t.truncateLines(startRow)
|
|
t.findCursor(true, startRow)
|
|
}
|
|
|
|
// getSelection returns the current selection as span locations where the first
|
|
// returned location is always before or the same as the second returned
|
|
// location. This assumes that the cursor and selection positions are known. The
|
|
// third return value is the starting row of the selection.
|
|
func (t *TextArea) getSelection() ([3]int, [3]int, int) {
|
|
from := t.selectionStart.pos
|
|
to := t.cursor.pos
|
|
row := t.selectionStart.row
|
|
if t.cursor.row < t.selectionStart.row ||
|
|
(t.cursor.row == t.selectionStart.row && t.cursor.actualColumn < t.selectionStart.actualColumn) {
|
|
from, to = to, from
|
|
row = t.cursor.row
|
|
}
|
|
return from, to, row
|
|
}
|
|
|
|
// getSelectedText returns the text of the current selection.
|
|
func (t *TextArea) getSelectedText() string {
|
|
var text strings.Builder
|
|
|
|
from, to, _ := t.getSelection()
|
|
for from[0] != to[0] {
|
|
span := t.spans[from[0]]
|
|
if span.length < 0 {
|
|
text.WriteString(t.initialText[span.offset+from[1] : span.offset-span.length])
|
|
} else {
|
|
text.WriteString(t.editText.String()[span.offset+from[1] : span.offset+span.length])
|
|
}
|
|
from[0], from[1] = span.next, 0
|
|
}
|
|
if from[0] != 1 && from[1] < to[1] {
|
|
span := t.spans[from[0]]
|
|
if span.length < 0 {
|
|
text.WriteString(t.initialText[span.offset+from[1] : span.offset+to[1]])
|
|
} else {
|
|
text.WriteString(t.editText.String()[span.offset+from[1] : span.offset+to[1]])
|
|
}
|
|
}
|
|
|
|
return text.String()
|
|
}
|
|
|
|
// InputHandler returns the handler for this primitive.
|
|
func (t *TextArea) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
|
return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
|
if t.disabled {
|
|
return
|
|
}
|
|
|
|
// All actions except a few specific ones are "other" actions.
|
|
newLastAction := taActionOther
|
|
defer func() {
|
|
t.lastAction = newLastAction
|
|
}()
|
|
|
|
// Trigger a "moved" event if requested.
|
|
if t.moved != nil {
|
|
selectionStart, cursor := t.selectionStart, t.cursor
|
|
defer func() {
|
|
if selectionStart != t.selectionStart || cursor != t.cursor {
|
|
t.moved()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Process the different key events.
|
|
switch key := event.Key(); key {
|
|
case tcell.KeyLeft: // Move one grapheme cluster to the left.
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
// Regular movement.
|
|
if event.Modifiers()&tcell.ModShift == 0 && t.selectionStart.pos != t.cursor.pos {
|
|
// Move to the start of the selection.
|
|
if t.selectionStart.row < t.cursor.row || (t.selectionStart.row == t.cursor.row && t.selectionStart.actualColumn < t.cursor.actualColumn) {
|
|
t.cursor = t.selectionStart
|
|
}
|
|
t.findCursor(true, t.cursor.row)
|
|
} else if event.Modifiers()&tcell.ModMeta != 0 || event.Modifiers()&tcell.ModCtrl != 0 {
|
|
// This captures Ctrl-Left on some systems.
|
|
t.moveWordLeft(event.Modifiers()&tcell.ModShift != 0)
|
|
} else if t.cursor.actualColumn == 0 {
|
|
// Move to the end of the previous row.
|
|
if t.cursor.row > 0 {
|
|
t.moveCursor(t.cursor.row-1, -1)
|
|
}
|
|
} else {
|
|
// Move one grapheme cluster to the left.
|
|
t.moveCursor(t.cursor.row, t.cursor.actualColumn-1)
|
|
}
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
} else if !t.wrap { // This doesn't work on all terminals.
|
|
// Just scroll.
|
|
t.columnOffset--
|
|
if t.columnOffset < 0 {
|
|
t.columnOffset = 0
|
|
}
|
|
}
|
|
case tcell.KeyRight: // Move one grapheme cluster to the right.
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
// Regular movement.
|
|
if event.Modifiers()&tcell.ModShift == 0 && t.selectionStart.pos != t.cursor.pos {
|
|
// Move to the end of the selection.
|
|
if t.selectionStart.row > t.cursor.row || (t.selectionStart.row == t.cursor.row && t.selectionStart.actualColumn > t.cursor.actualColumn) {
|
|
t.cursor = t.selectionStart
|
|
}
|
|
t.findCursor(true, t.cursor.row)
|
|
} else if t.cursor.pos[0] != 1 {
|
|
if event.Modifiers()&tcell.ModMeta != 0 || event.Modifiers()&tcell.ModCtrl != 0 {
|
|
// This captures Ctrl-Right on some systems.
|
|
t.moveWordRight(event.Modifiers()&tcell.ModShift != 0, true)
|
|
} else {
|
|
// Move one grapheme cluster to the right.
|
|
var clusterWidth int
|
|
_, _, _, clusterWidth, t.cursor.pos, _ = t.step("", t.cursor.pos, t.cursor.pos)
|
|
if len(t.lineStarts) <= t.cursor.row+1 {
|
|
t.extendLines(t.lastWidth, t.cursor.row+1)
|
|
}
|
|
if t.cursor.row+1 < len(t.lineStarts) && t.lineStarts[t.cursor.row+1] == t.cursor.pos {
|
|
// We've reached the end of the line.
|
|
t.cursor.row++
|
|
t.cursor.actualColumn = 0
|
|
t.cursor.column = 0
|
|
t.findCursor(true, t.cursor.row)
|
|
} else {
|
|
// Move one character to the right.
|
|
t.moveCursor(t.cursor.row, t.cursor.actualColumn+clusterWidth)
|
|
}
|
|
}
|
|
}
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
} else if !t.wrap { // This doesn't work on all terminals.
|
|
// Just scroll.
|
|
t.columnOffset++
|
|
if t.columnOffset >= t.widestLine {
|
|
t.columnOffset = t.widestLine - 1
|
|
if t.columnOffset < 0 {
|
|
t.columnOffset = 0
|
|
}
|
|
}
|
|
}
|
|
case tcell.KeyDown: // Move one row down.
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
// Regular movement.
|
|
column := t.cursor.column
|
|
t.moveCursor(t.cursor.row+1, t.cursor.column)
|
|
t.cursor.column = column
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
} else {
|
|
// Just scroll.
|
|
t.rowOffset++
|
|
if t.rowOffset >= len(t.lineStarts) {
|
|
t.extendLines(t.lastWidth, t.rowOffset)
|
|
if t.rowOffset >= len(t.lineStarts) {
|
|
t.rowOffset = len(t.lineStarts) - 1
|
|
if t.rowOffset < 0 {
|
|
t.rowOffset = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case tcell.KeyUp: // Move one row up.
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
// Regular movement.
|
|
column := t.cursor.column
|
|
t.moveCursor(t.cursor.row-1, t.cursor.column)
|
|
t.cursor.column = column
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
} else {
|
|
// Just scroll.
|
|
t.rowOffset--
|
|
if t.rowOffset < 0 {
|
|
t.rowOffset = 0
|
|
}
|
|
}
|
|
case tcell.KeyHome, tcell.KeyCtrlA: // Move to the start of the line.
|
|
t.moveCursor(t.cursor.row, 0)
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyEnd, tcell.KeyCtrlE: // Move to the end of the line.
|
|
t.moveCursor(t.cursor.row, -1)
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyPgDn, tcell.KeyCtrlF: // Move one page down.
|
|
column := t.cursor.column
|
|
t.moveCursor(t.cursor.row+t.lastHeight, t.cursor.column)
|
|
t.cursor.column = column
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyPgUp, tcell.KeyCtrlB: // Move one page up.
|
|
column := t.cursor.column
|
|
t.moveCursor(t.cursor.row-t.lastHeight, t.cursor.column)
|
|
t.cursor.column = column
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyEnter: // Insert a newline.
|
|
from, to, row := t.getSelection()
|
|
t.cursor.pos = t.replace(from, to, NewLine, t.lastAction == taActionTypeSpace)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
newLastAction = taActionTypeSpace
|
|
case tcell.KeyTab: // Insert a tab character. It will be rendered as TabSize spaces.
|
|
// But forwarding takes precedence.
|
|
if t.finished != nil {
|
|
t.finished(key)
|
|
return
|
|
}
|
|
|
|
from, to, row := t.getSelection()
|
|
t.cursor.pos = t.replace(from, to, "\t", t.lastAction == taActionTypeSpace)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
newLastAction = taActionTypeSpace
|
|
case tcell.KeyBacktab, tcell.KeyEscape: // Only used in forms.
|
|
if t.finished != nil {
|
|
t.finished(key)
|
|
return
|
|
}
|
|
case tcell.KeyRune:
|
|
if event.Modifiers()&tcell.ModAlt > 0 {
|
|
// We accept some Alt- key combinations.
|
|
switch event.Rune() {
|
|
case 'f':
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.moveWordRight(false, true)
|
|
t.selectionStart = t.cursor
|
|
} else {
|
|
t.moveWordRight(true, true)
|
|
}
|
|
case 'b':
|
|
t.moveWordLeft(true)
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
}
|
|
} else {
|
|
// Other keys are simply accepted as regular characters.
|
|
r := event.Rune()
|
|
from, to, row := t.getSelection()
|
|
newLastAction = taActionTypeNonSpace
|
|
if unicode.IsSpace(r) {
|
|
newLastAction = taActionTypeSpace
|
|
}
|
|
t.cursor.pos = t.replace(from, to, string(r), newLastAction == t.lastAction || t.lastAction == taActionTypeNonSpace && newLastAction == taActionTypeSpace)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete backwards. tcell.KeyBackspace is the same as tcell.CtrlH.
|
|
from, to, row := t.getSelection()
|
|
if from != to {
|
|
// Simply delete the current selection.
|
|
t.cursor.pos = t.replace(from, to, "", false)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
break
|
|
}
|
|
|
|
beforeCursor := t.cursor
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
// Move the cursor back by one grapheme cluster.
|
|
if t.cursor.actualColumn == 0 {
|
|
// Move to the end of the previous row.
|
|
if t.cursor.row > 0 {
|
|
t.moveCursor(t.cursor.row-1, -1)
|
|
}
|
|
} else {
|
|
// Move one grapheme cluster to the left.
|
|
t.moveCursor(t.cursor.row, t.cursor.actualColumn-1)
|
|
}
|
|
newLastAction = taActionBackspace
|
|
} else {
|
|
// Move the cursor back by one word.
|
|
t.moveWordLeft(false)
|
|
}
|
|
|
|
// Remove that last grapheme cluster.
|
|
if t.cursor.pos != beforeCursor.pos {
|
|
t.cursor, beforeCursor = beforeCursor, t.cursor // So we put the right position on the stack.
|
|
t.cursor.pos = t.replace(beforeCursor.pos, t.cursor.pos, "", t.lastAction == taActionBackspace) // Delete the character.
|
|
t.cursor.row = -1
|
|
t.truncateLines(beforeCursor.row - 1)
|
|
t.findCursor(true, beforeCursor.row-1)
|
|
}
|
|
t.selectionStart = t.cursor
|
|
case tcell.KeyDelete, tcell.KeyCtrlD: // Delete forward.
|
|
from, to, row := t.getSelection()
|
|
if from != to {
|
|
// Simply delete the current selection.
|
|
t.cursor.pos = t.replace(from, to, "", false)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
break
|
|
}
|
|
|
|
if t.cursor.pos[0] != 1 {
|
|
_, _, _, _, endPos, _ := t.step("", t.cursor.pos, t.cursor.pos)
|
|
t.cursor.pos = t.replace(t.cursor.pos, endPos, "", t.lastAction == taActionDelete) // Delete the character.
|
|
t.cursor.pos[2] = endPos[2]
|
|
t.truncateLines(t.cursor.row - 1)
|
|
t.findCursor(true, t.cursor.row)
|
|
newLastAction = taActionDelete
|
|
}
|
|
t.selectionStart = t.cursor
|
|
case tcell.KeyCtrlK: // Delete everything under and to the right of the cursor until before the next newline character.
|
|
pos := t.cursor.pos
|
|
endPos := pos
|
|
var cluster, text string
|
|
for pos[0] != 1 {
|
|
var boundaries int
|
|
oldPos := pos
|
|
cluster, text, boundaries, _, pos, endPos = t.step(text, pos, endPos)
|
|
if boundaries&uniseg.MaskLine == uniseg.LineMustBreak {
|
|
if uniseg.HasTrailingLineBreakInString(cluster) {
|
|
pos = oldPos
|
|
}
|
|
break
|
|
}
|
|
}
|
|
t.cursor.pos = t.replace(t.cursor.pos, pos, "", false)
|
|
row := t.cursor.row
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
case tcell.KeyCtrlW: // Delete from the start of the current word to the left of the cursor.
|
|
pos := t.cursor.pos
|
|
t.moveWordLeft(true)
|
|
t.cursor.pos = t.replace(t.cursor.pos, pos, "", false)
|
|
row := t.cursor.row - 1
|
|
t.cursor.row = -1
|
|
t.truncateLines(row)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
case tcell.KeyCtrlU: // Delete the current line.
|
|
t.deleteLine()
|
|
t.selectionStart = t.cursor
|
|
case tcell.KeyCtrlL: // Select everything.
|
|
t.selectionStart.row, t.selectionStart.column, t.selectionStart.actualColumn = 0, 0, 0
|
|
t.selectionStart.pos = [3]int{t.spans[0].next, 0, -1}
|
|
row := t.cursor.row
|
|
t.cursor.row = -1
|
|
t.cursor.pos = [3]int{1, 0, -1}
|
|
t.findCursor(false, row)
|
|
case tcell.KeyCtrlQ: // Copy to clipboard.
|
|
if t.cursor != t.selectionStart {
|
|
t.copyToClipboard(t.getSelectedText())
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyCtrlX: // Cut to clipboard.
|
|
if t.cursor != t.selectionStart {
|
|
t.copyToClipboard(t.getSelectedText())
|
|
from, to, row := t.getSelection()
|
|
t.cursor.pos = t.replace(from, to, "", false)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
}
|
|
case tcell.KeyCtrlV: // Paste from clipboard.
|
|
from, to, row := t.getSelection()
|
|
t.cursor.pos = t.replace(from, to, t.pasteFromClipboard(), false)
|
|
t.cursor.row = -1
|
|
t.truncateLines(row - 1)
|
|
t.findCursor(true, row)
|
|
t.selectionStart = t.cursor
|
|
case tcell.KeyCtrlZ: // Undo.
|
|
if t.nextUndo <= 0 {
|
|
break
|
|
}
|
|
for t.nextUndo > 0 {
|
|
t.nextUndo--
|
|
undo := t.undoStack[t.nextUndo]
|
|
t.spans[undo.originalBefore], t.spans[undo.before] = t.spans[undo.before], t.spans[undo.originalBefore]
|
|
t.spans[undo.originalAfter], t.spans[undo.after] = t.spans[undo.after], t.spans[undo.originalAfter]
|
|
t.cursor.pos, t.undoStack[t.nextUndo].pos = undo.pos, t.cursor.pos
|
|
t.length, t.undoStack[t.nextUndo].length = undo.length, t.length
|
|
if !undo.continuation {
|
|
break
|
|
}
|
|
}
|
|
t.cursor.row = -1
|
|
t.truncateLines(0) // This is why Undo is expensive for large texts. (t.lineStarts can get largely unusable after an undo.)
|
|
t.findCursor(true, 0)
|
|
t.selectionStart = t.cursor
|
|
if t.changed != nil {
|
|
defer t.changed()
|
|
}
|
|
case tcell.KeyCtrlY: // Redo.
|
|
if t.nextUndo >= len(t.undoStack) {
|
|
break
|
|
}
|
|
for t.nextUndo < len(t.undoStack) {
|
|
undo := t.undoStack[t.nextUndo]
|
|
t.spans[undo.originalBefore], t.spans[undo.before] = t.spans[undo.before], t.spans[undo.originalBefore]
|
|
t.spans[undo.originalAfter], t.spans[undo.after] = t.spans[undo.after], t.spans[undo.originalAfter]
|
|
t.cursor.pos, t.undoStack[t.nextUndo].pos = undo.pos, t.cursor.pos
|
|
t.length, t.undoStack[t.nextUndo].length = undo.length, t.length
|
|
t.nextUndo++
|
|
if t.nextUndo < len(t.undoStack) && !t.undoStack[t.nextUndo].continuation {
|
|
break
|
|
}
|
|
}
|
|
t.cursor.row = -1
|
|
t.truncateLines(0) // This is why Redo is expensive for large texts. (t.lineStarts can get largely unusable after an undo.)
|
|
t.findCursor(true, 0)
|
|
t.selectionStart = t.cursor
|
|
if t.changed != nil {
|
|
defer t.changed()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// MouseHandler returns the mouse handler for this primitive.
|
|
func (t *TextArea) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) {
|
|
return t.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) {
|
|
if t.disabled {
|
|
return false, nil
|
|
}
|
|
|
|
x, y := event.Position()
|
|
rectX, rectY, _, _ := t.GetInnerRect()
|
|
if !t.InRect(x, y) {
|
|
return false, nil
|
|
}
|
|
|
|
// Trigger a "moved" event at the end if requested.
|
|
if t.moved != nil {
|
|
selectionStart, cursor := t.selectionStart, t.cursor
|
|
defer func() {
|
|
if selectionStart != t.selectionStart || cursor != t.cursor {
|
|
t.moved()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Turn mouse coordinates into text coordinates.
|
|
labelWidth := t.labelWidth
|
|
if labelWidth == 0 && t.label != "" {
|
|
labelWidth = TaggedStringWidth(t.label)
|
|
}
|
|
column := x - rectX - labelWidth
|
|
row := y - rectY
|
|
if !t.wrap {
|
|
column += t.columnOffset
|
|
}
|
|
row += t.rowOffset
|
|
|
|
// Process mouse actions.
|
|
switch action {
|
|
case MouseLeftDown:
|
|
t.moveCursor(row, column)
|
|
if event.Modifiers()&tcell.ModShift == 0 {
|
|
t.selectionStart = t.cursor
|
|
}
|
|
setFocus(t)
|
|
consumed = true
|
|
capture = t
|
|
t.dragging = true
|
|
case MouseMove:
|
|
if !t.dragging {
|
|
break
|
|
}
|
|
t.moveCursor(row, column)
|
|
consumed = true
|
|
case MouseLeftUp:
|
|
t.moveCursor(row, column)
|
|
consumed = true
|
|
capture = nil
|
|
t.dragging = false
|
|
case MouseLeftDoubleClick: // Select word.
|
|
// Left down/up was already triggered so we are at the correct
|
|
// position.
|
|
t.moveWordLeft(false)
|
|
t.selectionStart = t.cursor
|
|
t.moveWordRight(true, false)
|
|
consumed = true
|
|
case MouseScrollUp:
|
|
if t.rowOffset > 0 {
|
|
t.rowOffset--
|
|
}
|
|
consumed = true
|
|
case MouseScrollDown:
|
|
t.rowOffset++
|
|
if t.rowOffset >= len(t.lineStarts) {
|
|
t.rowOffset = len(t.lineStarts) - 1
|
|
if t.rowOffset < 0 {
|
|
t.rowOffset = 0
|
|
}
|
|
}
|
|
consumed = true
|
|
case MouseScrollLeft:
|
|
if t.columnOffset > 0 {
|
|
t.columnOffset--
|
|
}
|
|
consumed = true
|
|
case MouseScrollRight:
|
|
t.columnOffset++
|
|
if t.columnOffset >= t.widestLine {
|
|
t.columnOffset = t.widestLine - 1
|
|
if t.columnOffset < 0 {
|
|
t.columnOffset = 0
|
|
}
|
|
}
|
|
consumed = true
|
|
}
|
|
|
|
return
|
|
})
|
|
}
|