From 726c4c24452bbb131e92df138a86b53923451306 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Sat, 1 Aug 2026 16:07:44 -0700 Subject: [PATCH] Rework the panel onto SI figures, labelled chips and one spacing scale --- main.go | 39 +++---- render.go | 47 +++++++-- text.go | 32 +++++- ui.go | 308 ++++++++++++++++++++++++++++++++++-------------------- 4 files changed, 280 insertions(+), 146 deletions(-) diff --git a/main.go b/main.go index d510281..f3b7e73 100644 --- a/main.go +++ b/main.go @@ -303,17 +303,17 @@ func gbps(bytes, frames uint64, secs float64) float64 { } var intervalCols = []colSpec{ - {title: "UPTIME", width: 9, right: true}, + {title: "ELAPSED", width: 9, right: true}, {title: "DIR", width: 5}, - {title: "TX pps", width: 9, right: true}, - {title: "TX Gb/s", width: 7, right: true}, - {title: "RX pps", width: 9, right: true}, - {title: "RX Gb/s", width: 7, right: true}, - {title: "LOST", width: 11, right: true}, - {title: "CORRUPT", width: 11, right: true}, - {title: "LINK", width: 11, right: true}, - {title: "INTERNAL", width: 11, right: true}, - {title: "ERRORS", width: 13, right: true}, + {title: "TX packets/s", width: 12, right: true}, + {title: "TX bits/s", width: 10, right: true}, + {title: "RX packets/s", width: 12, right: true}, + {title: "RX bits/s", width: 10, right: true}, + {title: "LOST", width: 9, right: true}, + {title: "CORRUPT", width: 9, right: true}, + {title: "LINK", width: 9, right: true}, + {title: "INTERNAL", width: 9, right: true}, + {title: "ERRORS", width: 9, right: true}, {title: "MIN ns", width: 9, right: true}, {title: "LEN m", width: 6, right: true}, } @@ -430,12 +430,12 @@ func (d *direction) displayView(t time.Time) view { func (d *direction) row(elapsed time.Duration, v view, target float64, length string) []string { return []string{ - uptime(elapsed), + scaleTime(elapsed), paint(d.short, cCyan), - commas(uint64(v.txPPS)), - rateCell(v.txGbps, target), - commas(uint64(v.rxPPS)), - rateCell(v.rxGbps, target), + scaleSI(v.txPPS), + rateCell(v.txGbps*1e9, target*1e9), + scaleSI(v.rxPPS), + rateCell(v.rxGbps*1e9, target*1e9), statusCell(v.since.lost), statusCell(v.since.corrupt), statusCell(v.since.link), @@ -801,12 +801,13 @@ func run(aName, bName, sizesArg string, for i, d := range dirs { views[i] = d.displayView(now) } - cable := "-" + // Empty until the probe has a stamp from each direction, so the + // panel shows nothing there rather than a placeholder. + cable := "" if m, ok := cfg.cableMetres(views); ok { - cable = fmt.Sprintf("%.1f m", m) + cable = fmt.Sprintf("%.1f", m) } - if err := disp.render(totalView(views), now.Sub(start), - target*float64(len(dirs)), cable); err != nil { + if err := disp.render(totalView(views), now.Sub(start), cable); err != nil { return err } case now := <-tick.C: diff --git a/render.go b/render.go index c700405..356c486 100644 --- a/render.go +++ b/render.go @@ -57,11 +57,6 @@ func pad(s string, w int, right bool) string { return s + strings.Repeat(" ", gap) } -func uptime(d time.Duration) string { - total := int(d.Seconds()) - return fmt.Sprintf("%d:%02d:%02d", total/3600, (total/60)%60, total%60) -} - func commas(v uint64) string { s := fmt.Sprintf("%d", v) if len(s) <= 3 { @@ -94,6 +89,37 @@ func humanBytes(b uint64) string { return fmt.Sprintf("%.1f PB", v) } +// A figure with the letter for its magnitude, so the unit itself can stay a +// fixed word on the label and only the letter moves with the value. Below a +// thousand there is no letter and none is left dangling, since a trailing space +// would push the figure off centre. +func scaleSI(v float64) string { + for _, mag := range []string{"", "k", "M", "G", "T"} { + if v < 1000 { + if mag == "" { + return fmt.Sprintf("%.2f", v) + } + return fmt.Sprintf("%.2f %s", v, mag) + } + v /= 1000 + } + return fmt.Sprintf("%.2f P", v) +} + +// The same shape for time, whose magnitudes are sixties and twenty-fours rather +// than thousands. The letter changes with the value; the label does not. +func scaleTime(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%.2f s", d.Seconds()) + case d < time.Hour: + return fmt.Sprintf("%.2f m", d.Minutes()) + case d < 24*time.Hour: + return fmt.Sprintf("%.2f h", d.Hours()) + } + return fmt.Sprintf("%.2f d", d.Hours()/24) +} + type colSpec struct { title string width int @@ -216,6 +242,9 @@ func renderBox(title string, headers []string, rights []bool, rows [][]string) s return b.String() } +// Counted exactly rather than scaled: these are whole frames, and the figure +// that matters most is the small one. Scaled, a single lost frame and a +// thousand of them both read as 1.00, separated only by a letter. func statusCell(v uint64) string { s := commas(v) if v == 0 { @@ -231,12 +260,12 @@ const ( rateYellowFrac = 0.80 ) -func rateCell(gb float64, target float64) string { - s := fmt.Sprintf("%.2f", gb) +func rateCell(bits float64, target float64) string { + s := scaleSI(bits) switch { - case gb >= target*rateGreenFrac: + case bits >= target*rateGreenFrac: return paint(s, cGreen) - case gb >= target*rateYellowFrac: + case bits >= target*rateYellowFrac: return paint(s, cYellow) default: return paint(s, cRed) diff --git a/text.go b/text.go index 7fd2091..86bf962 100644 --- a/text.go +++ b/text.go @@ -26,6 +26,30 @@ type textFace struct { cellH int ascent int cache map[rune]*glyph + + // Where a line of text starts and stops as far as the eye is concerned: the + // top of a digit or capital, down to the baseline. The cell is taller at + // both ends, reserving space above for accents nothing here uses and below + // for descenders, which hang past the line without being read as part of + // it. Laying out by the cell therefore puts visibly more air around text + // than around a bordered box the same distance away. + capTop int + lineH int +} + +// Digits carry no ascender or descender, so the first row one marks is the top +// of the line and the baseline is the bottom. +func (t *textFace) measureLine() error { + g := t.glyph('0') + for y := 0; y < g.h; y++ { + for x := 0; x < g.w; x++ { + if g.cov[y*g.w+x] != 0 { + t.capTop, t.lineH = y, t.ascent-y + return nil + } + } + } + return fmt.Errorf("font rasterised no ink for a digit") } func loadFace(bold bool, sizePx float64) (*textFace, error) { @@ -54,13 +78,17 @@ func loadFace(bold bool, sizePx float64) (*textFace, error) { if !ok { return nil, fmt.Errorf("font has no digit glyphs") } - return &textFace{ + t := &textFace{ face: face, cellW: adv.Ceil(), cellH: (m.Ascent + m.Descent).Ceil(), ascent: m.Ascent.Ceil(), cache: make(map[rune]*glyph), - }, nil + } + if err := t.measureLine(); err != nil { + return nil, err + } + return t, nil } // Rasterises once per rune and keeps the coverage mask, since the same few diff --git a/ui.go b/ui.go index 3afb3a3..91f2766 100644 --- a/ui.go +++ b/ui.go @@ -17,27 +17,47 @@ var ( uiCyan = rgb{0x5c, 0xc8, 0xe0} uiGreen = rgb{0x6c, 0xdc, 0x86} uiRed = rgb{0xf0, 0x6b, 0x6b} - uiYellow = rgb{0xe0, 0xb0, 0x40} +) + +// Every gap is a multiple of one step, so the spacing carries meaning: things +// a step apart belong together, things eight steps apart do not. Picking each +// number for itself is what produced a panel where a label could have gone with +// either the figure above it or the one below. +// +// These are distances actually seen, since layout measures a line of text from +// the top of a digit to the baseline rather than across a cell with accent and +// descender slack in it. Values that looked right when that slack was padding +// them out are too small once it is gone. +const ( + step = 4 + + spaceTight = step * 2 // neighbouring chips + spaceGroup = step * 4 // a figure and its label, chip padding, block to block + spaceRow = step * 8 // one labelled pair and the next ) const ( - uiMargin = 16 - uiPad = 14 - uiBorder = 12 - rowGap = 5 - blockGap = 16 + uiMargin = spaceGroup + uiPad = spaceGroup + uiBorder = step * 3 + pairGap = spaceGroup + blockGap = spaceGroup - btnW = 240 - btnH = 64 - btnRadius = 10 - btnBorder = 2 + btnW = 300 + btnH = 80 holdDuration = time.Second - chipCols = 2 - chipPadY = 9 - chipGap = 8 - chipRadius = 8 + // Shared by the chips and the button, which are the same object drawn at + // different sizes. + chipRadius = spaceTight chipBorder = 2 + + // One grid for the panel: the figures and the chips beneath them stand in + // the same columns because they are placed by the same arithmetic. + gridCols = 2 + chipPadY = spaceGroup + chipGap = spaceTight + statRowGap = spaceRow ) type rect struct { @@ -50,14 +70,14 @@ func (r rect) contains(x, y int) bool { type display struct { fb *framebuffer - huge *textFace + big *textFace grid *textFace gridB *textFace nowPanel rect sincePanel rect - nowH int - sinceH int + nowYs []int + sinceYs []int resetBtn rect holdStart time.Time holdFrac float64 @@ -75,9 +95,9 @@ func newDisplay() (*display, error) { bold bool size float64 }{ - {&d.huge, true, 60}, - {&d.grid, false, 22}, - {&d.gridB, true, 22}, + {&d.big, true, 40}, + {&d.grid, false, 34}, + {&d.gridB, true, 34}, } { face, err := loadFace(spec.bold, spec.size) if err != nil { @@ -91,28 +111,63 @@ 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 + // Guessed heights collide on a screen this small, so the layout 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 + now := []int{d.statsH(d.big, 2), d.chipsH()} + since := []int{d.statsH(d.gridB, 4), d.countsH(), btnH} + + // One gap for the whole screen rather than one per panel: whatever is left + // after the blocks is divided between every gap in both of them, so the + // space above the first figure, between each block, and below the last is + // the same distance everywhere. Each panel is then sized to exactly the + // blocks it holds plus its share, which is also what puts the button in the + // flow instead of pinned to the bottom with the remainder above it. + // Vertically the frame is the border and nothing else: the gap is the only + // whitespace there is. Insetting by uiPad as well would add it to the gaps + // at the top and bottom of a panel 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 := 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 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} + 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.sincePanel.y + d.sincePanel.h - uiBorder - uiPad - btnH, + y: d.sinceYs[len(d.sinceYs)-1], w: btnW, h: btnH, } return d, 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 +} + // 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. @@ -141,23 +196,23 @@ func (d *display) holdReset(x, y int, down bool, now time.Time) bool { // 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) + 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) // 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 + split := r.x + chipBorder 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) + 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 := "RESET" lx := r.x + (r.w-len(label)*d.gridB.cellW)/2 - ly := r.y + (r.h-d.gridB.cellH)/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 { @@ -174,20 +229,54 @@ 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) +// y is the top of the line as read, so text and a bordered box placed the same +// distance apart are the same distance apart to look at. +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 } -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 +type statCell struct { + value string + label string + col rgb } -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 +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 +} + +// Where cell i of n falls in the panel's grid. A last row that does not fill +// the grid is centred, so the odd one out balances the rows above rather than +// hanging off the left of them. +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 +} + +// A figure with its label directly underneath, two to a row. The gap between +// rows is wider than the one inside a pair, so which label belongs to which +// figure is a matter of spacing rather than of guessing. An empty value takes +// its space without drawing, so nothing below 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), 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 { @@ -200,19 +289,31 @@ var errRows = []struct { {"internal", func(e errs) uint64 { return e.internal }}, } -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.lineH + 2*chipPadY } -func (d *display) chipH() int { return d.grid.cellH + 2*chipPadY } +// Taller by a line, since these carry the count under the kind. +func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap } func (d *display) chipsH() int { - rows := (len(errRows) + chipCols - 1) / chipCols - return rows*(d.chipH()+chipGap) - chipGap + return gridRows(len(errRows))*(d.chipH()+chipGap) - chipGap +} + +func (d *display) countsH() int { + return gridRows(len(errRows))*(d.countChipH()+chipGap) - chipGap +} + +// Shared by both panels so they are demonstrably the same object, one carrying +// a count and one not. +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) + + // Outlined by drawing the border colour and then sinking a smaller well of + // background into it, so both curves get the same antialiasing. + 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 } // The same kinds as errBlock, but answering whether rather than how many, and @@ -220,30 +321,28 @@ func (d *display) chipsH() int { // 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) + cx, cw, cy := d.chipAt(i, x, w, y, d.chipH(), c) + d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c) } return y + d.chipsH() } -func (d *display) panel(p rect, e errs, contentH int) (int, int, int) { +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) + ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, commas(n), c) + d.centerIn(d.grid, cx, cw, ty, r.label, c) + } + return y + d.countsH() +} + +// Draws the frame and hands back the writable width inside it. Where the blocks +// sit within it was settled once at startup, since it never changes. +func (d *display) panel(p rect, e errs) (int, int) { fill, edge := uiOKFill, uiOKEdge if e.total() > 0 { fill, edge = uiErrFil, uiErrEdg @@ -253,23 +352,7 @@ func (d *display) panel(p rect, e errs, contentH int) (int, int, int) { 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 + return p.x + inset, p.w - 2*inset } func errColor(n uint64) rgb { @@ -279,32 +362,25 @@ func errColor(n uint64) rgb { 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 { +func (d *display) render(v view, elapsed time.Duration, 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 := 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, 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) + x, w = d.panel(d.sincePanel, v.since) + d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{ + {scaleTime(elapsed), "elapsed", uiFg}, + {scaleSI(float64(v.rxFrames)), "packets", uiFg}, + {scaleSI(float64(v.rxGot)), "bytes", uiFg}, + {cable, "m", uiFg}, + }) + d.errCounts(x, w, d.sinceYs[1], v.since) d.drawResetButton() return fb.flush()