diff --git a/demos/presentation/cover.go b/demos/presentation/cover.go index eaeea9d..5245a69 100644 --- a/demos/presentation/cover.go +++ b/demos/presentation/cover.go @@ -19,7 +19,7 @@ const logo = ` const ( subtitle = `tview - Rich Widgets for Terminal UIs` - navigation = `Ctrl-N: Next slide Ctrl-P: Previous slide Ctrl-C: Exit` + navigation = `[yellow]Ctrl-N[-]: Next slide [yellow]Ctrl-P[-]: Previous slide [yellow]Ctrl-C[-]: Exit` mouse = `(or use your mouse)` ) diff --git a/demos/presentation/end.go b/demos/presentation/end.go index c085b31..dad0525 100644 --- a/demos/presentation/end.go +++ b/demos/presentation/end.go @@ -9,10 +9,12 @@ import ( // End shows the final slide. func End(nextSlide func()) (title string, content tview.Primitive) { - textView := tview.NewTextView().SetDoneFunc(func(key tcell.Key) { - nextSlide() - }) - url := "https://github.com/rivo/tview" + textView := tview.NewTextView(). + SetDynamicColors(true). + SetDoneFunc(func(key tcell.Key) { + nextSlide() + }) + url := "[:::https://github.com/rivo/tview]https://github.com/rivo/tview" fmt.Fprint(textView, url) - return "End", Center(len(url), 1, textView) + return "End", Center(tview.TaggedStringWidth(url), 1, textView) } diff --git a/demos/textview/screenshot.png b/demos/textview/screenshot.png index 8d7f0bb..9021134 100644 Binary files a/demos/textview/screenshot.png and b/demos/textview/screenshot.png differ diff --git a/doc.go b/doc.go index 3a3c41c..185a1ff 100644 --- a/doc.go +++ b/doc.go @@ -53,11 +53,6 @@ application, set the box as its root primitive, and run the event loop. The application exits when the application's [Application.Stop] function is called or when Ctrl-C is pressed. -If we have a primitive which consumes key presses, we call the application's -[Application.SetFocus] function to redirect all key presses to that primitive. -Most primitives then offer ways to install handlers that allow you to react to -any actions performed on them. - # More Demos You will find more demos in the "demos" subdirectory. It also contains a @@ -114,8 +109,8 @@ 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 "]"). +must only contain single-byte characters (e.g. ASCII) and they may not contain +bracket characters ("[" or "]"). Examples: @@ -128,7 +123,7 @@ Examples: [-]Reset foreground color [::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[:::-]. + Send an email to [:::mailto:her@example.com]her/[:::mail:him@example.com]him/[:::mail:them@example.com]them[:::-]. [-:-:-:-]Reset everything [:]No effect []Not a valid style tag, will print square brackets as they are @@ -151,18 +146,24 @@ You can use the Escape() function to insert brackets automatically where needed. # Styles When primitives are instantiated, they are initialized with colors taken from -the global Styles variable. You may change this variable to adapt the look and +the global [Styles] variable. You may change this variable to adapt the look and feel of the primitives to your preferred style. +Note that most terminals will not report information about their color theme. +This package therefore does not support using the terminal's color theme. The +default style is a dark theme and you must change the [Styles] variable to +switch to a light (or other) theme. + # Unicode Support -This package supports unicode characters including wide characters. +This package supports all unicode characters supported by your terminal. # Concurrency Many functions in this package are not thread-safe. For many applications, this -may not be an issue: If your code makes changes in response to key events, it -will execute in the main goroutine and thus will not cause any race conditions. +is not an issue: If your code makes changes in response to key events, the +corresponding callback function will execute in the main goroutine and thus will +not cause any race conditions. (Exceptions to this are documented.) If you access your primitives from other goroutines, however, you will need to synchronize execution. The easiest way to do this is to call @@ -182,15 +183,17 @@ documentation for details. You can also call [Application.Draw] from any goroutine without having to wrap it in [Application.QueueUpdate]. And, as mentioned above, key event callbacks are executed in the main goroutine and thus should not use -[Application.QueueUpdate] as that may lead to deadlocks. +[Application.QueueUpdate] as that may lead to deadlocks. It is also not +necessary to call [Application.Draw] from such callbacks as it will be called +automatically. # Type Hierarchy All widgets listed above contain the [Box] type. All of [Box]'s functions are therefore available for all widgets, too. Please note that if you are using the -functions of [Box] on a subclass, they will return a *Box, not the subclass. So -while tview supports method chaining in many places, these chains must be broken -when using [Box]'s functions. Example: +functions of [Box] on a subclass, they will return a *Box, not the subclass. +This is a Golang limitation. So while tview supports method chaining in many +places, these chains must be broken when using [Box]'s functions. Example: // This will cause "textArea" to be an empty Box. textArea := tview.NewTextArea(). @@ -207,7 +210,8 @@ You will need to call [Box.SetBorder] separately: All widgets also implement the [Primitive] interface. -The tview package is based on https://github.com/gdamore/tcell. It uses types -and constants from that package (e.g. colors and keyboard values). +The tview package's rendering is based on version 2 of +https://github.com/gdamore/tcell. It uses types and constants from that package +(e.g. colors, styles, and keyboard values). */ package tview diff --git a/inputfield.go b/inputfield.go index 0d6442a..9118fbc 100644 --- a/inputfield.go +++ b/inputfield.go @@ -18,6 +18,21 @@ const ( AutocompletedClick // The user selected an autocomplete entry by clicking the mouse button on it. ) +// Predefined InputField acceptance functions. +var ( + // InputFieldInteger accepts integers. + InputFieldInteger func(text string, ch rune) bool + + // InputFieldFloat accepts floating-point numbers. + InputFieldFloat func(text string, ch rune) bool + + // InputFieldMaxLength returns an input field accept handler which accepts + // input strings up to a given length. Use it like this: + // + // inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters. + InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool +) + // InputField is a one-line box (three lines if there is a title) where the // user can enter text. Use [InputField.SetAcceptanceFunc] to accept or reject // input, [InputField.SetChangedFunc] to listen for changes, and diff --git a/strings.go b/strings.go index 801e7c1..b5d291b 100644 --- a/strings.go +++ b/strings.go @@ -495,3 +495,102 @@ func parseTag(str string, state *stepState) (length int, style tcell.Style, regi return } + +// TaggedStringWidth returns the width of the given string needed to print it on +// 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. Split points are determined using the algorithm described +// in [Unicode Standard Annex #14]. +// +// This function considers style tags to have no width. +// +// [Unicode Standard Annex #14]: https://www.unicode.org/reports/tr14/ +func WordWrap(text string, width int) (lines []string) { + if width <= 0 { + return + } + + var ( + state *stepState + lineWidth, lineLength, lastOption, lastOptionWidth int + ) + 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 { + // Split at the last split point. + lines = append(lines, text[:lastOption]) + text = text[lastOption:] + lineWidth -= lastOptionWidth + lineLength -= lastOption + lastOption, lastOptionWidth = 0, 0 + } + } + + // 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 + } + } + } + lines = append(lines, text) + + return +} + +// Escape escapes the given text such that color and/or region tags are not +// recognized and substituted by the print functions of this package. For +// example, to include a tag-like string in a box title or in a TextView: +// +// box.SetTitle(tview.Escape("[squarebrackets]")) +// fmt.Fprint(textView, tview.Escape(`["quoted"]`)) +func Escape(text string) string { + return nonEscapePattern.ReplaceAllString(text, "$1[]") +} + +// stripTags strips style tags from the given string. (Region tags are not +// stripped.) +func stripTags(text string) string { + 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() +} diff --git a/textview.go b/textview.go index 63bf2d3..892f7a8 100644 --- a/textview.go +++ b/textview.go @@ -1,47 +1,24 @@ package tview import ( - "bytes" - "fmt" - "regexp" "strings" "sync" - "unicode/utf8" "github.com/gdamore/tcell/v2" colorful "github.com/lucasb-eyer/go-colorful" "github.com/rivo/uniseg" ) -var ( - openColorRegex = regexp.MustCompile(`\[([a-zA-Z]*|#[0-9a-zA-Z]*)$`) - openRegionRegex = regexp.MustCompile(`\["[a-zA-Z0-9_,;: \-\.]*"?$`) - newLineRegex = regexp.MustCompile(`\r?\n`) +// TabSize is the number of spaces with which a tab character will be replaced. +var TabSize = 4 - // TabSize is the number of spaces with which a tab character will be replaced. - TabSize = 4 -) - -// textViewIndex contains information about a line displayed in the text view. -type textViewIndex struct { - Line int // The index into the "buffer" slice. - Pos int // The index into the "buffer" string (byte position). - NextPos int // The (byte) index of the next line start within this buffer string. - Width int // The screen width of this line. - ForegroundColor string // The starting foreground color ("" = don't change, "-" = reset). - BackgroundColor string // The starting background color ("" = don't change, "-" = reset). - Attributes string // The starting attributes ("" = don't change, "-" = reset). - Region string // The starting region ID. -} - -// textViewRegion contains information about a region. -type textViewRegion struct { - // The region ID. - ID string - - // The starting and end screen position of the region as determined the last - // time Draw() was called. A negative value indicates out-of-rect positions. - FromX, FromY, ToX, ToY int +// textViewLine contains information about a line displayed in the text view. +type textViewLine struct { + offset int // The string position in the buffer where this line starts. + width int // The screen width of this line. + length int // The string length (in bytes) of this line. + state *stepState // The parser state at the beginning of the line, before parsing the first character. + regions map[string][2]int // The start and end columns of all regions in this line. Only valid for visible lines. May be nil. } // TextViewWriter is a writer that can be used to write to and clear a TextView @@ -74,19 +51,24 @@ func (w TextViewWriter) HasFocus() bool { return w.t.hasFocus } -// TextView is a box which displays text. While the text to be displayed can be -// changed or appended to, there is no functionality that allows the user to -// edit text. For that, TextArea should be used. +// TextView is a component to display read-only text. While the text to be +// displayed can be changed or appended to, there is no functionality that +// allows the user to edit it. For that, [TextArea] should be used. // // TextView implements the io.Writer interface so you can stream text to it, // appending to the existing text. This does not trigger a redraw automatically -// but if a handler is installed via SetChangedFunc(), you can cause it to be -// redrawn. (See SetChangedFunc() for more details.) +// but if a handler is installed via [TextView.SetChangedFunc], you can cause it +// to be redrawn. (See [TextView.SetChangedFunc] for more details.) +// +// Tab characters advance the text to the next tab stop at every [TabSize] +// screen columns, but only if the text is left-aligned. If the text is centered +// or right-aligned, tab characters are simply replaced with [TabSize] spaces. // // # Navigation // -// If the text view is scrollable (the default), text is kept in a buffer which -// may be larger than the screen and can be navigated similarly to Vim: +// If the text view is set to be scrollable (which is the default), text is kept +// in a buffer which may be larger than the screen and can be navigated +// with Vim-like key binds: // // - h, left arrow: Move left. // - l, right arrow: Move right. @@ -98,23 +80,24 @@ func (w TextViewWriter) HasFocus() bool { // - Ctrl-B, page up: Move up by one page. // // If the text is not scrollable, any text above the top visible line is -// discarded. +// discarded. This can be useful when you want to continuously stream text to +// the text view and only keep the latest lines. // -// Use SetInputCapture() to override or modify keyboard input. +// Use [Box.SetInputCapture] to override or modify keyboard input. // -// # Colors +// # Styles / Colors // -// If dynamic colors are enabled via SetDynamicColors(), text color can be -// changed dynamically by embedding color strings in square brackets. This works -// the same way as anywhere else. Please see the package documentation for more +// If dynamic colors are enabled via [TextView.SetDynamicColors], text style can +// be changed dynamically by embedding color strings in square brackets. This +// works the same way as anywhere else. See the package documentation for more // information. // // # Regions and Highlights // -// If regions are enabled via SetRegions(), you can define text regions within -// the text and assign region IDs to them. Text regions start with region tags. -// Region tags are square brackets that contain a region ID in double quotes, -// for example: +// If regions are enabled via [TextView.SetRegions], you can define text regions +// within the text and assign region IDs to them. Text regions start with region +// tags. Region tags are square brackets that contain a region ID in double +// quotes, for example: // // We define a ["rg"]region[""] here. // @@ -124,18 +107,27 @@ func (w TextViewWriter) HasFocus() bool { // // [a-zA-Z0-9_,;: \-\.]+ // -// Regions can be highlighted by calling the Highlight() function with one or -// more region IDs. This can be used to display search results, for example. +// Regions can be highlighted by calling the [TextView.Highlight] function with +// one or more region IDs. This can be used to display search results, for +// example. // -// The ScrollToHighlight() function can be used to jump to the currently -// highlighted region once when the text view is drawn the next time. +// The [TextView.ScrollToHighlight] function can be used to jump to the +// currently highlighted region once when the text view is drawn the next time. // // # Large Texts // -// This widget is not designed for very large texts as word wrapping, color and -// region tag handling, and proper Unicode handling will result in a significant -// performance hit the longer your text gets. Consider using SetMaxLines() to -// limit the number of lines in the text view. +// The text view can handle reasonably large texts. It will parse the text as +// needed. For optimal performance, it is best to access or display parts of the +// text very far down only if really needed. For example, call +// [TextView.ScrollToBeginning] before adding the text to the text view, to +// avoid scrolling the text all the way to the bottom, forcing a full-text +// parse. +// +// For even larger texts or "infinite" streams of text such as log files, you +// should consider using [TextView.SetMaxLines] to limit the number of lines in +// the text view buffer. Or disable the text view's scrollability altogether +// (using [TextView.SetScrollable]). This will cause the text view to discard +// lines moving out of the visible area at the top. // // See https://github.com/rivo/tview/wiki/TextView for an example. type TextView struct { @@ -147,14 +139,18 @@ type TextView struct { width, height int // The text buffer. - buffer []string + text strings.Builder - // The last bytes that have been received but are not part of the buffer yet. - recentBytes []byte + // The line index. It is valid at any time but may not contain trailing + // lines which are not visible. + lineIndex []*textViewLine - // The processed line index. This is nil if the buffer has changed and needs - // to be re-indexed. - index []*textViewIndex + // The screen width of the longest line in the index. + longestLine int + + // Regions mapped by their ID to the line where they start. Regions which + // cannot be found in [TextView.lineIndex] are not contained. + regions map[string]int // The label text shown, usually when part of a form. label string @@ -168,34 +164,23 @@ type TextView struct { // The text alignment, one of AlignLeft, AlignCenter, or AlignRight. align int - // Information about visible regions as of the last call to Draw(). - regionInfos []*textViewRegion - - // Indices into the "index" slice which correspond to the first line of the - // first highlight and the last line of the last highlight. This is calculated - // during re-indexing. Set to -1 if there is no current highlight. - fromHighlight, toHighlight int - - // The screen space column of the highlight in its first line. Set to -1 if - // there is no current highlight. - posHighlight int - - // A set of region IDs that are currently highlighted. + // Currently highlighted regions. highlights map[string]struct{} - // The last width for which the current text view is drawn. + // The last width for which the current text view was drawn. lastWidth int - // The screen width of the longest line in the index (not the buffer). - longestLine int + // The height of the content the last time the text view was drawn. + pageSize int // The index of the first line shown in the text view. lineOffset int - // If set to true, the text view will always remain at the end of the content. + // If set to true, the text view will always remain at the end of the + // content when text is added. trackEnd bool - // The number of characters to be skipped on each line (not used in wrap + // The width of the characters to be skipped on each line (not used in wrap // mode). columnOffset int @@ -203,43 +188,40 @@ type TextView struct { // latest word-wrapped lines. Ignored if 0. maxLines int - // The height of the content the last time the text view was drawn. - pageSize int - // If set to true, the text view will keep a buffer of text which can be // navigated when the text is longer than what fits into the box. scrollable bool - // If set to true, lines that are longer than the available width are wrapped - // onto the next line. If set to false, any characters beyond the available - // width are discarded. + // If set to true, lines that are longer than the available width are + // wrapped onto the next line. If set to false, any characters beyond the + // available width are discarded. wrap bool - // If set to true and if wrap is also true, lines are split at spaces or - // after punctuation characters. + // If set to true and if wrap is also true, Unicode line breaking is + // applied. wordWrap bool // The (starting) style of the text. This also defines the background color // of the main text element. textStyle tcell.Style - // If set to true, the text color can be changed dynamically by piping color - // strings in square brackets to the text view. - dynamicColors bool + // Whether or not style tags are used. + styleTags bool - // If set to true, region tags can be used to define regions. - regions bool + // Whether or not region tags are used. + regionTags bool // A temporary flag which, when true, will automatically bring the current - // highlight(s) into the visible screen. + // highlight(s) into the visible screen the next time the text view is + // drawn. scrollToHighlights bool // If true, setting new highlights will be a XOR instead of an overwrite // operation. toggleHighlights bool - // An optional function which is called when the content of the text view has - // changed. + // An optional function which is called when the content of the text view + // has changed. changed func() // An optional function which is called when the user presses one of the @@ -258,16 +240,16 @@ type TextView struct { // NewTextView returns a new text view. func NewTextView() *TextView { return &TextView{ - Box: NewBox(), - labelStyle: tcell.StyleDefault.Foreground(Styles.SecondaryTextColor), - highlights: make(map[string]struct{}), - lineOffset: -1, - scrollable: true, - align: AlignLeft, - wrap: true, - textStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.PrimaryTextColor), - regions: false, - dynamicColors: false, + Box: NewBox(), + labelStyle: tcell.StyleDefault.Foreground(Styles.SecondaryTextColor), + highlights: make(map[string]struct{}), + lineOffset: -1, + scrollable: true, + align: AlignLeft, + wrap: true, + textStyle: tcell.StyleDefault.Background(Styles.PrimitiveBackgroundColor).Foreground(Styles.PrimaryTextColor), + regionTags: false, + styleTags: false, } } @@ -315,8 +297,8 @@ func (t *TextView) SetDisabled(disabled bool) FormItem { } // SetScrollable sets the flag that decides whether or not the text view is -// scrollable. If true, text is kept in a buffer and can be navigated. If false, -// the last line will always be visible. +// scrollable. If false, text that moves above the text view's top row will be +// permanently deleted. func (t *TextView) SetScrollable(scrollable bool) *TextView { t.scrollable = scrollable if !scrollable { @@ -330,20 +312,19 @@ func (t *TextView) SetScrollable(scrollable bool) *TextView { // beyond the available width are not displayed. func (t *TextView) SetWrap(wrap bool) *TextView { if t.wrap != wrap { - t.index = nil + t.resetIndex() // This invalidates the entire index. } t.wrap = wrap return t } // SetWordWrap sets the flag that, if true and if the "wrap" flag is also true -// (see SetWrap()), wraps the line at spaces or after punctuation marks. Note -// that trailing spaces will not be printed. +// (see [TextView.SetWrap]), wraps according to [Unicode Standard Annex #14]. // // This flag is ignored if the "wrap" flag is false. func (t *TextView) SetWordWrap(wrapOnWords bool) *TextView { - if t.wordWrap != wrapOnWords { - t.index = nil + if t.wrap && t.wordWrap != wrapOnWords { + t.resetIndex() // This invalidates the entire index. } t.wordWrap = wrapOnWords return t @@ -351,11 +332,11 @@ func (t *TextView) SetWordWrap(wrapOnWords bool) *TextView { // SetMaxLines sets the maximum number of lines for this text view. Lines at the // beginning of the text will be discarded when the text view is drawn, so as to -// remain below this value. Broken lines via word wrapping are counted -// individually. +// remain below this value. Only lines above the first visible line are removed. +// +// Broken-over lines via word/character wrapping are counted individually. // -// Note that GetText() will return the shortened text and may start with color -// and/or region tags that were open at the cutoff point. +// Note that [TextView.GetText] will return the shortened text. // // A value of 0 (the default) will keep all lines in place. func (t *TextView) SetMaxLines(maxLines int) *TextView { @@ -366,18 +347,14 @@ func (t *TextView) SetMaxLines(maxLines int) *TextView { // SetTextAlign sets the text alignment within the text view. This must be // either AlignLeft, AlignCenter, or AlignRight. func (t *TextView) SetTextAlign(align int) *TextView { - if t.align != align { - t.index = nil - } t.align = align return t } -// SetTextColor sets the initial color of the text (which can be changed -// dynamically by sending color strings in square brackets to the text view if -// dynamic colors are enabled). +// SetTextColor sets the initial color of the text. func (t *TextView) SetTextColor(color tcell.Color) *TextView { t.textStyle = t.textStyle.Foreground(color) + t.resetIndex() return t } @@ -387,15 +364,15 @@ func (t *TextView) SetTextColor(color tcell.Color) *TextView { func (t *TextView) SetBackgroundColor(color tcell.Color) *Box { t.Box.SetBackgroundColor(color) t.textStyle = t.textStyle.Background(color) + t.resetIndex() return t.Box } -// SetTextStyle sets the initial style of the text (which can be changed -// dynamically by sending color strings in square brackets to the text view if -// dynamic colors are enabled). This style's background color also determines -// the background color of the main text element (even if empty). +// SetTextStyle sets the initial style of the text. This style's background +// color also determines the background color of the main text element. func (t *TextView) SetTextStyle(style tcell.Style) *TextView { t.textStyle = style + t.resetIndex() return t } @@ -404,84 +381,101 @@ func (t *TextView) SetTextStyle(style tcell.Style) *TextView { // interface directly, this does not trigger an automatic redraw but it will // trigger the "changed" callback if one is set. func (t *TextView) SetText(text string) *TextView { - batch := t.BatchWriter() - defer batch.Close() - - batch.Clear() - fmt.Fprint(batch, text) + t.Lock() + defer t.Unlock() + t.text.Reset() + t.text.WriteString(text) + t.resetIndex() + if t.changed != nil { + go t.changed() + } return t } // GetText returns the current text of this text view. If "stripAllTags" is set // to true, any region/style tags are stripped from the text. func (t *TextView) GetText(stripAllTags bool) string { - // Get the buffer. - buffer := t.buffer - if !stripAllTags { - buffer = make([]string, len(t.buffer), len(t.buffer)+1) - copy(buffer, t.buffer) - buffer = append(buffer, string(t.recentBytes)) + if !stripAllTags || (!t.styleTags && !t.regionTags) { + return t.text.String() } - // Add newlines again. - text := strings.Join(buffer, "\n") - - // Strip from tags if required. - if stripAllTags { - if t.regions { - text = regionPattern.ReplaceAllString(text, "") - } - if t.dynamicColors { - text = stripTags(text) - } - if t.regions && !t.dynamicColors { - text = escapePattern.ReplaceAllString(text, `[$1$2]`) - } + var ( + str strings.Builder + state *stepState + text = t.text.String() + opts stepOptions + ch string + ) + if t.styleTags { + opts = stepOptionsStyle } - - return text + if t.regionTags { + opts |= stepOptionsRegion + } + for len(text) > 0 { + ch, text, state = step(text, state, opts) + str.WriteString(ch) + } + return str.String() } // GetOriginalLineCount returns the number of lines in the original text buffer, -// i.e. the number of newline characters plus one. +// without applying any wrapping. This is an expensive call as it needs to +// iterate over the entire text. func (t *TextView) GetOriginalLineCount() int { - return len(t.buffer) + if t.text.Len() == 0 { + return 0 + } + + var ( + state *stepState + str = t.text.String() + lines int = 1 + ) + for len(str) > 0 { + _, str, state = step(str, state, stepOptionsNone) + if lineBreak, optional := state.LineBreak(); lineBreak && !optional { + lines++ + } + } + + return lines } // SetDynamicColors sets the flag that allows the text color to be changed -// dynamically. See class description for details. +// dynamically with style tags. See class description for details. func (t *TextView) SetDynamicColors(dynamic bool) *TextView { - if t.dynamicColors != dynamic { - t.index = nil + if t.styleTags != dynamic { + t.resetIndex() // This invalidates the entire index. } - t.dynamicColors = dynamic + t.styleTags = dynamic return t } // SetRegions sets the flag that allows to define regions in the text. See class // description for details. func (t *TextView) SetRegions(regions bool) *TextView { - if t.regions != regions { - t.index = nil + if t.regionTags != regions { + t.resetIndex() // This invalidates the entire index. } - t.regions = regions + t.regionTags = regions return t } // SetChangedFunc sets a handler function which is called when the text of the -// text view has changed. This is useful when text is written to this io.Writer -// in a separate goroutine. Doing so does not automatically cause the screen to -// be refreshed so you may want to use the "changed" handler to redraw the -// screen. +// text view has changed. This is useful when text is written to this +// [io.Writer] in a separate goroutine. Doing so does not automatically cause +// the screen to be refreshed so you may want to use the "changed" handler to +// redraw the screen. // // Note that to avoid race conditions or deadlocks, there are a few rules you // should follow: // -// - You can call Application.Draw() from this handler. -// - You can call TextView.HasFocus() from this handler. +// - You can call [Application.Draw] from this handler. +// - You can call [TextView.HasFocus] from this handler. // - During the execution of this handler, access to any other variables from // this primitive or any other primitive must be queued using -// Application.QueueUpdate(). +// [Application.QueueUpdate]. // // See package description for details on dealing with concurrency. func (t *TextView) SetChangedFunc(handler func()) *TextView { @@ -502,8 +496,9 @@ func (t *TextView) SetDoneFunc(handler func(key tcell.Key)) *TextView { // highlighted, those that are not highlighted anymore, and those that remain // highlighted. // -// Note that because regions are only determined during drawing, this function -// can only fire for regions that have existed during the last call to Draw(). +// Note that because regions are only determined when drawing the text view, +// this function can only fire for regions that have existed when the text view +// was last drawn. func (t *TextView) SetHighlightedFunc(handler func(added, removed, remaining []string)) *TextView { t.highlighted = handler return t @@ -566,36 +561,53 @@ func (t *TextView) GetScrollOffset() (row, column int) { return t.lineOffset, t.columnOffset } -// Clear removes all text from the buffer. +// Clear removes all text from the buffer. This triggers the "changed" callback. func (t *TextView) Clear() *TextView { t.Lock() defer t.Unlock() - t.clear() + if t.changed != nil { + go t.changed() + } return t } // clear is the internal implementaton of clear. It is used by TextViewWriter // and anywhere that we need to perform a write without locking the buffer. func (t *TextView) clear() { - t.buffer = nil - t.recentBytes = nil - t.index = nil + t.text.Reset() + t.resetIndex() } // Highlight specifies which regions should be highlighted. If highlight -// toggling is set to true (see SetToggleHighlights()), the highlight of the -// provided regions is toggled (highlighted regions are un-highlighted and vice -// versa). If toggling is set to false, the provided regions are highlighted and -// all other regions will not be highlighted (you may also provide nil to turn -// off all highlights). +// toggling is set to true (see [TextView.SetToggleHighlights]), the highlight +// of the provided regions is toggled (i.e. highlighted regions are +// un-highlighted and vice versa). If toggling is set to false, the provided +// regions are highlighted and all other regions will not be highlighted (you +// may also provide nil to turn off all highlights). // // For more information on regions, see class description. Empty region strings // are ignored. // // Text in highlighted regions will be drawn inverted, i.e. with their // background and foreground colors swapped. +// +// If toggling is set to false, clicking outside of any region will remove all +// highlights. +// +// This function is expensive if a specified region is in a part of the text +// that has not yet been parsed. func (t *TextView) Highlight(regionIDs ...string) *TextView { + // Make sure we know these regions. + t.parseAhead(t.lastWidth, func(lineNumber int, line *textViewLine) bool { + for _, regionID := range regionIDs { + if _, ok := t.regions[regionID]; !ok { + return false + } + } + return true + }) + // Toggle highlights. if t.toggleHighlights { var newIDs []string @@ -640,10 +652,9 @@ func (t *TextView) Highlight(regionIDs ...string) *TextView { } t.highlights[id] = struct{}{} } - t.index = nil // Notify. - if t.highlighted != nil && len(added) > 0 || len(removed) > 0 { + if t.highlighted != nil && (len(added) > 0 || len(removed) > 0) { t.highlighted(added, removed, remaining) } @@ -659,9 +670,9 @@ func (t *TextView) GetHighlights() (regionIDs []string) { } // SetToggleHighlights sets a flag to determine how regions are highlighted. -// When set to true, the Highlight() function (or a mouse click) will toggle the -// provided/selected regions. When set to false, Highlight() (or a mouse click) -// will simply highlight the provided regions. +// When set to true, the [TextView.Highlight] function (or a mouse click) will +// toggle the provided/selected regions. When set to false, [TextView.Highlight] +// (or a mouse click) will simply highlight the provided regions. func (t *TextView) SetToggleHighlights(toggle bool) *TextView { t.toggleHighlights = toggle return t @@ -676,88 +687,64 @@ func (t *TextView) SetToggleHighlights(toggle bool) *TextView { // Nothing happens if there are no highlighted regions or if the text view is // not scrollable. func (t *TextView) ScrollToHighlight() *TextView { - if len(t.highlights) == 0 || !t.scrollable || !t.regions { + if len(t.highlights) == 0 || !t.scrollable || !t.regionTags { return t } - t.index = nil t.scrollToHighlights = true t.trackEnd = false return t } -// GetRegionText returns the text of the region with the given ID. If dynamic -// colors are enabled, style tags are stripped from the text. Newlines are -// always returned as '\n' runes. +// GetRegionText returns the text of the first region with the given ID. If +// dynamic colors are enabled, style tags are stripped from the text. // // If the region does not exist or if regions are turned off, an empty string // is returned. +// +// This function can be expensive if the specified region is way beyond the +// visible area of the text view as the text needs to be parsed until the region +// can be found, or if the region does not contain any text. func (t *TextView) GetRegionText(regionID string) string { - if !t.regions || regionID == "" { + if !t.regionTags || regionID == "" { return "" } - var ( - buffer bytes.Buffer - currentRegionID string - ) - - for _, str := range t.buffer { - // Find all style tags in this line. - var colorTagIndices [][]int - if t.dynamicColors { - colorTagIndices = colorPattern.FindAllStringIndex(str, -1) - } - - // Find all regions in this line. - var ( - regionIndices [][]int - regions [][]string - ) - if t.regions { - regionIndices = regionPattern.FindAllStringIndex(str, -1) - regions = regionPattern.FindAllStringSubmatch(str, -1) - } - - // Analyze this line. - var currentTag, currentRegion int - for pos, ch := range str { - // Skip any style tags. - if currentTag < len(colorTagIndices) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { - tag := currentTag - if pos == colorTagIndices[tag][1]-1 { - currentTag++ - } - if colorTagIndices[tag][1]-colorTagIndices[tag][0] > 2 { - continue - } - } - - // Skip any regions. - if currentRegion < len(regionIndices) && pos >= regionIndices[currentRegion][0] && pos < regionIndices[currentRegion][1] { - if pos == regionIndices[currentRegion][1]-1 { - if currentRegionID == regionID { - // This is the end of the requested region. We're done. - return buffer.String() - } - currentRegionID = regions[currentRegion][1] - currentRegion++ - } - continue - } - - // Add this rune. - if currentRegionID == regionID { - buffer.WriteRune(ch) - } + // Parse until we find the region. + lineNumber, ok := t.regions[regionID] + if !ok { + lineNumber = -1 + t.parseAhead(t.lastWidth, func(number int, line *textViewLine) bool { + lineNumber, ok = t.regions[regionID] + return ok + }) + if lineNumber < 0 { + return "" // We couldn't find this region. } + } - // Add newline. - if currentRegionID == regionID { - buffer.WriteRune('\n') + // Extract text from region. + var ( + line = t.lineIndex[lineNumber] + text = t.text.String()[line.offset:] + st = *line.state + state = &st + options = stepOptionsRegion + regionText strings.Builder + ) + if t.styleTags { + options |= stepOptionsStyle + } + for len(text) > 0 { + var ch string + ch, text, state = step(text, state, options) + if state.region == regionID { + regionText.WriteString(ch) + } else if regionText.Len() > 0 { + break } } - return escapePattern.ReplaceAllString(buffer.String(), `[$1$2]`) + return regionText.String() } // Focus is called when this primitive receives focus. @@ -785,9 +772,7 @@ func (t *TextView) HasFocus() bool { return t.Box.HasFocus() } -// Write lets us implement the io.Writer interface. Tab characters will be -// replaced with TabSize space characters. A "\n" or "\r\n" will be interpreted -// as a new line. +// Write lets us implement the io.Writer interface. func (t *TextView) Write(p []byte) (n int, err error) { t.Lock() defer t.Unlock() @@ -795,7 +780,7 @@ func (t *TextView) Write(p []byte) (n int, err error) { return t.write(p) } -// write is the internal implementation of Write. It is used by TextViewWriter +// write is the internal implementation of Write. It is used by [TextViewWriter] // and anywhere that we need to perform a write without locking the buffer. func (t *TextView) write(p []byte) (n int, err error) { // Notify at the end. @@ -808,59 +793,13 @@ func (t *TextView) write(p []byte) (n int, err error) { }() } - // Copy data over. - newBytes := append(t.recentBytes, p...) - t.recentBytes = nil - - // If we have a trailing invalid UTF-8 byte, we'll wait. - if r, _ := utf8.DecodeLastRune(p); r == utf8.RuneError { - t.recentBytes = newBytes - return len(p), nil - } - - // If we have a trailing open dynamic color, exclude it. - if t.dynamicColors { - location := openColorRegex.FindIndex(newBytes) - if location != nil { - t.recentBytes = newBytes[location[0]:] - newBytes = newBytes[:location[0]] - } - } - - // If we have a trailing open region, exclude it. - if t.regions { - location := openRegionRegex.FindIndex(newBytes) - if location != nil { - t.recentBytes = newBytes[location[0]:] - newBytes = newBytes[:location[0]] - } - } - - // Transform the new bytes into strings. - newBytes = bytes.Replace(newBytes, []byte{'\t'}, bytes.Repeat([]byte{' '}, TabSize), -1) - for index, line := range newLineRegex.Split(string(newBytes), -1) { - if index == 0 { - if len(t.buffer) == 0 { - t.buffer = []string{line} - } else { - t.buffer[len(t.buffer)-1] += line - } - } else { - t.buffer = append(t.buffer, line) - } - } - - // Reset the index. - t.index = nil - - return len(p), nil + return t.text.Write(p) } // BatchWriter returns a new writer that can be used to write into the buffer -// but without Locking/Unlocking the buffer on every write, as TextView's -// Write() and Clear() functions do. The lock will be acquired once when -// BatchWriter is called, and will be released when the returned writer is -// closed. Example: +// but without Locking/Unlocking the buffer on every write, as [TextView.Write] +// and [TextView.Clear] do. The lock will be acquired once when BatchWriter is +// called, and will be released when the returned writer is closed. Example: // // tv := tview.NewTextView() // w := tv.BatchWriter() @@ -880,238 +819,155 @@ func (t *TextView) BatchWriter() TextViewWriter { } } -// reindexBuffer re-indexes the buffer such that we can use it to easily draw -// the buffer onto the screen. Each line in the index will contain a pointer -// into the buffer from which on we will print text. It will also contain the -// colors, attributes, and region with which the line starts. +// resetIndex resets all indexed data, including the line index. +func (t *TextView) resetIndex() { + t.lineIndex = nil + t.regions = make(map[string]int) + t.longestLine = 0 +} + +// parseAhead parses the text buffer starting at the last line in +// [TextView.lineIndex] until either the end of the buffer or until stop returns +// true for the last complete line that was parsed. If wrapping is enabled, +// width will be used as the available screen width. +// +// There is no guarantee that stop will ever be called. // -// If maxLines is greater than 0, any extra lines will be dropped from the -// buffer. -func (t *TextView) reindexBuffer(width int) { - if t.index != nil { - return // Nothing has changed. We can still use the current index. +// The function adds entries to the [TextView.lineIndex] slice and the +// [TextView.regions] map and adjusts [TextView.longestLine]. +func (t *TextView) parseAhead(width int, stop func(lineNumber int, line *textViewLine) bool) { + if t.text.Len() == 0 || width == 0 { + return // No text. Nothing to parse. } - t.index = nil - t.fromHighlight, t.toHighlight, t.posHighlight = -1, -1, -1 - // If there's no space, there's no index. - if width < 1 { - return + // What kind of tags do we scan for? + var options stepOptions + if t.styleTags { + options |= stepOptionsStyle + } + if t.regionTags { + options |= stepOptionsRegion } - // Initial states. - regionID := "" + // Start parsing at the last line in the index. + var lastLine *textViewLine + str := t.text.String() + if len(t.lineIndex) == 0 { + // Insert the first line. + lastLine = &textViewLine{ + state: &stepState{ + unisegState: -1, + style: t.textStyle, + }, + } + t.lineIndex = append(t.lineIndex, lastLine) + } else { + // Reset the last line. + lastLine = t.lineIndex[len(t.lineIndex)-1] + lastLine.width = 0 + lastLine.length = 0 + str = str[lastLine.offset:] + } + + // Parse. var ( - highlighted bool - foregroundColor, backgroundColor, attributes string + lastOption int // Text index of the last optional split point. + lastOptionWidth int // Line width at last optional split point. + lastOptionState *stepState // State at last optional split point. + leftPos int // The current position in the line (only for left-alignment). + offset = lastLine.offset // Text index of the current position. + st = *lastLine.state // Current state. + state = &st // Pointer to current state. ) - - // Go through each line in the buffer. - for bufferIndex, str := range t.buffer { - colorTagIndices, colorTags, regionIndices, regions, escapeIndices, strippedStr, _ := decomposeString(str, t.dynamicColors, t.regions) - - // Split the line if required. - var splitLines []string - str = strippedStr - if t.wrap && len(str) > 0 { - for len(str) > 0 { - // Truncate str to width. - var splitPos, clusterWidth, lineWidth int - state := -1 - remaining := str - for splitPos == 0 || len(remaining) > 0 { // We'll extract at least one grapheme cluster. - var cluster string - cluster, remaining, clusterWidth, state = uniseg.FirstGraphemeClusterInString(remaining, state) - lineWidth += clusterWidth - if splitPos > 0 && lineWidth > width { - break - } - splitPos += len(cluster) - } - extract := str[:splitPos] - - if t.wordWrap && len(extract) < len(str) { - // Add any spaces from the next line. - if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 { - extract = str[:len(extract)+spaces[1]] - } - - // Can we split before the mandatory end? - matches := boundaryPattern.FindAllStringIndex(extract, -1) - if len(matches) > 0 { - // Yes. Let's split there. - extract = extract[:matches[len(matches)-1][1]] - } - } - splitLines = append(splitLines, extract) - str = str[len(extract):] + for len(str) > 0 { + var c string + region := state.region + c, str, state = step(str, state, options) + w := state.Width() + if c == "\t" { + if t.align == AlignLeft { + w = TabSize - leftPos%TabSize + } else { + w = TabSize } - } else { - // No need to split the line. - splitLines = []string{str} } - - // Create index from split lines. - var originalPos, colorPos, regionPos, escapePos int - for _, splitLine := range splitLines { - line := &textViewIndex{ - Line: bufferIndex, - Pos: originalPos, - ForegroundColor: foregroundColor, - BackgroundColor: backgroundColor, - Attributes: attributes, - Region: regionID, - } - - // Shift original position with tags. - lineLength := len(splitLine) - remainingLength := lineLength - tagEnd := originalPos - totalTagLength := 0 - for { - // 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 = style tag. - } - if regionPos < len(regionIndices) { - nextTag = append(nextTag, [3]int{regionIndices[regionPos][0], regionIndices[regionPos][1], 1}) // 1 = region tag. - } - if escapePos < len(escapeIndices) { - nextTag = append(nextTag, [3]int{escapeIndices[escapePos][0], escapeIndices[escapePos][1], 2}) // 2 = escape tag. - } - minPos := -1 - tagIndex := -1 - for index, pair := range nextTag { - if minPos < 0 || pair[0] < minPos { - minPos = pair[0] - tagIndex = index - } + length := state.GrossLength() + + // Would it exceed the line width? + if t.wrap && lastLine.width+w > width { + if lastOptionWidth == 0 { + // No split point so far. Just split at the current position. + if stop(len(t.lineIndex)-1, lastLine) { + return } - - // Is the next tag in range? - if tagIndex < 0 || minPos > tagEnd+remainingLength { - break // No. We're done with this line. + st := *state + lastLine = &textViewLine{ + offset: offset, + state: &st, } - - // Advance. - strippedTagStart := nextTag[tagIndex][0] - originalPos - totalTagLength - tagEnd = nextTag[tagIndex][1] - tagLength := tagEnd - nextTag[tagIndex][0] - if nextTag[tagIndex][2] == 2 { - tagLength = 1 + lastOption, lastOptionWidth, leftPos = 0, 0, 0 + } else { + // Split at the last split point. + newLine := &textViewLine{ + offset: lastLine.offset + lastOption, + width: lastLine.width - lastOptionWidth, + length: lastLine.length - lastOption, + state: lastOptionState, } - totalTagLength += tagLength - remainingLength = lineLength - (tagEnd - originalPos - totalTagLength) - - // Process the tag. - switch nextTag[tagIndex][2] { - case 0: - // Process style tags. - foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) - colorPos++ - case 1: - // Process region tags. - regionID = regions[regionPos][1] - _, highlighted = t.highlights[regionID] - - // Update highlight range. - if highlighted { - line := len(t.index) - if t.fromHighlight < 0 { - t.fromHighlight, t.toHighlight = line, line - t.posHighlight = uniseg.StringWidth(splitLine[:strippedTagStart]) - } else if line > t.toHighlight { - t.toHighlight = line - } - } - - regionPos++ - case 2: - // Process escape tags. - escapePos++ + lastLine.width = lastOptionWidth + lastLine.length = lastOption + if stop(len(t.lineIndex)-1, lastLine) { + return } + lastLine = newLine + lastOption, lastOptionWidth = 0, 0 + leftPos -= lastOptionWidth } + t.lineIndex = append(t.lineIndex, lastLine) + } - // Advance to next line. - originalPos += lineLength + totalTagLength + // Move ahead. + lastLine.width += w + lastLine.length += length + offset += length + leftPos += w - // Append this line. - line.NextPos = originalPos - line.Width = uniseg.StringWidth(splitLine) - t.index = append(t.index, line) + // Do we have a new longest line? + if lastLine.width > t.longestLine { + t.longestLine = lastLine.width } - // Word-wrapped lines may have trailing whitespace. Remove it. - if t.wrap && t.wordWrap { - for _, line := range t.index { - str := t.buffer[line.Line][line.Pos:line.NextPos] - spaces := spacePattern.FindAllStringIndex(str, -1) - if spaces != nil && spaces[len(spaces)-1][1] == len(str) { - oldNextPos := line.NextPos - line.NextPos -= spaces[len(spaces)-1][1] - spaces[len(spaces)-1][0] - line.Width -= uniseg.StringWidth(t.buffer[line.Line][line.NextPos:oldNextPos]) + // Check for split points. + if lineBreak, optional := state.LineBreak(); lineBreak { + if optional { + if t.wrap && t.wordWrap { + // Remember this split point. + lastOption = offset - lastLine.offset + lastOptionWidth = lastLine.width + st := *state + lastOptionState = &st + } + } else if str != "" || c != "" && uniseg.HasTrailingLineBreakInString(c) { + // We must split here. + if stop(len(t.lineIndex)-1, lastLine) { + return } + st := *state + lastLine = &textViewLine{ + offset: offset, + state: &st, + } + t.lineIndex = append(t.lineIndex, lastLine) + lastOption, lastOptionWidth, leftPos = 0, 0, 0 } } - } - // Drop lines beyond maxLines. - if t.maxLines > 0 && len(t.index) > t.maxLines { - removedLines := len(t.index) - t.maxLines - - // Adjust the index. - t.index = t.index[removedLines:] - if t.fromHighlight >= 0 { - t.fromHighlight -= removedLines - if t.fromHighlight < 0 { - t.fromHighlight = 0 - } - } - if t.toHighlight >= 0 { - t.toHighlight -= removedLines - if t.toHighlight < 0 { - t.fromHighlight, t.toHighlight, t.posHighlight = -1, -1, -1 + // Add new regions if any. + if t.regionTags && state.region != "" && state.region != region { + if _, ok := t.regions[state.region]; !ok { + t.regions[state.region] = len(t.lineIndex) - 1 } } - bufferShift := t.index[0].Line - for _, line := range t.index { - line.Line -= bufferShift - } - - // Adjust the original buffer. - t.buffer = t.buffer[bufferShift:] - var prefix string - if t.index[0].ForegroundColor != "" || t.index[0].BackgroundColor != "" || t.index[0].Attributes != "" { - prefix = fmt.Sprintf("[%s:%s:%s]", t.index[0].ForegroundColor, t.index[0].BackgroundColor, t.index[0].Attributes) - } - if t.index[0].Region != "" { - prefix += fmt.Sprintf(`["%s"]`, t.index[0].Region) - } - posShift := t.index[0].Pos - t.buffer[0] = prefix + t.buffer[0][posShift:] - t.lineOffset -= removedLines - if t.lineOffset < 0 { - t.lineOffset = 0 - } - - // Adjust positions of first buffer line. - posShift -= len(prefix) - for _, line := range t.index { - if line.Line != 0 { - break - } - line.Pos -= posShift - line.NextPos -= posShift - } - } - - // Calculate longest line. - t.longestLine = 0 - for _, line := range t.index { - if line.Width > t.longestLine { - t.longestLine = line.Width - } } } @@ -1164,70 +1020,97 @@ func (t *TextView) Draw(screen tcell.Screen) { // If the width has changed, we need to reindex. if width != t.lastWidth && t.wrap { - t.index = nil + t.resetIndex() } t.lastWidth = width - // Re-index. - t.reindexBuffer(width) - if t.regions { - t.regionInfos = nil + // What are our parse options? + var options stepOptions + if t.styleTags { + options |= stepOptionsStyle } - - // If we don't have an index, there's nothing to draw. - if t.index == nil { - return + if t.regionTags { + options |= stepOptionsRegion } - // Move to highlighted regions. - if t.regions && t.scrollToHighlights && t.fromHighlight >= 0 { - // Do we fit the entire height? - if t.toHighlight-t.fromHighlight+1 < height { - // Yes, let's center the highlights. - t.lineOffset = (t.fromHighlight + t.toHighlight - height) / 2 - } else { - // No, let's move to the start of the highlights. - t.lineOffset = t.fromHighlight + // Scroll to highlighted regions. + if t.regionTags && t.scrollToHighlights { + // What is the line range for all highlighted regions? + var ( + firstRegion string + fromHighlight, toHighlight int + ) + for regionID := range t.highlights { + // We can safely assume that the region is known. + line := t.regions[regionID] + if firstRegion == "" || line > toHighlight { + toHighlight = line + } + if firstRegion == "" || line < fromHighlight { + fromHighlight = line + firstRegion = regionID + } } + if firstRegion != "" { + // Do we fit the entire height? + if toHighlight-fromHighlight+1 < height { + // Yes, let's center the highlights. + t.lineOffset = (fromHighlight + toHighlight - height) / 2 + } else { + // No, let's move to the start of the highlights. + t.lineOffset = fromHighlight + } - // If the highlight is too far to the right, move it to the middle. - if t.posHighlight-t.columnOffset > 3*width/4 { - t.columnOffset = t.posHighlight - width/2 - } + // If the highlight is too far to the right, move it to the middle. + if t.wrap { + // Find the first highlight's column in screen space. + line := t.lineIndex[fromHighlight] + st := *line.state + state := &st + str := t.text.String()[line.offset:] + var posHighlight int + for len(str) > 0 && posHighlight < line.width && state.region != firstRegion { + _, str, state = step(str, state, options) + posHighlight += state.Width() + } - // If the highlight is off-screen on the left, move it on-screen. - if t.posHighlight-t.columnOffset < 0 { - t.columnOffset = t.posHighlight - width/4 + if posHighlight-t.columnOffset > 3*width/4 { + t.columnOffset = posHighlight - width/2 + } + + // If the highlight is off-screen on the left, move it on-screen. + if posHighlight-t.columnOffset < 0 { + t.columnOffset = posHighlight - width/4 + } + } } } t.scrollToHighlights = false + // Make sure our index has enough lines. + t.parseAhead(width, func(lineNumber int, line *textViewLine) bool { + return lineNumber >= t.lineOffset+height + }) + // Adjust line offset. - if t.lineOffset+height > len(t.index) { - t.trackEnd = true - } if t.trackEnd { - t.lineOffset = len(t.index) - height + t.parseAhead(width, func(lineNumber int, line *textViewLine) bool { + return false + }) + t.lineOffset = len(t.lineIndex) - height } if t.lineOffset < 0 { t.lineOffset = 0 } // Adjust column offset. - if t.align == AlignLeft { + if t.align == AlignLeft || t.align == AlignRight { if t.columnOffset+width > t.longestLine { t.columnOffset = t.longestLine - width } if t.columnOffset < 0 { t.columnOffset = 0 } - } else if t.align == AlignRight { - if t.columnOffset-width < -t.longestLine { - t.columnOffset = width - t.longestLine - } - if t.columnOffset > 0 { - t.columnOffset = 0 - } } else { // AlignCenter. half := (t.longestLine - width) / 2 if half > 0 { @@ -1242,104 +1125,71 @@ func (t *TextView) Draw(screen tcell.Screen) { } } - // Draw the buffer. - for line := t.lineOffset; line < len(t.index); line++ { + // Draw visible lines. + for line := t.lineOffset; line < len(t.lineIndex); line++ { // Are we done? if line-t.lineOffset >= height { break } - // Get the text for this line. - index := t.index[line] - text := t.buffer[index.Line][index.Pos:index.NextPos] - foregroundColor := index.ForegroundColor - backgroundColor := index.BackgroundColor - attributes := index.Attributes - regionID := index.Region - if t.regions { - if len(t.regionInfos) > 0 && t.regionInfos[len(t.regionInfos)-1].ID != regionID { - // End last region. - t.regionInfos[len(t.regionInfos)-1].ToX = x - t.regionInfos[len(t.regionInfos)-1].ToY = y + line - t.lineOffset + info := t.lineIndex[line] + info.regions = nil + + // Determine starting point of the text and the screen. + var skipWidth, xPos int + switch t.align { + case AlignLeft: + skipWidth = t.columnOffset + case AlignCenter: + skipWidth = t.columnOffset + (info.width-width)/2 + if skipWidth < 0 { + skipWidth = 0 + xPos = (width-info.width)/2 - t.columnOffset } - if regionID != "" && (len(t.regionInfos) == 0 || t.regionInfos[len(t.regionInfos)-1].ID != regionID) { - // Start a new region. - t.regionInfos = append(t.regionInfos, &textViewRegion{ - ID: regionID, - FromX: x, - FromY: y + line - t.lineOffset, - ToX: -1, - ToY: -1, - }) + case AlignRight: + maxWidth := width + if t.longestLine > width { + maxWidth = t.longestLine + } + skipWidth = t.columnOffset - (maxWidth - info.width) + if skipWidth < 0 { + skipWidth = 0 + xPos = maxWidth - info.width - t.columnOffset } } - // Process tags. - colorTagIndices, colorTags, regionIndices, regions, escapeIndices, strippedText, _ := decomposeString(text, t.dynamicColors, t.regions) - - // Calculate the position of the line. - var skip, posX int - if t.align == AlignLeft { - posX = -t.columnOffset - } else if t.align == AlignRight { - posX = width - index.Width - t.columnOffset - } else { // AlignCenter. - posX = (width-index.Width)/2 - t.columnOffset - } - if posX < 0 { - skip = -posX - posX = 0 - } - - // Print the line. - if y+line-t.lineOffset >= 0 { - var colorPos, regionPos, escapePos, tagOffset, skipped int - iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool { - // Process tags. - for { - if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { - // Get the color. - foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) - tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] - colorPos++ - } else if regionPos < len(regionIndices) && textPos+tagOffset >= regionIndices[regionPos][0] && textPos+tagOffset < regionIndices[regionPos][1] { - // Get the region. - if regionID != "" && len(t.regionInfos) > 0 && t.regionInfos[len(t.regionInfos)-1].ID == regionID { - // End last region. - t.regionInfos[len(t.regionInfos)-1].ToX = x + posX - t.regionInfos[len(t.regionInfos)-1].ToY = y + line - t.lineOffset - } - regionID = regions[regionPos][1] - if regionID != "" { - // Start new region. - t.regionInfos = append(t.regionInfos, &textViewRegion{ - ID: regionID, - FromX: x + posX, - FromY: y + line - t.lineOffset, - ToX: -1, - ToY: -1, - }) - } - tagOffset += regionIndices[regionPos][1] - regionIndices[regionPos][0] - regionPos++ - } else { - break - } + // Draw the line text. + str := t.text.String()[info.offset:] + st := *info.state + state := &st + var processed int + for len(str) > 0 && xPos < width && processed < info.length { + var ch string + ch, str, state = step(str, state, options) + w := state.Width() + if ch == "\t" { + if t.align == AlignLeft { + w = TabSize - xPos%TabSize + } else { + w = TabSize } + } + processed += state.GrossLength() - // Skip the second-to-last character of an escape tag. - if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 { - tagOffset++ - escapePos++ - } + // Don't draw anything while we skip characters. + if skipWidth > 0 { + skipWidth -= w + continue + } - // Mix the existing style with the new style. - style := overlayStyle(t.textStyle, foregroundColor, backgroundColor, attributes) + // Draw this character. + if w > 0 { + style := state.Style() // Do we highlight this character? var highlighted bool - if regionID != "" { - if _, ok := t.highlights[regionID]; ok { + if state.region != "" { + if _, ok := t.highlights[state.region]; ok { highlighted = true } } @@ -1358,42 +1208,59 @@ func (t *TextView) Draw(screen tcell.Screen) { style = style.Background(fg).Foreground(bg) } - // Skip to the right. - if !t.wrap && skipped < skip { - skipped += screenWidth - return false - } - - // Stop at the right border. - if posX+screenWidth > width { - return true + // Paint on screen. + for offset := w - 1; offset >= 0; offset-- { + runes := []rune(ch) + if offset == 0 { + screen.SetContent(x+xPos+offset, y+line-t.lineOffset, runes[0], runes[1:], style) + } else { + screen.SetContent(x+xPos+offset, y+line-t.lineOffset, ' ', nil, style) + } } - // Draw the character. - for offset := screenWidth - 1; offset >= 0; offset-- { - if offset == 0 { - screen.SetContent(x+posX+offset, y+line-t.lineOffset, main, comb, style) + // Register this region. + if state.region != "" { + if info.regions == nil { + info.regions = make(map[string][2]int) + } + fromTo, ok := info.regions[state.region] + if !ok { + fromTo = [2]int{xPos, xPos + w} } else { - screen.SetContent(x+posX+offset, y+line-t.lineOffset, ' ', nil, style) + if xPos < fromTo[0] { + fromTo[0] = xPos + } + if xPos+w > fromTo[1] { + fromTo[1] = xPos + w + } } + info.regions[state.region] = fromTo } + } - // Advance. - posX += screenWidth - return false - }) + xPos += w } } // If this view is not scrollable, we'll purge the buffer of lines that have // scrolled out of view. + var purgeStart int if !t.scrollable && t.lineOffset > 0 { - if t.lineOffset >= len(t.index) { - t.buffer = nil - } else { - t.buffer = t.buffer[t.index[t.lineOffset].Line:] - } - t.index = nil + purgeStart = t.lineOffset + } + + // If we reached the maximum number of lines, we'll purge the buffer of the + // oldest lines. + if t.maxLines > 0 && len(t.lineIndex) > t.maxLines { + purgeStart = len(t.lineIndex) - t.maxLines + } + + // Purge. + if purgeStart > 0 { + newText := t.text.String()[t.lineIndex[purgeStart].offset:] + t.text.Reset() + t.text.WriteString(newText) + t.resetIndex() t.lineOffset = 0 } } @@ -1475,17 +1342,25 @@ func (t *TextView) MouseHandler() func(action MouseAction, event *tcell.EventMou setFocus(t) consumed = true case MouseLeftClick: - if t.regions { + if t.regionTags { // Find a region to highlight. - for _, region := range t.regionInfos { - if y == region.FromY && x < region.FromX || - y == region.ToY && x >= region.ToX || - region.FromY >= 0 && y < region.FromY || - region.ToY >= 0 && y > region.ToY { - continue + rectX, rectY, _, _ := t.GetInnerRect() + x -= rectX + y -= rectY + var highlightedID string + if y+t.lineOffset < len(t.lineIndex) { + line := t.lineIndex[y+t.lineOffset] + for regionID, fromTo := range line.regions { + if x >= fromTo[0] && x < fromTo[1] { + highlightedID = regionID + break + } } - t.Highlight(region.ID) - break + } + if highlightedID != "" { + t.Highlight(highlightedID) + } else if !t.toggleHighlights { + t.Highlight() } } consumed = true diff --git a/util.go b/util.go index d6a4147..8920307 100644 --- a/util.go +++ b/util.go @@ -4,12 +4,9 @@ import ( "math" "os" "regexp" - "sort" "strconv" - "strings" "github.com/gdamore/tcell/v2" - "github.com/rivo/uniseg" ) // Text alignment within a box. Also used to align images. @@ -21,39 +18,12 @@ const ( AlignBottom = 2 ) -// Common regular expressions. var ( - colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([lbidrus]+|\-)?)?)?\]`) - regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) - escapePattern = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`) + // Regular expression used to escape style/region tags. nonEscapePattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`) - boundaryPattern = regexp.MustCompile(`(([,\.\-:;!\?&#+]|\n)[ \t\f\r]*|([ \t\f\r]+))`) - spacePattern = regexp.MustCompile(`\s+`) -) - -// Positions of substrings in regular expressions. -const ( - colorForegroundPos = 1 - colorBackgroundPos = 3 - colorFlagPos = 5 -) - -// The number of colors available in the terminal. -var availableColors = 256 - -// Predefined InputField acceptance functions. -var ( - // InputFieldInteger accepts integers. - InputFieldInteger func(text string, ch rune) bool - - // InputFieldFloat accepts floating-point numbers. - InputFieldFloat func(text string, ch rune) bool - // InputFieldMaxLength returns an input field accept handler which accepts - // input strings up to a given length. Use it like this: - // - // inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters. - InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool + // The number of colors available in the terminal. + availableColors = 256 ) // Package initialization. @@ -86,153 +56,6 @@ func init() { } } -// styleFromTag takes the given style, defined by a foreground color (fgColor), -// a background color (bgColor), and style attributes, and modifies it based on -// the substrings (tagSubstrings) extracted by the regular expression for color -// tags. The new colors and attributes are returned where empty strings mean -// "don't modify" and a dash ("-") means "reset to default". -func styleFromTag(fgColor, bgColor, attributes string, tagSubstrings []string) (newFgColor, newBgColor, newAttributes string) { - if tagSubstrings[colorForegroundPos] != "" { - color := tagSubstrings[colorForegroundPos] - if color == "-" { - fgColor = "-" - } else if color != "" { - fgColor = color - } - } - - if tagSubstrings[colorBackgroundPos-1] != "" { - color := tagSubstrings[colorBackgroundPos] - if color == "-" { - bgColor = "-" - } else if color != "" { - bgColor = color - } - } - - if tagSubstrings[colorFlagPos-1] != "" { - flags := tagSubstrings[colorFlagPos] - if flags == "-" { - attributes = "-" - } else if flags != "" { - attributes = flags - } - } - - return fgColor, bgColor, attributes -} - -// overlayStyle calculates a new style based on "style" and applying tag-based -// colors/attributes to it (see also styleFromTag()). -func overlayStyle(style tcell.Style, fgColor, bgColor, attributes string) tcell.Style { - _, _, defAttr := style.Decompose() - - if fgColor != "" && fgColor != "-" { - style = style.Foreground(tcell.GetColor(fgColor)) - } - - if bgColor != "" && bgColor != "-" { - style = style.Background(tcell.GetColor(bgColor)) - } - - if attributes == "-" { - style = style.Bold(defAttr&tcell.AttrBold > 0). - Italic(defAttr&tcell.AttrItalic > 0). - Blink(defAttr&tcell.AttrBlink > 0). - Reverse(defAttr&tcell.AttrReverse > 0). - Underline(defAttr&tcell.AttrUnderline > 0). - Dim(defAttr&tcell.AttrDim > 0) - } else if attributes != "" { - style = style.Normal() - for _, flag := range attributes { - switch flag { - case 'l': - style = style.Blink(true) - case 'b': - style = style.Bold(true) - case 'i': - style = style.Italic(true) - case 'd': - style = style.Dim(true) - case 'r': - style = style.Reverse(true) - case 'u': - style = style.Underline(true) - case 's': - style = style.StrikeThrough(true) - } - } - } - - return style -} - -// 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 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 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) { - // Shortcut for the trivial case. - if !findColors && !findRegions { - return nil, nil, nil, nil, nil, text, uniseg.StringWidth(text) - } - - // Get positions of any tags. - if findColors { - colorIndices = colorPattern.FindAllStringIndex(text, -1) - colors = colorPattern.FindAllStringSubmatch(text, -1) - } - if findRegions { - regionIndices = regionPattern.FindAllStringIndex(text, -1) - regions = regionPattern.FindAllStringSubmatch(text, -1) - } - escapeIndices = escapePattern.FindAllStringIndex(text, -1) - - // Because the color pattern detects empty tags, we need to filter them out. - for i := len(colorIndices) - 1; i >= 0; i-- { - if colorIndices[i][1]-colorIndices[i][0] == 2 { - colorIndices = append(colorIndices[:i], colorIndices[i+1:]...) - colors = append(colors[:i], colors[i+1:]...) - } - } - - // Make a (sorted) list of all tags. - allIndices := make([][3]int, 0, len(colorIndices)+len(regionIndices)+len(escapeIndices)) - for indexType, index := range [][][]int{colorIndices, regionIndices, escapeIndices} { - for _, tag := range index { - allIndices = append(allIndices, [3]int{tag[0], tag[1], indexType}) - } - } - sort.Slice(allIndices, func(i int, j int) bool { - return allIndices[i][0] < allIndices[j][0] - }) - - // Remove the tags from the original string. - var from int - buf := make([]byte, 0, len(text)) - for _, indices := range allIndices { - if indices[2] == 2 { // Escape sequences are not simply removed. - buf = append(buf, []byte(text[from:indices[1]-2])...) - buf = append(buf, ']') - from = indices[1] - } else { - buf = append(buf, []byte(text[from:indices[0]])...) - from = indices[1] - } - } - buf = append(buf, text[from:]...) - stripped = string(buf) - - // Get the width of the stripped string. - width = uniseg.StringWidth(stripped) - - return -} - // Print prints text onto the screen into the given box at (x,y,maxWidth,1), // not exceeding that box. "align" is one of AlignLeft, AlignCenter, or // AlignRight. The screen's background color will not be changed. @@ -324,24 +147,26 @@ func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth, if c == "" { break // We don't care about the style at the end. } - runes := []rune(c) width := state.Width() - finalStyle := state.Style() - if maintainBackground { - _, backgroundColor, _ := finalStyle.Decompose() - if backgroundColor == tcell.ColorDefault { - _, _, existingStyle, _ := screen.GetContent(x, y) - _, background, _ := existingStyle.Decompose() - finalStyle = finalStyle.Background(background) + if width > 0 { + finalStyle := state.Style() + if maintainBackground { + _, backgroundColor, _ := finalStyle.Decompose() + if backgroundColor == tcell.ColorDefault { + _, _, existingStyle, _ := screen.GetContent(x, y) + _, background, _ := existingStyle.Decompose() + finalStyle = finalStyle.Background(background) + } } - } - for offset := width - 1; offset >= 0; offset-- { - // To avoid undesired effects, we populate all cells. - if offset == 0 { - screen.SetContent(x+offset, y, runes[0], runes[1:], finalStyle) - } else { - screen.SetContent(x+offset, y, ' ', nil, finalStyle) + for offset := width - 1; offset >= 0; offset-- { + // To avoid undesired effects, we populate all cells. + runes := []rune(c) + if offset == 0 { + screen.SetContent(x+offset, y, runes[0], runes[1:], finalStyle) + } else { + screen.SetContent(x+offset, y, ' ', nil, finalStyle) + } } } @@ -357,138 +182,3 @@ func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth, func PrintSimple(screen tcell.Screen, text string, x, y int) { Print(screen, text, x, y, math.MaxInt32, AlignLeft, Styles.PrimaryTextColor) } - -// TaggedStringWidth returns the width of the given string needed to print it on -// 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. Split points are determined using the algorithm described -// in [Unicode Standard Annex #14] . -// -// This function considers style tags to have no width. -// -// [Unicode Standard Annex #14]: https://www.unicode.org/reports/tr14/ -func WordWrap(text string, width int) (lines []string) { - if width <= 0 { - return - } - - var ( - state *stepState - lineWidth, lineLength, lastOption, lastOptionWidth int - ) - 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 { - // Split at the last split point. - lines = append(lines, text[:lastOption]) - text = text[lastOption:] - lineWidth -= lastOptionWidth - lineLength -= lastOption - lastOption, lastOptionWidth = 0, 0 - } - } - - // 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 - } - } - } - lines = append(lines, text) - - return -} - -// Escape escapes the given text such that color and/or region tags are not -// recognized and substituted by the print functions of this package. For -// example, to include a tag-like string in a box title or in a TextView: -// -// box.SetTitle(tview.Escape("[squarebrackets]")) -// fmt.Fprint(textView, tview.Escape(`["quoted"]`)) -func Escape(text string) string { - return nonEscapePattern.ReplaceAllString(text, "$1[]") -} - -// iterateString iterates through the given 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, the screen width of it, and a boundaries value which includes -// word/sentence boundary or line break information (see the -// github.com/rivo/uniseg package, Step() function, for more information). The -// iteration stops if the callback returns true. This function returns true if -// the iteration was stopped before the last character. -func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool) bool { - var screenPos, textPos, boundaries int - - state := -1 - for len(text) > 0 { - var cluster string - cluster, text, boundaries, state = uniseg.StepString(text, state) - - width := boundaries >> uniseg.ShiftWidth - runes := []rune(cluster) - var comb []rune - if len(runes) > 1 { - comb = runes[1:] - } - - if callback(runes[0], comb, textPos, len(cluster), screenPos, width, boundaries) { - return true - } - - screenPos += width - textPos += len(cluster) - } - - return false -} - -// stripTags strips style tags from the given string. (Region tags are not -// stripped.) -func stripTags(text string) string { - 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() -}