diff --git a/application.go b/application.go index 5341599..a34e28a 100644 --- a/application.go +++ b/application.go @@ -32,13 +32,16 @@ func NewApplication() *Application { // when Stop() was called. func (a *Application) Run() error { var err error + a.Lock() // Make a screen. a.screen, err = tcell.NewScreen() if err != nil { + a.Unlock() return err } if err = a.screen.Init(); err != nil { + a.Unlock() return err } @@ -57,6 +60,7 @@ func (a *Application) Run() error { width, height := a.screen.Size() a.root.SetRect(0, 0, width, height) } + a.Unlock() a.Draw() // Start event loop. @@ -86,8 +90,8 @@ func (a *Application) Run() error { } case *tcell.EventResize: if a.rootAutoSize && a.root != nil { - width, height := a.screen.Size() a.Lock() + width, height := a.screen.Size() a.root.SetRect(0, 0, width, height) a.Unlock() a.Draw() @@ -114,14 +118,12 @@ func (a *Application) Draw() *Application { defer a.Unlock() // Maybe we're not ready yet or not anymore. - if a.screen == nil { + if a.screen == nil || a.root == nil { return a } // Draw all primitives. - if a.root != nil { - a.root.Draw(a.screen) - } + a.root.Draw(a.screen) // Sync screen. a.screen.Show() diff --git a/box.go b/box.go index 59148a5..d9e88a6 100644 --- a/box.go +++ b/box.go @@ -67,7 +67,7 @@ func NewBox() *Box { return b } -// SetPadding sets the size of the borders around the box content. +// SetBorderPadding sets the size of the borders around the box content. func (b *Box) SetBorderPadding(top, bottom, left, right int) *Box { b.paddingTop, b.paddingBottom, b.paddingLeft, b.paddingRight = top, bottom, left, right return b diff --git a/demos/basic.go b/demos/basic.go index 76a53ab..8917f08 100644 --- a/demos/basic.go +++ b/demos/basic.go @@ -2,8 +2,6 @@ package main import ( "fmt" - "io" - "net/http" "github.com/gdamore/tcell" "github.com/rivo/tview" @@ -45,26 +43,19 @@ func main() { }) form.SetTitle("Customer").SetBorder(true) - textView := tview.NewTextView(). - SetWrap(false). - SetDynamicColors(false). + textView := tview.NewTextView() + textView.SetWrap(true). + SetDynamicColors(true). + SetScrollable(true). + SetRegions(true). SetChangedFunc(func() { app.Draw() }). - SetDoneFunc(func(key tcell.Key) { app.SetFocus(list) }) + SetDoneFunc(func(key tcell.Key) { textView.ScrollToHighlight(); app.SetFocus(list) }) textView.SetBorder(true).SetTitle("Text view") go func() { - url := "https://www.rentafounder.com" - fmt.Fprintf(textView, "Reading from: %s\n\n", url) - resp, err := http.Get(url) - if err != nil { - fmt.Fprint(textView, err) - return + for i := 0; i < 200; i++ { + fmt.Fprintf(textView, "[\"%d\"]%d\n", i, i) } - defer resp.Body.Close() - n, err := io.Copy(textView, resp.Body) - if err != nil { - fmt.Fprint(textView, err) - } - fmt.Fprintf(textView, "\n\n%d bytes read", n) + textView.Highlight("199") }() list = tview.NewList(). diff --git a/textview.go b/textview.go index 308a227..574e751 100644 --- a/textview.go +++ b/textview.go @@ -1,6 +1,7 @@ package tview import ( + "bytes" "math" "regexp" "sync" @@ -19,33 +20,77 @@ var textColors = map[string]tcell.Color{ "green": tcell.ColorGreen, } -// A regular expression commonly used throughout the TextView class. -var colorPattern = regexp.MustCompile(`\[(white|yellow|blue|green|red)\]`) +// Regular expressions commonly used throughout the TextView class. +var ( + colorPattern = regexp.MustCompile(`\[(white|yellow|blue|green|red)\]`) + regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) +) // textViewIndex contains information about each line displayed in the text // view. type textViewIndex struct { - Line int // The index into the "buffer" variable. - Pos int // The index into the "buffer" string. - Color tcell.Color // The starting color. + Line int // The index into the "buffer" variable. + Pos int // The index into the "buffer" string. + Color tcell.Color // The starting color. + Region string // The starting region ID. } -// TextView is a box which displays text. It implements the Reader interface so -// you can stream text to it. +// TextView is a box which displays text. It implements the io.Writer interface +// so you can stream text to it. This does not trigger a redraw automatically +// but if a handler is installed via SetChangedFunc(), you can cause it to be +// redrawn. // -// If the text view is scrollable (the default), text is kept in a buffer and -// can be navigated using the arrow keys, Ctrl-F and Ctrl-B for page jumps, "g" -// for the beginning of the text, and "G" for the end of the text. +// 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: +// +// - h, left arrow: Move left. +// - l, right arrow: Move right. +// - j, down arrow: Move down. +// - k, up arrow: Move up. +// - g, home: Move to the beginning. +// - G, end: Move to the end. +// - Ctrl-F, page down: Move down by one page. +// - Ctrl-B, page up: Move up by one page. // // If the text is not scrollable, any text above the top line is discarded. // -// If dynamic colors are enabled, text color can be changed dynamically by -// embedding it into square brackets. For example, +// Navigation can be intercepted by installing a callback function via +// SetCaptureFunc() which receives all keyboard events and decides which ones +// to forward to the default handler. // -// "This is a [red]warning[white]!" +// Colors +// +// If dynamic colors are enabled via SetDynamicColors(), text color can be +// changed dynamically by embedding color strings in square brackets. For +// example, +// +// This is a [red]warning[white]! // // will print the word "warning" in red. The following colors are currently // supported: white, yellow, blue, green, red. +// +// 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: +// +// We define a ["rg"]region[""] here. +// +// A text region ends with the next region tag. Tags with no region ID ([""]) +// don't start new regions. They can therefore be used to mark the end of a +// region. Region IDs must satisfy the following regular expression: +// +// [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. +// +// The ScrollToHighlight() function can be used to jump to the currently +// highlighted region once when the text view is drawn the next time. type TextView struct { sync.Mutex *Box @@ -53,16 +98,24 @@ type TextView struct { // The text buffer. buffer []string + // The last bytes that have been received but are not part of the buffer yet. + recentBytes []byte + // The processed line index. This is nil if the buffer has changed and needs // to be re-indexed. index []*textViewIndex + // 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 + + // A set of region IDs that are currently highlighted. + highlights map[string]struct{} + // The display width for which the index is created. indexWidth int - // The last bytes that have been received but are not part of the buffer yet. - recentBytes []byte - // The index of the first line shown in the text view. lineOffset int @@ -91,6 +144,17 @@ type TextView struct { // strings in square brackets to the text view. dynamicColors bool + // If set to true, region tags can be used to define regions. + regions bool + + // A temporary flag which, when true, will automatically bring the current + // highlight(s) into the visible screen. + scrollToHighlights bool + + // An optional function which will receive all key events sent to this text + // view. Returning true also invokes the default key handling. + capture func(*tcell.EventKey) bool + // An optional function which is called when the content of the text view has // changed. changed func() @@ -104,11 +168,12 @@ type TextView struct { func NewTextView() *TextView { return &TextView{ Box: NewBox(), + highlights: make(map[string]struct{}), lineOffset: -1, scrollable: true, wrap: true, textColor: tcell.ColorWhite, - dynamicColors: true, + dynamicColors: false, } } @@ -139,7 +204,7 @@ func (t *TextView) SetTextColor(color tcell.Color) *TextView { } // SetDynamicColors sets the flag that allows the text color to be changed -// dynamically. See type description for details. +// dynamically. See class description for details. func (t *TextView) SetDynamicColors(dynamic bool) *TextView { if t.dynamicColors != dynamic { t.index = nil @@ -148,6 +213,22 @@ func (t *TextView) SetDynamicColors(dynamic bool) *TextView { 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 { + t.regions = regions + return t +} + +// SetCaptureFunc sets a handler which is called whenever a key is pressed. +// This allows you to override the default key handling of the text view. +// Returning true will allow the default key handling to go forward after the +// handler returns. Returning false will disable any default key handling. +func (t *TextView) SetCaptureFunc(handler func(event *tcell.EventKey) bool) *TextView { + t.capture = handler + return t +} + // SetChangedFunc sets a handler function which is called when the text of the // text view has changed. This is typically used to cause the application to // redraw the screen. @@ -172,6 +253,115 @@ func (t *TextView) Clear() *TextView { return t } +// Highlight specifies which regions should be highlighted. See class +// description for details on regions. Empty region strings are ignored. +// +// Text in highlighted regions will be drawn inverted, i.e. with their +// background and foreground colors swapped. +// +// Calling this function will remove any previous highlights. To remove all +// highlights, call this function without any arguments. +func (t *TextView) Highlight(regionIDs ...string) *TextView { + t.highlights = make(map[string]struct{}) + for _, id := range regionIDs { + if id == "" { + continue + } + t.highlights[id] = struct{}{} + } + return t +} + +// ScrollToHighlight will cause the visible area to be scrolled so that the +// highlighted regions appear in the visible area of the text view. This +// repositioning happens the next time the text view is drawn. It happens only +// once so you will need to call this function repeatedly to always keep +// highlighted regions in view. +// +// 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 { + 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, color 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 +// is returned. +func (t *TextView) GetRegionText(regionID string) string { + if !t.regions || regionID == "" { + return "" + } + + var ( + buffer bytes.Buffer + currentRegionID string + ) + + for _, str := range t.buffer { + // Find all color 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 color tags. + if currentTag < len(colorTagIndices) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { + if pos == colorTagIndices[currentTag][1]-1 { + currentTag++ + } + 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) + } + } + + // Add newline. + if currentRegionID == regionID { + buffer.WriteRune('\n') + } + } + + return buffer.String() +} + // Write lets us implement the io.Writer interface. func (t *TextView) Write(p []byte) (n int, err error) { // Notify at the end. @@ -231,7 +421,12 @@ func (t *TextView) reindexBuffer(width int) { return // Nothing has changed. We can still use the current index. } t.index = nil + t.fromHighlight, t.toHighlight = -1, -1 + var ( + regionID string + highlighted bool + ) color := t.textColor if !t.wrap { width = math.MaxInt64 @@ -247,28 +442,63 @@ func (t *TextView) reindexBuffer(width int) { colorTags = colorPattern.FindAllStringSubmatch(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) + } + // Break down the line. - var currentTag, currentWidth int + var currentTag, currentRegion, currentWidth int for pos := range str { // Skip any color tags. - if currentTag < len(colorTags) { - if pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { + if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { + if pos == colorTagIndices[currentTag][1]-1 { color = textColors[colorTags[currentTag][1]] - continue - } else if pos >= colorTagIndices[currentTag][1] { currentTag++ } + continue + } + + // Check regions. + if currentRegion < len(regionIndices) && pos >= regionIndices[currentRegion][0] && pos < regionIndices[currentRegion][1] { + if pos == regionIndices[currentRegion][1]-1 { + // We're done with this region. + regionID = regions[currentRegion][1] + + // Is this region highlighted? + _, highlighted = t.highlights[regionID] + + currentRegion++ + } + continue } // Add this line. if currentWidth == 0 { t.index = append(t.index, &textViewIndex{ - Line: index, - Pos: pos, - Color: color, + Line: index, + Pos: pos, + Color: color, + Region: regionID, }) } + // Update highlight range. + if highlighted { + line := len(t.index) - 1 + if t.fromHighlight < 0 { + t.fromHighlight, t.toHighlight = line, line + } else if line > t.toHighlight { + t.toHighlight = line + } + } + + // Proceed. currentWidth++ // Have we crossed the width? @@ -294,6 +524,19 @@ func (t *TextView) Draw(screen tcell.Screen) { // Re-index. t.reindexBuffer(width) + // 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 + } + } + t.scrollToHighlights = false + // Adjust line offset. if t.lineOffset+height > len(t.index) { t.trackEnd = true @@ -306,7 +549,6 @@ func (t *TextView) Draw(screen tcell.Screen) { } // Draw the buffer. - style := tcell.StyleDefault.Background(t.backgroundColor) for line := t.lineOffset; line < len(t.index); line++ { // Are we done? if line-t.lineOffset >= height { @@ -316,7 +558,8 @@ func (t *TextView) Draw(screen tcell.Screen) { // Get the text for this line. index := t.index[line] text := t.buffer[index.Line][index.Pos:] - style = style.Foreground(index.Color) + color := index.Color + regionID := index.Region // Get color tags. var ( @@ -328,16 +571,35 @@ func (t *TextView) Draw(screen tcell.Screen) { colorTags = colorPattern.FindAllStringSubmatch(text, -1) } + // Get regions. + var ( + regionIndices [][]int + regions [][]string + ) + if t.regions { + regionIndices = regionPattern.FindAllStringIndex(text, -1) + regions = regionPattern.FindAllStringSubmatch(text, -1) + } + // Print one line. - var currentTag, skip, posX int + var currentTag, currentRegion, skip, posX int for pos, ch := range text { - if currentTag < len(colorTags) { - if pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { - style = style.Foreground(textColors[colorTags[currentTag][1]]) - continue - } else if pos >= colorTagIndices[currentTag][1] { + // Get the color. + if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { + if pos == colorTagIndices[currentTag][1]-1 { + color = textColors[colorTags[currentTag][1]] currentTag++ } + continue + } + + // Get the region. + if currentRegion < len(regionIndices) && pos >= regionIndices[currentRegion][0] && pos < regionIndices[currentRegion][1] { + if pos == regionIndices[currentRegion][1]-1 { + regionID = regions[currentRegion][1] + currentRegion++ + } + continue } // Skip to the right. @@ -351,17 +613,54 @@ func (t *TextView) Draw(screen tcell.Screen) { break } + // Do we highlight this character? + style := tcell.StyleDefault.Background(t.backgroundColor).Foreground(color) + if len(regionID) > 0 { + if _, ok := t.highlights[regionID]; ok { + style = tcell.StyleDefault.Background(color).Foreground(t.backgroundColor) + } + } + + // Draw the character. screen.SetContent(x+posX, y+line-t.lineOffset, ch, nil, style) + // Advance. posX++ } } + + // If this view is not scrollable, we'll purge the buffer of lines that have + // scrolled out of view. + if !t.scrollable && t.lineOffset > 0 { + t.buffer = t.buffer[t.index[t.lineOffset].Line:] + t.index = nil + } } // InputHandler returns the handler for this primitive. func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) { return func(event *tcell.EventKey, setFocus func(p Primitive)) { - switch key := event.Key(); key { + // Do we pass this event on? + if t.capture != nil { + if !t.capture(event) { + return + } + } + + key := event.Key() + + if key == tcell.KeyEscape || key == tcell.KeyEnter || key == tcell.KeyTab || key == tcell.KeyBacktab { + if t.done != nil { + t.done(key) + } + return + } + + if !t.scrollable { + return + } + + switch key { case tcell.KeyRune: switch event.Rune() { case 'g': // Home. @@ -408,10 +707,6 @@ func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr case tcell.KeyPgUp, tcell.KeyCtrlB: t.trackEnd = false t.lineOffset -= t.pageSize - case tcell.KeyEscape, tcell.KeyEnter, tcell.KeyTab, tcell.KeyBacktab: - if t.done != nil { - t.done(key) - } } } }