From 8eb4c5ef4867a14a148992999e4ee864f08c4b97 Mon Sep 17 00:00:00 2001 From: Oliver <480930+rivo@users.noreply.github.com> Date: Thu, 21 Dec 2017 18:08:53 +0100 Subject: [PATCH] Added TextView. All subclasses of Box now also have inside padding. --- box.go | 125 +++++++++------ button.go | 9 +- checkbox.go | 12 +- demos/basic.go | 29 +++- dropdown.go | 14 +- flex.go | 13 +- form.go | 30 +--- frame.go | 13 +- inputfield.go | 12 +- list.go | 12 +- modal.go | 3 +- textview.go | 417 +++++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 545 insertions(+), 144 deletions(-) create mode 100644 textview.go diff --git a/box.go b/box.go index aa03f33..59148a5 100644 --- a/box.go +++ b/box.go @@ -28,6 +28,9 @@ type Box struct { // The position of the rect. x, y, width, height int + // Border padding. + paddingTop, paddingBottom, paddingLeft, paddingRight int + // The box's background color. backgroundColor tcell.Color @@ -64,6 +67,78 @@ func NewBox() *Box { return b } +// SetPadding 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 +} + +// GetRect returns the current position of the rectangle, x, y, width, and +// height. +func (b *Box) GetRect() (int, int, int, int) { + return b.x, b.y, b.width, b.height +} + +// GetInnerRect returns the position of the inner rectangle, without the border +// and without any padding. +func (b *Box) GetInnerRect() (int, int, int, int) { + x, y, width, height := b.GetRect() + if b.border { + x++ + y++ + width -= 2 + height -= 2 + } + return x + b.paddingLeft, + y + b.paddingTop, + width - b.paddingLeft - b.paddingRight, + height - b.paddingTop - b.paddingBottom +} + +// SetRect sets a new position of the rectangle. +func (b *Box) SetRect(x, y, width, height int) { + b.x = x + b.y = y + b.width = width + b.height = height +} + +// InputHandler returns nil. +func (b *Box) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) { + return nil +} + +// SetBackgroundColor sets the box's background color. +func (b *Box) SetBackgroundColor(color tcell.Color) *Box { + b.backgroundColor = color + return b +} + +// SetBorder sets the flag indicating whether or not the box should have a +// border. +func (b *Box) SetBorder(show bool) *Box { + b.border = show + return b +} + +// SetBorderColor sets the box's border color. +func (b *Box) SetBorderColor(color tcell.Color) *Box { + b.borderColor = color + return b +} + +// SetTitle sets the box's title. +func (b *Box) SetTitle(title string) *Box { + b.title = title + return b +} + +// SetTitleColor sets the box's title color. +func (b *Box) SetTitleColor(color tcell.Color) *Box { + b.titleColor = color + return b +} + // Draw draws this primitive onto the screen. func (b *Box) Draw(screen tcell.Screen) { // Don't draw anything if there is no space. @@ -131,56 +206,6 @@ func (b *Box) Draw(screen tcell.Screen) { } } -// GetRect returns the current position of the rectangle, x, y, width, and -// height. -func (b *Box) GetRect() (int, int, int, int) { - return b.x, b.y, b.width, b.height -} - -// SetRect sets a new position of the rectangle. -func (b *Box) SetRect(x, y, width, height int) { - b.x = x - b.y = y - b.width = width - b.height = height -} - -// InputHandler returns nil. -func (b *Box) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) { - return nil -} - -// SetBackgroundColor sets the box's background color. -func (b *Box) SetBackgroundColor(color tcell.Color) *Box { - b.backgroundColor = color - return b -} - -// SetBorder sets the flag indicating whether or not the box should have a -// border. -func (b *Box) SetBorder(show bool) *Box { - b.border = show - return b -} - -// SetBorderColor sets the box's border color. -func (b *Box) SetBorderColor(color tcell.Color) *Box { - b.borderColor = color - return b -} - -// SetTitle sets the box's title. -func (b *Box) SetTitle(title string) *Box { - b.title = title - return b -} - -// SetTitleColor sets the box's title color. -func (b *Box) SetTitleColor(color tcell.Color) *Box { - b.titleColor = color - return b -} - // Focus is called when this primitive receives focus. func (b *Box) Focus(delegate func(p Primitive)) { b.hasFocus = true diff --git a/button.go b/button.go index 473f847..b2178cb 100644 --- a/button.go +++ b/button.go @@ -100,12 +100,9 @@ func (b *Button) Draw(screen tcell.Screen) { b.backgroundColor = backgroundColor // Draw label. - x := b.x + b.width/2 - y := b.y + b.height/2 - width := b.width - if b.border { - width -= 2 - } + x, y, width, height := b.GetInnerRect() + x = x + width/2 + y = y + height/2 labelColor := b.labelColor if b.focus.HasFocus() { labelColor = b.labelColorActivated diff --git a/checkbox.go b/checkbox.go index 958fb08..d298fcf 100644 --- a/checkbox.go +++ b/checkbox.go @@ -119,16 +119,8 @@ func (c *Checkbox) Draw(screen tcell.Screen) { c.Box.Draw(screen) // Prepare - x := c.x - y := c.y - rightLimit := x + c.width - height := c.height - if c.border { - x++ - y++ - rightLimit -= 2 - height -= 2 - } + x, y, width, height := c.GetInnerRect() + rightLimit := x + width if height < 1 || rightLimit <= x { return } diff --git a/demos/basic.go b/demos/basic.go index d0a5d1b..76a53ab 100644 --- a/demos/basic.go +++ b/demos/basic.go @@ -1,6 +1,10 @@ package main import ( + "fmt" + "io" + "net/http" + "github.com/gdamore/tcell" "github.com/rivo/tview" ) @@ -41,8 +45,31 @@ func main() { }) form.SetTitle("Customer").SetBorder(true) + textView := tview.NewTextView(). + SetWrap(false). + SetDynamicColors(false). + SetChangedFunc(func() { app.Draw() }). + SetDoneFunc(func(key tcell.Key) { 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 + } + 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) + }() + list = tview.NewList(). AddItem("Edit a form", "You can do whatever you want", 'e', func() { app.SetFocus(form) }). + AddItem("Navigate text", "Try all the navigations", 't', func() { app.SetFocus(textView) }). AddItem("Quit the program", "Do it!", 0, func() { app.Stop() }) frame := tview.NewFrame(list).AddText("Choose!", true, tview.AlignCenter, tcell.ColorRed) @@ -53,7 +80,7 @@ func main() { AddItem(tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(frame, 0). - AddItem(tview.NewBox().SetBorder(true).SetTitle("Third"), 0), 0). + AddItem(textView, 0), 0). AddItem(tview.NewBox().SetBorder(true).SetTitle("Fourth"), 0). AddItem(tview.NewBox().SetBorder(true).SetTitle("Fifth"), 20) diff --git a/dropdown.go b/dropdown.go index 2d72e07..55493b5 100644 --- a/dropdown.go +++ b/dropdown.go @@ -173,17 +173,9 @@ func (d *DropDown) SetFinishedFunc(handler func(key tcell.Key)) FormItem { func (d *DropDown) Draw(screen tcell.Screen) { d.Box.Draw(screen) - // Prepare - x := d.x - y := d.y - rightLimit := x + d.width - height := d.height - if d.border { - x++ - y++ - rightLimit -= 2 - height -= 2 - } + // Prepare. + x, y, width, height := d.GetInnerRect() + rightLimit := x + width if height < 1 || rightLimit <= x { return } diff --git a/flex.go b/flex.go index 950d3b9..18c7596 100644 --- a/flex.go +++ b/flex.go @@ -76,10 +76,11 @@ func (f *Flex) Draw(screen tcell.Screen) { } // How much space can we distribute? + x, y, width, height := f.GetInnerRect() var variables int - distSize := f.width + distSize := width if f.direction == FlexRow { - distSize = f.height + distSize = height } for _, item := range f.items { if item.FixedSize > 0 { @@ -90,9 +91,9 @@ func (f *Flex) Draw(screen tcell.Screen) { } // Calculate positions and draw items. - pos := f.x + pos := x if f.direction == FlexRow { - pos = f.y + pos = y } for _, item := range f.items { size := item.FixedSize @@ -102,9 +103,9 @@ func (f *Flex) Draw(screen tcell.Screen) { variables-- } if f.direction == FlexColumn { - item.Item.SetRect(pos, f.y, size, f.height) + item.Item.SetRect(pos, y, size, height) } else { - item.Item.SetRect(f.x, pos, f.width, size) + item.Item.SetRect(x, pos, width, size) } pos += size diff --git a/form.go b/form.go index b6bbe92..b9bced8 100644 --- a/form.go +++ b/form.go @@ -37,9 +37,6 @@ type Form struct { // The alignment of the buttons. buttonsAlign int - // Border padding. - paddingTop, paddingBottom, paddingLeft, paddingRight int - // The number of empty rows between items. itemPadding int @@ -68,15 +65,11 @@ type Form struct { // NewForm returns a new form. func NewForm() *Form { - box := NewBox() + box := NewBox().SetBorderPadding(1, 1, 1, 1) f := &Form{ Box: box, itemPadding: 1, - paddingTop: 1, - paddingBottom: 1, - paddingLeft: 1, - paddingRight: 1, labelColor: tcell.ColorYellow, fieldBackgroundColor: tcell.ColorBlue, fieldTextColor: tcell.ColorWhite, @@ -89,12 +82,6 @@ func NewForm() *Form { return f } -// SetPadding sets the size of the borders around the form items. -func (f *Form) SetPadding(top, bottom, left, right int) *Form { - f.paddingTop, f.paddingBottom, f.paddingLeft, f.paddingRight = top, bottom, left, right - return f -} - // SetItemPadding sets the number of empty rows between form items. func (f *Form) SetItemPadding(padding int) *Form { f.itemPadding = padding @@ -192,20 +179,7 @@ func (f *Form) Draw(screen tcell.Screen) { f.Box.Draw(screen) // Determine the dimensions. - x := f.x - y := f.y - width := f.width - height := f.height - if f.border { - x++ - y++ - width -= 2 - height -= 2 - } - x += f.paddingLeft - y += f.paddingTop - width -= f.paddingLeft + f.paddingRight - height -= f.paddingTop + f.paddingBottom + x, y, width, height := f.GetInnerRect() bottomLimit := y + height rightLimit := x + width diff --git a/frame.go b/frame.go index 1de7534..9964c79 100644 --- a/frame.go +++ b/frame.go @@ -83,16 +83,9 @@ func (f *Frame) Draw(screen tcell.Screen) { f.Box.Draw(screen) // Calculate start positions. - left := f.x - right := f.x + f.width - 1 - top := f.y - bottom := f.y + f.height - 1 - if f.border { - left++ - right-- - top++ - bottom-- - } + left, top, width, height := f.GetInnerRect() + right := left + width - 1 + bottom := top + height - 1 left += f.left right -= f.right top += f.top diff --git a/inputfield.go b/inputfield.go index 3fb310d..cc547d5 100644 --- a/inputfield.go +++ b/inputfield.go @@ -182,16 +182,8 @@ func (i *InputField) Draw(screen tcell.Screen) { i.Box.Draw(screen) // Prepare - x := i.x - y := i.y - rightLimit := x + i.width - height := i.height - if i.border { - x++ - y++ - rightLimit -= 2 - height -= 2 - } + x, y, width, height := i.GetInnerRect() + rightLimit := x + width if height < 1 || rightLimit <= x { return } diff --git a/list.go b/list.go index ff3c890..8390091 100644 --- a/list.go +++ b/list.go @@ -143,16 +143,8 @@ func (l *List) Draw(screen tcell.Screen) { l.Box.Draw(screen) // Determine the dimensions. - x := l.x - y := l.y - width := l.width - bottomLimit := l.y + l.height - if l.border { - x++ - y++ - width -= 2 - bottomLimit -= 2 - } + x, y, width, height := l.GetInnerRect() + bottomLimit := y + height // Do we show any shortcuts? var showShortcuts bool diff --git a/modal.go b/modal.go index efc0fe1..6801722 100644 --- a/modal.go +++ b/modal.go @@ -30,11 +30,10 @@ func NewModal() *Modal { textColor: tcell.ColorWhite, } m.form = NewForm(). - SetPadding(0, 0, 0, 0). SetButtonsAlign(AlignCenter). SetButtonBackgroundColor(tcell.ColorBlack). SetButtonTextColor(tcell.ColorWhite) - m.form.SetBackgroundColor(tcell.ColorBlue) + m.form.SetBackgroundColor(tcell.ColorBlue).SetBorderPadding(0, 0, 0, 0) m.Frame = NewFrame(m.form) m.Box.SetBorder(true).SetBackgroundColor(tcell.ColorBlue) return m diff --git a/textview.go b/textview.go new file mode 100644 index 0000000..308a227 --- /dev/null +++ b/textview.go @@ -0,0 +1,417 @@ +package tview + +import ( + "math" + "regexp" + "sync" + "unicode/utf8" + + "github.com/gdamore/tcell" +) + +// textColors maps color strings which may be embedded in text sent to a +// TextView to their tcell counterparts. +var textColors = map[string]tcell.Color{ + "red": tcell.ColorRed, + "white": tcell.ColorWhite, + "yellow": tcell.ColorYellow, + "blue": tcell.ColorBlue, + "green": tcell.ColorGreen, +} + +// A regular expression commonly used throughout the TextView class. +var colorPattern = regexp.MustCompile(`\[(white|yellow|blue|green|red)\]`) + +// 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. +} + +// TextView is a box which displays text. It implements the Reader interface so +// you can stream text to it. +// +// 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. +// +// 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, +// +// "This is a [red]warning[white]!" +// +// will print the word "warning" in red. The following colors are currently +// supported: white, yellow, blue, green, red. +type TextView struct { + sync.Mutex + *Box + + // The text buffer. + buffer []string + + // The processed line index. This is nil if the buffer has changed and needs + // to be re-indexed. + index []*textViewIndex + + // 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 + + // If set to true, the text view will always remain at the end of the content. + trackEnd bool + + // The number of characters to be skipped on each line (not in wrap mode). + columnOffset 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. + wrap bool + + // The (starting) color of the text. + textColor tcell.Color + + // If set to true, the text color can be changed dynamically by piping color + // strings in square brackets to the text view. + dynamicColors bool + + // 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 + // following keys: Escape, Enter, Tab, Backtab. + done func(tcell.Key) +} + +// NewTextView returns a new text view. +func NewTextView() *TextView { + return &TextView{ + Box: NewBox(), + lineOffset: -1, + scrollable: true, + wrap: true, + textColor: tcell.ColorWhite, + dynamicColors: true, + } +} + +// SetScrollable sets the flag that decides whether or not the text view is +// scollable. If true, text is kept in a buffer and can be navigated. +func (t *TextView) SetScrollable(scrollable bool) *TextView { + t.scrollable = scrollable + return t +} + +// SetWrap sets the flag that, if true, leads to lines that are longer than the +// available width being wrapped onto the next line. If false, any characters +// beyond the available width are not displayed. +func (t *TextView) SetWrap(wrap bool) *TextView { + if t.wrap != wrap { + t.index = nil + } + t.wrap = wrap + 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). +func (t *TextView) SetTextColor(color tcell.Color) *TextView { + t.textColor = color + return t +} + +// SetDynamicColors sets the flag that allows the text color to be changed +// dynamically. See type description for details. +func (t *TextView) SetDynamicColors(dynamic bool) *TextView { + if t.dynamicColors != dynamic { + t.index = nil + } + t.dynamicColors = dynamic + 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. +func (t *TextView) SetChangedFunc(handler func()) *TextView { + t.changed = handler + return t +} + +// SetDoneFunc sets a handler which is called when the user presses on the +// following keys: Escape, Enter, Tab, Backtab. The key is passed to the +// handler. +func (t *TextView) SetDoneFunc(handler func(key tcell.Key)) *TextView { + t.done = handler + return t +} + +// Clear removes all text from the buffer. +func (t *TextView) Clear() *TextView { + t.buffer = nil + t.recentBytes = nil + t.index = nil + return t +} + +// Write lets us implement the io.Writer interface. +func (t *TextView) Write(p []byte) (n int, err error) { + // Notify at the end. + if t.changed != nil { + defer t.changed() + } + + t.Lock() + defer t.Unlock() + + // 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 { + openColor := regexp.MustCompile(`\[[a-z]+$`) + location := openColor.FindIndex(newBytes) + if location != nil { + t.recentBytes = newBytes[location[0]:] + newBytes = newBytes[:location[0]] + } + } + + // Transform the new bytes into strings. + newLine := regexp.MustCompile(`\r?\n`) + for index, line := range newLine.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 +} + +// 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 +// color with which the line starts. +func (t *TextView) reindexBuffer(width int) { + if t.index != nil && width == t.indexWidth { + return // Nothing has changed. We can still use the current index. + } + t.index = nil + + color := t.textColor + if !t.wrap { + width = math.MaxInt64 + } + for index, str := range t.buffer { + // Find all color tags in this line. + var ( + colorTagIndices [][]int + colorTags [][]string + ) + if t.dynamicColors { + colorTagIndices = colorPattern.FindAllStringIndex(str, -1) + colorTags = colorPattern.FindAllStringSubmatch(str, -1) + } + + // Break down the line. + var currentTag, currentWidth int + for pos := range str { + // Skip any color tags. + if currentTag < len(colorTags) { + if pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { + color = textColors[colorTags[currentTag][1]] + continue + } else if pos >= colorTagIndices[currentTag][1] { + currentTag++ + } + } + + // Add this line. + if currentWidth == 0 { + t.index = append(t.index, &textViewIndex{ + Line: index, + Pos: pos, + Color: color, + }) + } + + currentWidth++ + + // Have we crossed the width? + if t.wrap && currentWidth >= width { + currentWidth = 0 + } + } + } + + t.indexWidth = width +} + +// Draw draws this primitive onto the screen. +func (t *TextView) Draw(screen tcell.Screen) { + t.Lock() + defer t.Unlock() + t.Box.Draw(screen) + + // Get the available size. + x, y, width, height := t.GetInnerRect() + t.pageSize = height + + // Re-index. + t.reindexBuffer(width) + + // Adjust line offset. + if t.lineOffset+height > len(t.index) { + t.trackEnd = true + } + if t.trackEnd { + t.lineOffset = len(t.index) - height + } + if t.lineOffset < 0 { + t.lineOffset = 0 + } + + // 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 { + break + } + + // Get the text for this line. + index := t.index[line] + text := t.buffer[index.Line][index.Pos:] + style = style.Foreground(index.Color) + + // Get color tags. + var ( + colorTagIndices [][]int + colorTags [][]string + ) + if t.dynamicColors { + colorTagIndices = colorPattern.FindAllStringIndex(text, -1) + colorTags = colorPattern.FindAllStringSubmatch(text, -1) + } + + // Print one line. + var currentTag, 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] { + currentTag++ + } + } + + // Skip to the right. + if !t.wrap && skip < t.columnOffset { + skip++ + continue + } + + // Stop at the right border. + if posX >= width { + break + } + + screen.SetContent(x+posX, y+line-t.lineOffset, ch, nil, style) + + posX++ + } + } +} + +// 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 { + case tcell.KeyRune: + switch event.Rune() { + case 'g': // Home. + t.trackEnd = false + t.lineOffset = 0 + t.columnOffset = 0 + case 'G': // End. + t.trackEnd = true + t.columnOffset = 0 + case 'j': // Down. + t.lineOffset++ + case 'k': // Up. + t.trackEnd = false + t.lineOffset-- + case 'h': // Left. + t.columnOffset-- + if t.columnOffset < 0 { + t.columnOffset = 0 + } + case 'l': // Right. + t.columnOffset++ + } + case tcell.KeyHome: + t.trackEnd = false + t.lineOffset = 0 + t.columnOffset = 0 + case tcell.KeyEnd: + t.trackEnd = true + t.columnOffset = 0 + case tcell.KeyUp: + t.trackEnd = false + t.lineOffset-- + case tcell.KeyDown: + t.lineOffset++ + case tcell.KeyLeft: + t.columnOffset-- + if t.columnOffset < 0 { + t.columnOffset = 0 + } + case tcell.KeyRight: + t.columnOffset++ + case tcell.KeyPgDn, tcell.KeyCtrlF: + t.lineOffset += t.pageSize + 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) + } + } + } +}