Compare commits

..

1 Commits

Author SHA1 Message Date
Kevin Schoon
47b27a7786 vendor dependencies with glide 2017-03-13 08:05:40 +11:00
23 changed files with 108 additions and 368 deletions

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
ctop
.idea

View File

@@ -1,10 +1,7 @@
FROM quay.io/vektorcloud/glibc:latest
ARG CTOP_VERSION=0.5
ENV CTOP_URL https://github.com/bcicen/ctop/releases/download/v${CTOP_VERSION}/ctop-${CTOP_VERSION}-linux-amd64
RUN echo $CTOP_URL && \
wget -q $CTOP_URL -O /ctop && \
RUN ctop_url=$(wget -q -O - https://api.github.com/repos/bcicen/ctop/releases/latest | grep 'browser_' | cut -d\" -f4 |grep 'linux-amd64') && \
wget -q $ctop_url -O /ctop && \
chmod +x /ctop
ENTRYPOINT ["/ctop"]

View File

@@ -17,7 +17,7 @@ Fetch the [latest release](https://github.com/bcicen/ctop/releases) for your pla
#### Linux
```bash
wget https://github.com/bcicen/ctop/releases/download/v0.5/ctop-0.5-linux-amd64 -O ctop
wget https://github.com/bcicen/ctop/releases/download/v0.4.1/ctop-0.4.1-linux-amd64 -O ctop
sudo mv ctop /usr/local/bin/
sudo chmod +x /usr/local/bin/ctop
```
@@ -25,27 +25,25 @@ sudo chmod +x /usr/local/bin/ctop
#### OS X
```bash
curl -Lo ctop https://github.com/bcicen/ctop/releases/download/v0.5/ctop-0.5-darwin-amd64
curl -Lo ctop https://github.com/bcicen/ctop/releases/download/v0.4.1/ctop-0.4.1-darwin-amd64
sudo mv ctop /usr/local/bin/
sudo chmod +x /usr/local/bin/ctop
```
or run via Docker:
```bash
docker run -ti --name ctop --rm -v /var/run/docker.sock:/var/run/docker.sock quay.io/vektorlab/ctop:latest
docker run -ti -v /var/run/docker.sock:/var/run/docker.sock quay.io/vektorlab/ctop:latest
```
`ctop` is also available for Arch in the [AUR](https://aur.archlinux.org/packages/ctop/)
## Building
To build `ctop` from source, ensure you have a recent version of [glide](http://glide.sh/) installed and run:
To build `ctop` from source ensure you have a recent version of [glide](http://glide.sh/) installed.
```bash
git clone https://github.com/bcicen/ctop.git $GOPATH/src/github.com/bcicen/ctop && \
cd $GOPATH/src/github.com/bcicen/ctop && \
glide install && \
go build
cd $GOPATH/src/github.com/bcicen/ctop
glide install
```
## Usage
@@ -56,24 +54,12 @@ export DOCKER_HOST=tcp://127.0.0.1:4243
ctop
```
### Options
Option | Description
--- | ---
-a | show active containers only
-f <string> | set an initial filter string
-h | display help dialog
-i | invert default colors
-r | reverse container sort order
-s | select initial container sort field
-v | output version information and exit
### Keybindings
Key | Action
--- | ---
a | Toggle display of all (running and non-running) containers
f | Filter displayed containers (`esc` to clear when open)
f | Filter displayed containers
H | Toggle ctop header
h | Open help dialog
s | Select container sort field

View File

@@ -1 +1 @@
0.5
0.4.1

View File

@@ -1,23 +1,24 @@
machine:
services:
- docker
environment:
IMAGE_NAME: quay.io/vektorlab/ctop
dependencies:
override:
- docker info
- docker build --build-arg CTOP_VERSION=$(cat VERSION) -t ctop .
- |
if [[ "$CIRCLE_BRANCH" == "master" ]]; then
docker build -t quay.io/vektorlab/ctop:latest .
else
docker build -t quay.io/vektorlab/ctop:${CIRCLE_BRANCH} .
fi
test:
override:
- docker run -ti ctop -v
- docker run -t --entrypoint /bin/sh quay.io/vektorlab/ctop:latest -v
deployment:
hub:
branch: master
commands:
- docker tag ctop ${IMAGE_NAME}:latest
- docker tag ctop ${IMAGE_NAME}:$(cat VERSION)
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS quay.io
- docker push ${IMAGE_NAME}
- docker push quay.io/vektorlab/ctop:latest

View File

@@ -1,10 +1,6 @@
package main
import (
"regexp"
ui "github.com/gizak/termui"
)
import ui "github.com/gizak/termui"
/*
Valid colors:
@@ -42,17 +38,6 @@ var ColorMap = map[string]ui.Attribute{
"mbarchart.text.fg": ui.ColorWhite,
"par.text.fg": ui.ColorWhite,
"par.text.bg": ui.ColorDefault,
"par.text.hi": ui.ColorBlack,
"sparkline.line.fg": ui.ColorGreen,
"sparkline.title.fg": ui.ColorWhite,
}
func InvertColorMap() {
re := regexp.MustCompile(".*.fg")
for k, _ := range ColorMap {
if re.FindAllString(k, 1) != nil {
ColorMap[k] = ui.ColorBlack
}
}
ColorMap["par.text.hi"] = ui.ColorWhite
}

View File

@@ -4,7 +4,7 @@ import (
ui "github.com/gizak/termui"
)
var header *CompactHeader
var header = NewCompactHeader()
type CompactGrid struct {
ui.GridBufferer
@@ -16,7 +16,6 @@ type CompactGrid struct {
}
func NewCompactGrid() *CompactGrid {
header = NewCompactHeader() // init column header
return &CompactGrid{}
}

View File

@@ -12,7 +12,7 @@ type CompactHeader struct {
}
func NewCompactHeader() *CompactHeader {
fields := []string{"", "NAME", "CID", "CPU", "MEM", "NET RX/TX", "IO R/W", "PIDS"}
fields := []string{"", "NAME", "CID", "CPU", "MEM", "NET RX/TX"}
ch := &CompactHeader{}
ch.Height = 2
for _, f := range fields {
@@ -27,13 +27,13 @@ func (ch *CompactHeader) GetHeight() int {
func (ch *CompactHeader) SetWidth(w int) {
x := ch.X
autoWidth := calcWidth(w)
autoWidth := calcWidth(w, 5)
for n, col := range ch.pars {
// set column to static width
if colWidths[n] != 0 {
// set status column to static width
if n == 0 {
col.SetX(x)
col.SetWidth(colWidths[n])
x += colWidths[n]
col.SetWidth(statusWidth)
x += statusWidth
continue
}
col.SetX(x)

View File

@@ -15,8 +15,6 @@ type Compact struct {
Cpu *GaugeCol
Memory *GaugeCol
Net *TextCol
IO *TextCol
Pids *TextCol
X, Y int
Width int
Height int
@@ -34,8 +32,6 @@ func NewCompact(id string) *Compact {
Cpu: NewGaugeCol(),
Memory: NewGaugeCol(),
Net: NewTextCol("-"),
IO: NewTextCol("-"),
Pids: NewTextCol("-"),
X: 1,
Height: 1,
}
@@ -63,8 +59,6 @@ func (row *Compact) SetMetrics(m metrics.Metrics) {
row.SetCPU(m.CPUUtil)
row.SetNet(m.NetRx, m.NetTx)
row.SetMem(m.MemUsage, m.MemLimit, m.MemPercent)
row.SetIO(m.IOBytesRead, m.IOBytesWrite)
row.SetPids(m.Pids)
}
// Set gauges, counters to default unread values
@@ -72,8 +66,6 @@ func (row *Compact) Reset() {
row.Cpu.Reset()
row.Memory.Reset()
row.Net.Reset()
row.IO.Reset()
row.Pids.Reset()
}
func (row *Compact) GetHeight() int {
@@ -99,12 +91,13 @@ func (row *Compact) SetWidth(width int) {
return
}
x := row.X
autoWidth := calcWidth(width)
autoWidth := calcWidth(width, 5)
for n, col := range row.all() {
if colWidths[n] != 0 {
// set status column to static width
if n == 0 {
col.SetX(x)
col.SetWidth(colWidths[n])
x += colWidths[n]
col.SetWidth(statusWidth)
x += statusWidth
continue
}
col.SetX(x)
@@ -123,8 +116,7 @@ func (row *Compact) Buffer() ui.Buffer {
buf.Merge(row.Cpu.Buffer())
buf.Merge(row.Memory.Buffer())
buf.Merge(row.Net.Buffer())
buf.Merge(row.IO.Buffer())
buf.Merge(row.Pids.Buffer())
return buf
}
@@ -136,7 +128,5 @@ func (row *Compact) all() []ui.GridBufferer {
row.Cpu,
row.Memory,
row.Net,
row.IO,
row.Pids,
}
}

View File

@@ -13,16 +13,6 @@ func (row *Compact) SetNet(rx int64, tx int64) {
row.Net.Set(label)
}
func (row *Compact) SetIO(read int64, write int64) {
label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat(read), cwidgets.ByteFormat(write))
row.IO.Set(label)
}
func (row *Compact) SetPids(val int) {
label := fmt.Sprintf("%s", strconv.Itoa(val))
row.Pids.Set(label)
}
func (row *Compact) SetCPU(val int) {
row.Cpu.BarColor = colorScale(val)
row.Cpu.Label = fmt.Sprintf("%s%%", strconv.Itoa(val))
@@ -30,9 +20,6 @@ func (row *Compact) SetCPU(val int) {
val = 5
row.Cpu.BarColor = ui.ThemeAttr("gauge.bar.bg")
}
if val > 100 {
val = 100
}
row.Cpu.Percent = val
}

View File

@@ -17,7 +17,7 @@ func NewTextCol(s string) *TextCol {
}
func (w *TextCol) Highlight() {
w.TextFgColor = ui.ThemeAttr("par.text.hi")
w.TextFgColor = ui.ColorBlack
w.TextBgColor = ui.ThemeAttr("par.text.fg")
}

View File

@@ -9,29 +9,10 @@ import (
const colSpacing = 1
// per-column width. 0 == auto width
var colWidths = []int{
3, // status
0, // name
0, // cid
0, // cpu
0, // memory
0, // net
0, // io
4, // pids
}
// Calculate per-column width, given total width
func calcWidth(width int) int {
spacing := colSpacing * len(colWidths)
var staticCols int
for _, w := range colWidths {
width -= w
if w == 0 {
staticCols += 1
}
}
return (width - spacing) / staticCols
// Calculate per-column width, given total width and number of items
func calcWidth(width, items int) int {
spacing := colSpacing * items
return (width - statusWidth - spacing) / items
}
func centerParText(p *ui.Par) {

View File

@@ -1,51 +0,0 @@
package expanded
import (
"fmt"
"strings"
"github.com/bcicen/ctop/cwidgets"
ui "github.com/gizak/termui"
)
type IO struct {
*ui.Sparklines
readHist *DiffHist
writeHist *DiffHist
}
func NewIO() *IO {
io := &IO{ui.NewSparklines(), NewDiffHist(60), NewDiffHist(60)}
io.BorderLabel = "IO"
io.Height = 6
io.Width = colWidth[0]
io.X = 0
io.Y = 24
read := ui.NewSparkline()
read.Title = "READ"
read.Height = 1
read.Data = io.readHist.Data
read.LineColor = ui.ColorGreen
write := ui.NewSparkline()
write.Title = "WRITE"
write.Height = 1
write.Data = io.writeHist.Data
write.LineColor = ui.ColorYellow
io.Lines = []ui.Sparkline{read, write}
return io
}
func (w *IO) Update(read int64, write int64) {
var rate string
w.readHist.Append(int(read))
rate = strings.ToLower(cwidgets.ByteFormatInt(w.readHist.Val))
w.Lines[0].Title = fmt.Sprintf("read [%s/s]", rate)
w.writeHist.Append(int(write))
rate = strings.ToLower(cwidgets.ByteFormatInt(w.writeHist.Val))
w.Lines[1].Title = fmt.Sprintf("write [%s/s]", rate)
}

View File

@@ -17,8 +17,6 @@ type Expanded struct {
Net *Net
Cpu *Cpu
Mem *Mem
IO *IO
X, Y int
Width int
}
@@ -31,59 +29,30 @@ func NewExpanded(id string) *Expanded {
Net: NewNet(),
Cpu: NewCpu(),
Mem: NewMem(),
IO: NewIO(),
Width: ui.TermWidth(),
}
}
func (e *Expanded) Up() {
if e.Y < 0 {
e.Y++
e.Align()
ui.Render(e)
}
func (e *Expanded) SetWidth(w int) {
e.Width = w
}
func (e *Expanded) Down() {
if e.Y > (ui.TermHeight() - e.GetHeight()) {
e.Y--
e.Align()
ui.Render(e)
}
func (e *Expanded) SetMeta(k, v string) {
e.Info.Set(k, v)
}
func (e *Expanded) SetWidth(w int) { e.Width = w }
func (e *Expanded) SetMeta(k, v string) { e.Info.Set(k, v) }
func (e *Expanded) SetMetrics(m metrics.Metrics) {
e.Cpu.Update(m.CPUUtil)
e.Net.Update(m.NetRx, m.NetTx)
e.Mem.Update(int(m.MemUsage), int(m.MemLimit))
e.IO.Update(m.IOBytesRead, m.IOBytesWrite)
}
// Return total column height
func (e *Expanded) GetHeight() (h int) {
h += e.Info.Height
h += e.Net.Height
h += e.Cpu.Height
h += e.Mem.Height
h += e.IO.Height
return h
}
func (e *Expanded) Align() {
// reset offset if needed
if e.GetHeight() <= ui.TermHeight() {
e.Y = 0
}
y := e.Y
y := 0
for _, i := range e.all() {
i.SetY(y)
y += i.GetHeight()
}
if e.Width > colWidth[0] {
colWidth[1] = e.Width - (colWidth[0] + 1)
}
@@ -105,7 +74,6 @@ func (e *Expanded) Buffer() ui.Buffer {
buf.Merge(e.Cpu.Buffer())
buf.Merge(e.Mem.Buffer())
buf.Merge(e.Net.Buffer())
buf.Merge(e.IO.Buffer())
return buf
}
@@ -115,7 +83,6 @@ func (e *Expanded) all() []ui.GridBufferer {
e.Cpu,
e.Mem,
e.Net,
e.IO,
}
}

32
grid.go
View File

@@ -44,19 +44,19 @@ func ExpandView(c *Container) {
ex.Align()
ui.Render(ex)
HandleKeys("up", ex.Up)
HandleKeys("down", ex.Down)
ui.Handle("/sys/kbd/", func(ui.Event) { ui.StopLoop() })
ui.Handle("/timer/1s", func(ui.Event) { ui.Render(ex) })
ui.Handle("/timer/1s", func(ui.Event) {
ui.Render(ex)
})
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
ex.SetWidth(ui.TermWidth())
ex.Align()
log.Infof("resize: width=%v max-rows=%v", ex.Width, cGrid.MaxRows())
})
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Loop()
c.SetUpdater(c.Widgets)
}
@@ -77,18 +77,16 @@ func Display() bool {
cursor.RefreshContainers()
RedrawRows(true)
HandleKeys("up", cursor.Up)
HandleKeys("down", cursor.Down)
HandleKeys("exit", ui.StopLoop)
HandleKeys("help", func() {
menu = HelpMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/<up>", func(ui.Event) { cursor.Up() })
ui.Handle("/sys/kbd/<down>", func(ui.Event) { cursor.Down() })
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
expand = true
ui.StopLoop()
})
ui.Handle("/sys/kbd/q", func(ui.Event) { ui.StopLoop() })
ui.Handle("/sys/kbd/C-c", func(ui.Event) { ui.StopLoop() })
ui.Handle("/sys/kbd/a", func(ui.Event) {
config.Toggle("allContainers")
RefreshDisplay()
@@ -100,6 +98,10 @@ func Display() bool {
menu = FilterMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/h", func(ui.Event) {
menu = HelpMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/H", func(ui.Event) {
config.Toggle("enableHeader")
RedrawRows(true)

32
keys.go
View File

@@ -1,32 +0,0 @@
package main
import (
ui "github.com/gizak/termui"
)
// Common action keybindings
var keyMap = map[string][]string{
"up": []string{
"/sys/kbd/<up>",
"/sys/kbd/k",
},
"down": []string{
"/sys/kbd/<down>",
"/sys/kbd/j",
},
"exit": []string{
"/sys/kbd/q",
"/sys/kbd/C-c",
},
"help": []string{
"/sys/kbd/h",
"/sys/kbd/?",
},
}
// Apply a common handler function to all given keys
func HandleKeys(i string, f func()) {
for _, k := range keyMap[i] {
ui.Handle(k, func(ui.Event) { f() })
}
}

81
main.go
View File

@@ -1,7 +1,6 @@
package main
import (
"flag"
"fmt"
"os"
@@ -23,65 +22,25 @@ var (
)
func main() {
readArgs()
defer panicExit()
// init ui
ui.ColorMap = ColorMap // override default colormap
if err := ui.Init(); err != nil {
panic(err)
}
defer ui.Close()
// init global config
config.Init()
// parse command line arguments
var versionFlag = flag.Bool("v", false, "output version information and exit")
var helpFlag = flag.Bool("h", false, "display this help dialog")
var filterFlag = flag.String("f", "", "filter containers")
var activeOnlyFlag = flag.Bool("a", false, "show active containers only")
var sortFieldFlag = flag.String("s", "", "select container sort field")
var reverseSortFlag = flag.Bool("r", false, "reverse container sort order")
var invertFlag = flag.Bool("i", false, "invert default colors")
flag.Parse()
if *versionFlag {
printVersion()
os.Exit(0)
}
if *helpFlag {
printHelp()
os.Exit(0)
}
// override default config values with command line flags
if *filterFlag != "" {
config.Update("filterStr", *filterFlag)
}
if *activeOnlyFlag {
config.Toggle("allContainers")
}
if *sortFieldFlag != "" {
validSort(*sortFieldFlag)
config.Update("sortField", *sortFieldFlag)
}
if *reverseSortFlag {
config.Toggle("sortReversed")
}
// init logger
log = logging.Init()
if config.GetSwitchVal("loggingEnabled") {
logging.StartServer()
}
// init ui
if *invertFlag {
InvertColorMap()
}
ui.ColorMap = ColorMap // override default colormap
if err := ui.Init(); err != nil {
panic(err)
}
defer ui.Close()
// init grid, cursor, header
cursor = NewGridCursor()
cGrid = compact.NewCompactGrid()
@@ -97,11 +56,22 @@ func main() {
}
}
// ensure a given sort field is valid
func validSort(s string) {
if _, ok := Sorters[s]; !ok {
fmt.Printf("invalid sort field: %s\n", s)
os.Exit(1)
func readArgs() {
if len(os.Args) < 2 {
return
}
for _, arg := range os.Args[1:] {
switch arg {
case "-v", "version":
printVersion()
os.Exit(0)
case "-h", "help":
printHelp()
os.Exit(0)
default:
fmt.Printf("invalid option or argument: \"%s\"\n", arg)
os.Exit(1)
}
}
}
@@ -118,11 +88,12 @@ var helpMsg = `ctop - container metric viewer
usage: ctop [options]
options:
-h display this help dialog
-v output version information and exit
`
func printHelp() {
fmt.Println(helpMsg)
flag.PrintDefaults()
}
func printVersion() {

View File

@@ -39,7 +39,6 @@ func FilterMenu() {
i := widgets.NewInput()
i.BorderLabel = "Filter"
i.SetY(ui.TermHeight() - i.Height)
i.Data = config.GetVal("filterStr")
ui.Render(i)
// refresh container rows on input
@@ -53,10 +52,6 @@ func FilterMenu() {
}()
i.InputHandlers()
ui.Handle("/sys/kbd/<escape>", func(ui.Event) {
config.Update("filterStr", "")
ui.StopLoop()
})
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
config.Update("filterStr", i.Data)
ui.StopLoop()
@@ -81,15 +76,11 @@ func SortMenu() {
// set cursor position to current sort field
m.SetCursor(config.GetVal("sortField"))
HandleKeys("up", m.Up)
HandleKeys("down", m.Down)
HandleKeys("exit", ui.StopLoop)
ui.Render(m)
m.NavigationHandlers()
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
config.Update("sortField", m.SelectedItem().Val)
ui.StopLoop()
})
ui.Render(m)
ui.Loop()
}

View File

@@ -46,7 +46,6 @@ func (c *Docker) Start() {
c.ReadCPU(s)
c.ReadMem(s)
c.ReadNet(s)
c.ReadIO(s)
c.stream <- c.Metrics
}
log.Infof("collector stopped for container: %s", c.id)
@@ -80,7 +79,6 @@ func (c *Docker) ReadCPU(stats *api.Stats) {
c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus)
c.lastCpu = total
c.lastSysCpu = system
c.Pids = int(stats.PidsStats.Current)
}
func (c *Docker) ReadMem(stats *api.Stats) {
@@ -97,16 +95,3 @@ func (c *Docker) ReadNet(stats *api.Stats) {
}
c.NetRx, c.NetTx = rx, tx
}
func (c *Docker) ReadIO(stats *api.Stats) {
var read, write int64
for _, blk := range stats.BlkioStats.IOServiceBytesRecursive {
if blk.Op == "Read" {
read = int64(blk.Value)
}
if blk.Op == "Write" {
write = int64(blk.Value)
}
}
c.IOBytesRead, c.IOBytesWrite = read, write
}

View File

@@ -9,27 +9,21 @@ import (
var log = logging.Init()
type Metrics struct {
CPUUtil int
NetTx int64
NetRx int64
MemLimit int64
MemPercent int
MemUsage int64
IOBytesRead int64
IOBytesWrite int64
Pids int
CPUUtil int
NetTx int64
NetRx int64
MemLimit int64
MemPercent int
MemUsage int64
}
func NewMetrics() Metrics {
return Metrics{
CPUUtil: -1,
NetTx: -1,
NetRx: -1,
MemUsage: -1,
MemPercent: -1,
IOBytesRead: -1,
IOBytesWrite: -1,
Pids: -1,
CPUUtil: -1,
NetTx: -1,
NetRx: -1,
MemUsage: -1,
MemPercent: -1,
}
}

18
sort.go
View File

@@ -53,22 +53,6 @@ var Sorters = map[string]sortMethod{
}
return sum1 > sum2
},
"pids": func(c1, c2 *Container) bool {
// Use secondary sort method if equal values
if c1.Pids == c2.Pids {
return nameSorter(c1, c2)
}
return c1.Pids > c2.Pids
},
"io": func(c1, c2 *Container) bool {
sum1 := sumIO(c1)
sum2 := sumIO(c2)
// Use secondary sort method if equal values
if sum1 == sum2 {
return nameSorter(c1, c2)
}
return sum1 > sum2
},
"state": func(c1, c2 *Container) bool {
// Use secondary sort method if equal values
c1state := c1.GetMeta("state")
@@ -117,5 +101,3 @@ func (a Containers) Filter() {
}
func sumNet(c *Container) int64 { return c.NetRx + c.NetTx }
func sumIO(c *Container) int64 { return c.IOBytesRead + c.IOBytesWrite }

View File

@@ -7,7 +7,7 @@ import (
)
var (
input_chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_."
input_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_."
)
type Padding [2]int // x,y padding

View File

@@ -100,20 +100,27 @@ func (m *Menu) Buffer() ui.Buffer {
return buf
}
func (m *Menu) Up() {
func (m *Menu) Up(ui.Event) {
if m.cursorPos > 0 {
m.cursorPos--
ui.Render(m)
}
}
func (m *Menu) Down() {
func (m *Menu) Down(ui.Event) {
if m.cursorPos < (len(m.items) - 1) {
m.cursorPos++
ui.Render(m)
}
}
// Setup some default handlers for menu navigation
func (m *Menu) NavigationHandlers() {
ui.Handle("/sys/kbd/<up>", m.Up)
ui.Handle("/sys/kbd/<down>", m.Down)
ui.Handle("/sys/kbd/q", func(ui.Event) { ui.StopLoop() })
}
// Set width and height based on menu items
func (m *Menu) calcSize() {
m.Width = 7 // minimum width