2026-07-25 19:41:53 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-07-25 21:41:21 -07:00
|
|
|
"math"
|
2026-07-25 19:41:53 -07:00
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
uiBg = rgb{0x12, 0x14, 0x18}
|
2026-07-26 10:27:41 -07:00
|
|
|
uiOKFill = rgb{0x18, 0x42, 0x26}
|
|
|
|
|
uiOKEdge = rgb{0x3c, 0xe0, 0x70}
|
|
|
|
|
uiErrFil = rgb{0x54, 0x18, 0x1c}
|
|
|
|
|
uiErrEdg = rgb{0xff, 0x46, 0x46}
|
2026-07-25 19:41:53 -07:00
|
|
|
uiFg = rgb{0xe6, 0xe8, 0xea}
|
2026-07-26 10:27:41 -07:00
|
|
|
uiDim = rgb{0x9a, 0xa2, 0xac}
|
2026-07-25 19:41:53 -07:00
|
|
|
uiCyan = rgb{0x5c, 0xc8, 0xe0}
|
2026-07-26 10:27:41 -07:00
|
|
|
uiGreen = rgb{0x6c, 0xdc, 0x86}
|
|
|
|
|
uiRed = rgb{0xf0, 0x6b, 0x6b}
|
2026-07-25 19:41:53 -07:00
|
|
|
)
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// 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.
|
2026-07-25 19:48:15 -07:00
|
|
|
const (
|
2026-08-01 16:07:44 -07:00
|
|
|
step = 4
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
spaceTight = step * 2
|
|
|
|
|
spaceGroup = step * 4
|
|
|
|
|
spaceRow = step * 8
|
2026-08-01 16:07:44 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
uiMargin = spaceGroup
|
|
|
|
|
uiPad = spaceGroup
|
|
|
|
|
uiBorder = step * 3
|
|
|
|
|
pairGap = spaceGroup
|
|
|
|
|
blockGap = spaceGroup
|
|
|
|
|
|
|
|
|
|
btnW = 300
|
|
|
|
|
btnH = 80
|
2026-07-25 21:41:21 -07:00
|
|
|
holdDuration = time.Second
|
2026-07-31 17:05:31 -07:00
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
chipRadius = spaceTight
|
2026-07-31 17:05:31 -07:00
|
|
|
chipBorder = 2
|
2026-08-01 16:07:44 -07:00
|
|
|
|
|
|
|
|
gridCols = 2
|
|
|
|
|
chipPadY = spaceGroup
|
|
|
|
|
chipGap = spaceTight
|
|
|
|
|
statRowGap = spaceRow
|
2026-07-25 19:48:15 -07:00
|
|
|
)
|
|
|
|
|
|
2026-07-25 21:41:21 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:41:53 -07:00
|
|
|
type display struct {
|
2026-07-25 19:48:15 -07:00
|
|
|
fb *framebuffer
|
2026-08-01 16:07:44 -07:00
|
|
|
big *textFace
|
2026-07-25 19:48:15 -07:00
|
|
|
grid *textFace
|
|
|
|
|
gridB *textFace
|
2026-07-25 21:41:21 -07:00
|
|
|
|
2026-07-26 10:27:41 -07:00
|
|
|
nowPanel rect
|
|
|
|
|
sincePanel rect
|
2026-08-01 16:07:44 -07:00
|
|
|
nowYs []int
|
|
|
|
|
sinceYs []int
|
2026-07-26 10:27:41 -07:00
|
|
|
resetBtn rect
|
|
|
|
|
holdStart time.Time
|
|
|
|
|
holdFrac float64
|
|
|
|
|
fired bool
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newDisplay() (*display, error) {
|
2026-07-31 16:46:11 -07:00
|
|
|
fb, err := openFramebuffer()
|
2026-07-25 19:41:53 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
d := &display{fb: fb}
|
|
|
|
|
for _, spec := range []struct {
|
|
|
|
|
dst **textFace
|
|
|
|
|
bold bool
|
|
|
|
|
size float64
|
|
|
|
|
}{
|
2026-08-01 16:07:44 -07:00
|
|
|
{&d.big, true, 40},
|
|
|
|
|
{&d.grid, false, 34},
|
|
|
|
|
{&d.gridB, true, 34},
|
2026-07-25 19:48:15 -07:00
|
|
|
} {
|
|
|
|
|
face, err := loadFace(spec.bold, spec.size)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fb.close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
*spec.dst = face
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
if d.grid.cellW != d.gridB.cellW {
|
2026-07-25 19:41:53 -07:00
|
|
|
fb.close()
|
2026-07-25 19:48:15 -07:00
|
|
|
return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d",
|
|
|
|
|
d.grid.cellW, d.gridB.cellW)
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
2026-08-01 16:07:44 -07:00
|
|
|
now := []int{d.statsH(d.big, 2), d.chipsH()}
|
|
|
|
|
since := []int{d.statsH(d.gridB, 4), d.countsH(), btnH}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// 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.
|
2026-08-01 16:07:44 -07:00
|
|
|
gaps := len(now) + len(since) + 2
|
|
|
|
|
spare := fb.h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since)
|
|
|
|
|
if spare < 0 {
|
|
|
|
|
fb.close()
|
|
|
|
|
return nil, fmt.Errorf("panel content is %dpx taller than the screen", -spare)
|
|
|
|
|
}
|
|
|
|
|
gap := spare / gaps
|
2026-07-26 10:27:41 -07:00
|
|
|
|
|
|
|
|
inner := fb.w - 2*uiMargin
|
2026-08-01 16:07:44 -07:00
|
|
|
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)
|
2026-07-25 21:41:21 -07:00
|
|
|
d.resetBtn = rect{
|
2026-07-26 10:27:41 -07:00
|
|
|
x: d.sincePanel.x + (inner-btnW)/2,
|
2026-08-01 16:07:44 -07:00
|
|
|
y: d.sinceYs[len(d.sinceYs)-1],
|
2026-07-25 21:41:21 -07:00
|
|
|
w: btnW,
|
|
|
|
|
h: btnH,
|
|
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
return d, nil
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// Lifting or sliding off cancels, and the press has to be released before it
|
|
|
|
|
// can arm again.
|
2026-07-25 21:41:21 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// Cyan rather than the status colours because it is something to press, not
|
|
|
|
|
// something being reported.
|
2026-07-25 21:41:21 -07:00
|
|
|
func (d *display) drawResetButton() {
|
|
|
|
|
r := d.resetBtn
|
2026-08-01 16:07:44 -07:00
|
|
|
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)
|
2026-07-25 21:41:21 -07:00
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
split := r.x + chipBorder
|
2026-07-25 21:41:21 -07:00
|
|
|
if d.holdFrac > 0 {
|
2026-08-01 16:07:44 -07:00
|
|
|
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)
|
2026-07-31 17:05:31 -07:00
|
|
|
split += w
|
2026-07-25 21:41:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
label := "RESET"
|
|
|
|
|
lx := r.x + (r.w-len(label)*d.gridB.cellW)/2
|
2026-08-01 16:07:44 -07:00
|
|
|
ly := r.y + (r.h-d.gridB.lineH)/2 - d.gridB.capTop
|
2026-07-25 21:41:21 -07:00
|
|
|
// 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
|
2026-07-31 17:05:31 -07:00
|
|
|
col := uiCyan
|
2026-07-25 21:41:21 -07:00
|
|
|
if gx+d.gridB.cellW/2 < split {
|
|
|
|
|
col = uiBg
|
|
|
|
|
}
|
|
|
|
|
d.gridB.draw(d.fb, gx, ly, string(c), col)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:41:53 -07:00
|
|
|
func (d *display) close() {
|
|
|
|
|
d.fb.close()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// 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.
|
2026-08-01 16:07:44 -07:00
|
|
|
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
|
2026-07-25 19:48:15 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
func (d *display) statPairH(vf *textFace) int {
|
|
|
|
|
return vf.lineH + pairGap + d.grid.lineH
|
2026-07-25 19:48:15 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// A last row that does not fill the grid is centred.
|
2026-08-01 16:07:44 -07:00
|
|
|
func gridCell(i, n, x, w int) (cx, cw int) {
|
|
|
|
|
cw = (w - (gridCols-1)*chipGap) / gridCols
|
|
|
|
|
inRow := min(n-(i/gridCols)*gridCols, gridCols)
|
|
|
|
|
cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%gridCols)*(cw+chipGap)
|
|
|
|
|
return cx, cw
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// An empty value takes its space without drawing, so nothing below it moves
|
|
|
|
|
// when it arrives.
|
2026-08-01 16:07:44 -07:00
|
|
|
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), 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))
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-26 10:27:41 -07:00
|
|
|
var errRows = []struct {
|
|
|
|
|
label string
|
|
|
|
|
get func(errs) uint64
|
|
|
|
|
}{
|
|
|
|
|
{"lost", func(e errs) uint64 { return e.lost }},
|
2026-07-31 17:10:25 -07:00
|
|
|
{"corrupt", func(e errs) uint64 { return e.corrupt }},
|
2026-07-26 10:27:41 -07:00
|
|
|
{"link", func(e errs) uint64 { return e.link }},
|
2026-07-31 17:10:25 -07:00
|
|
|
{"internal", func(e errs) uint64 { return e.internal }},
|
2026-07-25 19:48:15 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
func (d *display) chipH() int { return d.grid.lineH + 2*chipPadY }
|
2026-07-25 19:48:15 -07:00
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap }
|
2026-07-31 17:05:31 -07:00
|
|
|
|
|
|
|
|
func (d *display) chipsH() int {
|
2026-08-01 16:07:44 -07:00
|
|
|
return gridRows(len(errRows))*(d.chipH()+chipGap) - chipGap
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *display) countsH() int {
|
|
|
|
|
return gridRows(len(errRows))*(d.countChipH()+chipGap) - chipGap
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// Outlined by drawing the border colour and sinking a smaller well of
|
|
|
|
|
// background into it, so both curves get the same antialiasing.
|
2026-08-01 16:07:44 -07:00
|
|
|
func (d *display) chipAt(i, x, w, y, h int, c rgb) (int, int, int) {
|
|
|
|
|
cx, cw := gridCell(i, len(errRows), x, w)
|
|
|
|
|
cy := y + (i/gridCols)*(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
|
2026-07-31 17:05:31 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// Whether rather than how many: over a window this short a count changes faster
|
|
|
|
|
// than it can be read.
|
2026-07-31 17:05:31 -07:00
|
|
|
func (d *display) errChips(x, w, y int, e errs) int {
|
|
|
|
|
for i, r := range errRows {
|
|
|
|
|
c := errColor(r.get(e))
|
2026-08-01 16:07:44 -07:00
|
|
|
cx, cw, cy := d.chipAt(i, x, w, y, d.chipH(), c)
|
|
|
|
|
d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c)
|
2026-07-31 17:05:31 -07:00
|
|
|
}
|
|
|
|
|
return y + d.chipsH()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
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, x, w, y, d.countChipH(), c)
|
2026-08-04 11:27:35 -07:00
|
|
|
ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c)
|
2026-08-01 16:07:44 -07:00
|
|
|
d.centerIn(d.grid, cx, cw, ty, r.label, c)
|
|
|
|
|
}
|
|
|
|
|
return y + d.countsH()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *display) panel(p rect, e errs) (int, int) {
|
2026-07-26 10:27:41 -07:00
|
|
|
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
|
2026-08-01 16:07:44 -07:00
|
|
|
return p.x + inset, p.w - 2*inset
|
2026-07-25 21:30:37 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:41:53 -07:00
|
|
|
func errColor(n uint64) rgb {
|
|
|
|
|
if n == 0 {
|
|
|
|
|
return uiGreen
|
|
|
|
|
}
|
|
|
|
|
return uiRed
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
func (d *display) render(v view, elapsed time.Duration, cable string) error {
|
2026-07-25 19:41:53 -07:00
|
|
|
fb := d.fb
|
|
|
|
|
fb.fill(uiBg)
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
x, w := d.panel(d.nowPanel, v.window)
|
|
|
|
|
d.stats(d.big, x, w, d.nowYs[0], []statCell{
|
|
|
|
|
{scaleSI(v.rxGbps * 1e9), "bits/s", uiFg},
|
|
|
|
|
{scaleSI(v.rxPPS), "packets/s", uiFg},
|
|
|
|
|
})
|
|
|
|
|
d.errChips(x, w, d.nowYs[1], v.window)
|
|
|
|
|
|
|
|
|
|
x, w = d.panel(d.sincePanel, v.since)
|
|
|
|
|
d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{
|
|
|
|
|
{scaleTime(elapsed), "elapsed", uiFg},
|
2026-08-04 11:27:35 -07:00
|
|
|
{scaleCount(v.rxFrames), "packets", uiFg},
|
2026-08-04 13:37:19 -07:00
|
|
|
{scaleCount(v.rxBytes), "bytes", uiFg},
|
2026-08-01 16:07:44 -07:00
|
|
|
{cable, "m", uiFg},
|
|
|
|
|
})
|
|
|
|
|
d.errCounts(x, w, d.sinceYs[1], v.since)
|
2026-07-25 19:48:15 -07:00
|
|
|
|
2026-07-25 21:41:21 -07:00
|
|
|
d.drawResetButton()
|
2026-07-31 16:46:11 -07:00
|
|
|
return fb.flush()
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|