Files
cabletest/ui.go
T

458 lines
12 KiB
Go

package main
import (
"fmt"
"math"
"runtime/debug"
"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}
uiAmber = rgb{0xe8, 0xc4, 0x52}
uiRed = rgb{0xf0, 0x6b, 0x6b}
)
func classColor(c int) rgb {
switch c {
case clsGood:
return uiGreen
case clsWarn:
return uiAmber
case clsBad:
return uiRed
}
return uiDim
}
// Distances between ink, since layout measures a line from the top of a digit
// to the baseline rather than across a cell with accent and descender slack in
// it. Values carried over from spacing cells will look too small here.
const (
step = 4
spaceTight = step * 2
spaceGroup = step * 4
spaceRow = step * 8
)
const (
uiMargin = spaceGroup
uiPad = spaceGroup
uiBorder = step * 3
pairGap = spaceGroup
blockGap = spaceGroup
btnW = 300
btnH = 80
holdDuration = time.Second
versionSpotSide = 200
chipRadius = spaceTight
chipBorder = 2
gridCols = 2
chipPadY = step * 3
chipGap = spaceTight
statRowGap = spaceRow
)
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
big *textFace
grid *textFace
gridB *textFace
nowPanel rect
sincePanel rect
nowYs []int
sinceYs []int
resetBtn rect
holdStart time.Time
holdFrac float64
fired bool
version string
versionSpot rect
showVersion bool
}
func newDisplay() (*display, error) {
fb, err := openFramebuffer()
if err != nil {
return nil, err
}
d := &display{fb: fb, version: buildVersion()}
for _, spec := range []struct {
dst **textFace
bold bool
size float64
}{
{&d.big, true, 36},
{&d.grid, false, 30},
{&d.gridB, true, 30},
} {
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)
}
if err := d.layout(fb.w, fb.h); err != nil {
fb.close()
return nil, err
}
return d, nil
}
func (d *display) layout(w, h int) error {
now := []int{d.statsH(d.big, 3), d.chipsH()}
since := []int{d.statsH(d.gridB, 4), d.statsH(d.gridB, 1), d.countsH(), btnH}
// One gap for both panels, and vertically the frame is the border alone.
// Insetting by uiPad as well would add it to the gaps at a panel's ends but
// not to the ones between blocks, which is not equal spacing however evenly
// the remainder is divided.
gaps := len(now) + len(since) + 2
spare := h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since)
if spare < 0 {
return fmt.Errorf("panel content is %dpx taller than the screen", -spare)
}
gap := spare / gaps
inner := w - 2*uiMargin
nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap
sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap
d.nowPanel = rect{uiMargin, uiMargin, inner, nowH}
d.sincePanel = rect{uiMargin, uiMargin + nowH + blockGap, inner, sinceH}
d.nowYs = stack(d.nowPanel.y+uiBorder+gap, now, gap)
d.sinceYs = stack(d.sincePanel.y+uiBorder+gap, since, gap)
d.resetBtn = rect{
x: d.sincePanel.x + (inner-btnW)/2,
y: d.sinceYs[len(d.sinceYs)-1],
w: btnW,
h: btnH,
}
d.versionSpot = rect{w - versionSpotSide, 0, versionSpotSide, versionSpotSide}
return nil
}
func sum(hs []int) int {
var t int
for _, h := range hs {
t += h
}
return t
}
func stack(y int, hs []int, gap int) []int {
ys := make([]int, len(hs))
for i, h := range hs {
ys[i] = y
y += h + gap
}
return ys
}
// 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
}
// 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, chipRadius, uiCyan)
d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, r.w-2*chipBorder, r.h-2*chipBorder,
chipRadius-chipBorder, uiBg)
split := r.x + chipBorder
if d.holdFrac > 0 {
w := int(float64(r.w-2*chipBorder) * math.Min(d.holdFrac, 1))
d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, w, r.h-2*chipBorder,
chipRadius-chipBorder, uiCyan)
split += w
}
label, base := "RESET", uiCyan
if d.showVersion {
label, base = d.version, uiDim
}
lx := r.x + (r.w-len(label)*d.gridB.cellW)/2
ly := r.y + (r.h-d.gridB.lineH)/2 - d.gridB.capTop
// 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 := base
if gx+d.gridB.cellW/2 < split {
col = uiBg
}
d.gridB.draw(d.fb, gx, ly, string(c), col)
}
}
// go run never stamps VCS info, so dev builds have no revision to show.
func buildVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "dev"
}
var rev string
var dirty bool
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if rev == "" {
return "dev"
}
if len(rev) > 9 {
rev = rev[:9]
}
if dirty {
rev += "+"
}
return rev
}
func (d *display) close() {
d.fb.close()
}
// y is the top of the line as read, not the top of the cell, so text and a
// bordered box placed the same distance apart look it.
func (d *display) centerIn(f *textFace, x, w, y int, s string, col rgb) int {
f.draw(d.fb, x+(w-len([]rune(s))*f.cellW)/2, y-f.capTop, s, col)
return y + f.lineH + pairGap
}
type statCell struct {
value string
label string
col rgb
}
func (d *display) statPairH(vf *textFace) int {
return vf.lineH + pairGap + d.grid.lineH
}
func gridRows(n int) int { return (n + gridCols - 1) / gridCols }
func (d *display) statsH(vf *textFace, n int) int {
return gridRows(n)*(d.statPairH(vf)+statRowGap) - statRowGap
}
// A last row that does not fill the grid is centred.
func gridCell(i, n, cols, x, w int) (cx, cw int) {
cw = (w - (cols-1)*chipGap) / cols
inRow := min(n-(i/cols)*cols, cols)
cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%cols)*(cw+chipGap)
return cx, cw
}
// An empty value takes its space without drawing, so nothing below it moves
// when it arrives.
func (d *display) stats(vf *textFace, x, w, y int, cells []statCell) int {
for i, c := range cells {
if c.value == "" {
continue
}
cx, cw := gridCell(i, len(cells), gridCols, x, w)
cy := y + (i/gridCols)*(d.statPairH(vf)+statRowGap)
ly := d.centerIn(vf, cx, cw, cy, c.value, c.col)
d.centerIn(d.grid, cx, cw, ly, c.label, uiDim)
}
return y + d.statsH(vf, len(cells))
}
var errRows = []struct {
label string
get func(errs) uint64
}{
{"lost", func(e errs) uint64 { return e.lost }},
{"corrupt", func(e errs) uint64 { return e.corrupt }},
{"link", func(e errs) uint64 { return e.link }},
{"internal", func(e errs) uint64 { return e.internal }},
}
func (d *display) chipH() int { return d.grid.lineH + 2*chipPadY }
func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap }
// The error chips plus corrected and the noise cable's, which share their
// row grid.
func (d *display) chipsH() int {
return gridRows(len(errRows)+2)*(d.chipH()+chipGap) - chipGap
}
func (d *display) countsH() int {
return gridRows(len(errRows))*(d.countChipH()+chipGap) - chipGap
}
// Outlined by drawing the border colour and sinking a smaller well of
// background into it, so both curves get the same antialiasing.
func (d *display) chipAt(i, n, cols, x, w, y, h int, c rgb) (int, int, int) {
cx, cw := gridCell(i, n, cols, x, w)
cy := y + (i/cols)*(h+chipGap)
d.fb.roundRect(cx, cy, cw, h, chipRadius, c)
d.fb.roundRect(cx+chipBorder, cy+chipBorder,
cw-2*chipBorder, h-2*chipBorder, chipRadius-chipBorder, uiBg)
return cx, cw, cy
}
// Whether rather than how many: over a window this short a count changes faster
// than it can be read. The noise chip rides along at the end, presence rather
// than health: red is the cable missing, not the cable failing.
func (d *display) errChips(x, w, y int, e errs, recentCorrected, noiseMissing uint64) int {
n := len(errRows) + 2
for i, r := range errRows {
c := errColor(r.get(e))
cx, cw, cy := d.chipAt(i, n, gridCols, x, w, y, d.chipH(), c)
d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c)
}
// Amber rather than red: the phy absorbed these before they cost a frame.
c := uiGreen
if recentCorrected > 0 {
c = uiAmber
}
cx, cw, cy := d.chipAt(len(errRows), n, gridCols, x, w, y, d.chipH(), c)
d.centerIn(d.grid, cx, cw, cy+chipPadY, "corrected", c)
c = errColor(noiseMissing)
cx, cw, cy = d.chipAt(len(errRows)+1, n, gridCols, x, w, y, d.chipH(), c)
d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c)
return y + d.chipsH()
}
func (d *display) errCounts(x, w, y int, e errs) int {
for i, r := range errRows {
n := r.get(e)
c := errColor(n)
cx, cw, cy := d.chipAt(i, len(errRows), gridCols, x, w, y, d.countChipH(), c)
ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c)
d.centerIn(d.grid, cx, cw, ty, r.label, c)
}
return y + d.countsH()
}
// White when the wiring is clean: a measurement rather than a judgment. The
// colours are reserved for the diag finding a fault or a swapped pair.
func metresStat(phy phyDisplay) statCell {
col := uiFg
if phy.metresClass != clsGood {
col = classColor(phy.metresClass)
}
return statCell{phy.metres, "m", col}
}
func (d *display) panel(p rect, bad bool) (int, int) {
fill, edge := uiOKFill, uiOKEdge
if bad {
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
return p.x + inset, p.w - 2*inset
}
func errColor(n uint64) rgb {
if n == 0 {
return uiGreen
}
return uiRed
}
func snrStat(phy phyDisplay) statCell {
if !phy.haveSNR {
return statCell{"-", "db margin", uiDim}
}
return statCell{fmt.Sprintf("%+.1f", phy.worstMargin), "db margin",
classColor(snrClass(phy.worstMargin))}
}
// Errors the phy absorbed before they could cost a frame: amber rather than
// red, the cable being stressed rather than failing.
func correctedStat(v uint64) statCell {
col := uiGreen
if v > 0 {
col = uiAmber
}
return statCell{scaleCount(v), "corrected", col}
}
func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, noiseMissing uint64) error {
fb := d.fb
fb.fill(uiBg)
x, w := d.panel(d.nowPanel, v.window.total() > 0)
d.stats(d.big, x, w, d.nowYs[0], []statCell{
{scaleSI(v.rxGbps * 1e9), "bits/s", uiFg},
{scaleSI(v.rxPPS), "packets/s", uiFg},
snrStat(phy),
})
d.errChips(x, w, d.nowYs[1], v.window, phy.recent, noiseMissing)
x, w = d.panel(d.sincePanel, v.since.total() > 0 || phy.metresClass == clsBad)
d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{
{scaleTime(elapsed), "elapsed", uiFg},
{scaleCount(v.rxFrames), "packets", uiFg},
{scaleCount(v.rxBytes), "bytes", uiFg},
correctedStat(phy.corrected),
})
d.stats(d.gridB, x, w, d.sinceYs[1], []statCell{metresStat(phy)})
d.errCounts(x, w, d.sinceYs[2], v.since)
d.drawResetButton()
return fb.flush()
}