diff --git a/src/termui/linegraph.go b/src/termui/linegraph.go new file mode 100644 index 0000000..631a9bd --- /dev/null +++ b/src/termui/linegraph.go @@ -0,0 +1,128 @@ +package termui + +import ( + "sort" + + drawille "github.com/cjbassi/drawille-go" +) + +// LineGraph implements a line graph of data points. +type LineGraph struct { + *Block + Data map[string][]float64 + LineColor map[string]Color + Zoom int + Labels map[string]string + + DefaultLineColor Color +} + +// NewLineGraph returns a new LineGraph with current theme. +func NewLineGraph() *LineGraph { + return &LineGraph{ + Block: NewBlock(), + Data: make(map[string][]float64), + LineColor: make(map[string]Color), + Labels: make(map[string]string), + Zoom: 5, + + DefaultLineColor: Theme.LineGraph, + } +} + +// Buffer implements Bufferer interface. +func (self *LineGraph) Buffer() *Buffer { + buf := self.Block.Buffer() + // we render each data point on to the canvas then copy over the braille to the buffer at the end + // fyi braille characters have 2x4 dots for each character + c := drawille.NewCanvas() + // used to keep track of the braille colors until the end when we render the braille to the buffer + colors := make([][]Color, self.X+2) + for i := range colors { + colors[i] = make([]Color, self.Y+2) + } + + // sort the series so that overlapping data will overlap the same way each time + seriesList := make([]string, len(self.Data)) + i := 0 + for seriesName := range self.Data { + seriesList[i] = seriesName + i++ + } + sort.Strings(seriesList) + + // draw lines in reverse order so that the first color defined in the colorscheme is on top + for i := len(seriesList) - 1; i >= 0; i-- { + seriesName := seriesList[i] + seriesData := self.Data[seriesName] + seriesLineColor, ok := self.LineColor[seriesName] + if !ok { + seriesLineColor = self.DefaultLineColor + } + + // coordinates of last point + lastY, lastX := -1, -1 + // assign colors to `colors` and lines/points to the canvas + for i := len(seriesData) - 1; i >= 0; i-- { + x := ((self.X + 1) * 2) - 1 - (((len(seriesData) - 1) - i) * self.Zoom) + y := ((self.Y + 1) * 4) - 1 - int((float64((self.Y)*4)-1)*(seriesData[i]/100)) + if x < 0 { + // render the line to the last point up to the wall + if x > 0-self.Zoom { + for _, p := range drawille.Line(lastX, lastY, x, y) { + if p.X > 0 { + c.Set(p.X, p.Y) + colors[p.X/2][p.Y/4] = seriesLineColor + } + } + } + break + } + if lastY == -1 { // if this is the first point + c.Set(x, y) + colors[x/2][y/4] = seriesLineColor + } else { + c.DrawLine(lastX, lastY, x, y) + for _, p := range drawille.Line(lastX, lastY, x, y) { + colors[p.X/2][p.Y/4] = seriesLineColor + } + } + lastX, lastY = x, y + } + + // copy braille and colors to buffer + for y, line := range c.Rows(c.MinX(), c.MinY(), c.MaxX(), c.MaxY()) { + for x, char := range line { + x /= 3 // idk why but it works + if x == 0 { + continue + } + if char != 10240 { // empty braille character + buf.SetCell(x, y, Cell{char, colors[x][y], self.Bg}) + } + } + } + } + + // renders key/label ontop + for i, seriesName := range seriesList { + if i+2 > self.Y { + continue + } + seriesLineColor, ok := self.LineColor[seriesName] + if !ok { + seriesLineColor = self.DefaultLineColor + } + + // render key ontop, but let braille be drawn over space characters + str := seriesName + " " + self.Labels[seriesName] + for k, char := range str { + if char != ' ' { + buf.SetCell(3+k, i+2, Cell{char, seriesLineColor, self.Bg}) + } + } + + } + + return buf +} diff --git a/src/termui/sparkline.go b/src/termui/sparkline.go new file mode 100644 index 0000000..e9073d3 --- /dev/null +++ b/src/termui/sparkline.go @@ -0,0 +1,99 @@ +package termui + +import ( + "fmt" +) + +var SPARKS = [8]rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +// Sparkline is like: ▅▆▂▂▅▇▂▂▃▆▆▆▅▃. The data points should be non-negative integers. +type Sparkline struct { + Data []int + Title1 string + Title2 string + TitleColor Color + LineColor Color +} + +// Sparklines is a renderable widget which groups together the given sparklines. +type Sparklines struct { + *Block + Lines []*Sparkline +} + +// Add appends a given Sparkline to the *Sparklines. +func (self *Sparklines) Add(sl Sparkline) { + self.Lines = append(self.Lines, &sl) +} + +// NewSparkline returns an unrenderable single sparkline that intended to be added into a Sparklines. +func NewSparkline() *Sparkline { + return &Sparkline{ + TitleColor: Theme.Fg, + LineColor: Theme.Sparkline, + } +} + +// NewSparklines return a new *Sparklines with given Sparklines, you can always add a new Sparkline later. +func NewSparklines(ss ...*Sparkline) *Sparklines { + return &Sparklines{ + Block: NewBlock(), + Lines: ss, + } +} + +// Buffer implements Bufferer interface. +func (self *Sparklines) Buffer() *Buffer { + buf := self.Block.Buffer() + + lc := len(self.Lines) // lineCount + + // renders each sparkline and its titles + for i, line := range self.Lines { + + // prints titles + title1Y := 2 + (self.Y/lc)*i + title2Y := (2 + (self.Y/lc)*i) + 1 + title1 := MaxString(line.Title1, self.X) + title2 := MaxString(line.Title2, self.X) + buf.SetString(1, title1Y, title1, line.TitleColor|AttrBold, self.Bg) + buf.SetString(1, title2Y, title2, line.TitleColor|AttrBold, self.Bg) + + sparkY := (self.Y / lc) * (i + 1) + // finds max data in current view used for relative heights + max := 1 + for i := len(line.Data) - 1; i >= 0 && self.X-((len(line.Data)-1)-i) >= 1; i-- { + if line.Data[i] > max { + max = line.Data[i] + } + } + // prints sparkline + for x := self.X; x >= 1; x-- { + char := SPARKS[0] + if (self.X - x) < len(line.Data) { + offset := self.X - x + cur_item := line.Data[(len(line.Data)-1)-offset] + percent := float64(cur_item) / float64(max) + index := int(percent * 7) + if index < 0 || index >= len(SPARKS) { + Error("sparkline", + fmt.Sprint( + "len(line.Data): ", len(line.Data), "\n", + "max: ", max, "\n", + "x: ", x, "\n", + "self.X: ", self.X, "\n", + "offset: ", offset, "\n", + "cur_item: ", cur_item, "\n", + "percent: ", percent, "\n", + "index: ", index, "\n", + "len(SPARKS): ", len(SPARKS), + )) + } + char = SPARKS[index] + } + buf.SetCell(x, sparkY, Cell{char, line.LineColor, self.Bg}) + } + } + + return buf +} diff --git a/src/termui/table.go b/src/termui/table.go new file mode 100644 index 0000000..7f18f1a --- /dev/null +++ b/src/termui/table.go @@ -0,0 +1,195 @@ +package termui + +import ( + "fmt" + "strings" +) + +// Table tracks all the attributes of a Table instance +type Table struct { + *Block + + Header []string + Rows [][]string + + ColWidths []int + CellXPos []int // column position + ColResizer func() // for widgets that inherit a Table and want to overload the ColResize method + Gap int // gap between columns + PadLeft int + + Cursor bool + CursorColor Color + + UniqueCol int // the column used to identify the selected item + SelectedItem string // used to keep the cursor on the correct item if the data changes + SelectedRow int + TopRow int // used to indicate where in the table we are scrolled at +} + +// NewTable returns a new Table instance +func NewTable() *Table { + self := &Table{ + Block: NewBlock(), + CursorColor: Theme.TableCursor, + SelectedRow: 0, + TopRow: 0, + UniqueCol: 0, + } + self.ColResizer = self.ColResize + return self +} + +// ColResize is the default column resizer, but can be overriden. +// ColResize calculates the width of each column. +func (self *Table) ColResize() { +} + +// Buffer implements the Bufferer interface. +func (self *Table) Buffer() *Buffer { + buf := self.Block.Buffer() + + self.ColResizer() + + // finds exact column starting position + self.CellXPos = []int{} + cur := 1 + self.PadLeft + for _, w := range self.ColWidths { + self.CellXPos = append(self.CellXPos, cur) + cur += w + cur += self.Gap + } + + // prints header + for i, h := range self.Header { + width := self.ColWidths[i] + if width == 0 { + continue + } + // don't render column if it doesn't fit in widget + if width > (self.X-self.CellXPos[i])+1 { + continue + } + buf.SetString(self.CellXPos[i], 1, h, self.Fg|AttrBold, self.Bg) + } + + // prints each row + for rowNum := self.TopRow; rowNum < self.TopRow+self.Y-1 && rowNum < len(self.Rows); rowNum++ { + if rowNum < 0 || rowNum >= len(self.Rows) { + Error("table rows", + fmt.Sprint( + "rowNum: ", rowNum, "\n", + "self.TopRow: ", self.TopRow, "\n", + "len(self.Rows): ", len(self.Rows), "\n", + "self.Y: ", self.Y, + )) + } + row := self.Rows[rowNum] + y := (rowNum + 2) - self.TopRow + + // prints cursor + fg := self.Fg + if self.Cursor { + if (self.SelectedItem == "" && rowNum == self.SelectedRow) || (self.SelectedItem != "" && self.SelectedItem == row[self.UniqueCol]) { + fg = self.CursorColor | AttrReverse + for _, width := range self.ColWidths { + if width == 0 { + continue + } + buf.SetString(1, y, strings.Repeat(" ", self.X), fg, self.Bg) + } + self.SelectedItem = row[self.UniqueCol] + self.SelectedRow = rowNum + } + } + + // prints each col of the row + for i, width := range self.ColWidths { + if width == 0 { + continue + } + // don't render column if width is greater than distance to end of widget + if width > (self.X-self.CellXPos[i])+1 { + continue + } + r := MaxString(row[i], width) + buf.SetString(self.CellXPos[i], y, r, fg, self.Bg) + } + } + + return buf +} + +///////////////////////////////////////////////////////////////////////////////// +// Cursor Movement // +///////////////////////////////////////////////////////////////////////////////// + +// calcPos is used to calculate the cursor position and the current view. +func (self *Table) calcPos() { + self.SelectedItem = "" + + if self.SelectedRow < 0 { + self.SelectedRow = 0 + } + if self.SelectedRow < self.TopRow { + self.TopRow = self.SelectedRow + } + + if self.SelectedRow > len(self.Rows)-1 { + self.SelectedRow = len(self.Rows) - 1 + } + if self.SelectedRow > self.TopRow+(self.Y-2) { + self.TopRow = self.SelectedRow - (self.Y - 2) + } +} + +func (self *Table) Up() { + self.SelectedRow -= 1 + self.calcPos() +} + +func (self *Table) Down() { + self.SelectedRow += 1 + self.calcPos() +} + +func (self *Table) Top() { + self.SelectedRow = 0 + self.calcPos() +} + +func (self *Table) Bottom() { + self.SelectedRow = len(self.Rows) - 1 + self.calcPos() +} + +// The number of lines in a page is equal to the height of the widgeself. + +func (self *Table) HalfPageUp() { + self.SelectedRow = self.SelectedRow - (self.Y-2)/2 + self.calcPos() +} + +func (self *Table) HalfPageDown() { + self.SelectedRow = self.SelectedRow + (self.Y-2)/2 + self.calcPos() +} + +func (self *Table) PageUp() { + self.SelectedRow -= (self.Y - 2) + self.calcPos() +} + +func (self *Table) PageDown() { + self.SelectedRow += (self.Y - 2) + self.calcPos() +} + +func (self *Table) Click(x, y int) { + x = x - self.XOffset + y = y - self.YOffset + if (x > 0 && x <= self.X) && (y > 0 && y <= self.Y) { + self.SelectedRow = (self.TopRow + y) - 2 + self.calcPos() + } +}