Turn the display portrait and split it into live and since-reset panels

This commit is contained in:
flamingcow
2026-07-26 10:27:41 -07:00
parent 4d102f377e
commit 102d53fb9b
4 changed files with 276 additions and 221 deletions
+35 -20
View File
@@ -31,6 +31,9 @@ const (
viScreenInfoLen = 40
)
// w and h are the logical canvas, which is portrait; pw and ph are the panel,
// which is landscape. Every draw is turned a quarter turn on its way to memory,
// so logical top lands on the panel's right edge.
type framebuffer struct {
file *os.File
tty *os.File
@@ -38,12 +41,22 @@ type framebuffer struct {
back []byte
w int
h int
pw int
ph int
stride int
rShift uint
gShift uint
bShift uint
}
func (fb *framebuffer) offset(x, y int) int {
return x*fb.stride + (fb.pw-1-y)*4
}
func (fb *framebuffer) fromPanel(x, y int) (int, int) {
return y, fb.pw - 1 - x
}
// The kernel console draws into the same framebuffer, including a blinking
// cursor and any keyboard echo, which fights every frame we write. Putting the
// active console into graphics mode stops it touching the framebuffer at all.
@@ -88,21 +101,23 @@ func openFramebuffer(path string) (*framebuffer, error) {
fb := &framebuffer{
file: f,
w: int(vi[viXres]),
h: int(vi[viYres]),
pw: int(vi[viXres]),
ph: int(vi[viYres]),
w: int(vi[viYres]),
h: int(vi[viXres]),
stride: int(stride),
rShift: uint(vi[viRedOffset]),
gShift: uint(vi[viGreenOffset]),
bShift: uint(vi[viBlueOffset]),
}
fb.mem, err = unix.Mmap(int(f.Fd()), 0, fb.stride*fb.h,
fb.mem, err = unix.Mmap(int(f.Fd()), 0, fb.stride*fb.ph,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
f.Close()
return nil, fmt.Errorf("mmap: %w", err)
}
fb.back = make([]byte, fb.stride*fb.h)
fb.back = make([]byte, fb.stride*fb.ph)
fb.tty, err = takeConsole()
if err != nil {
@@ -139,28 +154,28 @@ func (fb *framebuffer) fill(c rgb) {
row[x+2] = byte(v >> 16)
row[x+3] = byte(v >> 24)
}
for y := 0; y < fb.h; y++ {
for y := 0; y < fb.ph; y++ {
copy(fb.back[y*fb.stride:], row)
}
}
// One logical column is contiguous after the turn, so it fills a span at a time.
func (fb *framebuffer) rect(x0, y0, w, h int, c rgb) {
x1, y1 := min(x0+w, fb.w), min(y0+h, fb.h)
x0, y0 = max(x0, 0), max(y0, 0)
if x0 >= x1 || y0 >= y1 {
return
}
v := fb.pixel(c)
for y := y0; y < y0+h; y++ {
if y < 0 || y >= fb.h {
continue
}
base := y * fb.stride
for x := x0; x < x0+w; x++ {
if x < 0 || x >= fb.w {
continue
}
o := base + x*4
fb.back[o+0] = byte(v)
fb.back[o+1] = byte(v >> 8)
fb.back[o+2] = byte(v >> 16)
fb.back[o+3] = byte(v >> 24)
span := make([]byte, (y1-y0)*4)
for i := 0; i+4 <= len(span); i += 4 {
span[i+0] = byte(v)
span[i+1] = byte(v >> 8)
span[i+2] = byte(v >> 16)
span[i+3] = byte(v >> 24)
}
for x := x0; x < x1; x++ {
copy(fb.back[fb.offset(x, y1-1):], span)
}
}
@@ -169,7 +184,7 @@ func (fb *framebuffer) blend(x, y int, c rgb, cov uint8) {
if x < 0 || y < 0 || x >= fb.w || y >= fb.h || cov == 0 {
return
}
o := y*fb.stride + x*4
o := fb.offset(x, y)
if cov == 255 {
v := fb.pixel(c)
fb.back[o+0] = byte(v)
+134 -40
View File
@@ -58,8 +58,14 @@ type direction struct {
drops uint64
errBase sample
dropBase uint64
nicRaw uint64
nicNow uint64
nicBase uint64
recent errs
recentBase sample
recentDrops uint64
recentNic uint64
}
// Smoothing has to be steady against high-frequency noise yet still chase a
@@ -84,7 +90,13 @@ type rateEstimator struct {
n int
}
func (e *rateEstimator) update(x float64) {
// While the rate window is still growing it already averages everything there
// is, so smoothing it again would only average in the startup ramp twice.
func (e *rateEstimator) update(x float64, windowFull bool) {
if !windowFull {
e.est, e.shown, e.mad, e.n = x, x, 0, 0
return
}
if e.n == 0 {
e.est, e.shown, e.n = x, x, 1
return
@@ -147,6 +159,27 @@ type rateSample struct {
txFrames, txBytes, rxFrames, rxBytes uint64
}
// Late is counted but left out of the total, since a reordered frame arrived.
type errs struct {
lost, late uint64
crc, badMagic uint64
badLen uint64
kdrop, link uint64
}
func (e errs) total() uint64 {
return e.lost + e.crc + e.badMagic + e.badLen + e.kdrop + e.link
}
func (e errs) add(o errs) errs {
return errs{
lost: e.lost + o.lost, late: e.late + o.late,
crc: e.crc + o.crc, badMagic: e.badMagic + o.badMagic,
badLen: e.badLen + o.badLen,
kdrop: e.kdrop + o.kdrop, link: e.link + o.link,
}
}
// A sliding window: the counters are sampled every frame and the rate is taken
// across the whole window, so the figure moves every frame while still being
// measured over a long enough span to be steady.
@@ -313,12 +346,9 @@ var intervalCols = []colSpec{
type view struct {
txPPS, rxPPS float64
txGbps, rxGbps float64
txFrames, txSent uint64
rxFrames, rxGot uint64
lost, late uint64
crc, badMagic uint64
kdrop, link uint64
errors uint64
since errs
window errs
cable cableView
}
@@ -326,25 +356,39 @@ type view struct {
// console and the display.
func (d *direction) counters(now sample) view {
b := d.errBase
v := view{
txFrames: now.txFrames - b.txFrames,
txSent: now.txBytes - b.txBytes,
return view{
rxFrames: now.rxFrames - b.rxFrames,
rxGot: now.rxBytes - b.rxBytes,
cable: d.cable.view(),
since: errs{
lost: now.lost - b.lost,
late: now.late - b.late,
crc: now.crcErr - b.crcErr,
badMagic: now.badMagic - b.badMagic,
badLen: now.badLen - b.badLen,
kdrop: d.drops - d.dropBase,
cable: d.cable.view(),
}
// A frame the stack refused and a frame the driver dropped are the same
// failure seen from either side of the ring, and never the same frame twice:
// a send that fails never reaches the driver to be dropped.
v.link = d.nicNow - d.nicBase +
(now.txErrs - b.txErrs) + (now.rxErrs - b.rxErrs)
v.errors = v.lost + v.crc + v.badMagic + (now.badLen - b.badLen) + v.kdrop + v.link
return v
// failure seen from either side of the ring, and never the same frame
// twice: a send that fails never reaches the driver to be dropped.
link: d.nicNow - d.nicBase +
(now.txErrs - b.txErrs) + (now.rxErrs - b.rxErrs),
},
}
}
func totalView(views []view) view {
var t view
for _, v := range views {
t.txPPS += v.txPPS
t.rxPPS += v.rxPPS
t.txGbps += v.txGbps
t.rxGbps += v.rxGbps
t.rxFrames += v.rxFrames
t.rxGot += v.rxGot
t.since = t.since.add(v.since)
t.window = t.window.add(v.window)
}
return t
}
func (d *direction) view(prev *sample, secs float64) view {
@@ -369,8 +413,9 @@ func (d *direction) displayView(t time.Time) view {
d.win.push(rateSample{t, now.txFrames, now.txBytes, now.rxFrames, now.rxBytes})
v := d.counters(now)
v.txFrames = d.heldFrames.get(t, v.txFrames)
v.txSent = d.heldSent.get(t, v.txSent)
v.window = d.recent
v.rxFrames = d.heldFrames.get(t, v.rxFrames)
v.rxGot = d.heldSent.get(t, v.rxGot)
o, n, ok := d.win.span()
if !ok {
@@ -382,10 +427,11 @@ func (d *direction) displayView(t time.Time) view {
}
txF := n.txFrames - o.txFrames
rxF := n.rxFrames - o.rxFrames
d.est.txPPS.update(float64(txF) / secs)
d.est.rxPPS.update(float64(rxF) / secs)
d.est.txGbps.update(gbps(n.txBytes-o.txBytes, txF, secs))
d.est.rxGbps.update(gbps(n.rxBytes-o.rxBytes, rxF, secs))
full := d.win.filled
d.est.txPPS.update(float64(txF)/secs, full)
d.est.rxPPS.update(float64(rxF)/secs, full)
d.est.txGbps.update(gbps(n.txBytes-o.txBytes, txF, secs), full)
d.est.rxGbps.update(gbps(n.rxBytes-o.rxBytes, rxF, secs), full)
v.txPPS = d.est.txPPS.value()
v.rxPPS = d.est.rxPPS.value()
@@ -402,24 +448,64 @@ func (d *direction) row(elapsed time.Duration, v view, target float64, length st
rateCell(v.txGbps, target),
commas(uint64(v.rxPPS)),
rateCell(v.rxGbps, target),
statusCell(v.lost),
statusCell(v.late),
statusCell(v.crc),
statusCell(v.badMagic),
statusCell(v.kdrop),
statusCell(v.link),
statusCell(v.errors),
statusCell(v.since.lost),
statusCell(v.since.late),
statusCell(v.since.crc),
statusCell(v.since.badMagic),
statusCell(v.since.kdrop),
statusCell(v.since.link),
statusCell(v.since.total()),
paint(v.cable.minText(), cCyan),
paint(length, cCyan),
}
}
// Sampled once a second, since these are sysfs reads; the display uses whatever
// the last sample left behind.
// Sampled once a second, since these are sysfs reads. The recent errors roll
// here rather than over the rate window because the nic counters only move at
// this rate, and a shorter span would alias them into a flicker.
func (d *direction) sampleNIC() {
d.accumulateNIC()
now := d.snapshot()
b := d.recentBase
d.recent = errs{
lost: now.lost - b.lost,
late: now.late - b.late,
crc: now.crcErr - b.crcErr,
badMagic: now.badMagic - b.badMagic,
badLen: now.badLen - b.badLen,
kdrop: d.drops - d.recentDrops,
link: d.nicNow - d.recentNic +
(now.txErrs - b.txErrs) + (now.rxErrs - b.rxErrs),
}
d.recentBase, d.recentDrops, d.recentNic = now, d.drops, d.nicNow
}
func (d *direction) readNICTotal() uint64 {
tx := readNIC(d.tx.name)
rx := readNIC(d.rx.name)
d.nicNow = tx.tx + rx.rx + tx.carrierDown
return tx.tx + rx.rx + tx.carrierDown
}
// Sysfs nic counters run from boot and restart from zero whenever the driver
// resets its statistics, so only their forward motion is accumulated. Taking
// raw differences instead charges a boot's worth of errors to the first sample
// and turns a reset into a near-2^64 underflow.
func (d *direction) accumulateNIC() {
raw := d.readNICTotal()
if raw > d.nicRaw {
d.nicNow += raw - d.nicRaw
}
d.nicRaw = raw
}
// Whatever the interfaces counted before now is not ours, and no interval has
// elapsed yet, so every baseline starts here and nothing is reported until the
// first one completes.
func (d *direction) primeCounters() {
d.nicRaw = d.readNICTotal()
d.reset()
d.recentBase, d.recentDrops, d.recentNic = d.snapshot(), d.drops, d.nicNow
}
func buildDirection(label string, tx, rx endpoint, sizes []int, cfg config) (*direction, error) {
@@ -471,9 +557,6 @@ func buildDirection(label string, tx, rx endpoint, sizes []int, cfg config) (*di
}
d.probeRxFD = fd
// Whatever the interfaces have counted before now is not ours.
d.sampleNIC()
d.nicBase = d.nicNow
return d, nil
}
@@ -550,7 +633,7 @@ func main() {
streams = flag.Int("streams", 7, "independent streams per direction, capped by rx rings; each gets its own ethertype, steered by a flow rule to its own rx queue")
batch = flag.Int("batch", 64, "frames per sendmmsg/recvmmsg call")
nsPerM = flag.Float64("ns-per-m", 5.3, "mean of both directions, per metre of cable")
nsPerM = flag.Float64("ns-per-m", 5.2, "mean of both directions, per metre of cable")
)
flag.Parse()
@@ -703,11 +786,15 @@ func run(aName, bName, sizesArg string,
}
defer disp.close()
touch, err := watchTouch(disp.fb.w, disp.fb.h)
touch, err := watchTouch(disp.fb.pw, disp.fb.ph)
if err != nil {
return fmt.Errorf("touchscreen: %w", err)
}
for _, d := range dirs {
d.primeCounters()
}
start := time.Now()
close(startTx)
tick := time.NewTicker(reportInterval)
@@ -733,13 +820,20 @@ func run(aName, bName, sizesArg string,
case <-space:
start = resetAll(dirs, stats)
case now := <-frame.C:
if x, y, down := touch.get(); disp.holdReset(x, y, down, now) {
px, py, down := touch.get()
x, y := disp.fb.fromPanel(px, py)
if disp.holdReset(x, y, down, now) {
start = resetAll(dirs, stats)
}
for i, d := range dirs {
views[i] = d.displayView(now)
}
disp.render(dirs, views, now.Sub(start), target, cfg.cableText(views))
cable := "-"
if m, ok := cfg.cableMetres(views); ok {
cable = fmt.Sprintf("%.1f m", m)
}
disp.render(totalView(views), now.Sub(start),
target*float64(len(dirs)), cable)
case now := <-tick.C:
secs := now.Sub(last).Seconds()
last = now
-9
View File
@@ -1,7 +1,6 @@
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
@@ -69,14 +68,6 @@ func (c config) cableMetres(views []view) (float64, bool) {
return excess / float64(len(views)) / c.nsPerM, true
}
func (c config) cableText(views []view) string {
m, ok := c.cableMetres(views)
if !ok {
return ""
}
return fmt.Sprintf("cable %.1f m", m)
}
func newCableStats() *cableStats {
return &cableStats{
txPend: make(map[uint64]int64, probePendCap),
+94 -139
View File
@@ -8,39 +8,27 @@ import (
var (
uiBg = rgb{0x12, 0x14, 0x18}
uiPanel = rgb{0x1c, 0x20, 0x26}
uiRule = rgb{0x2e, 0x34, 0x3c}
uiOKFill = rgb{0x18, 0x42, 0x26}
uiOKEdge = rgb{0x3c, 0xe0, 0x70}
uiErrFil = rgb{0x54, 0x18, 0x1c}
uiErrEdg = rgb{0xff, 0x46, 0x46}
uiButton = rgb{0x25, 0x2b, 0x33}
uiFg = rgb{0xe6, 0xe8, 0xea}
uiDim = rgb{0x7a, 0x82, 0x8c}
uiDim = rgb{0x9a, 0xa2, 0xac}
uiCyan = rgb{0x5c, 0xc8, 0xe0}
uiGreen = rgb{0x4c, 0xc2, 0x6a}
uiRed = rgb{0xe0, 0x4b, 0x4b}
uiGreen = rgb{0x6c, 0xdc, 0x86}
uiRed = rgb{0xf0, 0x6b, 0x6b}
uiYellow = rgb{0xe0, 0xb0, 0x40}
)
// Everything tabular sits on one monospace cell grid and every number is
// right-aligned to a column end, so columns line up by construction.
const (
uiMargin = 16
uiPad = 14
uiBorder = 12
rowGap = 5
blockGap = 16
cellDirA = 0
cellArrow = 2
cellDirB = 4
colTxGbEnd = 14
colTxPPSEnd = 27
colRxGbEnd = 37
colRxPPSEnd = 50
colFramesEnd = 18
colDataEnd = 27
colLostEnd = 38
colLateEnd = 46
colCRCEnd = 53
colKdropEnd = 62
btnW = 200
btnW = 240
btnH = 64
holdDuration = time.Second
)
@@ -58,8 +46,11 @@ type display struct {
huge *textFace
grid *textFace
gridB *textFace
small *textFace
nowPanel rect
sincePanel rect
nowH int
sinceH int
resetBtn rect
holdStart time.Time
holdFrac float64
@@ -77,10 +68,9 @@ func newDisplay() (*display, error) {
bold bool
size float64
}{
{&d.huge, true, 72},
{&d.grid, false, 24},
{&d.gridB, true, 24},
{&d.small, false, 18},
{&d.huge, true, 60},
{&d.grid, false, 22},
{&d.gridB, true, 22},
} {
face, err := loadFace(spec.bold, spec.size)
if err != nil {
@@ -94,9 +84,22 @@ func newDisplay() (*display, error) {
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 + errH
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: fb.w - uiMargin - btnW,
y: fb.h - uiMargin - btnH,
x: d.sincePanel.x + (inner-btnW)/2,
y: d.sincePanel.y + d.sincePanel.h - uiBorder - uiPad - btnH,
w: btnW,
h: btnH,
}
@@ -163,28 +166,59 @@ func (d *display) close() {
d.fb.close()
}
func (d *display) cellX(c int) int {
return uiMargin + c*d.grid.cellW
}
func (d *display) at(c, y int, s string, col rgb) {
d.grid.draw(d.fb, d.cellX(c), y, s, col)
}
func (d *display) atBold(c, y int, s string, col rgb) {
d.gridB.draw(d.fb, d.cellX(c), y, s, col)
}
func (d *display) rightAt(cEnd, y int, s string, col rgb) {
d.grid.draw(d.fb, d.cellX(cEnd)-len([]rune(s))*d.grid.cellW, y, s, col)
}
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) center(f *textFace, y int, s string, col rgb) {
f.draw(d.fb, (d.fb.w-len([]rune(s))*f.cellW)/2, 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) 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
@@ -216,101 +250,22 @@ func rateColor(gb, target float64) rgb {
}
}
func (d *display) render(dirs []*direction, views []view, elapsed time.Duration, target float64, cable string) {
func (d *display) render(v view, elapsed time.Duration, target float64, cable string) {
fb := d.fb
fb.fill(uiBg)
d.small.draw(fb, uiMargin, 10, "cabletest", uiCyan)
d.small.draw(fb, uiMargin+11*d.small.cellW, 10,
fmt.Sprintf("%s %s %s %s", dirs[0].tx.tag, dirs[0].tx.name,
dirs[0].rx.tag, dirs[0].rx.name), uiDim)
d.right(d.small, fb.w-uiMargin, 10, uptime(elapsed), uiDim)
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.errBlock(x, w, y+blockGap, v.window)
var total uint64
for _, v := range views {
total += v.errors
}
bandY, bandH := 40, 150
fb.rect(0, bandY, fb.w, bandH, uiPanel)
fb.rect(0, bandY, 8, bandH, errColor(total))
word, wc := "NO ERRORS", uiGreen
if total > 0 {
word, wc = fmt.Sprintf("%s ERRORS", commas(total)), uiRed
}
d.center(d.huge, bandY+(bandH-d.huge.cellH)/2, word, wc)
// Two tight blocks rather than rows spread over the whole panel, centred in
// what is left below the band.
lineH := d.grid.cellH + 4
sectionH := d.small.cellH + 10 + (1+len(dirs))*lineH
gap := 30
avail := fb.h - (bandY + bandH) - btnH - 2*uiMargin
y := bandY + bandH + 8 + (avail-2*sectionH-gap)/2
y = d.section(y, "RATE", "")
d.rightAt(colTxGbEnd, y, "TX Gb/s", uiDim)
d.rightAt(colTxPPSEnd, y, "TX pps", uiDim)
d.rightAt(colRxGbEnd, y, "RX Gb/s", uiDim)
d.rightAt(colRxPPSEnd, y, "RX pps", uiDim)
y += lineH
for i, dir := range dirs {
d.dirTag(dir, y)
v := views[i]
d.rightAt(colTxGbEnd, y, fmt.Sprintf("%.2f", v.txGbps), rateColor(v.txGbps, target))
d.rightAt(colTxPPSEnd, y, commas(roundPPS(v.txPPS)), uiFg)
d.rightAt(colRxGbEnd, y, fmt.Sprintf("%.2f", v.rxGbps), rateColor(v.rxGbps, target))
d.rightAt(colRxPPSEnd, y, commas(roundPPS(v.rxPPS)), uiFg)
y += lineH
}
y += gap
y = d.section(y, "OVERALL", cable)
d.rightAt(colFramesEnd, y, "frames", uiDim)
d.rightAt(colDataEnd, y, "data", uiDim)
d.rightAt(colLostEnd, y, "lost", uiDim)
d.rightAt(colLateEnd, y, "late", uiDim)
d.rightAt(colCRCEnd, y, "crc", uiDim)
d.rightAt(colKdropEnd, y, "kdrop", uiDim)
y += lineH
for i, dir := range dirs {
d.dirTag(dir, y)
v := views[i]
d.rightAt(colFramesEnd, y, commas(v.txFrames), uiFg)
d.rightAt(colDataEnd, y, humanBytes(v.txSent), uiFg)
d.rightAt(colLostEnd, y, commas(v.lost), errColor(v.lost))
d.rightAt(colLateEnd, y, commas(v.late), errColor(v.late))
d.rightAt(colCRCEnd, y, commas(v.crc), errColor(v.crc))
d.rightAt(colKdropEnd, y, commas(v.kdrop), errColor(v.kdrop))
y += lineH
}
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()
fb.flush()
}
func (d *display) section(y int, title, right string) int {
d.small.draw(d.fb, uiMargin, y, title, uiFg)
if right != "" {
d.right(d.small, d.cellX(colKdropEnd), y, right, uiCyan)
}
ruleY := y + d.small.cellH + 3
d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule)
return ruleY + 7
}
func (d *display) dirTag(dir *direction, y int) {
d.atBold(cellDirA, y, dir.tx.tag, uiCyan)
d.arrow(d.cellX(cellArrow), y+d.grid.cellH/2, uiDim)
d.atBold(cellDirB, y, dir.rx.tag, uiCyan)
}
// Drawn because the font has no arrow glyph. Occupies exactly one cell so the
// grid is unaffected.
func (d *display) arrow(x, y int, c rgb) {
w := d.grid.cellW
d.fb.rect(x, y-1, w-5, 2, c)
for i := 0; i < 6; i++ {
d.fb.rect(x+w-6+i, y-5+i, 1, 11-2*i, c)
}
}