Implemented basic text area printing.

This commit is contained in:
Oliver 2022-07-30 15:59:31 +01:00
parent 6537221da8
commit ac1f564949
6 changed files with 592 additions and 71 deletions

2
box.go
View File

@ -125,7 +125,7 @@ func (b *Box) GetInnerRect() (int, int, int, int) {
// if this primitive is part of a layout (e.g. Flex, Grid) or if it was added // if this primitive is part of a layout (e.g. Flex, Grid) or if it was added
// like this: // like this:
// //
// application.SetRoot(b, true) // application.SetRoot(p, true)
func (b *Box) SetRect(x, y, width, height int) { func (b *Box) SetRect(x, y, width, height int) {
b.x = x b.x = x
b.y = y b.y = y

4
go.sum
View File

@ -10,8 +10,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk= github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk=
github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@ -418,7 +418,7 @@ func (i *InputField) Draw(screen tcell.Screen) {
// We have enough space for the full text. // We have enough space for the full text.
printWithStyle(screen, Escape(text), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true) printWithStyle(screen, Escape(text), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true)
i.offset = 0 i.offset = 0
iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if textPos >= i.cursorPos { if textPos >= i.cursorPos {
return true return true
} }
@ -440,7 +440,7 @@ func (i *InputField) Draw(screen tcell.Screen) {
shiftLeft = subWidth - fieldWidth + 1 shiftLeft = subWidth - fieldWidth + 1
} }
currentOffset := i.offset currentOffset := i.offset
iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if textPos >= currentOffset { if textPos >= currentOffset {
if shiftLeft > 0 { if shiftLeft > 0 {
i.offset = textPos + textWidth i.offset = textPos + textWidth
@ -520,7 +520,7 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
}) })
} }
moveRight := func() { moveRight := func() {
iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
i.cursorPos += textWidth i.cursorPos += textWidth
return true return true
}) })
@ -616,7 +616,7 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
i.offset = 0 i.offset = 0
} }
case tcell.KeyDelete, tcell.KeyCtrlD: // Delete character after the cursor. case tcell.KeyDelete, tcell.KeyCtrlD: // Delete character after the cursor.
iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
i.text = i.text[:i.cursorPos] + i.text[i.cursorPos+textWidth:] i.text = i.text[:i.cursorPos] + i.text[i.cursorPos+textWidth:]
return true return true
}) })
@ -688,7 +688,7 @@ func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventM
if action == MouseLeftClick && y == rectY { if action == MouseLeftClick && y == rectY {
// Determine where to place the cursor. // Determine where to place the cursor.
if x >= i.fieldX { if x >= i.fieldX {
if !iterateString(i.text[i.offset:], func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool { if !iterateString(i.text[i.offset:], func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth, boundaries int) bool {
if x-i.fieldX < screenPos+screenWidth { if x-i.fieldX < screenPos+screenWidth {
i.cursorPos = textPos + i.offset i.cursorPos = textPos + i.offset
return true return true

View File

@ -1,6 +1,23 @@
package tview package tview
import "github.com/gdamore/tcell/v2" import (
"strings"
"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
)
var ( var (
// NewLine is the string sequence to be inserted when hitting the Enter key // NewLine is the string sequence to be inserted when hitting the Enter key
@ -9,9 +26,40 @@ var (
NewLine = "\n" NewLine = "\n"
) )
// textAreaSpan represents a range of text in a text area. The text area widget
// roughly follows the concept of Piece Chains outline 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
// two-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.
//
// A range of text is represented by a span range which is a starting position
// ([2]int) and an ending position ([2]int). 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 therefore always
// [2]int{1, 0}, position 0 of the ending sentinel.
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.
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 must be 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.
offset, length int
}
// TextArea implements a simple text editor for multi-line text. Multi-color // TextArea implements a simple text editor for multi-line text. Multi-color
// text is not supported. Text can be optionally word-wrapped to fit the // text is not supported. Word-wrapping is enabled by default but can be turned
// available width. // off or be changed to character-wrapping.
// //
// Navigation and Editing // Navigation and Editing
// //
@ -33,11 +81,6 @@ var (
// - Alt-Right arrow: Scroll the page to the left, leaving the cursor in its // - Alt-Right arrow: Scroll the page to the left, leaving the cursor in its
// position. Ignored if wrapping is enabled. // position. Ignored if wrapping is enabled.
// //
// If the mouse is enabled, clicking on a screen cell will move the cursor to
// that location or to the end of the line if past the last character. Turning
// the scroll wheel will scroll the text. Text can also be selected by moving
// the mouse while pressing the left mouse button (see below for details).
//
// Entering a character (rune) will insert it at the current cursor location. // Entering a character (rune) will insert it at the current cursor location.
// Subsequent characters are moved accordingly. If the cursor is outside the // Subsequent characters are moved accordingly. If the cursor is outside the
// visible area, any changes to the text will move it into the visible area. The // visible area, any changes to the text will move it into the visible area. The
@ -55,38 +98,84 @@ var (
// character before the cursor up until the next newline character. This may // character before the cursor up until the next newline character. This may
// span multiple lines if wrapping is enabled. // span multiple lines if wrapping is enabled.
// //
// Text can be selected by moving the cursor while holding the Shift key or // Text can be selected by moving the cursor while holding the Shift key. Thus
// dragging the mouse. When text is selected: // when text is selected:
// //
// - Entering a character (rune) will replace the selected text with the new // - Entering a character (rune) will replace the selected text with the new
// character. (The Enter key is an exception, see further below.) // character.
// - Backspace, delete: Delete the selected text. // - Backspace, delete: Delete the selected text.
// - Enter: Copy the selected text into the clipboard, unselect the 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-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 // - 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. // selected, the clipboard text will be inserted at the cursor location.
// //
// The default clipboard is an internal text buffer, i.e. the operating system's // The default clipboard is an internal text buffer, i.e. the operating system's
// clipboard is not used. The Enter key was chosen for the "copy" function // clipboard is not used. 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 // 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 // application frees up the global Ctrl-C key and you want to bind it to the
// "copy to clipboard" function, you may use SetInputCapture() to override the // "copy to clipboard" function, you may use SetInputCapture() to override the
// Enter/Ctrl-C keys to implement copying to the clipboard. // Ctrl-Q key to implement copying to the clipboard.
// //
// Similarly, if you want to implement your own clipboard (or make use of your // Similarly, if you want to implement your own clipboard (or make use of your
// operating system's clipboard), you can also use SetInputCapture() to override // operating system's clipboard), you can also use SetInputCapture() to override
// the key binds for copy, cut, and paste. The GetSelection(), ReplaceText(), // the key binds for copy, cut, and paste. The GetSelection(), ReplaceText(),
// and SetSelection() provide all the functionality needed for your own // and SetSelection() provide all the functionality needed for your own
// clipboard. // clipboard. TODO: This will need to be reviewed.
//
// The text area also supports Undo:
// //
// - Ctrl-Z: Undo the last change. // - Ctrl-Z: Undo the last change.
// - Ctrl-Y: Redo the last change. // - Ctrl-Y: Redo the last Undo change.
//
// If the mouse is enabled, clicking on a screen cell will move the cursor to
// that location or to the end of the line if past the last character. Turning
// the scroll wheel will scroll the text. Text can also be selected by moving
// the mouse while pressing the left mouse button (see below for details). The
// word underneath the mouse cursor can be selected by double-clicking.
type TextArea struct { type TextArea struct {
*Box *Box
// The text to be shown in the text area when it is empty. // The text to be shown in the text area when it is empty.
placeholder string placeholder string
// Styles:
// 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 placeholder text.
placeholderStyle tcell.Style
// Text manipulation related fields:
// The text area's text prior to any editing.
initialText string
// Any text that's been added by the user at some point.
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.
spans []textAreaSpan
// The undo stack's items are the first of two consecutive indices into the
// spans slice. The first referenced span is a copy of the one before the
// modified span range, thse second referenced span is a copy of the one
// after the modified span range.
undoStack []int
// Display, navigation, and cursor related fields:
// If set to true, lines that are longer than the available width are // 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 // wrapped onto the next line. If set to false, any characters beyond the
// available width are discarded. // available width are discarded.
@ -96,32 +185,49 @@ type TextArea struct {
// after punctuation characters. // after punctuation characters.
wordWrap bool wordWrap bool
// The maximum number of bytes allowed in the text area. If 0, there is no
// limit.
maxLength int
// The index of the first line shown in the text area. // The index of the first line shown in the text area.
lineOffset int lineOffset int
// The number of cells to be skipped on each line (not used in wrap mode). // The number of cells to be skipped on each line (not used in wrap mode).
columnOffset int columnOffset int
// The height of the content the last time the text area was drawn. // The inner width of the text area the last time it was drawn.
pageSize int lastWidth int
// The style of the text. Background colors different from the Box's // Text positions and states of the start of lines. Each element is a span
// background color may lead to unwanted artefacts. // position (see textAreaSpan) and a state as returned by uniseg.Step(). Not
textStyle tcell.Style // all lines of the text may be contained at any time, extend as needed with
// the TextArea.extendLines() function.
lineStarts [][3]int
} }
// NewTextArea returns a new text area. // NewTextArea returns a new text area. For an empty text area, provide an empty
func NewTextArea() *TextArea { // string.
return &TextArea{ func NewTextArea(text string) *TextArea {
Box: NewBox(), t := &TextArea{
wrap: true, Box: NewBox(),
wordWrap: true, wrap: true,
textStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.PrimaryTextColor), wordWrap: true,
placeholderStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.TertiaryTextColor),
textStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.PrimaryTextColor),
initialText: text,
spans: make([]textAreaSpan, 2, pieceChainMinCap), // We reserve some space to avoid reallocations right when editing starts.
} }
t.editText.Grow(editBufferMinCap)
t.spans[0] = textAreaSpan{previous: -1, next: 1}
t.spans[1] = textAreaSpan{previous: 0, next: -1}
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
t.length = len(text)
}
return t
} }
// SetWrap sets the flag that, if true, leads to lines that are longer than the // SetWrap sets the flag that, if true, leads to lines that are longer than the
@ -143,26 +249,439 @@ func (t *TextArea) SetWordWrap(wrapOnWords bool) *TextArea {
return t return t
} }
// SetSelection selects the text starting at index "start" and ending just // SetPlaceholder sets the text to be displayed when the text area is empty.
// before the index "end". Any previous selection is discarded. If "start" and func (t *TextArea) SetPlaceholder(placeholder string) *TextArea {
// "end" are the same, currently selected text is unselected. t.placeholder = placeholder
func (t *TextArea) SetSelection(start, end int) *TextArea {
//TODO
return t return t
} }
// GetSelection returns the currently selected text or an empty string if no // SetMaxLength sets the maximum number of bytes allowed in the text area. If 0,
// text is currently selected. The start and end indices (a half-open range) // there is no limit.
// into the text area's text are also returned. func (t *TextArea) SetMaxLength(maxLength int) *TextArea {
func (t *TextArea) GetSelection() (string, int, int) { t.maxLength = maxLength
return "", 0, 0 //TODO
}
// ReplaceText replaces the text in the given range with the given text. The
// range is half-open, that is, the character at the "end" index is not
// replaced. If the provided range overlaps with a selection, the selected text
// will be unselected.
func (t *TextArea) ReplaceText(start, end int, text string) *TextArea {
//TODO
return t return t
} }
// 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
}
// SetPlaceholderStyle sets the style of the placeholder text.
func (t *TextArea) SetPlaceholderStyle(style tcell.Style) *TextArea {
t.placeholderStyle = style
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. See textAreaSpan for information about text positions and span
// ranges.
//
// This function does not generate Undo events. Undo events are generated
// elsewhere, when the user changes their type of edit.
func (t *TextArea) replace(deleteStart, deleteEnd [2]int, insert string) {
// Check max length.
if t.maxLength > 0 && t.length+len(insert) > t.maxLength {
return
}
// Delete.
for deleteStart[0] != deleteEnd[0] {
if deleteStart[1] == 0 {
// Delete this entire span.
deleteStart[0] = t.deleteSpan(deleteStart[0])
deleteStart[1] = 0
} else {
// Delete a partial span at the end.
if t.spans[deleteStart[0]].length < 0 {
// Initial text span. Has negative length.
t.length -= -t.spans[deleteStart[0]].length - deleteStart[1]
t.spans[deleteStart[0]].length = -deleteStart[1]
} else {
// Edit buffer span. Has positive length.
t.length -= t.spans[deleteStart[0]].length - deleteStart[1]
t.spans[deleteStart[0]].length = deleteStart[1]
}
deleteStart[0] = t.spans[deleteStart[0]].next
deleteStart[1] = 0
}
} // At this point, deleteStart[0] == deleteEnd[0].
if deleteEnd[1] > deleteStart[1] {
if deleteStart[1] == 0 {
// Delete a partial span at the beginning.
t.length -= deleteEnd[1]
if t.spans[deleteEnd[0]].length < 0 {
// Initial text span. Has negative length.
t.spans[deleteEnd[0]].length += deleteEnd[1]
} else {
// Edit buffer span. Has positive length.
t.spans[deleteEnd[0]].length -= deleteEnd[1]
}
t.spans[deleteEnd[0]].offset += deleteEnd[1]
} else {
// Delete in the middle by splitting the span.
deleteStart[0] = t.splitSpan(deleteStart[0], deleteStart[1])
deleteStart[1] = 0
}
}
// Insert.
if len(insert) > 0 {
spanIndex, offset := deleteStart[0], deleteStart[1]
span := t.spans[spanIndex]
if offset == 0 {
previousSpan := t.spans[span.previous]
if previousSpan.length > 0 && previousSpan.offset+previousSpan.length == t.editText.Len() {
// We can simply append to the edit buffer.
length, _ := t.editText.WriteString(insert)
previousSpan.length += length
t.length += length
} else {
// Insert a new span.
t.insertSpan(insert, spanIndex)
}
} else {
// Split and insert.
spanIndex = t.splitSpan(spanIndex, offset)
t.insertSpan(insert, spanIndex)
}
}
}
// deleteSpan removes the span with the given index from the piece chain. It
// returns the index of the span after the deleted span (or the provided index
// if no span was deleted due to an invalid span index).
//
// This function also adjusts TextArea.length.
func (t *TextArea) deleteSpan(index int) int {
if index < 2 || index >= len(t.spans) {
return index
}
// Remove from piece chain.
previous := t.spans[index].previous
next := t.spans[index].next
t.spans[previous].next = next
t.spans[next].previous = previous
// Adjust total length.
length := t.spans[index].length
if length < 0 {
length = -length
}
t.length -= length
return next
}
// splitSpan splits the span with the given index at the given offset into two
// spans. It returns the index of the span after the split or the provided
// index if no span was split due to an invalid span index or an invalid
// offset.
func (t *TextArea) splitSpan(index, offset int) int {
if index < 2 || index >= len(t.spans) || offset <= 0 || offset >= t.spans[index].length {
return index
}
// Make a new trailing span.
span := t.spans[index]
newSpan := textAreaSpan{
previous: index,
next: span.next,
offset: span.offset + offset,
}
// Adjust lengths.
if span.length < 0 {
// Initial text span. Has negative length.
newSpan.length = span.length + offset
span.length = -offset
} else {
// Edit buffer span. Has positive length.
newSpan.length = span.length - offset
span.length = offset
}
// Insert it.
newIndex := len(t.spans)
t.spans = append(t.spans, newSpan)
t.spans[span.next].previous = newIndex
span.next = newIndex
return newIndex
}
// insertSpan inserts the a span with the given text into the piece chain before
// the span with the given index and returns the index of the newly inserted
// span. If index <= 0, nothing happens and 1 is returned. The text is appended
// to the edit buffer. The length of the text is added to TextArea.length.
func (t *TextArea) insertSpan(text string, index int) int {
if index < 1 || index >= len(t.spans) {
return 1
}
// Make a new span.
nextSpan := t.spans[index]
span := textAreaSpan{
previous: nextSpan.previous,
next: index,
offset: t.editText.Len(),
}
span.length, _ = t.editText.WriteString(text)
// Insert into piece chain.
newIndex := len(t.spans)
t.spans[nextSpan.previous].next = newIndex
nextSpan.previous = newIndex
t.spans = append(t.spans, span)
// Adjust text area length.
t.length += span.length
return newIndex
}
// 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.
}
// 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.
if t.lastWidth != width && t.lineStarts != nil {
t.lineStarts = t.lineStarts[:0]
t.lastWidth = width
}
t.extendLines(width, t.lineOffset+height)
if len(t.lineStarts) <= t.lineOffset {
return // It's scrolled out of view.
}
// Print the text.
var cluster, text string
line := t.lineOffset
pos := t.lineStarts[line]
endPos := pos
posX, posY := x, y
for pos[0] != 1 {
cluster, text, _, pos, endPos = t.step(text, pos, endPos)
clusterWidth := stringWidth(cluster)
runes := []rune(cluster)
if posX+clusterWidth <= x+width {
screen.SetContent(posX, posY, runes[0], runes[1:], t.textStyle)
}
posX += clusterWidth
if line+1 < len(t.lineStarts) && t.lineStarts[line+1] == pos {
// We must break over.
posY++
if posY >= y+height {
break // Done.
}
posX = x
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
})
}
// extendLines traverses the current text and extends t.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 t.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, boundaries int
lastGraphemeBreak, lastLineBreak [3]int
widthSinceLineBreak int
)
for pos[0] != 1 {
// Get the next grapheme cluster.
cluster, text, boundaries, pos, endPos = t.step(text, pos, endPos)
clusterWidth := stringWidth(cluster)
lineWidth += clusterWidth
widthSinceLineBreak += clusterWidth
// Any line breaks?
if !t.wrap || lineWidth <= width {
if pos[0] != 1 && boundaries&uniseg.MaskLine == uniseg.LineMustBreak {
// We must break over.
t.lineStarts = append(t.lineStarts, pos)
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)
lineWidth = clusterWidth
lastLineBreak = [3]int{}
}
} else { // t.wordWrap && lastLineBreak != [3]int{}
// Break after last line break opportunity.
t.lineStarts = append(t.lineStarts, lastLineBreak)
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
}
}
}
// step is similar to 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
// uniseg.StepString(). 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 not
// used.
func (t *TextArea) step(text string, pos, endPos [3]int) (cluster, rest string, boundaries 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
pos[1] -= span.length
span = t.spans[pos[0]]
}
return cluster, text, boundaries, pos, endPos
}

View File

@ -1143,7 +1143,7 @@ func (t *TextView) Draw(screen tcell.Screen) {
// Print the line. // Print the line.
if y+line-t.lineOffset >= 0 { if y+line-t.lineOffset >= 0 {
var colorPos, regionPos, escapePos, tagOffset, skipped int var colorPos, regionPos, escapePos, tagOffset, skipped int
iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
// Process tags. // Process tags.
for { for {
if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {

26
util.go
View File

@ -262,7 +262,7 @@ func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth,
foregroundColor, backgroundColor, attributes string foregroundColor, backgroundColor, attributes string
) )
originalStyle := style originalStyle := style
iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
// Update color/escape tag offset and style. // Update color/escape tag offset and style.
if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] { if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos]) foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
@ -305,7 +305,7 @@ func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth,
for rightIndex-1 > leftIndex && strippedWidth-skipWidth-choppedLeft-choppedRight > maxWidth { for rightIndex-1 > leftIndex && strippedWidth-skipWidth-choppedLeft-choppedRight > maxWidth {
if skipWidth > 0 || choppedLeft < choppedRight { if skipWidth > 0 || choppedLeft < choppedRight {
// Iterate on the left by one character. // Iterate on the left by one character.
iterateString(strippedText[leftIndex:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(strippedText[leftIndex:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if skipWidth > 0 { if skipWidth > 0 {
skipWidth -= screenWidth skipWidth -= screenWidth
strippedWidth -= screenWidth strippedWidth -= screenWidth
@ -369,7 +369,7 @@ func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth,
drawn, drawnWidth, colorPos, escapePos, tagOffset, from, to int drawn, drawnWidth, colorPos, escapePos, tagOffset, from, to int
foregroundColor, backgroundColor, attributes string foregroundColor, backgroundColor, attributes string
) )
iterateString(strippedText, func(main rune, comb []rune, textPos, length, screenPos, screenWidth int) bool { iterateString(strippedText, func(main rune, comb []rune, textPos, length, screenPos, screenWidth, boundaries int) bool {
// Skip character if necessary. // Skip character if necessary.
if skipWidth > 0 { if skipWidth > 0 {
skipWidth -= screenWidth skipWidth -= screenWidth
@ -496,7 +496,7 @@ func WordWrap(text string, width int) (lines []string) {
} }
return substr return substr
} }
iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
// Handle tags. // Handle tags.
for { for {
if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {
@ -582,16 +582,18 @@ func Escape(text string) string {
// Unicode code points of the character (the first rune and any combining runes // Unicode code points of the character (the first rune and any combining runes
// which may be nil if there aren't any), the starting position (in bytes) // which may be nil if there aren't any), the starting position (in bytes)
// within the original string, its length in bytes, the screen position of the // within the original string, its length in bytes, the screen position of the
// character, and the screen width of it. The iteration stops if the callback // character, the screen width of it, and a boundaries value which includes
// returns true. This function returns true if the iteration was stopped before // word/sentence boundary or line break information (see the
// the last character. // github.com/rivo/uniseg package, Step() function, for more information). The
func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool { // iteration stops if the callback returns true. This function returns true if
var screenPos, textPos int // the iteration was stopped before the last character.
func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool) bool {
var screenPos, textPos, boundaries int
state := -1 state := -1
for len(text) > 0 { for len(text) > 0 {
var cluster string var cluster string
cluster, text, _, state = uniseg.FirstGraphemeClusterInString(text, state) cluster, text, boundaries, state = uniseg.StepString(text, state)
var width int var width int
runes := make([]rune, 0, len(cluster)) runes := make([]rune, 0, len(cluster))
@ -608,7 +610,7 @@ func iterateString(text string, callback func(main rune, comb []rune, textPos, t
comb = runes[1:] comb = runes[1:]
} }
if callback(runes[0], comb, textPos, len(cluster), screenPos, width) { if callback(runes[0], comb, textPos, len(cluster), screenPos, width, boundaries) {
return true return true
} }
@ -636,7 +638,7 @@ func iterateStringReverse(text string, callback func(main rune, comb []rune, tex
// Create the grapheme clusters. // Create the grapheme clusters.
var clusters []cluster var clusters []cluster
iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool { iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth, boundaries int) bool {
clusters = append(clusters, cluster{ clusters = append(clusters, cluster{
main: main, main: main,
comb: comb, comb: comb,