v1 of dynamic layout for GUI

Fixes the deepfind functions.
This commit is contained in:
Sean E. Russell 2020-02-13 09:40:20 -06:00
parent 97203db7d2
commit 4bfe0251a8
6 changed files with 426 additions and 222 deletions

5
layouts/default Normal file
View File

@ -0,0 +1,5 @@
cpu
disk/1 2:mem/2
temp
net procs

5
layouts/procs Normal file
View File

@ -0,0 +1,5 @@
cpu 4:procs
disk
mem
net

65
main.go
View File

@ -3,12 +3,14 @@ package main
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@ -28,6 +30,7 @@ const (
version = "3.0.0"
graphHorizontalScaleDelta = 3
defaultUI = "cpu\ndisk/1 2:mem/2\ntemp\nnet procs"
)
var (
@ -43,18 +46,19 @@ func parseArgs() (config.Config, error) {
Usage: gotop [options]
Options:
-c, --color=NAME Set a colorscheme.
-h, --help Show this screen.
-m, --minimal Only show CPU, Mem and Process widgets.
-r, --rate=RATE Number of times per second to update CPU and Mem widgets [default: 1].
-V, --version Print version and exit.
-p, --percpu Show each CPU in the CPU widget.
-a, --averagecpu Show average CPU in the CPU widget.
-f, --fahrenheit Show temperatures in fahrenheit.
-s, --statusbar Show a statusbar with the time.
-b, --battery Show battery level widget ('minimal' turns off).
-i, --interface=NAME Select network interface [default: all].
--no-temps Disable temperature widget
-c, --color=NAME Set a colorscheme.
-h, --help Show this screen.
-m, --minimal Only show CPU, Mem and Process widgets.
-r, --rate=RATE Number of times per second to update CPU and Mem widgets [default: 1].
-V, --version Print version and exit.
-p, --percpu Show each CPU in the CPU widget.
-a, --averagecpu Show average CPU in the CPU widget.
-f, --fahrenheit Show temperatures in fahrenheit.
-s, --statusbar Show a statusbar with the time.
-b, --battery Show battery level widget ('minimal' turns off).
-i, --interface=NAME Select network interface [default: all].
-l, --layout=NAME Name of layout spec file for the UI
--layout-file=NAME Path to a layout file
Colorschemes:
default
@ -65,8 +69,9 @@ Colorschemes:
`
ld := utils.GetLogDir(appName)
cd := utils.GetConfigDir(appName)
conf = config.Config{
ConfigDir: utils.GetConfigDir(appName),
ConfigDir: cd,
LogDir: ld,
LogPath: filepath.Join(ld, "errors.log"),
GraphHorizontalScale: 7,
@ -87,6 +92,22 @@ Colorschemes:
return conf, err
}
if val, _ := args["--layout"]; val != nil {
fp := filepath.Join(cd, val.(string))
if _, err := os.Stat(fp); err == nil {
conf.LayoutFile = fp
} else {
conf.LayoutFile = ""
}
}
if val, _ := args["--layout-file"]; val != nil {
fp := val.(string)
if _, err := os.Stat(fp); err == nil {
conf.LayoutFile = fp
} else {
conf.LayoutFile = ""
}
}
if val, _ := args["--color"]; val != nil {
cs, err := handleColorscheme(val.(string))
if err != nil {
@ -100,8 +121,6 @@ Colorschemes:
conf.MinimalMode, _ = args["--minimal"].(bool)
statusbar, _ = args["--statusbar"].(bool)
notemps, _ := args["--no-temps"].(bool)
rateStr, _ := args["--rate"].(string)
rate, err := strconv.ParseFloat(rateStr, 64)
if err != nil {
@ -116,9 +135,6 @@ Colorschemes:
if fahrenheit {
conf.TempScale = w.Fahrenheit
}
if notemps {
conf.TempScale = w.Disabled
}
conf.NetInterface, _ = args["--interface"].(string)
return conf, nil
@ -369,8 +385,17 @@ func main() {
bar = w.NewStatusBar()
}
lf, _ := os.Open("layout.txt")
ly := layout.ParseLayout(lf)
var lin io.Reader
lin = strings.NewReader(defaultUI)
if conf.LayoutFile != "" {
fin, err := os.Open(conf.LayoutFile)
defer fin.Close()
if err != nil {
stderrLogger.Fatalf("Layout %s not found.", conf.LayoutFile)
}
lin = fin
}
ly := layout.ParseLayout(lin)
grid, err := layout.Layout(ly, conf)
if err != nil {
stderrLogger.Fatalf("failed to initialize termui: %v", err)

View File

@ -24,4 +24,5 @@ type Config struct {
Battery bool
Statusbar bool
NetInterface string
LayoutFile string
}

View File

@ -2,14 +2,8 @@
package layout
import (
"bufio"
"errors"
"fmt"
"io"
"log"
"sort"
"strconv"
"strings"
"github.com/cjbassi/gotop/src/config"
"github.com/cjbassi/gotop/src/widgets"
@ -34,205 +28,255 @@ type MyGrid struct {
var widgetNames []string = []string{"cpu", "disk", "mem", "temp", "net", "procs", "batt"}
// 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.
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
}
func Layout(wl layout, c config.Config) (*MyGrid, error) {
log.Printf("laying out %v", wl)
var rows [][]interface{}
var lines []widgets.Scalable
var mouser *widgets.ProcWidget
for _, rowDef := range wl.Rows {
var gi []interface{}
var w interface{}
for _, widRule := range rowDef {
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++
}
lines = append(lines, cpu)
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)
lines = append(lines, m)
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)
mouser = p
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:
return nil, errors.New(fmt.Sprintf("Invalid widget name %s. Must be one of %v", widRule.Widget, widgetNames))
}
gi = append(gi, ui.NewCol(widRule.Weight, w))
}
if len(gi) > 0 {
rows = append(rows, gi)
} else {
log.Printf("WARN: no rows created from %v", rowDef)
}
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)
}
var rgs []interface{}
rowHeight := 1.0 / float64(len(rows))
for _, r := range rows {
rgs = append(rgs, ui.NewRow(rowHeight, r...))
rgs := make([]interface{}, 0)
for _, ur := range uiRows {
ur.HeightRatio = ur.HeightRatio / float64(numRows)
rgs = append(rgs, ur)
}
grid := &MyGrid{ui.NewGrid(), make([]widgets.Scalable, 0), mouser}
grid := &MyGrid{ui.NewGrid(), nil, nil}
grid.Set(rgs...)
grid.Lines = lines
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 config.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 config.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
}

124
src/layout/parser.go Normal file
View File

@ -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
}