From 4bfe0251a8893ed08654d59b8a8b8182958e907f Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Thu, 13 Feb 2020 09:40:20 -0600 Subject: [PATCH] v1 of dynamic layout for GUI Fixes the deepfind functions. --- layouts/default | 5 + layouts/procs | 5 + main.go | 65 +++++-- src/config/config.go | 1 + src/layout/layout.go | 448 ++++++++++++++++++++++++------------------- src/layout/parser.go | 124 ++++++++++++ 6 files changed, 426 insertions(+), 222 deletions(-) create mode 100644 layouts/default create mode 100644 layouts/procs create mode 100644 src/layout/parser.go diff --git a/layouts/default b/layouts/default new file mode 100644 index 0000000..755eb8e --- /dev/null +++ b/layouts/default @@ -0,0 +1,5 @@ +cpu +disk/1 2:mem/2 +temp +net procs + diff --git a/layouts/procs b/layouts/procs new file mode 100644 index 0000000..d0565de --- /dev/null +++ b/layouts/procs @@ -0,0 +1,5 @@ +cpu 4:procs +disk +mem +net + diff --git a/main.go b/main.go index 9c19134..f963f58 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/src/config/config.go b/src/config/config.go index cfa4838..d2d2a4a 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -24,4 +24,5 @@ type Config struct { Battery bool Statusbar bool NetInterface string + LayoutFile string } diff --git a/src/layout/layout.go b/src/layout/layout.go index cbd40ea..31404fc 100644 --- a/src/layout/layout.go +++ b/src/layout/layout.go @@ -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 +} diff --git a/src/layout/parser.go b/src/layout/parser.go new file mode 100644 index 0000000..024fabb --- /dev/null +++ b/src/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 +}