Implemented string parser and migrated all widgets but TextView.

pull/873/head
Oliver 9 months ago
parent ccc2c81197
commit caea67a4ef

@ -31,7 +31,7 @@ type ansi struct {
}
// ANSIWriter returns an io.Writer which translates any ANSI escape codes
// written to it into tview color tags. Other escape codes don't have an effect
// written to it into tview style tags. Other escape codes don't have an effect
// and are simply removed. The translated text is written to the provided
// writer.
func ANSIWriter(writer io.Writer) io.Writer {
@ -45,7 +45,7 @@ func ANSIWriter(writer io.Writer) io.Writer {
}
// Write parses the given text as a string of runes, translates ANSI escape
// codes to color tags and writes them to the output writer.
// codes to style tags and writes them to the output writer.
func (a *ansi) Write(text []byte) (int, error) {
defer func() {
a.buffer.Reset()
@ -249,7 +249,7 @@ func (a *ansi) Write(text []byte) (int, error) {
}
// TranslateANSI replaces ANSI escape sequences found in the provided string
// with tview's color tags and returns the resulting string.
// with tview's style tags and returns the resulting string.
func TranslateANSI(text string) string {
var buffer bytes.Buffer
writer := ANSIWriter(&buffer)

@ -387,9 +387,13 @@ func (b *Box) DrawForSubclass(screen tcell.Screen, p Primitive) {
if b.title != "" && b.width >= 4 {
printed, _ := Print(screen, b.title, b.x+1, b.y, b.width-2, b.titleAlign, b.titleColor)
if len(b.title)-printed > 0 && printed > 0 {
_, _, style, _ := screen.GetContent(b.x+b.width-2, b.y)
xEllipsis := b.x + b.width - 2
if b.titleAlign == AlignRight {
xEllipsis = b.x + 1
}
_, _, style, _ := screen.GetContent(xEllipsis, b.y)
fg, _, _ := style.Decompose()
Print(screen, string(SemigraphicsHorizontalEllipsis), b.x+b.width-2, b.y, 1, AlignLeft, fg)
Print(screen, string(SemigraphicsHorizontalEllipsis), xEllipsis, b.y, 1, AlignLeft, fg)
}
}
}

@ -10,7 +10,7 @@ func main() {
box := tview.NewBox().
SetBorder(true).
SetBorderAttributes(tcell.AttrBold).
SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:] [::bu]title")
SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:-] [::bu]title")
if err := tview.NewApplication().SetRoot(box, true).Run(); err != nil {
panic(err)
}

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

@ -7,7 +7,7 @@ import (
"github.com/rivo/tview"
)
const colorsText = `You can use color tags almost everywhere to partially change the color of a string. Simply put a color name or hex string in square brackets to change [::s]all[::-]the following characters' color. H[green]er[white]e i[yellow]s a[darkcyan]n ex[red]amp[white]le. [::i]The [black:red]tags [black:green]look [black:yellow]like [::u]this: [blue:yellow:u[] [#00ff00[]`
const colorsText = `You can use style tags almost everywhere to partially change the color of a string. Simply put a color name or hex string in square brackets to change [::s]all[::-]the following characters' color. H[green]er[white]e i[yellow]s a[darkcyan]n ex[red]amp[white]le. [::i]The [black:red]tags [black:green]look [black:yellow]like [::u]this: [blue:yellow:u[] or [#00ff00[]. [:::https://github.com/rivo/tview]Hyperlinks[:::-] are also supported.`
// Colors demonstrates how to use colors.
func Colors(nextSlide func()) (title string, content tview.Primitive) {
@ -28,6 +28,6 @@ func Colors(nextSlide func()) (title string, content tview.Primitive) {
}
table.SetBorderPadding(1, 1, 2, 2).
SetBorder(true).
SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:] [::bu]title")
return "Colors", Center(78, 19, table)
SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:-] [::bu]title")
return "Colors", Center(82, 19, table)
}

@ -115,7 +115,7 @@ func TextView2(nextSlide func()) (title string, content tview.Primitive) {
codeView := tview.NewTextView().
SetWrap(false)
fmt.Fprint(codeView, textView2)
codeView.SetBorder(true).SetTitle("Buffer content")
codeView.SetBorder(true).SetTitle("Raw text")
textView := tview.NewTextView()
textView.SetDynamicColors(true).

@ -64,37 +64,41 @@ You will find more demos in the "demos" subdirectory. It also contains a
presentation (written using tview) which gives an overview of the different
widgets and how they can be used.
# Colors
# Styles, Colors, and Hyperlinks
Throughout this package, colors are specified using the [tcell.Color] type.
Functions such as [tcell.GetColor], [tcell.NewHexColor], and [tcell.NewRGBColor]
can be used to create colors from W3C color names or RGB values.
Throughout this package, styles are specified using the [tcell.Style] type.
Styles specify colors with the [tcell.Color] type. Functions such as
[tcell.GetColor], [tcell.NewHexColor], and [tcell.NewRGBColor] can be used to
create colors from W3C color names or RGB values. The [tcell.Style] type also
allows you to specify text attributes such as "bold" or "underline" or a URL
which some terminals use to display hyperlinks.
Almost all strings which are displayed can contain color tags. Color tags are
W3C color names or six hexadecimal digits following a hash tag, wrapped in
square brackets. Examples:
Almost all strings which are displayed may contain style tags. A style tag's
content is always wrapped in square brackets. In its simplest form, a style tag
specifies the foreground color of the text. Colors in these tags are W3C color
names or six hexadecimal digits following a hash tag. Examples:
This is a [red]warning[white]!
The sky is [#8080ff]blue[#ffffff].
A color tag changes the color of the characters following that color tag. This
applies to almost everything from box titles, list text, form item labels, to
table cells. In a TextView, this functionality has to be switched on explicitly.
See the TextView documentation for more information.
A style tag changes the style of the characters following that style tag. There
is no style stack and no nesting of style tags.
Color tags may contain not just the foreground (text) color but also the
background color and additional flags. In fact, the full definition of a color
tag is as follows:
Style tags are used in almost everything from box titles, list text, form item
labels, to table cells. In a [TextView], this functionality has to be switched
on explicitly. See the [TextView] documentation for more information.
[<foreground>:<background>:<flags>]
A style tag's full format looks like this:
Each of the three fields can be left blank and trailing fields can be omitted.
(Empty square brackets "[]", however, are not considered color tags.) Colors
[<foreground>:<background>:<attribute flags>:<url>]
Each of the four fields can be left blank and trailing fields can be omitted.
(Empty square brackets "[]", however, are not considered style tags.) Fields
that are not specified will be left unchanged. A field with just a dash ("-")
means "reset to default".
You can specify the following flags (some flags may not be supported by your
terminal):
You can specify the following flags to turn on certain attributes (some flags
may not be supported by your terminal):
l: blink
b: bold
@ -104,6 +108,15 @@ terminal):
u: underline
s: strike-through
Use uppercase letters to turn off the corresponding attribute, for example,
"B" to turn off bold. Uppercase letters have no effect if the attribute was not
previously set.
Setting a URL allows you to turn a piece of text into a hyperlink in some
terminals. Specify a dash ("-") to specify the end of the hyperlink. Hyperlinks
must only contain single-byte characters (e.g. ASCII), excluding bracket
characters ("[" or "]").
Examples:
[yellow]Yellow text
@ -113,9 +126,12 @@ Examples:
[::bl]Bold, blinking text
[::-]Colors unchanged, flags reset
[-]Reset foreground color
[-:-:-]Reset everything
[::i]Italic and [::I]not italic
Click [:::https://example.com]here[:::-] for example.com.
Send an email to [:::mailto:her@example.com]her/[:::mail:him@example.com]him[:::-].
[-:-:-:-]Reset everything
[:]No effect
[]Not a valid color tag, will print square brackets as they are
[]Not a valid style tag, will print square brackets as they are
In the rare event that you want to display a string such as "[red]" or
"[#00ff1a]" without applying its effect, you need to put an opening square
@ -127,7 +143,7 @@ character that may be used in color or region tags will be recognized. Examples:
["123"[] will be output as ["123"]
[#6aff00[[] will be output as [#6aff00[]
[a#"[[[] will be output as [a#"[[]
[] will be output as [] (see color tags above)
[] will be output as [] (see style tags above)
[[] will be output as [[] (not an escaped tag)
You can use the Escape() function to insert brackets automatically where needed.

@ -119,6 +119,7 @@ func NewForm() *Form {
fieldTextColor: Styles.PrimaryTextColor,
buttonStyle: tcell.StyleDefault.Background(Styles.ContrastBackgroundColor).Foreground(Styles.PrimaryTextColor),
buttonActivatedStyle: tcell.StyleDefault.Background(Styles.PrimaryTextColor).Foreground(Styles.ContrastBackgroundColor),
buttonDisabledStyle: tcell.StyleDefault.Background(Styles.ContrastBackgroundColor).Foreground(Styles.ContrastSecondaryTextColor),
lastFinishedKey: tcell.KeyTab, // To skip over inactive elements at the beginning of the form.
}
@ -195,6 +196,12 @@ func (f *Form) SetButtonActivatedStyle(style tcell.Style) *Form {
return f
}
// SetButtonDisabledStyle sets the style of the buttons when they are disabled.
func (f *Form) SetButtonDisabledStyle(style tcell.Style) *Form {
f.buttonDisabledStyle = style
return f
}
// SetFocus shifts the focus to the form element with the given index, counting
// non-button items first and buttons last. Note that this index is only used
// when the form itself receives focus.
@ -605,7 +612,8 @@ func (f *Form) Draw(screen tcell.Screen) {
buttonWidth = space
}
button.SetStyle(f.buttonStyle).
SetActivatedStyle(f.buttonActivatedStyle)
SetActivatedStyle(f.buttonActivatedStyle).
SetDisabledStyle(f.buttonDisabledStyle)
buttonIndex := index + len(f.items)
positions[buttonIndex].x = x

@ -729,7 +729,7 @@ func (i *Image) Draw(screen tcell.Screen) {
viewX += labelWidth
viewWidth -= labelWidth
} else {
_, drawnWidth, _, _ := printWithStyle(screen, i.label, viewX, viewY, 0, viewWidth, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
_, _, drawnWidth := printWithStyle(screen, i.label, viewX, viewY, 0, viewWidth, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
viewX += drawnWidth
viewWidth -= drawnWidth
}

@ -310,7 +310,7 @@ func (i *InputField) SetAutocompleteFunc(callback func(currentText string) (entr
// SetAutocompletedFunc sets a callback function which is invoked when the user
// selects an entry from the autocomplete drop-down list. The function is passed
// the text of the selected entry (stripped of any color tags), the index of the
// the text of the selected entry (stripped of any style tags), the index of the
// entry, and the user action that caused the selection, e.g.
// [AutocompletedNavigate]. It returns true if the autocomplete drop-down should
// be closed after the callback returns or false if it should remain open, in
@ -454,7 +454,7 @@ func (i *InputField) Draw(screen tcell.Screen) {
printWithStyle(screen, i.label, x, y, 0, labelWidth, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
x += labelWidth
} else {
_, drawnWidth, _, _ := printWithStyle(screen, i.label, x, y, 0, width, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
_, _, drawnWidth := printWithStyle(screen, i.label, x, y, 0, width, AlignLeft, i.labelStyle, labelBg == tcell.ColorDefault)
x += drawnWidth
}
@ -498,13 +498,20 @@ func (i *InputField) Draw(screen tcell.Screen) {
// We have enough space for the full text.
printWithStyle(screen, Escape(text), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true)
i.offset = 0
iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if textPos >= i.cursorPos {
return true
// Find cursor position.
var (
state *stepState
textPos int
)
str := text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
textPos += state.GrossLength()
if textPos > i.cursorPos {
break
}
cursorScreenPos += screenWidth
return false
})
cursorScreenPos += state.Width()
}
} else {
// The text doesn't fit. Where is the cursor?
if i.cursorPos < 0 {
@ -520,20 +527,26 @@ func (i *InputField) Draw(screen tcell.Screen) {
shiftLeft = subWidth - fieldWidth + 1
}
currentOffset := i.offset
iterateString(text, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
var (
state *stepState
textPos int
)
str := text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
if textPos >= currentOffset {
if shiftLeft > 0 {
i.offset = textPos + textWidth
shiftLeft -= screenWidth
i.offset = textPos + state.GrossLength()
shiftLeft -= state.Width()
} else {
if textPos+textWidth > i.cursorPos {
return true
if textPos+state.GrossLength() > i.cursorPos {
break
}
cursorScreenPos += screenWidth
cursorScreenPos += state.Width()
}
}
return false
})
textPos += state.GrossLength()
}
printWithStyle(screen, Escape(text[i.offset:]), x, y, 0, fieldWidth, AlignLeft, i.fieldStyle, true)
}
}
@ -598,16 +611,22 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
home := func() { i.cursorPos = 0 }
end := func() { i.cursorPos = len(i.text) }
moveLeft := func() {
iterateStringReverse(i.text[:i.cursorPos], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
i.cursorPos -= textWidth
return true
})
var state *stepState
str := i.text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
if len(str) <= len(i.text)-i.cursorPos {
i.cursorPos -= state.GrossLength()
if i.cursorPos < 0 {
i.cursorPos = 0
}
break
}
}
}
moveRight := func() {
iterateString(i.text[i.cursorPos:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
i.cursorPos += textWidth
return true
})
_, _, state := step(i.text[i.cursorPos:], nil, stepOptionsNone)
i.cursorPos += state.GrossLength()
}
moveWordLeft := func() {
i.cursorPos = len(regexp.MustCompile(`\S+\s*$`).ReplaceAllString(i.text[:i.cursorPos], ""))
@ -718,19 +737,24 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
i.cursorPos -= len(i.text) - len(newText)
i.text = newText
case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete character before the cursor.
iterateStringReverse(i.text[:i.cursorPos], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
i.text = i.text[:textPos] + i.text[textPos+textWidth:]
i.cursorPos -= textWidth
return true
})
var state *stepState
str := i.text
for len(str) > 0 && i.cursorPos > 0 {
_, str, state = step(str, state, stepOptionsNone)
if len(str) <= len(i.text)-i.cursorPos {
i.cursorPos -= state.GrossLength()
i.text = i.text[:i.cursorPos] + i.text[i.cursorPos+state.GrossLength():]
break
}
}
if i.offset >= i.cursorPos {
i.offset = 0
}
case tcell.KeyDelete, tcell.KeyCtrlD: // Delete character after the cursor.
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:]
return true
})
if len(i.text) > i.cursorPos {
_, rest, _ := step(i.text[i.cursorPos:], nil, stepOptionsNone)
i.text = i.text[:i.cursorPos] + rest
}
case tcell.KeyLeft:
if event.Modifiers()&tcell.ModAlt > 0 {
moveWordLeft()
@ -815,14 +839,19 @@ func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventM
} else if action == MouseLeftClick {
// Determine where to place the cursor.
if x >= i.fieldX {
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 {
i.cursorPos = textPos + i.offset
return true
var (
state *stepState
screenPos int
str = i.text[i.offset:]
)
i.cursorPos = i.offset
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsNone)
screenPos += state.Width()
if screenPos > x-i.fieldX {
break
}
return false
}) {
i.cursorPos = len(i.text)
i.cursorPos += state.GrossLength()
}
}
consumed = true

@ -253,7 +253,7 @@ func (l *List) SetShortcutStyle(style tcell.Style) *List {
// SetSelectedTextColor sets the text color of selected items. Note that the
// color of main text characters that are different from the main text color
// (e.g. color tags) is maintained.
// (e.g. style tags) is maintained.
func (l *List) SetSelectedTextColor(color tcell.Color) *List {
l.selectedStyle = l.selectedStyle.Foreground(color)
return l
@ -514,7 +514,7 @@ func (l *List) Draw(screen tcell.Screen) {
}
// Main text.
_, printedWidth, _, end := printWithStyle(screen, item.MainText, x, y, l.horizontalOffset, width, AlignLeft, l.mainTextStyle, true)
_, end, printedWidth := printWithStyle(screen, item.MainText, x, y, l.horizontalOffset, width, AlignLeft, l.mainTextStyle, true)
if printedWidth > maxWidth {
maxWidth = printedWidth
}
@ -551,7 +551,7 @@ func (l *List) Draw(screen tcell.Screen) {
// Secondary text.
if l.showSecondaryText {
_, printedWidth, _, end := printWithStyle(screen, item.SecondaryText, x, y, l.horizontalOffset, width, AlignLeft, l.secondaryTextStyle, true)
_, end, printedWidth := printWithStyle(screen, item.SecondaryText, x, y, l.horizontalOffset, width, AlignLeft, l.secondaryTextStyle, true)
if printedWidth > maxWidth {
maxWidth = printedWidth
}

@ -1,8 +1,6 @@
package tview
import (
"strings"
"github.com/gdamore/tcell/v2"
)
@ -101,7 +99,7 @@ func (m *Modal) SetDoneFunc(handler func(buttonIndex int, buttonLabel string)) *
}
// SetText sets the message text of the window. The text may contain line
// breaks but color tag states will not transfer to following lines. Note that
// breaks but style tag states will not transfer to following lines. Note that
// words are wrapped, too, based on the final size of the window.
func (m *Modal) SetText(text string) *Modal {
m.text = text
@ -172,15 +170,7 @@ func (m *Modal) Draw(screen tcell.Screen) {
// Reset the text and find out how wide it is.
m.frame.Clear()
var lines []string
for _, line := range strings.Split(m.text, "\n") {
if len(line) == 0 {
lines = append(lines, "")
continue
}
lines = append(lines, WordWrap(line, width)...)
}
//lines := WordWrap(m.text, width)
lines := WordWrap(m.text, width)
for _, line := range lines {
m.frame.AddText(line, true, AlignCenter, m.textColor)
}

@ -0,0 +1,497 @@
package tview
import (
"math/rand"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
"github.com/rivo/uniseg"
)
// escapedTagPattern matches an escaped tag, e.g. "[red[]", at the beginning of
// a string.
var escapedTagPattern = regexp.MustCompile(`^\[[^\[\]]+\[+\]`)
// stepOptions is a bit field of options for [step]. A value of 0 results in
// [step] having the same behavior as uniseg.Step, i.e. no tview-related parsing
// is performed.
type stepOptions int
// Bit fields for [stepOptions].
const (
stepOptionsNone stepOptions = 0
stepOptionsStyle stepOptions = 1 << iota // Parse style tags.
stepOptionsRegion // Parse region tags.
)
// stepState represents the current state of the parser implemented in [step].
type stepState struct {
unisegState int // The state of the uniseg parser.
boundaries int // Information about boundaries, as returned by uniseg.Step.
style tcell.Style // The current style.
region string // The current region.
escapedTagState int // States for parsing escaped tags (defined in [step]).
grossLength int // The length of the cluster, including any tags not returned.
// The styles for the initial call to [step].
initialForeground tcell.Color
initialBackground tcell.Color
initialAttributes tcell.AttrMask
}
// IsWordBoundary returns true if the boundary between the returned grapheme
// cluster and the one following it is a word boundary.
func (s *stepState) IsWordBoundary() bool {
return s.boundaries&uniseg.MaskWord != 0
}
// IsSentenceBoundary returns true if the boundary between the returned grapheme
// cluster and the one following it is a sentence boundary.
func (s *stepState) IsSentenceBoundary() bool {
return s.boundaries&uniseg.MaskSentence != 0
}
// LineBreak returns whether the string can be broken into the next line after
// the returned grapheme cluster. If optional is true, the line break is
// optional. If false, the line break is mandatory, e.g. after a newline
// character.
func (s *stepState) LineBreak() (lineBreak, optional bool) {
switch s.boundaries & uniseg.MaskLine {
case uniseg.LineCanBreak:
return true, true
case uniseg.LineMustBreak:
return true, false
}
return false, false // uniseg.LineDontBreak.
}
// Width returns the grapheme cluster's width in cells.
func (s *stepState) Width() int {
return s.boundaries >> uniseg.ShiftWidth
}
// GrossLength returns the grapheme cluster's length in bytes, including any
// tags that were parsed but not explicitly returned.
func (s *stepState) GrossLength() int {
return s.grossLength
}
// Style returns the style for the grapheme cluster.
func (s *stepState) Style() tcell.Style {
return s.style
}
// step uses uniseg.Step to iterate over the grapheme clusters of a string but
// (optionally) also parses the string for style or region tags.
//
// This function can be called consecutively to extract all grapheme clusters
// from str, without returning any contained (parsed) tags. The return values
// are the first grapheme cluster, the remaining string, and the new state. Pass
// the remaining string and the returned state to the next call. If the rest
// string is empty, parsing is complete. Call the returned state's methods for
// boundary and width information.
//
// The returned cluster may be empty if the given string consists of only
// (parsed) tags. The boundary and width information will be meaningless in
// this case but the style will describe the style at the end of the string.
//
// Pass nil for state on the first call. This will assume an initial style with
// [Styles.PrimitiveBackgroundColor] as the background color and
// [Styles.PrimaryTextColor] as the text color, no current region. If you want
// to start with a different style or region, you can set the state accordingly
// but you must then set [state.unisegState] to -1.
//
// You may call uniseg.HasTrailingLineBreakInString on the last non-empty
// cluster to determine if the string ends with a hard line break.
func step(str string, state *stepState, opts stepOptions) (cluster, rest string, newState *stepState) {
// Set up initial state.
if state == nil {
state = &stepState{
unisegState: -1,
style: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.PrimaryTextColor),
}
}
if state.unisegState < 0 {
state.initialForeground, state.initialBackground, state.initialAttributes = state.style.Decompose()
}
if len(str) == 0 {
newState = state
return
}
// Get a grapheme cluster.
preState := state.unisegState
cluster, rest, state.boundaries, state.unisegState = uniseg.StepString(str, preState)
state.grossLength = len(cluster)
// Parse tags.
if opts != 0 {
const (
etNone int = iota
etStart
etChar
etClosing
)
// Finite state machine for escaped tags.
switch state.escapedTagState {
case etStart:
if cluster[0] == '[' || cluster[0] == ']' { // Invalid escaped tag.
state.escapedTagState = etNone
} else { // Other characters are allowed.
state.escapedTagState = etChar
}
case etChar:
if cluster[0] == ']' { // In theory, this should not happen.
state.escapedTagState = etNone
} else if cluster[0] == '[' { // Starting closing sequence.
// Swallow the first one.
cluster, rest, state.boundaries, state.unisegState = uniseg.StepString(rest, preState)
state.grossLength = len(cluster)
if cluster[0] == ']' {
state.escapedTagState = etNone
} else {
state.escapedTagState = etClosing
}
} // More characters. Remain in etChar.
case etClosing:
if cluster[0] != '[' {
state.escapedTagState = etNone
}
}
// Regular tags.
if state.escapedTagState == etNone {
if cluster[0] == '[' {
// We've already opened a tag. Parse it.
length, style, region := parseTag(str, state)
if length > 0 {
state.style = style
state.region = region
cluster, rest, state.boundaries, state.unisegState = uniseg.StepString(str[length:], preState)
state.grossLength = len(cluster) + length
}
// Is this an escaped tag?
if escapedTagPattern.MatchString(str[length:]) {
state.escapedTagState = etStart
}
}
if len(rest) > 0 && rest[0] == '[' {
// A tag might follow the cluster. If so, we need to fix the state
// for the boundaries to be correct.
if length, _, _ := parseTag(rest, state); length > 0 {
if len(rest) > length {
_, l := utf8.DecodeRuneInString(rest[length:])
cluster += rest[length : length+l]
}
cluster, _, state.boundaries, state.unisegState = uniseg.StepString(cluster, preState)
}
}
}
}
newState = state
return
}
// parseTag parses str for consecutive style and/or region tags, assuming that
// str starts with the opening bracket for the first tag. It returns the string
// length of all valid tags (0 if the first tag is not valid) and the updated
// style and region for valid tags (based on the provided state).
func parseTag(str string, state *stepState) (length int, style tcell.Style, region string) {
// Automata states for parsing tags.
const (
tagStateNone = iota
tagStateDoneTag
tagStateStart
tagStateRegionStart
tagStateEndForeground
tagStateStartBackground
tagStateNumericForeground
tagStateNameForeground
tagStateEndBackground
tagStateStartAttributes
tagStateNumericBackground
tagStateNameBackground
tagStateAttributes
tagStateRegionEnd
tagStateRegionName
tagStateEndAttributes
tagStateStartURL
tagStateEndURL
tagStateURL
)
// Helper function which checks if the given byte is one of a list of
// characters, including letters and digits.
isOneOf := func(b byte, chars string) bool {
if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' {
return true
}
return strings.IndexByte(chars, b) >= 0
}
// Attribute map.
attrs := map[byte]tcell.AttrMask{
'B': tcell.AttrBold,
'U': tcell.AttrUnderline,
'I': tcell.AttrItalic,
'L': tcell.AttrBlink,
'D': tcell.AttrDim,
'S': tcell.AttrStrikeThrough,
'R': tcell.AttrReverse,
}
var (
tagState, tagLength int
tempStr strings.Builder
)
tStyle := state.style
tRegion := state.region
// Process state transitions.
for len(str) > 0 {
ch := str[0]
str = str[1:]
tagLength++
// Transition.
switch tagState {
case tagStateNone:
if ch == '[' { // Start of a tag.
tagState = tagStateStart
} else { // Not a tag. We're done.
return
}
case tagStateStart:
if ch == '"' { // Start of a region tag.
tempStr.Reset()
tagState = tagStateRegionStart
} else if !isOneOf(ch, "#:-") { // Invalid style tag.
return
} else if ch == '-' { // Reset foreground color.
tStyle = tStyle.Foreground(state.initialForeground)
tagState = tagStateEndForeground
} else if ch == ':' { // No foreground color.
tagState = tagStateStartBackground
} else {
tempStr.Reset()
tempStr.WriteByte(ch)
if ch == '#' { // Numeric foreground color.
tagState = tagStateNumericForeground
} else { // Letters or numbers.
tagState = tagStateNameForeground
}
}
case tagStateEndForeground:
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' {
tagState = tagStateStartBackground
} else { // Invalid tag.
return
}
case tagStateNumericForeground:
if ch == ']' || ch == ':' {
if tempStr.Len() != 7 { // Must be #rrggbb.
return
}
tStyle = tStyle.Foreground(tcell.GetColor(tempStr.String()))
}
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of background color.
tagState = tagStateStartBackground
} else if strings.IndexByte("0123456789abcdefABCDEF", ch) >= 0 { // Hex digit.
tempStr.WriteByte(ch)
tagState = tagStateNumericForeground
} else { // Invalid tag.
return
}
case tagStateNameForeground:
if ch == ']' || ch == ':' {
name := tempStr.String()
if name[0] >= '0' && name[0] <= '9' { // Must not start with a digit.
return
}
tStyle = tStyle.Foreground(tcell.ColorNames[name])
}
if !isOneOf(ch, "]:") { // Invalid tag.
return
} else if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of background color.
tagState = tagStateStartBackground
} else { // Letters or numbers.
tempStr.WriteByte(ch)
}
case tagStateStartBackground:
if !isOneOf(ch, "#:-]") { // Invalid style tag.
return
} else if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == '-' { // Reset background color.
tStyle = tStyle.Background(state.initialBackground)
tagState = tagStateEndBackground
} else if ch == ':' { // No background color.
tagState = tagStateStartAttributes
} else {
tempStr.Reset()
tempStr.WriteByte(ch)
if ch == '#' { // Numeric background color.
tagState = tagStateNumericBackground
} else { // Letters or numbers.
tagState = tagStateNameBackground
}
}
case tagStateEndBackground:
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of attributes.
tagState = tagStateStartAttributes
} else { // Invalid tag.
return
}
case tagStateNumericBackground:
if ch == ']' || ch == ':' {
if tempStr.Len() != 7 { // Must be #rrggbb.
return
}
tStyle = tStyle.Background(tcell.GetColor(tempStr.String()))
}
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of attributes.
tagState = tagStateStartAttributes
} else if strings.IndexByte("0123456789abcdefABCDEF", ch) >= 0 { // Hex digit.
tempStr.WriteByte(ch)
tagState = tagStateNumericBackground
} else { // Invalid tag.
return
}
case tagStateNameBackground:
if ch == ']' || ch == ':' {
name := tempStr.String()
if name[0] >= '0' && name[0] <= '9' { // Must not start with a digit.
return
}
tStyle = tStyle.Background(tcell.ColorNames[name])
}
if !isOneOf(ch, "]:") { // Invalid tag.
return
} else if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of background color.
tagState = tagStateStartAttributes
} else { // Letters or numbers.
tempStr.WriteByte(ch)
}
case tagStateStartAttributes:
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == '-' { // Reset attributes.
tStyle = tStyle.Attributes(state.initialAttributes)
tagState = tagStateEndAttributes
} else if ch == ':' { // Start of URL.
tagState = tagStateStartURL
} else if strings.IndexByte("buildsrBUILDSR", ch) >= 0 { // Attribute tag.
tempStr.Reset()
tempStr.WriteByte(ch)
tagState = tagStateAttributes
} else { // Invalid tag.
return
}
case tagStateAttributes:
if ch == ']' || ch == ':' {
flags := tempStr.String()
_, _, a := tStyle.Decompose()
for index := 0; index < len(flags); index++ {
ch := flags[index]
if ch >= 'a' && ch <= 'z' {
a |= attrs[ch-('a'-'A')]
} else {
a &^= attrs[ch]
}
}
tStyle = tStyle.Attributes(a)
}
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of URL.
tagState = tagStateStartURL
} else if strings.IndexByte("buildsrBUILDSR", ch) >= 0 { // Attribute tag.
tempStr.WriteByte(ch)
} else { // Invalid tag.
return
}
case tagStateEndAttributes:
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == ':' { // Start of URL.
tagState = tagStateStartURL
} else { // Invalid tag.
return
}
case tagStateStartURL:
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else if ch == '-' { // Reset URL.
tStyle = tStyle.Url("").UrlId("")
tagState = tagStateEndURL
} else { // URL character.
tempStr.Reset()
tempStr.WriteByte(ch)
tStyle = tStyle.UrlId(strconv.Itoa(int(rand.Uint32()))) // Generate a unique ID for this URL.
tagState = tagStateURL
}
case tagStateEndURL:
if ch == ']' { // End of tag.
tagState = tagStateDoneTag
} else { // Invalid tag.
return
}
case tagStateURL:
if ch == ']' { // End of tag.
tStyle = tStyle.Url(tempStr.String())
tagState = tagStateDoneTag
} else { // URL character.
tempStr.WriteByte(ch)
}
case tagStateRegionStart:
if ch == '"' { // End of region tag.
tagState = tagStateRegionEnd
} else if isOneOf(ch, "_,;: -.") { // Region name.
tempStr.WriteByte(ch)
tagState = tagStateRegionName
} else { // Invalid tag.
return
}
case tagStateRegionEnd:
if ch == ']' { // End of tag.
tRegion = tempStr.String()
tagState = tagStateDoneTag
} else { // Invalid tag.
return
}
case tagStateRegionName:
if ch == '"' { // End of region tag.
tagState = tagStateRegionEnd
} else if isOneOf(ch, "_,;: -.") { // Region name.
tempStr.WriteByte(ch)
} else { // Invalid tag.
return
}
}
// The last transition led to a tag end. Make the tag permanent.
if tagState == tagStateDoneTag {
length, style, region = tagLength, tStyle, tRegion
tagState = tagStateNone // Reset state.
}
}
return
}

@ -977,7 +977,7 @@ func (t *Table) Draw(screen tcell.Screen) {
}
for _, row := range evaluationRows {
if cell := t.content.GetCell(row, column); cell != nil {
_, _, _, _, _, _, cellWidth := decomposeString(cell.Text, true, false)
cellWidth := TaggedStringWidth(cell.Text)
if cell.MaxWidth > 0 && cell.MaxWidth < cellWidth {
cellWidth = cell.MaxWidth
}
@ -1154,7 +1154,8 @@ func (t *Table) Draw(screen tcell.Screen) {
finalWidth = width - columnX
}
cell.x, cell.y, cell.width = x+columnX, y+rowY, finalWidth
_, printed, _, _ := printWithStyle(screen, cell.Text, x+columnX, y+rowY, 0, finalWidth, cell.Align, tcell.StyleDefault.Foreground(cell.Color).Attributes(cell.Attributes), true)
start, end, _ := printWithStyle(screen, cell.Text, x+columnX, y+rowY, 0, finalWidth, cell.Align, tcell.StyleDefault.Foreground(cell.Color).Attributes(cell.Attributes), true)
printed := end - start
if TaggedStringWidth(cell.Text)-printed > 0 && printed > 0 {
_, _, style, _ := screen.GetContent(x+columnX+finalWidth-1, y+rowY)
printWithStyle(screen, string(SemigraphicsHorizontalEllipsis), x+columnX+finalWidth-1, y+rowY, 0, 1, AlignLeft, style, false)

@ -1074,7 +1074,7 @@ func (t *TextArea) Draw(screen tcell.Screen) {
x += labelWidth
width -= labelWidth
} else {
_, drawnWidth, _, _ := printWithStyle(screen, t.label, x, y, 0, width, AlignLeft, t.labelStyle, labelBg == tcell.ColorDefault)
_, _, drawnWidth := printWithStyle(screen, t.label, x, y, 0, width, AlignLeft, t.labelStyle, labelBg == tcell.ColorDefault)
x += drawnWidth
width -= drawnWidth
}
@ -1201,49 +1201,14 @@ func (t *TextArea) Draw(screen tcell.Screen) {
// 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
})
// We use a TextView to draw the placeholder. It will take care of word
// wrapping etc.
textView := NewTextView().
SetText(t.placeholder).
SetTextStyle(t.placeholderStyle)
textView.SetBackgroundColor(t.backgroundColor).
SetRect(x, y, width, height)
textView.Draw(screen)
}
// reset resets many of the local variables of the text area because they cannot

@ -413,7 +413,7 @@ func (t *TextView) SetText(text string) *TextView {
}
// GetText returns the current text of this text view. If "stripAllTags" is set
// to true, any region/color tags are stripped from the text.
// to true, any region/style tags are stripped from the text.
func (t *TextView) GetText(stripAllTags bool) string {
// Get the buffer.
buffer := t.buffer
@ -686,7 +686,7 @@ func (t *TextView) ScrollToHighlight() *TextView {
}
// GetRegionText returns the text of the region with the given ID. If dynamic
// colors are enabled, color tags are stripped from the text. Newlines are
// colors are enabled, style tags are stripped from the text. Newlines are
// always returned as '\n' runes.
//
// If the region does not exist or if regions are turned off, an empty string
@ -702,7 +702,7 @@ func (t *TextView) GetRegionText(regionID string) string {
)
for _, str := range t.buffer {
// Find all color tags in this line.
// Find all style tags in this line.
var colorTagIndices [][]int
if t.dynamicColors {
colorTagIndices = colorPattern.FindAllStringIndex(str, -1)
@ -721,7 +721,7 @@ func (t *TextView) GetRegionText(regionID string) string {
// Analyze this line.
var currentTag, currentRegion int
for pos, ch := range str {
// Skip any color tags.
// Skip any style tags.
if currentTag < len(colorTagIndices) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] {
tag := currentTag
if pos == colorTagIndices[tag][1]-1 {
@ -972,7 +972,7 @@ func (t *TextView) reindexBuffer(width int) {
// Which tag comes next?
nextTag := make([][3]int, 0, 3)
if colorPos < len(colorTagIndices) {
nextTag = append(nextTag, [3]int{colorTagIndices[colorPos][0], colorTagIndices[colorPos][1], 0}) // 0 = color tag.
nextTag = append(nextTag, [3]int{colorTagIndices[colorPos][0], colorTagIndices[colorPos][1], 0}) // 0 = style tag.
}
if regionPos < len(regionIndices) {
nextTag = append(nextTag, [3]int{regionIndices[regionPos][0], regionIndices[regionPos][1], 1}) // 1 = region tag.
@ -1007,7 +1007,7 @@ func (t *TextView) reindexBuffer(width int) {
// Process the tag.
switch nextTag[tagIndex][2] {
case 0:
// Process color tags.
// Process style tags.
foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos])
colorPos++
case 1:
@ -1136,7 +1136,7 @@ func (t *TextView) Draw(screen tcell.Screen) {
x += labelWidth
width -= labelWidth
} else {
_, drawnWidth, _, _ := printWithStyle(screen, t.label, x, y, 0, width, AlignLeft, t.labelStyle, labelBg == tcell.ColorDefault)
_, _, drawnWidth := printWithStyle(screen, t.label, x, y, 0, width, AlignLeft, t.labelStyle, labelBg == tcell.ColorDefault)
x += drawnWidth
width -= drawnWidth
}

@ -6,6 +6,7 @@ import (
"regexp"
"sort"
"strconv"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/uniseg"
@ -168,10 +169,10 @@ func overlayStyle(style tcell.Style, fgColor, bgColor, attributes string) tcell.
// decomposeString returns information about a string which may contain color
// tags or region tags, depending on which ones are requested to be found. It
// returns the indices of the color tags (as returned by
// re.FindAllStringIndex()), the color tags themselves (as returned by
// returns the indices of the style tags (as returned by
// re.FindAllStringIndex()), the style tags themselves (as returned by
// re.FindAllStringSubmatch()), the indices of region tags and the region tags
// themselves, the indices of an escaped tags (only if at least color tags or
// themselves, the indices of an escaped tags (only if at least style tags or
// region tags are requested), the string stripped by any tags and escaped, and
// the screen width of the stripped string.
func decomposeString(text string, findColors, findRegions bool) (colorIndices [][]int, colors [][]string, regionIndices [][]int, regions [][]string, escapeIndices [][]int, stripped string, width int) {
@ -236,208 +237,120 @@ func decomposeString(text string, findColors, findRegions bool) (colorIndices []
// not exceeding that box. "align" is one of AlignLeft, AlignCenter, or
// AlignRight. The screen's background color will not be changed.
//
// You can change the colors and text styles mid-text by inserting a color tag.
// You can change the colors and text styles mid-text by inserting a style tag.
// See the package description for details.
//
// Returns the number of actual bytes of the text printed (including color tags)
// Returns the number of actual bytes of the text printed (including style tags)
// and the actual width used for the printed runes.
func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tcell.Color) (int, int) {
bytes, width, _, _ := printWithStyle(screen, text, x, y, 0, maxWidth, align, tcell.StyleDefault.Foreground(color), true)
return bytes, width
start, end, width := printWithStyle(screen, text, x, y, 0, maxWidth, align, tcell.StyleDefault.Foreground(color), true)
return end - start, width
}
// printWithStyle works like Print() but it takes a style instead of just a
// foreground color. The skipWidth parameter specifies the number of cells
// skipped at the beginning of the text. It also returns the start and end index
// (exclusively) of the text actually printed. If maintainBackground is "true",
// The existing screen background is not changed (i.e. the style's background
// color is ignored).
func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth, align int, style tcell.Style, maintainBackground bool) (int, int, int, int) {
// skipped at the beginning of the text. It returns the start index, end index
// (exclusively), and screen width of the text actually printed. If
// maintainBackground is "true", the existing screen background is not changed
// (i.e. the style's background color is ignored).
func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth, align int, style tcell.Style, maintainBackground bool) (start, end, printedWidth int) {
totalWidth, totalHeight := screen.Size()
if maxWidth <= 0 || len(text) == 0 || y < 0 || y >= totalHeight {
return 0, 0, 0, 0
return 0, 0, 0
}
// Decompose the text.
colorIndices, colors, _, _, escapeIndices, strippedText, strippedWidth := decomposeString(text, true, false)
// We want to reduce all alignments to AlignLeft.
if align == AlignRight {
if strippedWidth-skipWidth <= maxWidth {
// There's enough space for the entire text.
return printWithStyle(screen, text, x+maxWidth-strippedWidth+skipWidth, y, skipWidth, maxWidth, AlignLeft, style, maintainBackground)
}
// Trim characters off the beginning.
var (
bytes, width, colorPos, escapePos, tagOffset, from, to int
foregroundColor, backgroundColor, attributes string
)
originalStyle := style
iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
// Update color/escape tag offset and style.
if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
style = overlayStyle(originalStyle, foregroundColor, backgroundColor, attributes)
tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
colorPos++
}
if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
tagOffset++
escapePos++
}
if strippedWidth-screenPos <= maxWidth {
// We chopped off enough.
if escapePos > 0 && textPos+tagOffset-1 >= escapeIndices[escapePos-1][0] && textPos+tagOffset-1 < escapeIndices[escapePos-1][1] {
// Unescape open escape sequences.
escapeCharPos := escapeIndices[escapePos-1][1] - 2
text = text[:escapeCharPos] + text[escapeCharPos+1:]
}
// Print and return.
bytes, width, from, to = printWithStyle(screen, text[textPos+tagOffset:], x, y, 0, maxWidth, AlignLeft, style, maintainBackground)
from += textPos + tagOffset
to += textPos + tagOffset
return true
}
return false
})
return bytes, width, from, to
} else if align == AlignCenter {
if strippedWidth-skipWidth == maxWidth {
// Use the exact space.
return printWithStyle(screen, text, x, y, skipWidth, maxWidth, AlignLeft, style, maintainBackground)
} else if strippedWidth-skipWidth < maxWidth {
// We have more space than we need.
half := (maxWidth - strippedWidth + skipWidth) / 2
return printWithStyle(screen, text, x+half, y, skipWidth, maxWidth-half, AlignLeft, style, maintainBackground)
} else {
// Chop off runes until we have a perfect fit.
var choppedLeft, choppedRight, leftIndex, rightIndex int
rightIndex = len(strippedText)
for rightIndex-1 > leftIndex && strippedWidth-skipWidth-choppedLeft-choppedRight > maxWidth {
if skipWidth > 0 || choppedLeft < choppedRight {
// Iterate on the left by one character.
iterateString(strippedText[leftIndex:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
if skipWidth > 0 {
skipWidth -= screenWidth
strippedWidth -= screenWidth
} else {
choppedLeft += screenWidth
}
leftIndex += textWidth
return true
})
} else {
// Iterate on the right by one character.
iterateStringReverse(strippedText[leftIndex:rightIndex], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
choppedRight += screenWidth
rightIndex -= textWidth
return true
})
}
}
// Add tag offsets and determine start style.
var (
colorPos, escapePos, tagOffset int
foregroundColor, backgroundColor, attributes string
)
originalStyle := style
for index := range strippedText {
// We only need the offset of the left index.
if index > leftIndex {
// We're done.
if escapePos > 0 && leftIndex+tagOffset-1 >= escapeIndices[escapePos-1][0] && leftIndex+tagOffset-1 < escapeIndices[escapePos-1][1] {
// Unescape open escape sequences.
escapeCharPos := escapeIndices[escapePos-1][1] - 2
text = text[:escapeCharPos] + text[escapeCharPos+1:]
}
break
}
// Update color/escape tag offset.
if colorPos < len(colorIndices) && index+tagOffset >= colorIndices[colorPos][0] && index+tagOffset < colorIndices[colorPos][1] {
if index <= leftIndex {
foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
style = overlayStyle(originalStyle, foregroundColor, backgroundColor, attributes)
}
tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
colorPos++
}
if escapePos < len(escapeIndices) && index+tagOffset >= escapeIndices[escapePos][0] && index+tagOffset < escapeIndices[escapePos][1] {
tagOffset++
escapePos++
}
}
bytes, width, from, to := printWithStyle(screen, text[leftIndex+tagOffset:], x, y, 0, maxWidth, AlignLeft, style, maintainBackground)
from += leftIndex + tagOffset
to += leftIndex + tagOffset
return bytes, width, from, to
}
// If we don't overwrite the background, we use the default color.
if maintainBackground {
style = style.Background(tcell.ColorDefault)
}
// Draw text.
// Skip beginning and measure width.
var (
drawn, drawnWidth, colorPos, escapePos, tagOffset, from, to int
foregroundColor, backgroundColor, attributes string
state *stepState
textWidth int
)
iterateString(strippedText, func(main rune, comb []rune, textPos, length, screenPos, screenWidth, boundaries int) bool {
// Skip character if necessary.
str := text
for len(str) > 0 {
_, str, state = step(str, state, stepOptionsStyle)
if skipWidth > 0 {
skipWidth -= screenWidth
from = textPos + length
to = from
return false
skipWidth -= state.Width()
if skipWidth <= 0 {
text = str
style = state.Style()
}
start += state.GrossLength()
} else {
textWidth += state.Width()
}
}
// Only continue if there is still space.
if drawnWidth+screenWidth > maxWidth || x+drawnWidth >= totalWidth {
return true
// Reduce all alignments to AlignLeft.
if align == AlignRight {
// Chop off characters on the left until it fits.
state = nil
for len(text) > 0 && textWidth > maxWidth {
_, text, state = step(text, state, stepOptionsStyle)
textWidth -= state.Width()
start += state.GrossLength()
style = state.Style()
}
// Handle color tags.
for colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
colorPos++
x, maxWidth = x+maxWidth-textWidth, textWidth
} else if align == AlignCenter {
// Chop off characters on the left until it fits.
state = nil
subtracted := (textWidth - maxWidth) / 2
for len(text) > 0 && subtracted > 0 {
_, text, state = step(text, state, stepOptionsStyle)
subtracted -= state.Width()
textWidth -= state.Width()
start += state.GrossLength()
style = state.Style()
}
// Handle escape tags.
if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
if textPos+tagOffset == escapeIndices[escapePos][1]-2 {
tagOffset++
escapePos++
}
if textWidth < maxWidth {
x, maxWidth = x+maxWidth/2-textWidth/2, textWidth
}
}
// Memorize positions.
to = textPos + length
// Draw left-aligned text.
end = start
rightBorder := x + maxWidth
state = &stepState{
unisegState: -1,
style: style,
}
for len(text) > 0 && x < rightBorder && x < totalWidth {
var c string
c, text, state = step(text, state, stepOptionsStyle)
if c == "" {
break // We don't care about the style at the end.
}
runes := []rune(c)
width := state.Width()
// Print the rune sequence.
finalX := x + drawnWidth
finalStyle := style
finalStyle := state.Style()
if maintainBackground {
_, _, existingStyle, _ := screen.GetContent(finalX, y)
_, background, _ := existingStyle.Decompose()
finalStyle = finalStyle.Background(background)
_, backgroundColor, _ := finalStyle.Decompose()
if backgroundColor == tcell.ColorDefault {
_, _, existingStyle, _ := screen.GetContent(x, y)
_, background, _ := existingStyle.Decompose()
finalStyle = finalStyle.Background(background)
}
}
finalStyle = overlayStyle(finalStyle, foregroundColor, backgroundColor, attributes)
for offset := screenWidth - 1; offset >= 0; offset-- {
for offset := width - 1; offset >= 0; offset-- {
// To avoid undesired effects, we populate all cells.
if offset == 0 {
screen.SetContent(finalX+offset, y, main, comb, finalStyle)
screen.SetContent(x+offset, y, runes[0], runes[1:], finalStyle)
} else {
screen.SetContent(finalX+offset, y, ' ', nil, finalStyle)
screen.SetContent(x+offset, y, ' ', nil, finalStyle)
}
}
// Advance.
drawn += length
drawnWidth += screenWidth
return false
})
x += width
end += state.GrossLength()
printedWidth += width
}
return drawn + tagOffset + len(escapeIndices), drawnWidth, from, to
return
}
// PrintSimple prints white text to the screen at the given position.
@ -446,112 +359,75 @@ func PrintSimple(screen tcell.Screen, text string, x, y int) {
}
// TaggedStringWidth returns the width of the given string needed to print it on
// screen. The text may contain color tags which are not counted.
func TaggedStringWidth(text string) int {
_, _, _, _, _, _, width := decomposeString(text, true, false)
return width
// screen. The text may contain style tags which are not counted.
func TaggedStringWidth(text string) (width int) {
var state *stepState
for len(text) > 0 {
_, text, state = step(text, state, stepOptionsStyle)
width += state.Width()
}
return
}
// WordWrap splits a text such that each resulting line does not exceed the
// given screen width. Possible split points are after any punctuation or
// whitespace. Whitespace after split points will be dropped.
// given screen width. Split points are determined using the algorithm described
// in [Unicode Standard Annex #14] .
//
// This function considers color tags to have no width.
// This function considers style tags to have no width.
//
// Text is always split at newline characters ('\n').
// [Unicode Standard Annex #14]: https://www.unicode.org/reports/tr14/
func WordWrap(text string, width int) (lines []string) {
colorTagIndices, _, _, _, escapeIndices, strippedText, _ := decomposeString(text, true, false)
// Find candidate breakpoints.
breakpoints := boundaryPattern.FindAllStringSubmatchIndex(strippedText, -1)
// Results in one entry for each candidate. Each entry is an array a of
// indices into strippedText where a[6] < 0 for newline/punctuation matches
// and a[4] < 0 for whitespace matches.
if width <= 0 {
return
}
// Process stripped text one character at a time.
var (
colorPos, escapePos, breakpointPos, tagOffset int
lastBreakpoint, lastContinuation, currentLineStart int
lineWidth, overflow int
forceBreak bool
state *stepState
lineWidth, lineLength, lastOption, lastOptionWidth int
)
unescape := func(substr string, startIndex int) string {
// A helper function to unescape escaped tags.
for index := escapePos; index >= 0; index-- {
if index < len(escapeIndices) && startIndex > escapeIndices[index][0] && startIndex < escapeIndices[index][1]-1 {
pos := escapeIndices[index][1] - 2 - startIndex
return substr[:pos] + substr[pos+1:]
}
}
return substr
}
iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool {
// Handle tags.
for {
if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {
// Colour tags.
tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0]
colorPos++
} else if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 {
// Escape tags.
tagOffset++
escapePos++
str := text
for len(str) > 0 {
// Parse the next character.
var c string
c, str, state = step(str, state, stepOptionsStyle)
cWidth := state.Width()
// Would it exceed the line width?
if lineWidth+cWidth > width {
if lastOptionWidth == 0 {
// No split point so far. Just split at the current position.
lines = append(lines, text[:lineLength])
text = text[lineLength:]
lineWidth, lineLength, lastOption, lastOptionWidth = 0, 0, 0, 0
} else {
break
// Split at the last split point.
lines = append(lines, text[:lastOption])
text = text[lastOption:]
lineWidth -= lastOptionWidth
lineLength -= lastOption
lastOption, lastOptionWidth = 0, 0
}
}
// Is this a breakpoint?
if breakpointPos < len(breakpoints) && textPos+tagOffset == breakpoints[breakpointPos][0] {
// Yes, it is. Set up breakpoint infos depending on its type.
lastBreakpoint = breakpoints[breakpointPos][0] + tagOffset
lastContinuation = breakpoints[breakpointPos][1] + tagOffset
overflow = 0
forceBreak = main == '\n'
if breakpoints[breakpointPos][6] < 0 && !forceBreak {
lastBreakpoint++ // Don't skip punctuation.
// Move ahead.
lineWidth += cWidth
lineLength += state.GrossLength()
// Check for split points.
if lineBreak, optional := state.LineBreak(); lineBreak {
if optional {
// Remember this split point.
lastOption = lineLength
lastOptionWidth = lineWidth
} else if str != "" || c != "" && uniseg.HasTrailingLineBreakInString(c) {
// We must split here.
lines = append(lines, strings.TrimRight(text[:lineLength], "\n\r"))
text = text[lineLength:]
lineWidth, lineLength, lastOption, lastOptionWidth = 0, 0, 0, 0
}
breakpointPos++
}
// Check if a break is warranted.
if forceBreak || lineWidth > 0 && lineWidth+screenWidth > width {
breakpoint := lastBreakpoint
continuation := lastContinuation
if forceBreak {
breakpoint = textPos + tagOffset
continuation = textPos + tagOffset + 1
lastBreakpoint = 0
overflow = 0
} else if lastBreakpoint <= currentLineStart {
breakpoint = textPos + tagOffset
continuation = textPos + tagOffset
overflow = 0
}
lines = append(lines, unescape(text[currentLineStart:breakpoint], currentLineStart))
currentLineStart, lineWidth, forceBreak = continuation, overflow, false
}
// Remember the characters since the last breakpoint.
if lastBreakpoint > 0 && lastContinuation <= textPos+tagOffset {
overflow += screenWidth
}
// Advance.
lineWidth += screenWidth
// But if we're still inside a breakpoint, skip next character (whitespace).
if textPos+tagOffset < currentLineStart {
lineWidth -= screenWidth
}
return false
})
// Flush the rest.
if currentLineStart < len(text) {
lines = append(lines, unescape(text[currentLineStart:], currentLineStart))
}
lines = append(lines, text)
return
}
@ -602,60 +478,17 @@ func iterateString(text string, callback func(main rune, comb []rune, textPos, t
return false
}
// iterateStringReverse iterates through the given string in reverse, starting
// from the end of the string, one printed character at a time. For each such
// character, the callback function is called with the 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) 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 returns true. This
// function returns true if the iteration was stopped before the last character.
func iterateStringReverse(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
type cluster struct {
main rune
comb []rune
textPos, textWidth, screenPos, screenWidth int
}
// Create the grapheme clusters.
var clusters []cluster
iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth, boundaries int) bool {
clusters = append(clusters, cluster{
main: main,
comb: comb,
textPos: textPos,
textWidth: textWidth,
screenPos: screenPos,
screenWidth: screenWidth,
})
return false
})
// Iterate in reverse.
for index := len(clusters) - 1; index >= 0; index-- {
if callback(
clusters[index].main,
clusters[index].comb,
clusters[index].textPos,
clusters[index].textWidth,
clusters[index].screenPos,
clusters[index].screenWidth,
) {
return true
}
}
return false
}
// stripTags strips colour tags from the given string. (Region tags are not
// stripTags strips style tags from the given string. (Region tags are not
// stripped.)
func stripTags(text string) string {
stripped := colorPattern.ReplaceAllStringFunc(text, func(match string) string {
if len(match) > 2 {
return ""
}
return match
})
return escapePattern.ReplaceAllString(stripped, `[$1$2]`)
var (
str strings.Builder
state *stepState
)
for len(text) > 0 {
var c string
c, text, state = step(text, state, stepOptionsStyle)
str.WriteString(c)
}
return str.String()
}

Loading…
Cancel
Save