From caea67a4efa54428ab7a6151a2d4e6487eb60152 Mon Sep 17 00:00:00 2001 From: Oliver <480930+rivo@users.noreply.github.com> Date: Tue, 22 Aug 2023 23:16:59 +0300 Subject: [PATCH] Implemented string parser and migrated all widgets but TextView. --- ansi.go | 6 +- box.go | 8 +- demos/box/main.go | 2 +- demos/inputfield/main.go | 2 +- demos/presentation/colors.go | 6 +- demos/presentation/textview.go | 2 +- doc.go | 60 ++-- form.go | 10 +- image.go | 2 +- inputfield.go | 109 +++++--- list.go | 6 +- modal.go | 14 +- strings.go | 497 +++++++++++++++++++++++++++++++++ table.go | 5 +- textarea.go | 53 +--- textview.go | 14 +- util.go | 465 ++++++++++-------------------- 17 files changed, 802 insertions(+), 459 deletions(-) create mode 100644 strings.go diff --git a/ansi.go b/ansi.go index b63b478..4c41dbd 100644 --- a/ansi.go +++ b/ansi.go @@ -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) diff --git a/box.go b/box.go index 0941bb7..bd82a6b 100644 --- a/box.go +++ b/box.go @@ -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) } } } diff --git a/demos/box/main.go b/demos/box/main.go index 9de4b8d..b1aacd3 100644 --- a/demos/box/main.go +++ b/demos/box/main.go @@ -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) } diff --git a/demos/inputfield/main.go b/demos/inputfield/main.go index 1666ac8..7684b5f 100644 --- a/demos/inputfield/main.go +++ b/demos/inputfield/main.go @@ -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() }) diff --git a/demos/presentation/colors.go b/demos/presentation/colors.go index d277750..ac07bd5 100644 --- a/demos/presentation/colors.go +++ b/demos/presentation/colors.go @@ -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) } diff --git a/demos/presentation/textview.go b/demos/presentation/textview.go index 5c6f282..fcb0bdf 100644 --- a/demos/presentation/textview.go +++ b/demos/presentation/textview.go @@ -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). diff --git a/doc.go b/doc.go index 8ebf3c5..3a3c41c 100644 --- a/doc.go +++ b/doc.go @@ -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. - [::] +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 + [:::] + +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. diff --git a/form.go b/form.go index de0be8a..c31fcf9 100644 --- a/form.go +++ b/form.go @@ -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 diff --git a/image.go b/image.go index 0780887..b6ed9cf 100644 --- a/image.go +++ b/image.go @@ -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 } diff --git a/inputfield.go b/inputfield.go index 75e180b..0d6442a 100644 --- a/inputfield.go +++ b/inputfield.go @@ -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 diff --git a/list.go b/list.go index beaaef4..8e59fe9 100644 --- a/list.go +++ b/list.go @@ -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 } diff --git a/modal.go b/modal.go index 564ed2b..98ed4db 100644 --- a/modal.go +++ b/modal.go @@ -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) } diff --git a/strings.go b/strings.go new file mode 100644 index 0000000..801e7c1 --- /dev/null +++ b/strings.go @@ -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 +} diff --git a/table.go b/table.go index ad88e58..ba28087 100644 --- a/table.go +++ b/table.go @@ -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) diff --git a/textarea.go b/textarea.go index 41cd14e..c537a8d 100644 --- a/textarea.go +++ b/textarea.go @@ -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 diff --git a/textview.go b/textview.go index 9f6508c..63bf2d3 100644 --- a/textview.go +++ b/textview.go @@ -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 } diff --git a/util.go b/util.go index 169a09c..d6a4147 100644 --- a/util.go +++ b/util.go @@ -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() }