clui/textview.go
Mark D Horn 890ef2c575 style: propagate style to view contents
Setting style on a text view now also applies to contents
within the window.
2020-01-14 18:02:12 -08:00

555 lines
11 KiB
Go

package clui
import (
"bufio"
xs "github.com/huandu/xstrings"
term "github.com/nsf/termbox-go"
"os"
"strings"
)
/*
TextView is control to display a read-only text. Text can be
loaded from a file or set manually. A portions of text can be
added on the fly and if the autoscroll is enabled the control
scroll down to the end - it may be useful to create a log
viewer.
Content is scrollable with arrow keys or by clicking buttons
on the scrolls(a control can have up to 2 scrollbars: vertical
and horizontal. The latter one is available only if WordWrap
mode is off).
*/
type TextView struct {
BaseControl
// own listbox members
lines []string
lengths []int
// for up/down scroll
topLine int
// for side scroll
leftShift int
wordWrap bool
colorized bool
virtualHeight int
virtualWidth int
autoscroll bool
maxLines int
}
/*
CreateTextView creates a new frame.
view - is a View that manages the control
parent - is container that keeps the control. The same View can be a view and a parent at the same time.
width and height - are minimal size of the control.
scale - the way of scaling the control when the parent is resized. Use DoNotScale constant if the
control should keep its original size.
*/
func CreateTextView(parent Control, width, height int, scale int) *TextView {
l := new(TextView)
l.BaseControl = NewBaseControl()
if height == AutoSize {
height = 3
}
if width == AutoSize {
width = 5
}
l.SetSize(width, height)
l.SetConstraints(width, height)
l.topLine = 0
l.lines = make([]string, 0)
l.parent = parent
l.maxLines = 0
l.SetTabStop(true)
l.SetScale(scale)
if parent != nil {
parent.AddChild(l)
}
return l
}
func (l *TextView) outputHeight() int {
h := l.height
if !l.wordWrap {
h--
}
return h
}
func (l *TextView) drawScrolls() {
height := l.outputHeight()
pos := ThumbPosition(l.topLine, l.virtualHeight-l.outputHeight(), height)
DrawScrollBar(l.x+l.width-1, l.y, 1, height, pos)
if !l.wordWrap {
pos = ThumbPosition(l.leftShift, l.virtualWidth-l.width+1, l.width-1)
DrawScrollBar(l.x, l.y+l.height-1, l.width-1, 1, pos)
}
}
func (l *TextView) drawText() {
PushAttributes()
defer PopAttributes()
maxWidth := l.width - 1
maxHeight := l.outputHeight()
bg, fg := RealColor(l.bg, l.Style(), ColorEditBack), RealColor(l.fg, l.Style(), ColorEditText)
if l.Active() {
bg, fg = RealColor(l.bg, l.Style(), ColorEditActiveBack), RealColor(l.fg, l.Style(), ColorEditActiveText)
}
SetTextColor(fg)
SetBackColor(bg)
if l.wordWrap {
lineID := l.posToItemNo(l.topLine)
linePos := l.itemNoToPos(lineID)
y := 0
for {
if y >= maxHeight || lineID >= len(l.lines) {
break
}
remained := l.lengths[lineID]
start := 0
for remained > 0 {
var s string
s = SliceColorized(l.lines[lineID], start, start+maxWidth)
if linePos >= l.topLine {
DrawText(l.x, l.y+y, s)
}
remained -= maxWidth
y++
linePos++
start += maxWidth
if y >= maxHeight {
break
}
}
lineID++
}
} else {
y := 0
total := len(l.lines)
for {
if y+l.topLine >= total {
break
}
if y >= maxHeight {
break
}
str := l.lines[l.topLine+y]
lineLength := l.lengths[l.topLine+y]
if l.leftShift == 0 {
if lineLength > maxWidth {
str = SliceColorized(str, 0, maxWidth)
}
} else {
if l.leftShift+maxWidth >= lineLength {
str = SliceColorized(str, l.leftShift, -1)
} else {
str = SliceColorized(str, l.leftShift, maxWidth+l.leftShift)
}
}
DrawText(l.x, l.y+y, str)
y++
}
}
}
// Repaint draws the control on its View surface
func (l *TextView) Draw() {
if l.hidden {
return
}
PushAttributes()
defer PopAttributes()
x, y := l.Pos()
w, h := l.Size()
bg, fg := RealColor(l.bg, l.Style(), ColorEditBack), RealColor(l.fg, l.Style(), ColorEditText)
if l.Active() {
bg, fg = RealColor(l.bg, l.Style(), ColorEditActiveBack), RealColor(l.fg, l.Style(), ColorEditActiveText)
}
SetTextColor(fg)
SetBackColor(bg)
FillRect(x, y, w, h, ' ')
l.drawText()
l.drawScrolls()
}
func (l *TextView) home() {
l.topLine = 0
}
func (l *TextView) end() {
height := l.outputHeight()
if l.virtualHeight <= height {
return
}
if l.topLine+height >= l.virtualHeight {
return
}
l.topLine = l.virtualHeight - height
}
func (l *TextView) moveUp(dy int) {
if l.topLine == 0 {
return
}
if l.topLine <= dy {
l.topLine = 0
} else {
l.topLine -= dy
}
}
func (l *TextView) moveDown(dy int) {
end := l.topLine + l.outputHeight()
if end >= l.virtualHeight {
return
}
if l.topLine+dy+l.outputHeight() >= l.virtualHeight {
l.topLine = l.virtualHeight - l.outputHeight()
} else {
l.topLine += dy
}
}
func (l *TextView) moveLeft() {
if l.wordWrap || l.leftShift == 0 {
return
}
l.leftShift--
}
func (l *TextView) moveRight() {
if l.wordWrap {
return
}
if l.leftShift+l.width-1 >= l.virtualWidth {
return
}
l.leftShift++
}
func (l *TextView) processMouseClick(ev Event) bool {
if ev.Key != term.MouseLeft {
return false
}
dx := ev.X - l.x
dy := ev.Y - l.y
yy := l.outputHeight()
// cursor is not on any scrollbar
if dx != l.width-1 && dy != l.height-1 {
return false
}
// wordwrap mode does not have horizontal scroll
if l.wordWrap && dx != l.width-1 {
return false
}
// corner in not wordwrap mode
if !l.wordWrap && dx == l.width-1 && dy == l.height-1 {
return false
}
// vertical scroll bar
if dx == l.width-1 {
if dy == 0 {
l.moveUp(1)
} else if dy == yy-1 {
l.moveDown(1)
} else {
newPos := ItemByThumbPosition(dy, l.virtualHeight-yy+1, yy)
if newPos >= 0 {
l.topLine = newPos
}
}
return true
}
// horizontal scrollbar
if dx == 0 {
l.moveLeft()
} else if dx == l.width-2 {
l.moveRight()
} else {
newPos := ItemByThumbPosition(dx, l.virtualWidth-l.width+2, l.width-1)
if newPos >= 0 {
l.leftShift = newPos
}
}
return true
}
/*
ProcessEvent processes all events come from the control parent. If a control
processes an event it should return true. If the method returns false it means
that the control do not want or cannot process the event and the caller sends
the event to the control parent
*/
func (l *TextView) ProcessEvent(event Event) bool {
if !l.Active() || !l.Enabled() {
return false
}
switch event.Type {
case EventKey:
switch event.Key {
case term.KeyHome:
l.home()
return true
case term.KeyEnd:
l.end()
return true
case term.KeyArrowUp:
l.moveUp(1)
return true
case term.KeyArrowDown:
l.moveDown(1)
return true
case term.KeyArrowLeft:
l.moveLeft()
return true
case term.KeyArrowRight:
l.moveRight()
return true
case term.KeyPgup:
l.moveUp(l.outputHeight())
case term.KeyPgdn:
l.moveDown(l.outputHeight())
default:
return false
}
case EventMouse:
return l.processMouseClick(event)
}
return false
}
// own methods
func (l *TextView) calculateVirtualSize() {
w := l.width - 1
l.virtualWidth = l.width - 1
l.virtualHeight = 0
l.lengths = make([]int, len(l.lines))
for idx, str := range l.lines {
str = UnColorizeText(str)
sz := xs.Len(str)
if l.wordWrap {
n := sz / w
r := sz % w
l.virtualHeight += n
if r != 0 {
l.virtualHeight++
}
} else {
l.virtualHeight++
if sz > l.virtualWidth {
l.virtualWidth = sz
}
}
l.lengths[idx] = sz
}
}
// SetText replaces existing content of the control
func (l *TextView) SetText(text []string) {
l.lines = make([]string, len(text))
copy(l.lines, text)
l.applyLimit()
l.calculateVirtualSize()
if l.autoscroll {
l.end()
}
}
func (l *TextView) posToItemNo(pos int) int {
id := 0
for idx, item := range l.lengths {
if l.virtualWidth >= item {
pos--
} else {
pos -= item / l.virtualWidth
if item%l.virtualWidth != 0 {
pos--
}
}
if pos <= 0 {
id = idx
break
}
}
return id
}
func (l *TextView) itemNoToPos(id int) int {
pos := 0
for i := 0; i < id; i++ {
if l.virtualWidth >= l.lengths[i] {
pos++
} else {
pos += l.lengths[i] / l.virtualWidth
if l.lengths[i]%l.virtualWidth != 0 {
pos++
}
}
}
return pos
}
// WordWrap returns if the wordwrap is enabled. If the wordwrap
// mode is enabled the control hides horizontal scrollbar and
// draws long lines on a few control lines. There is no
// visual indication if the line is new of it is the portion of
// the previous line yet
func (l *TextView) WordWrap() bool {
return l.wordWrap
}
func (l *TextView) recalculateTopLine() {
currLn := l.topLine
if l.wordWrap {
l.topLine = l.itemNoToPos(currLn)
} else {
l.topLine = l.posToItemNo(currLn)
}
}
// SetWordWrap enables or disables wordwrap mode
func (l *TextView) SetWordWrap(wrap bool) {
if wrap != l.wordWrap {
l.wordWrap = wrap
l.calculateVirtualSize()
l.recalculateTopLine()
l.Draw()
}
}
// LoadFile loads a text from file and replace the control
// text with the file one.
// Function returns false if loading text from file fails
func (l *TextView) LoadFile(filename string) bool {
l.lines = make([]string, 0)
file, err := os.Open(filename)
if err != nil {
return false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimRight(line, " ")
l.lines = append(l.lines, line)
}
l.applyLimit()
l.calculateVirtualSize()
if l.autoscroll {
l.end()
}
return true
}
// AutoScroll returns if autoscroll mode is enabled.
// If the autoscroll mode is enabled then the content always
// scrolled to the end after adding a text
func (l *TextView) AutoScroll() bool {
return l.autoscroll
}
// SetAutoScroll enables and disables autoscroll mode
func (l *TextView) SetAutoScroll(auto bool) {
l.autoscroll = auto
}
// AddText appends a text to the end of the control content.
// View position may be changed automatically depending on
// value of AutoScroll
func (l *TextView) AddText(text []string) {
l.lines = append(l.lines, text...)
l.applyLimit()
l.calculateVirtualSize()
if l.autoscroll {
l.end()
}
}
// MaxItems returns the maximum number of items that the
// TextView can keep. 0 means unlimited. It makes a TextView
// work like a FIFO queue: the oldest(the first) items are
// deleted if one adds an item to a full TextView
func (l *TextView) MaxItems() int {
return l.maxLines
}
// SetMaxItems sets the maximum items that TextView keeps
func (l *TextView) SetMaxItems(max int) {
l.maxLines = max
}
// ItemCount returns the number of items in the TextView
func (l *TextView) ItemCount() int {
return len(l.lines)
}
func (l *TextView) applyLimit() {
if l.maxLines == 0 {
return
}
delta := len(l.lines) - l.maxLines
if delta <= 0 {
return
}
l.lines = l.lines[delta:]
l.calculateVirtualSize()
if l.topLine+l.outputHeight() < len(l.lines) {
l.end()
}
}