| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327 |
- 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
- })
- }
|