summaryrefslogtreecommitdiffstats
path: root/layout
diff options
context:
space:
mode:
authorSean E. Russell <seanerussell@gmail.com>2020-02-13 10:15:52 -0600
committerSean E. Russell <seanerussell@gmail.com>2020-02-13 10:15:52 -0600
commit7e5c0c969c223973335c6fae5432411afc3fb060 (patch)
tree7f785ba4f692b81806209cb5f0c959e86efdba70 /layout
parent4bfe0251a8893ed08654d59b8a8b8182958e907f (diff)
Fixes the directory structure.
Diffstat (limited to 'layout')
-rw-r--r--layout/layout.go283
-rw-r--r--layout/layout_test.go111
-rw-r--r--layout/parser.go124
3 files changed, 518 insertions, 0 deletions
diff --git a/layout/layout.go b/layout/layout.go
new file mode 100644
index 0000000..e918bde
--- /dev/null
+++ b/layout/layout.go
@@ -0,0 +1,283 @@
+// Copyright 2020 The gotop Authors Licensed under terms of the LICENSE file in this repository.
+package layout
+
+import (
+ "log"
+ "sort"
+
+ "github.com/cjbassi/gotop"
+ "github.com/cjbassi/gotop/widgets"
+
+ ui "github.com/gizak/termui/v3"
+)
+
+type layout struct {
+ Rows [][]widgetRule
+}
+
+type widgetRule struct {
+ Widget string
+ Weight float64
+ Height int
+}
+
+type MyGrid struct {
+ *ui.Grid
+ Lines []widgets.Scalable
+ Proc *widgets.ProcWidget
+}
+
+var widgetNames []string = []string{"cpu", "disk", "mem", "temp", "net", "procs", "batt"}
+
+func Layout(wl layout, c gotop.Config) (*MyGrid, error) {
+ rowDefs := wl.Rows
+ uiRows := make([]ui.GridItem, 0)
+ numRows := countNumRows(wl.Rows)
+ var uiRow ui.GridItem
+ for len(rowDefs) > 0 {
+ uiRow, rowDefs = processRow(c, numRows, rowDefs)
+ uiRows = append(uiRows, uiRow)
+ }
+ rgs := make([]interface{}, 0)
+ for _, ur := range uiRows {
+ ur.HeightRatio = ur.HeightRatio / float64(numRows)
+ rgs = append(rgs, ur)
+ }
+ grid := &MyGrid{ui.NewGrid(), nil, nil}
+ grid.Set(rgs...)
+ grid.Lines = deepFindScalable(rgs)
+ grid.Proc = deepFindProc(uiRows)
+ return grid, nil
+}
+
+// processRow eats a single row from the input list of rows and returns a UI
+// row (GridItem) representation of the specification, along with a slice
+// without that row.
+//
+// It does more than that, actually, because it may consume more than one row
+// if there's a row span widget in the row; in this case, it'll consume as many
+// rows as the largest row span object in the row, and produce an uber-row
+// containing all that stuff. It returns a slice without the consumed elements.
+func processRow(c gotop.Config, numRows int, rowDefs [][]widgetRule) (ui.GridItem, [][]widgetRule) {
+ // Recursive function #3. See the comment in deepFindProc.
+ if len(rowDefs) < 1 {
+ return ui.GridItem{}, [][]widgetRule{}
+ }
+ // The height of the tallest widget in this row; the number of rows that
+ // will be consumed, and the overall height of the row that will be
+ // produced.
+ maxHeight := countMaxHeight([][]widgetRule{rowDefs[0]})
+ var processing [][]widgetRule
+ if maxHeight < len(rowDefs) {
+ processing = rowDefs[0:maxHeight]
+ rowDefs = rowDefs[maxHeight:]
+ } else {
+ processing = rowDefs[0:]
+ rowDefs = [][]widgetRule{}
+ }
+ var colWeights []float64
+ var columns [][]interface{}
+ numCols := len(processing[0])
+ if numCols < 1 {
+ numCols = 1
+ }
+ for _, rd := range processing[0] {
+ colWeights = append(colWeights, rd.Weight)
+ columns = append(columns, make([]interface{}, 0))
+ }
+ colHeights := make([]int, numCols)
+ for _, rds := range processing {
+ for i, rd := range rds {
+ if colHeights[i]+rd.Height <= maxHeight {
+ widget := makeWidget(c, rd)
+ columns[i] = append(columns[i], ui.NewRow(float64(rd.Height)/float64(maxHeight), widget))
+ colHeights[i] += rd.Height
+ }
+ }
+ }
+ var uiColumns []interface{}
+ for i, widgets := range columns {
+ if len(widgets) > 0 {
+ uiColumns = append(uiColumns, ui.NewCol(float64(colWeights[i]), widgets...))
+ }
+ }
+
+ return ui.NewRow(1.0/float64(numRows), uiColumns...), rowDefs
+}
+
+func makeWidget(c gotop.Config, widRule widgetRule) interface{} {
+ var w interface{}
+ switch widRule.Widget {
+ case "cpu":
+ cpu := widgets.NewCpuWidget(c.UpdateInterval, c.GraphHorizontalScale, c.AverageLoad, c.PercpuLoad)
+ var keys []string
+ for key := range cpu.Data {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ i := 0
+ for _, v := range keys {
+ if i >= len(c.Colorscheme.CPULines) {
+ // assuming colorscheme for CPU lines is not empty
+ i = 0
+ }
+ color := c.Colorscheme.CPULines[i]
+ cpu.LineColors[v] = ui.Color(color)
+ i++
+ }
+ w = cpu
+ case "disk":
+ w = widgets.NewDiskWidget()
+ case "mem":
+ m := widgets.NewMemWidget(c.UpdateInterval, c.GraphHorizontalScale)
+ m.LineColors["Main"] = ui.Color(c.Colorscheme.MainMem)
+ m.LineColors["Swap"] = ui.Color(c.Colorscheme.SwapMem)
+ w = m
+ case "temp":
+ t := widgets.NewTempWidget(c.TempScale)
+ t.TempLowColor = ui.Color(c.Colorscheme.TempLow)
+ t.TempHighColor = ui.Color(c.Colorscheme.TempHigh)
+ w = t
+ case "net":
+ n := widgets.NewNetWidget(c.NetInterface)
+ n.Lines[0].LineColor = ui.Color(c.Colorscheme.Sparkline)
+ n.Lines[0].TitleColor = ui.Color(c.Colorscheme.BorderLabel)
+ n.Lines[1].LineColor = ui.Color(c.Colorscheme.Sparkline)
+ n.Lines[1].TitleColor = ui.Color(c.Colorscheme.BorderLabel)
+ w = n
+ case "procs":
+ p := widgets.NewProcWidget()
+ p.CursorColor = ui.Color(c.Colorscheme.ProcCursor)
+ w = p
+ case "batt":
+ b := widgets.NewBatteryWidget(c.GraphHorizontalScale)
+ var battKeys []string
+ for key := range b.Data {
+ battKeys = append(battKeys, key)
+ }
+ sort.Strings(battKeys)
+ i := 0 // Re-using variable from CPU
+ for _, v := range battKeys {
+ if i >= len(c.Colorscheme.BattLines) {
+ // assuming colorscheme for battery lines is not empty
+ i = 0
+ }
+ color := c.Colorscheme.BattLines[i]
+ b.LineColors[v] = ui.Color(color)
+ i++
+ }
+ w = b
+ default:
+ log.Printf("Invalid widget name %s. Must be one of %v", widRule.Widget, widgetNames)
+ return ui.NewBlock()
+ }
+ return w
+}
+
+func countNumRows(rs [][]widgetRule) int {
+ var ttl int
+ for len(rs) > 0 {
+ ttl += 1
+ line := rs[0]
+ h := 1
+ for _, c := range line {
+ if c.Height > h {
+ h = c.Height
+ }
+ }
+ if h < len(rs) {
+ rs = rs[h:]
+ } else {
+ break
+ }
+ }
+ return ttl
+}
+
+// Counts the height of the window so rows can be proportionally scaled.
+func countMaxHeight(rs [][]widgetRule) int {
+ var ttl int
+ for len(rs) > 0 {
+ line := rs[0]
+ h := 1
+ for _, c := range line {
+ if c.Height > h {
+ h = c.Height
+ }
+ }
+ ttl += h
+ if h < len(rs) {
+ rs = rs[h:]
+ } else {
+ break
+ }
+ }
+ return ttl
+}
+
+// deepFindProc looks in the UI widget tree for the ProcWidget,
+// and returns it if found or nil if not.
+func deepFindProc(gs interface{}) *widgets.ProcWidget {
+ // Recursive function #1. Recursion is OK here because the number
+ // of UI elements, even in a very complex UI, is going to be
+ // relatively small.
+ t, ok := gs.(ui.GridItem)
+ if ok {
+ return deepFindProc(t.Entry)
+ }
+ es, ok := gs.([]ui.GridItem)
+ if ok {
+ for _, g := range es {
+ v := deepFindProc(g)
+ if v != nil {
+ return v
+ }
+ }
+ }
+ fs, ok := gs.([]interface{})
+ if ok {
+ for _, g := range fs {
+ v := deepFindProc(g)
+ if v != nil {
+ return v
+ }
+ }
+ }
+ p, ok := gs.(*widgets.ProcWidget)
+ if ok {
+ return p
+ }
+ return nil
+}
+
+// deepFindScalable looks in the UI widget tree for Scalable widgets,
+// and returns them if found or an empty slice if not.
+func deepFindScalable(gs interface{}) []widgets.Scalable {
+ // Recursive function #1. See the comment in deepFindProc.
+ t, ok := gs.(ui.GridItem)
+ if ok {
+ return deepFindScalable(t.Entry)
+ }
+ es, ok := gs.([]ui.GridItem)
+ rvs := make([]widgets.Scalable, 0)
+ if ok {
+ for _, g := range es {
+ vs := deepFindScalable(g)
+ rvs = append(rvs, vs...)
+ }
+ return rvs
+ }
+ fs, ok := gs.([]interface{})
+ if ok {
+ for _, g := range fs {
+ vs := deepFindScalable(g)
+ rvs = append(rvs, vs...)
+ }
+ return rvs
+ }
+ p, ok := gs.(widgets.Scalable)
+ if ok {
+ rvs = append(rvs, p)
+ }
+ return rvs
+}
diff --git a/layout/layout_test.go b/layout/layout_test.go
new file mode 100644
index 0000000..d1f016d
--- /dev/null
+++ b/layout/layout_test.go
@@ -0,0 +1,111 @@
+package layout
+
+// NOT MY FAULT. Some dependency already pulled in testify -- 13kLOC
+import (
+ "github.com/stretchr/testify/assert"
+ "strings"
+ "testing"
+)
+
+func TestParsing(t *testing.T) {
+ tests := []struct {
+ i string
+ f func(l layout)
+ }{
+ {"cpu", func(l layout) {
+ assert.Equal(t, 1, len(l.Rows))
+ assert.Equal(t, 1, len(l.Rows[0]))
+ }},
+ {" cpu \ndisk/1 mem/3\ntemp \nnet procs", func(l layout) {
+ assert.Equal(t, 4, len(l.Rows))
+ assert.Equal(t, 1, len(l.Rows[0]))
+ assert.Equal(t, 2, len(l.Rows[1]))
+ assert.Equal(t, 1, len(l.Rows[2]))
+ assert.Equal(t, 2, len(l.Rows[3]))
+ }},
+ {"cpu\ndisk/1 mem/3\ntemp\nnet procs", func(l layout) {
+ assert.Equal(t, 4, len(l.Rows))
+ // 1
+ assert.Equal(t, 1, len(l.Rows[0]))
+ assert.Equal(t, 1.0, l.Rows[0][0].Weight)
+ assert.Equal(t, 1, l.Rows[0][0].Height)
+ // 2
+ assert.Equal(t, 2, len(l.Rows[1]))
+ assert.Equal(t, 1.0/4, l.Rows[1][0].Weight)
+ assert.Equal(t, 1, l.Rows[1][0].Height)
+ assert.Equal(t, 3.0/4, l.Rows[1][1].Weight)
+ assert.Equal(t, 1, l.Rows[1][1].Height)
+ // 3
+ assert.Equal(t, 1, len(l.Rows[2]))
+ assert.Equal(t, 1.0, l.Rows[2][0].Weight)
+ assert.Equal(t, 1, l.Rows[2][0].Height)
+ // 4
+ assert.Equal(t, 2, len(l.Rows[3]))
+ assert.Equal(t, 0.5, l.Rows[3][0].Weight)
+ assert.Equal(t, 1, l.Rows[3][0].Height)
+ assert.Equal(t, 0.5, l.Rows[3][1].Weight)
+ assert.Equal(t, 1, l.Rows[3][1].Height)
+ }},
+ {"2:cpu\ndisk\nmem", func(l layout) {
+ assert.Equal(t, 3, len(l.Rows))
+ assert.Equal(t, 1, len(l.Rows[0]))
+ assert.Equal(t, 2, l.Rows[0][0].Height)
+ assert.Equal(t, 1, len(l.Rows[1]))
+ assert.Equal(t, 1, l.Rows[1][0].Height)
+ assert.Equal(t, 1, len(l.Rows[2]))
+ assert.Equal(t, 1, l.Rows[2][0].Height)
+ }},
+ {"2:cpu disk\nmem", func(l layout) {
+ assert.Equal(t, 2, len(l.Rows))
+ assert.Equal(t, 2, len(l.Rows[0]))
+ assert.Equal(t, 2, l.Rows[0][0].Height)
+ assert.Equal(t, 1, l.Rows[0][1].Height)
+ assert.Equal(t, 1, len(l.Rows[1]))
+ assert.Equal(t, 1, l.Rows[1][0].Height)
+ }},
+ {"cpu 2:disk\nmem", func(l layout) {
+ assert.Equal(t, 2, len(l.Rows))
+ assert.Equal(t, 2, len(l.Rows[0]))
+ assert.Equal(t, 1, l.Rows[0][0].Height)
+ assert.Equal(t, 2, l.Rows[0][1].Height)
+ assert.Equal(t, 1, len(l.Rows[1]))
+ assert.Equal(t, 1, l.Rows[1][0].Height)
+ }},
+ {"cpu disk\n2:mem", func(l layout) {
+ assert.Equal(t, 2, len(l.Rows))
+ assert.Equal(t, 2, len(l.Rows[0]))
+ assert.Equal(t, 1, l.Rows[0][0].Height)
+ assert.Equal(t, 1, l.Rows[0][1].Height)
+ assert.Equal(t, 1, len(l.Rows[1]))
+ assert.Equal(t, 2, l.Rows[1][0].Height)
+ }},
+ {"cpu 2:disk/3\nmem", func(l layout) {
+ assert.Equal(t, 2, len(l.Rows))
+ assert.Equal(t, 2, len(l.Rows[0]))
+ assert.Equal(t, 1, l.Rows[0][0].Height)
+ assert.Equal(t, 1.0/4, l.Rows[0][0].Weight)
+ assert.Equal(t, 2, l.Rows[0][1].Height)
+ assert.Equal(t, 3.0/4, l.Rows[0][1].Weight)
+ assert.Equal(t, 1, len(l.Rows[1]))
+ assert.Equal(t, 1, l.Rows[1][0].Height)
+ assert.Equal(t, 1.0, l.Rows[1][0].Weight)
+ }},
+ {"2:cpu disk\nmem/3", func(l layout) {
+ assert.Equal(t, 2, len(l.Rows))
+ assert.Equal(t, 2, len(l.Rows[0]))
+ assert.Equal(t, 2, l.Rows[0][0].Height)
+ assert.Equal(t, 0.5, l.Rows[0][0].Weight)
+ assert.Equal(t, 1, l.Rows[0][1].Height)
+ assert.Equal(t, 0.5, l.Rows[0][1].Weight)
+ assert.Equal(t, 1, len(l.Rows[1]))
+ assert.Equal(t, 1, l.Rows[1][0].Height)
+ assert.Equal(t, 1.0, l.Rows[1][0].Weight)
+ }},
+ }
+
+ for _, tc := range tests {
+ in := strings.NewReader(tc.i)
+ l := ParseLayout(in)
+ tc.f(l)
+ }
+}
diff --git a/layout/parser.go b/layout/parser.go
new file mode 100644
index 0000000..024fabb
--- /dev/null
+++ b/layout/parser.go
@@ -0,0 +1,124 @@
+package layout
+
+import (
+ "bufio"
+ "io"
+ "log"
+ "strconv"
+ "strings"
+)
+
+// The syntax for the layout specification is:
+// ```
+// (rowspan:)?widget(/weight)?
+// ```
+// 1. Each line is a row
+// 2. Empty lines are skipped
+// 3. Spaces are compressed
+// 4. Legal widget names are: cpu, disk, mem, temp, batt, net, procs
+// 5. Names are not case sensitive
+// 4. The simplest row is a single widget, by name, e.g.
+// ```
+// cpu
+// ```
+// 5. Widgets with no weights have a weight of 1.
+// 6. If multiple widgets are put on a row with no weights, they will all have
+// the same width.
+// 7. Weights are integers
+// 8. A widget will have a width proportional to its weight divided by the
+// total weight count of the row. E.g.,
+// ```
+// cpu net
+// disk/2 mem/4
+// ```
+// The first row will have two widgets: the CPU and network widgets; each
+// will be 50% of the total width wide. The second row will have two
+// widgets: disk and memory; the first will be 2/6 ~= 33% wide, and the
+// second will be 5/7 ~= 67% wide (or, memory will be twice as wide as disk).
+// 9. If prefixed by a number and colon, the widget will span that number of
+// rows downward. E.g.
+// ```
+// 2:cpu
+// mem
+// ```
+// The CPU widget will be twice as high as the memory widget. Similarly,
+// ```
+// mem 2:cpu
+// net
+// ```
+// memory and network will be in the same row as CPU, one over the other,
+// and each half as high as CPU.
+// 10. Negative, 0, or non-integer weights will be recorded as "1". Same for row spans.
+// 11. Unrecognized widgets will cause the application to abort.
+// 12. In rows with multi-row spanning widgets **and** weights, weights in
+// lower rows are ignored. Put the weight on the widgets in that row, not
+// in later (spanned) rows.
+// 13. Widgets are filled in top down, left-to-right order.
+// 14. The larges row span in a row defines the top-level row span; all smaller
+// row spans constitude sub-rows in the row. For example, `cpu mem/3 net/5`
+// means that net/5 will be 5 rows tall overall, and mem will compose 3 of
+// them. If following rows do not have enough widgets to fill the gaps,
+// spacers will be used.
+func ParseLayout(i io.Reader) layout {
+ r := bufio.NewScanner(i)
+ rv := layout{Rows: make([][]widgetRule, 0)}
+ var lineNo int
+ for r.Scan() {
+ l := strings.TrimSpace(r.Text())
+ if l == "" {
+ continue
+ }
+ row := make([]widgetRule, 0)
+ ws := strings.Fields(l)
+ weightTotal := 0
+ for _, w := range ws {
+ wr := widgetRule{Weight: 1}
+ ks := strings.Split(w, "/")
+ rs := strings.Split(ks[0], ":")
+ var wid string
+ if len(rs) > 1 {
+ v, e := strconv.Atoi(rs[0])
+ if e != nil {
+ log.Printf("Layout error on line %d: format must be INT:STRING/INT. Error parsing %s as a int. Word was %s. Using a row height of 1.", lineNo, rs[0], w)
+ v = 1
+ }
+ if v < 1 {
+ v = 1
+ }
+ wr.Height = v
+ wid = rs[1]
+ } else {
+ wr.Height = 1
+ wid = rs[0]
+ }
+ wr.Widget = strings.ToLower(wid)
+ if len(ks) > 1 {
+ weight, e := strconv.Atoi(ks[1])
+ if e != nil {
+ log.Printf("Layout error on line %d: format must be STRING/INT. Error parsing %s as a int. Word was %s. Using a weight of 1 for widget.", lineNo, ks[1], w)
+ weight = 1
+ }
+ if weight < 1 {
+ weight = 1
+ }
+ wr.Weight = float64(weight)
+ if len(ks) > 2 {
+ log.Printf("Layout warning on line %d: too many '/' in word %s; ignoring extra junk.", lineNo, w)
+ }
+ weightTotal += weight
+ } else {
+ weightTotal += 1
+ }
+ row = append(row, wr)
+ }
+ // Prevent tricksy users from breaking their own computers
+ if weightTotal <= 1 {
+ weightTotal = 1
+ }
+ for i, w := range row {
+ row[i].Weight = w.Weight / float64(weightTotal)
+ }
+ rv.Rows = append(rv.Rows, row)
+ }
+ return rv
+}