package main import ( "fmt" "math" "time" ) var ( uiBg = rgb{0x12, 0x14, 0x18} uiOKFill = rgb{0x18, 0x42, 0x26} uiOKEdge = rgb{0x3c, 0xe0, 0x70} uiErrFil = rgb{0x54, 0x18, 0x1c} uiErrEdg = rgb{0xff, 0x46, 0x46} uiFg = rgb{0xe6, 0xe8, 0xea} uiDim = rgb{0x9a, 0xa2, 0xac} uiCyan = rgb{0x5c, 0xc8, 0xe0} uiGreen = rgb{0x6c, 0xdc, 0x86} uiRed = rgb{0xf0, 0x6b, 0x6b} uiYellow = rgb{0xe0, 0xb0, 0x40} ) const ( uiMargin = 16 uiPad = 14 uiBorder = 12 rowGap = 5 blockGap = 16 btnW = 240 btnH = 64 btnRadius = 10 btnBorder = 2 holdDuration = time.Second chipCols = 2 chipPadY = 9 chipGap = 8 chipRadius = 8 chipBorder = 2 ) type rect struct { x, y, w, h int } func (r rect) contains(x, y int) bool { return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h } type display struct { fb *framebuffer huge *textFace grid *textFace gridB *textFace nowPanel rect sincePanel rect nowH int sinceH int resetBtn rect holdStart time.Time holdFrac float64 fired bool } func newDisplay() (*display, error) { fb, err := openFramebuffer() if err != nil { return nil, err } d := &display{fb: fb} for _, spec := range []struct { dst **textFace bold bool size float64 }{ {&d.huge, true, 60}, {&d.grid, false, 22}, {&d.gridB, true, 22}, } { face, err := loadFace(spec.bold, spec.size) if err != nil { fb.close() return nil, err } *spec.dst = face } if d.grid.cellW != d.gridB.cellW { fb.close() return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d", d.grid.cellW, d.gridB.cellW) } // Guessed heights collide on a screen this small, so the split follows what // the loaded faces actually measure. gridLine := d.grid.cellH + rowGap errH := len(errRows) * gridLine d.nowH = d.huge.cellH + rowGap + gridLine + blockGap + d.chipsH() d.sinceH = 4*gridLine + blockGap + errH + blockGap + btnH inner := fb.w - 2*uiMargin chrome := 2 * (uiBorder + uiPad) avail := fb.h - 2*uiMargin - blockGap h1 := (avail-2*chrome)*d.nowH/(d.nowH+d.sinceH) + chrome d.nowPanel = rect{uiMargin, uiMargin, inner, h1} d.sincePanel = rect{uiMargin, uiMargin + h1 + blockGap, inner, avail - h1} d.resetBtn = rect{ x: d.sincePanel.x + (inner-btnW)/2, y: d.sincePanel.y + d.sincePanel.h - uiBorder - uiPad - btnH, w: btnW, h: btnH, } return d, nil } // Tracks a press and hold on the reset button, returning true once it has been // held long enough. Lifting or sliding off cancels, and the press has to be // released before it can arm again. func (d *display) holdReset(x, y int, down bool, now time.Time) bool { if !down { d.holdStart, d.holdFrac, d.fired = time.Time{}, 0, false return false } if d.fired || !d.resetBtn.contains(x, y) { d.holdStart, d.holdFrac = time.Time{}, 0 return false } if d.holdStart.IsZero() { d.holdStart = now } d.holdFrac = now.Sub(d.holdStart).Seconds() / holdDuration.Seconds() if d.holdFrac < 1 { return false } d.holdFrac, d.fired, d.holdStart = 0, true, time.Time{} return true } // Built like the error chips, since it sits among them: a coloured outline // around a dark well. Cyan rather than the status colours because it is // something to press, not something being reported. func (d *display) drawResetButton() { r := d.resetBtn d.fb.roundRect(r.x, r.y, r.w, r.h, btnRadius, uiCyan) d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, r.w-2*btnBorder, r.h-2*btnBorder, btnRadius-btnBorder, uiBg) // The hold fills the well rather than the whole button, so the outline stays // put and it reads as the button filling up. split := r.x + btnBorder if d.holdFrac > 0 { w := int(float64(r.w-2*btnBorder) * math.Min(d.holdFrac, 1)) d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, w, r.h-2*btnBorder, btnRadius-btnBorder, uiCyan) split += w } label := "RESET" lx := r.x + (r.w-len(label)*d.gridB.cellW)/2 ly := r.y + (r.h-d.gridB.cellH)/2 // The label straddles the fill, so each glyph takes the colour that reads // against whatever is behind it. for i, c := range label { gx := lx + i*d.gridB.cellW col := uiCyan if gx+d.gridB.cellW/2 < split { col = uiBg } d.gridB.draw(d.fb, gx, ly, string(c), col) } } func (d *display) close() { d.fb.close() } func (d *display) right(f *textFace, xEnd, y int, s string, col rgb) { f.draw(d.fb, xEnd-len([]rune(s))*f.cellW, y, s, col) } func (d *display) row(f *textFace, x, w, y int, label, value string, col rgb) int { f.draw(d.fb, x, y, label, uiDim) d.right(f, x+w, y, value, col) return y + f.cellH + rowGap } func (d *display) rateRow(x, w, y int, label string, gb, target float64) int { d.gridB.draw(d.fb, x, y+(d.huge.cellH-d.gridB.cellH)/2, label, uiDim) d.right(d.huge, x+w, y, fmt.Sprintf("%.2f Gb/s", gb), rateColor(gb, target)) return y + d.huge.cellH + rowGap } var errRows = []struct { label string get func(errs) uint64 }{ {"lost", func(e errs) uint64 { return e.lost }}, {"late", func(e errs) uint64 { return e.late }}, {"crc", func(e errs) uint64 { return e.crc }}, {"bad magic", func(e errs) uint64 { return e.badMagic }}, {"bad length", func(e errs) uint64 { return e.badLen }}, {"kernel drops", func(e errs) uint64 { return e.kdrop }}, {"link", func(e errs) uint64 { return e.link }}, } func (d *display) errBlock(x, w, y int, e errs) int { for _, r := range errRows { n := r.get(e) y = d.row(d.grid, x, w, y, r.label, commas(n), errColor(n)) } return y } func (d *display) chipH() int { return d.grid.cellH + 2*chipPadY } func (d *display) chipsH() int { rows := (len(errRows) + chipCols - 1) / chipCols return rows*(d.chipH()+chipGap) - chipGap } // The same kinds as errBlock, but answering whether rather than how many, and // carrying their own labels so nothing has to be matched up across a row. Over // a window this short a count is a number nobody can read before it changes; // the only thing worth knowing at a glance is which kinds are happening now. func (d *display) errChips(x, w, y int, e errs) int { ch := d.chipH() cw := (w - (chipCols-1)*chipGap) / chipCols for i, r := range errRows { col, row := i%chipCols, i/chipCols // A last row with nothing to sit beside is centred, so the odd one out // balances the rows above rather than hanging off the left of them. n := min(len(errRows)-row*chipCols, chipCols) cx := x + (w-(n*cw+(n-1)*chipGap))/2 + col*(cw+chipGap) cy := y + row*(ch+chipGap) // Outlined by drawing the border colour and then sinking a smaller well // of background into it, so both curves get the same antialiasing. c := errColor(r.get(e)) d.fb.roundRect(cx, cy, cw, ch, chipRadius, c) d.fb.roundRect(cx+chipBorder, cy+chipBorder, cw-2*chipBorder, ch-2*chipBorder, chipRadius-chipBorder, uiBg) tx := cx + (cw-len([]rune(r.label))*d.grid.cellW)/2 d.grid.draw(d.fb, tx, cy+chipPadY, r.label, c) } return y + d.chipsH() } func (d *display) panel(p rect, e errs, contentH int) (int, int, int) { fill, edge := uiOKFill, uiOKEdge if e.total() > 0 { fill, edge = uiErrFil, uiErrEdg } d.fb.rect(p.x, p.y, p.w, p.h, edge) d.fb.rect(p.x+uiBorder, p.y+uiBorder, p.w-2*uiBorder, p.h-2*uiBorder, fill) inset := uiBorder + uiPad x, w := p.x+inset, p.w-2*inset y := p.y + inset if slack := (p.y + p.h - inset) - y - contentH; slack > 0 { y += slack / 2 } return x, w, y } // Rendered rates are quantised so the text only changes when the value moves // meaningfully. Without this the low digits churn every frame no matter how // long the averaging window is. func roundPPS(v float64) uint64 { const unit = 1000 if v < 0 { return 0 } return uint64((v+unit/2)/unit) * unit } func errColor(n uint64) rgb { if n == 0 { return uiGreen } return uiRed } func rateColor(gb, target float64) rgb { switch { case gb >= target*rateGreenFrac: return uiGreen case gb >= target*rateYellowFrac: return uiYellow default: return uiRed } } func (d *display) render(v view, elapsed time.Duration, target float64, cable string) error { fb := d.fb fb.fill(uiBg) x, w, y := d.panel(d.nowPanel, v.window, d.nowH) y = d.rateRow(x, w, y, "RX", v.rxGbps, target) y = d.row(d.grid, x, w, y, "packets/s", commas(roundPPS(v.rxPPS)), uiFg) d.errChips(x, w, y+blockGap, v.window) x, w, y = d.panel(d.sincePanel, v.since, d.sinceH) y = d.row(d.grid, x, w, y, "uptime", uptime(elapsed), uiFg) y = d.row(d.grid, x, w, y, "frames", commas(v.rxFrames), uiFg) y = d.row(d.grid, x, w, y, "data", humanBytes(v.rxGot), uiFg) y = d.row(d.grid, x, w, y, "cable", cable, uiCyan) d.errBlock(x, w, y+blockGap, v.since) d.drawResetButton() return fb.flush() }