summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-18 09:00:35 +0200
committerPaul Buetow <paul@buetow.org>2026-02-18 09:00:35 +0200
commit88f4e239a7521112a4db8c7842e3a05db4446cd4 (patch)
tree8c331f9f2e23ad9c9319d6dc8275205b23ce811a
parent11204092b5ab5dc0f71515adfcaa6f07111363e5 (diff)
feat: triple-toggle CPU display mode via 1 key; add tooltip, font, hit-test
CPU display now cycles through three states with each press of 1: 0 = CPUModeAverage – aggregate bar only (default) 1 = CPUModeCores – individual core bars + aggregate 2 = CPUModeOff – all CPU bars hidden Config file stores cpumode=N (integer); old showcores=0/1 is read for backward compatibility. CLI flag --showcores replaced by --cpumode. Other improvements landed in this commit: - internal/display: add font.go (text rendering), hittest.go (bar hit testing), tooltip.go (mouse-over tooltip), tooltip_test.go - internal/display: mouse tracking and drawOverlay hook in display.go - internal/display: update build tags to //go:build form - internal/collector: embed remote script via script_embed.go / scriptdata/loadbars-remote.sh - internal/collector: CPULine.Total() changed to value receiver - internal/collector: table test improvements (name field, t.Run) - internal/constants: BytesPerSec consts promoted from var to const - Magefile.go: fix error formatting and install path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--Magefile.go4
-rw-r--r--cmd/loadbars/main.go2
-rw-r--r--internal/collector/parse_test.go34
-rw-r--r--internal/collector/script_embed.go9
-rw-r--r--internal/collector/scriptdata/loadbars-remote.sh35
-rw-r--r--internal/collector/types.go2
-rw-r--r--internal/config/config.go20
-rw-r--r--internal/config/config_test.go9
-rw-r--r--internal/constants/constants.go10
-rw-r--r--internal/display/activate.go2
-rw-r--r--internal/display/activate_darwin.go2
-rw-r--r--internal/display/display.go90
-rw-r--r--internal/display/display_test.go114
-rw-r--r--internal/display/font.go248
-rw-r--r--internal/display/hittest.go82
-rw-r--r--internal/display/tooltip.go194
-rw-r--r--internal/display/tooltip_test.go587
17 files changed, 1332 insertions, 112 deletions
diff --git a/Magefile.go b/Magefile.go
index 46dd2ef..17c40ae 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -40,7 +40,7 @@ func Install() error {
}
bin := filepath.Join(gopath, "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
- return err
+ return fmt.Errorf("mkdir %s: %w", bin, err)
}
- return sh.RunV("cp", "-v", binaryName, bin+"/")
+ return sh.RunV("cp", "-v", binaryName, filepath.Join(bin, binaryName))
}
diff --git a/cmd/loadbars/main.go b/cmd/loadbars/main.go
index 555812c..0ee8071 100644
--- a/cmd/loadbars/main.go
+++ b/cmd/loadbars/main.go
@@ -27,7 +27,7 @@ func main() {
flag.IntVar(&cfg.CPUAverage, "cpuaverage", cfg.CPUAverage, "Num of CPU samples for avg")
flag.IntVar(&cfg.NetAverage, "netaverage", cfg.NetAverage, "Num of net samples for avg")
flag.StringVar(&cfg.NetLink, "netlink", cfg.NetLink, "Link speed (mbit, 10mbit, 100mbit, gbit, 10gbit or number)")
- flag.BoolVar(&cfg.ShowCores, "showcores", cfg.ShowCores, "Toggle core display")
+ flag.IntVar(&cfg.CPUMode, "cpumode", cfg.CPUMode, "CPU display mode (0=average, 1=cores, 2=off)")
flag.BoolVar(&cfg.ShowMem, "showmem", cfg.ShowMem, "Toggle mem display")
flag.BoolVar(&cfg.ShowNet, "shownet", cfg.ShowNet, "Toggle net display")
flag.BoolVar(&cfg.Extended, "extended", cfg.Extended, "Toggle extended display")
diff --git a/internal/collector/parse_test.go b/internal/collector/parse_test.go
index ec77067..fe7a73c 100644
--- a/internal/collector/parse_test.go
+++ b/internal/collector/parse_test.go
@@ -44,29 +44,31 @@ func TestParseCPULine(t *testing.T) {
func TestParseMemLine(t *testing.T) {
tests := []struct {
+ name string
line string
wantKey string
wantValue int64
wantOK bool
}{
- {"MemTotal: 123456 kB", "MemTotal", 123456, true},
- {"MemFree: 99999 kB", "MemFree", 99999, true},
- {"Buffers: 0 kB", "Buffers", 0, true},
- {"not a mem line", "", 0, false},
- {"", "", 0, false},
+ {"MemTotal", "MemTotal: 123456 kB", "MemTotal", 123456, true},
+ {"MemFree", "MemFree: 99999 kB", "MemFree", 99999, true},
+ {"Buffers_zero", "Buffers: 0 kB", "Buffers", 0, true},
+ {"not_a_mem_line", "not a mem line", "", 0, false},
+ {"empty_string", "", "", 0, false},
}
for _, tt := range tests {
- got, ok := ParseMemLine(tt.line)
- if ok != tt.wantOK {
- t.Errorf("ParseMemLine(%q) ok = %v, want %v", tt.line, ok, tt.wantOK)
- continue
- }
- if !tt.wantOK {
- continue
- }
- if got.Key != tt.wantKey || got.Value != tt.wantValue {
- t.Errorf("ParseMemLine(%q) = %+v, want key=%q value=%d", tt.line, got, tt.wantKey, tt.wantValue)
- }
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := ParseMemLine(tt.line)
+ if ok != tt.wantOK {
+ t.Fatalf("ParseMemLine(%q) ok = %v, want %v", tt.line, ok, tt.wantOK)
+ }
+ if !tt.wantOK {
+ return
+ }
+ if got.Key != tt.wantKey || got.Value != tt.wantValue {
+ t.Errorf("ParseMemLine(%q) = %+v, want key=%q value=%d", tt.line, got, tt.wantKey, tt.wantValue)
+ }
+ })
}
}
diff --git a/internal/collector/script_embed.go b/internal/collector/script_embed.go
new file mode 100644
index 0000000..168ca37
--- /dev/null
+++ b/internal/collector/script_embed.go
@@ -0,0 +1,9 @@
+package collector
+
+import _ "embed"
+
+// RemoteScript is the loadbars-remote.sh script embedded for local and SSH execution.
+// Path is relative to this file's directory (internal/collector).
+//
+//go:embed scriptdata/loadbars-remote.sh
+var RemoteScript []byte
diff --git a/internal/collector/scriptdata/loadbars-remote.sh b/internal/collector/scriptdata/loadbars-remote.sh
new file mode 100644
index 0000000..9037ad8
--- /dev/null
+++ b/internal/collector/scriptdata/loadbars-remote.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# loadbars-remote.sh - Emits loadbars protocol (M LOADAVG, M MEMSTATS, M NETSTATS, M CPUSTATS)
+# for local or remote execution. No Perl required.
+# Usage: bash loadbars-remote.sh
+# Interval for CPU sampling (seconds)
+INTERVAL=0.14
+
+while true; do
+ # Load average: first 3 fields of /proc/loadavg joined by ;
+ echo "M LOADAVG"
+ read -r l1 l5 l15 _ < /proc/loadavg 2>/dev/null || true
+ echo "${l1:-0};${l5:-0};${l15:-0}"
+
+ # Memory: full /proc/meminfo
+ echo "M MEMSTATS"
+ cat /proc/meminfo 2>/dev/null || true
+
+ # Network: /proc/net/dev, skip 2 header lines, then "iface: rx... tx..."
+ echo "M NETSTATS"
+ while IFS= read -r line; do
+ line="${line/:/ }"
+ set -- $line
+ # $1=iface, $2=rx_bytes $3=rx_packets $4=rx_errs $5=rx_drop ... $10=tx_bytes $11=tx_packets $12=tx_errs $13=tx_drop
+ if [ -n "$2" ] || [ -n "${10:-}" ]; then
+ echo "$1:b=${2:-0};tb=${10:-0};p=${3:-0};tp=${11:-0} e=${4:-0};te=${12:-0};d=${5:-0};td=${13:-0}"
+ fi
+ done < <(tail -n +3 /proc/net/dev 2>/dev/null)
+
+ # CPU: /proc/stat, 20 times with INTERVAL sleep
+ echo "M CPUSTATS"
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
+ cat /proc/stat 2>/dev/null || true
+ sleep "$INTERVAL" 2>/dev/null || true
+ done
+done
diff --git a/internal/collector/types.go b/internal/collector/types.go
index b0db600..13bcb1f 100644
--- a/internal/collector/types.go
+++ b/internal/collector/types.go
@@ -7,7 +7,7 @@ type CPULine struct {
}
// Total returns sum of all CPU counters.
-func (c *CPULine) Total() int64 {
+func (c CPULine) Total() int64 {
return c.User + c.Nice + c.System + c.Idle + c.Iowait + c.IRQ + c.SoftIRQ + c.Steal + c.Guest + c.GuestNice
}
diff --git a/internal/config/config.go b/internal/config/config.go
index ba7886e..90b8192 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -26,7 +26,7 @@ type Config struct {
NetLink string
ShowAvgLine bool
ShowIOAvgLine bool
- ShowCores bool
+ CPUMode int // constants.CPUModeAverage / CPUModeCores / CPUModeOff
ShowMem bool
ShowNet bool
ShowSeparators bool
@@ -46,7 +46,7 @@ func Default() Config {
MaxWidth: 1900,
NetAverage: 15,
NetLink: "gbit",
- ShowCores: false,
+ CPUMode: constants.CPUModeAverage, // start with aggregate bar only
ShowMem: false,
ShowNet: false,
MaxBarsPerRow: 0,
@@ -108,7 +108,7 @@ func (c *Config) parseReader(f *os.File) error {
validKeys := map[string]bool{
"title": true, "barwidth": true, "cpuaverage": true, "extended": true,
"hasagent": true, "height": true, "maxwidth": true, "netaverage": true,
- "netlink": true, "showcores": true, "showmem": true,
+ "netlink": true, "cpumode": true, "showcores": true, "showmem": true,
"showavgline": true, "showioavgline": true, "shownet": true, "showseparators": true,
"maxbarsperrow": true, "sshopts": true, "cluster": true,
}
@@ -169,8 +169,18 @@ func (c *Config) set(key, val string) {
c.ShowAvgLine = parseBool(val)
case "showioavgline":
c.ShowIOAvgLine = parseBool(val)
+ case "cpumode":
+ // 0=average, 1=cores, 2=off — clamp to valid range
+ if n, err := strconv.Atoi(val); err == nil && n >= 0 && n < constants.CPUModeCount {
+ c.CPUMode = n
+ }
case "showcores":
- c.ShowCores = parseBool(val)
+ // Backward-compatible: old boolean showcores maps to CPUMode
+ if parseBool(val) {
+ c.CPUMode = constants.CPUModeCores
+ } else {
+ c.CPUMode = constants.CPUModeAverage
+ }
case "showmem":
c.ShowMem = parseBool(val)
case "shownet":
@@ -209,7 +219,7 @@ func (c *Config) writeTo(f *os.File) error {
writeStr("netlink", c.NetLink)
writeBool("showavgline", c.ShowAvgLine)
writeBool("showioavgline", c.ShowIOAvgLine)
- writeBool("showcores", c.ShowCores)
+ writeInt("cpumode", c.CPUMode)
writeBool("showmem", c.ShowMem)
writeBool("shownet", c.ShowNet)
writeBool("showseparators", c.ShowSeparators)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 630fa47..7b0bb37 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -25,9 +25,6 @@ func TestConfig_parseReader(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := Default()
- f, _ := os.Open(os.DevNull)
- defer f.Close()
- // Use a temp file with the content since parseReader takes *os.File
dir := t.TempDir()
path := filepath.Join(dir, "rc")
if err := os.WriteFile(path, []byte(tt.input), 0600); err != nil {
@@ -54,7 +51,7 @@ func TestConfig_parseReader(t *testing.T) {
func TestConfig_writeTo(t *testing.T) {
c := Default()
c.BarWidth = 25
- c.ShowCores = true
+ c.CPUMode = 1 // CPUModeCores
dir := t.TempDir()
path := filepath.Join(dir, "out")
f, err := os.Create(path)
@@ -73,8 +70,8 @@ func TestConfig_writeTo(t *testing.T) {
if !bytes.Contains(data, []byte("barwidth=25")) {
t.Errorf("expected barwidth=25 in %s", data)
}
- if !bytes.Contains(data, []byte("showcores=1")) {
- t.Errorf("expected showcores=1 in %s", data)
+ if !bytes.Contains(data, []byte("cpumode=1")) {
+ t.Errorf("expected cpumode=1 in %s", data)
}
}
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 0022621..e2a4fcb 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -16,6 +16,14 @@ const (
UserYellowThreshold = 50 // CPU user % for dark yellow
)
+// CPUMode controls which CPU bars are displayed (cycles with the 1 key).
+const (
+ CPUModeAverage = 0 // Show only the aggregate CPU bar (default)
+ CPUModeCores = 1 // Show individual core bars plus the aggregate
+ CPUModeOff = 2 // Hide all CPU bars entirely
+ CPUModeCount = 3 // Total number of CPU modes for cycling
+)
+
// Exit codes
const (
Success = 0
@@ -52,7 +60,7 @@ var (
)
// BytesPerSec for link speed reference (bytes per second at given mbit)
-var (
+const (
BytesMbit = 125000
Bytes10Mbit = 1250000
Bytes100Mbit = 12500000
diff --git a/internal/display/activate.go b/internal/display/activate.go
index b9040d7..ac9b340 100644
--- a/internal/display/activate.go
+++ b/internal/display/activate.go
@@ -1,4 +1,4 @@
-// +build !darwin
+//go:build !darwin
package display
diff --git a/internal/display/activate_darwin.go b/internal/display/activate_darwin.go
index 54c6b94..478d558 100644
--- a/internal/display/activate_darwin.go
+++ b/internal/display/activate_darwin.go
@@ -1,4 +1,4 @@
-// +build darwin
+//go:build darwin
package display
diff --git a/internal/display/display.go b/internal/display/display.go
index 749c26c..47f8f10 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -17,7 +17,9 @@ import (
"github.com/veandco/go-sdl2/sdl"
)
-const smoothFactor = 0.12 // blend toward target each frame; lower = smoother
+// smoothFactor controls how quickly bars blend toward their target values each frame.
+// Lower values produce smoother animations.
+const smoothFactor = 0.12
// linkScales lists the supported network link speeds in ascending order,
// used by the f/v hotkeys to cycle through link scale values.
@@ -27,7 +29,7 @@ var linkScales = []string{"mbit", "10mbit", "100mbit", "gbit", "10gbit"}
type runState struct {
showAvgLine bool
showIOAvgLine bool
- showCores bool
+ cpuMode int // constants.CPUModeAverage / CPUModeCores / CPUModeOff
showMem bool
showNet bool
showSeparators bool
@@ -40,27 +42,9 @@ type runState struct {
smoothedNet map[string]*struct{ rxPct, txPct float64 }
prevNet map[string]stats.NetStamp // aggregated (summed) previous net stamp per host
peakHistory map[string][]float64
-}
-
-// newRunState builds initial run state from config.
-func newRunState(cfg *config.Config, winW, winH int32) *runState {
- return &runState{
- showAvgLine: cfg.ShowAvgLine,
- showIOAvgLine: cfg.ShowIOAvgLine,
- showCores: cfg.ShowCores,
- showMem: cfg.ShowMem,
- showNet: cfg.ShowNet,
- showSeparators: cfg.ShowSeparators,
- extended: cfg.Extended,
- winW: winW,
- winH: winH,
- prevCPU: make(map[string]collector.CPULine),
- smoothedCPU: make(map[string]*[10]float64),
- smoothedMem: make(map[string]*struct{ ramUsed, swapUsed float64 }),
- smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }),
- prevNet: make(map[string]stats.NetStamp),
- peakHistory: make(map[string][]float64),
- }
+ mouseX int32 // last known mouse X position (for tooltip hit testing)
+ mouseY int32 // last known mouse Y position (for tooltip hit testing)
+ mouseLastMove time.Time // timestamp of last mouse movement; tooltip hidden after 3s idle
}
// Run runs the SDL display loop until ctx is cancelled or user presses 'q'.
@@ -111,6 +95,29 @@ func Run(ctx context.Context, cfg *config.Config, src stats.Source) error {
}
}
+// newRunState builds initial run state from config.
+func newRunState(cfg *config.Config, winW, winH int32) *runState {
+ return &runState{
+ showAvgLine: cfg.ShowAvgLine,
+ showIOAvgLine: cfg.ShowIOAvgLine,
+ cpuMode: cfg.CPUMode,
+ showMem: cfg.ShowMem,
+ showNet: cfg.ShowNet,
+ showSeparators: cfg.ShowSeparators,
+ extended: cfg.Extended,
+ winW: winW,
+ winH: winH,
+ prevCPU: make(map[string]collector.CPULine),
+ smoothedCPU: make(map[string]*[10]float64),
+ smoothedMem: make(map[string]*struct{ ramUsed, swapUsed float64 }),
+ smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }),
+ prevNet: make(map[string]stats.NetStamp),
+ peakHistory: make(map[string][]float64),
+ mouseX: -1, // off-screen until first mouse move
+ mouseY: -1,
+ }
+}
+
func clampInt(v, min, max int) int {
if v < min {
return min
@@ -134,6 +141,9 @@ func handleEvents(window *sdl.Window, cfg *config.Config, state *runState) bool
if handleKey(ev.Keysym.Sym, window, cfg, state) {
return true
}
+ case *sdl.MouseMotionEvent:
+ state.mouseX, state.mouseY = ev.X, ev.Y
+ state.mouseLastMove = time.Now()
case *sdl.WindowEvent:
if ev.Event == sdl.WINDOWEVENT_RESIZED {
state.winW, state.winH = ev.Data1, ev.Data2
@@ -149,8 +159,16 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_q:
return true
case sdl.K_1:
- state.showCores = !state.showCores
- fmt.Println("==> Toggled show cores:", state.showCores)
+ // Cycle through three CPU display modes: average → all cores → off → average
+ state.cpuMode = (state.cpuMode + 1) % constants.CPUModeCount
+ switch state.cpuMode {
+ case constants.CPUModeAverage:
+ fmt.Println("==> CPU: average bar only")
+ case constants.CPUModeCores:
+ fmt.Println("==> CPU: individual cores")
+ case constants.CPUModeOff:
+ fmt.Println("==> CPU: off")
+ }
case sdl.K_2, sdl.K_m:
state.showMem = !state.showMem
fmt.Println("==> Toggled show mem:", state.showMem)
@@ -194,7 +212,7 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_w:
cfg.ShowAvgLine = state.showAvgLine
cfg.ShowIOAvgLine = state.showIOAvgLine
- cfg.ShowCores = state.showCores
+ cfg.CPUMode = state.cpuMode
cfg.ShowMem = state.showMem
cfg.ShowNet = state.showNet
cfg.ShowSeparators = state.showSeparators
@@ -270,7 +288,7 @@ func barRect(winW, winH int32, numBars, maxPerRow, barIndex int) (x, y, w, h int
// When showAvgLine/showIOAvgLine are enabled, global average lines are drawn on top.
func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, state *runState) {
snap := src.Snapshot()
- numBars := countBars(snap, state.showCores, state.showMem, state.showNet)
+ numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet)
// Always clear the entire window before drawing. SDL2 uses double-buffering,
// so skipping clear leaves stale content in the back buffer.
renderer.SetDrawColor(0, 0, 0, 255)
@@ -282,13 +300,15 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta
if state.showIOAvgLine {
drawGlobalIOAvgLine(renderer, snap, state, numBars, cfg.MaxBarsPerRow)
}
+ // Draw mouse-over tooltip and host highlight inversion on top of all bars
+ drawOverlay(renderer, snap, cfg, state)
}
-func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet bool) int {
+func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet bool) int {
n := 0
for _, host := range sortedHosts(snap) {
if h := snap[host]; h != nil {
- n += len(sortedCPUNames(h.CPU, showCores))
+ n += len(sortedCPUNames(h.CPU, cpuMode))
if showMem {
n++
}
@@ -430,7 +450,7 @@ func drawGlobalIOAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStat
// drawHostBars draws CPU, mem, and net bars for one host and advances barIndex.
// maxPerRow controls multi-row wrapping (0 = single row).
func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *config.Config, state *runState, numBars, maxPerRow int, barIndex *int) {
- cpuNames := sortedCPUNames(h.CPU, state.showCores)
+ cpuNames := sortedCPUNames(h.CPU, state.cpuMode)
for _, name := range cpuNames {
key := host + ";" + name
cur := h.CPU[name]
@@ -506,14 +526,20 @@ func sortedHosts(snap map[string]*stats.HostStats) []string {
return out
}
-func sortedCPUNames(cpu map[string]collector.CPULine, showCores bool) []string {
+func sortedCPUNames(cpu map[string]collector.CPULine, cpuMode int) []string {
+ // CPUModeOff: hide all CPU bars
+ if cpuMode == constants.CPUModeOff {
+ return nil
+ }
var names []string
for name := range cpu {
if name == "cpu" {
+ // Aggregate bar always shown unless CPUModeOff
names = append(names, "cpu")
continue
}
- if showCores {
+ // Individual core bars only shown in CPUModeCores
+ if cpuMode == constants.CPUModeCores {
names = append(names, name)
}
}
diff --git a/internal/display/display_test.go b/internal/display/display_test.go
index 332fe32..b51e6d1 100644
--- a/internal/display/display_test.go
+++ b/internal/display/display_test.go
@@ -141,7 +141,7 @@ func renderOneCPUBar(t *testing.T, systemPct, userPct, idlePct float64, extended
prev, cur := makeCPUPair(systemPct, userPct, idlePct)
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
cfg.Extended = extended
@@ -212,7 +212,7 @@ func TestMemBar_RamAndSwap(t *testing.T) {
defer surface.Free()
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = true
cfg.ShowNet = false
@@ -266,7 +266,7 @@ func TestNetBar_RxTx(t *testing.T) {
defer surface.Free()
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = true
cfg.NetLink = "gbit"
@@ -318,7 +318,7 @@ func TestNetBar_AggregatesAllInterfaces(t *testing.T) {
defer surface.Free()
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = true
cfg.NetLink = "gbit"
@@ -372,7 +372,7 @@ func TestMultiHost_BarCount(t *testing.T) {
defer surface.Free()
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = true
cfg.ShowNet = true
@@ -397,7 +397,7 @@ func TestMultiHost_BarCount(t *testing.T) {
}
snap := src.Snapshot()
- numBars := countBars(snap, false, true, true)
+ numBars := countBars(snap, constants.CPUModeAverage, true, true)
if numBars != 6 {
t.Fatalf("expected 6 bars (2 hosts × 3), got %d", numBars)
}
@@ -418,7 +418,7 @@ func TestMultiHost_BarCount(t *testing.T) {
}
func TestCores_Toggle(t *testing.T) {
- // With showCores=true and 2 cores, we get cpu + cpu0 + cpu1 = 3 CPU bars
+ // Three CPU mode states: average (1 bar), cores (3 bars), off (0 → floor 1)
hostStats := &stats.HostStats{
CPU: map[string]collector.CPULine{
"cpu": {System: 500, User: 0, Idle: 500},
@@ -429,16 +429,22 @@ func TestCores_Toggle(t *testing.T) {
snap := map[string]*stats.HostStats{"host1": hostStats}
- // showCores=true: should count 3 CPU bars
- nWith := countBars(snap, true, false, false)
- if nWith != 3 {
- t.Errorf("showCores=true: expected 3 bars, got %d", nWith)
+ // CPUModeAverage: aggregate bar only (1 bar)
+ nAverage := countBars(snap, constants.CPUModeAverage, false, false)
+ if nAverage != 1 {
+ t.Errorf("CPUModeAverage: expected 1 bar, got %d", nAverage)
}
- // showCores=false: should count 1 CPU bar (aggregate only)
- nWithout := countBars(snap, false, false, false)
- if nWithout != 1 {
- t.Errorf("showCores=false: expected 1 bar, got %d", nWithout)
+ // CPUModeCores: aggregate + individual cores = cpu + cpu0 + cpu1 (3 bars)
+ nCores := countBars(snap, constants.CPUModeCores, false, false)
+ if nCores != 3 {
+ t.Errorf("CPUModeCores: expected 3 bars, got %d", nCores)
+ }
+
+ // CPUModeOff: no CPU bars → countBars floors to 1 (window always shows something)
+ nOff := countBars(snap, constants.CPUModeOff, false, false)
+ if nOff != 1 {
+ t.Errorf("CPUModeOff: expected 1 (floor), got %d", nOff)
}
}
@@ -486,7 +492,7 @@ func TestNetBar_NoInterface(t *testing.T) {
defer surface.Free()
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = true
@@ -548,7 +554,7 @@ func TestRemainderPixels_AfterToggleMem(t *testing.T) {
src := &mockSource{data: hosts}
cfg := defaultTestConfig()
- cfg.ShowCores = true
+ cfg.CPUMode = constants.CPUModeCores
cfg.ShowMem = true
cfg.ShowNet = false
@@ -589,7 +595,7 @@ func TestRemainderPixels_AfterToggleMem(t *testing.T) {
// newHotkeyTestEnv creates a test environment with 1 host, 2 CPU cores, memory,
// and 2 net interfaces. Returns all components needed for handleKey + drawFrame
// pixel inspection tests.
-func newHotkeyTestEnv(t *testing.T, showCores, showMem, showNet bool) (
+func newHotkeyTestEnv(t *testing.T, cpuMode int, showMem, showNet bool) (
renderer *sdl.Renderer, surface *sdl.Surface,
cfg *config.Config, state *runState, src *mockSource,
) {
@@ -602,7 +608,7 @@ func newHotkeyTestEnv(t *testing.T, showCores, showMem, showNet bool) (
}
cfg = defaultTestConfig()
- cfg.ShowCores = showCores
+ cfg.CPUMode = cpuMode
cfg.ShowMem = showMem
cfg.ShowNet = showNet
@@ -662,36 +668,52 @@ func TestHandleKey_UnknownKey(t *testing.T) {
t.Error("expected handleKey(x) to return false")
}
// State should be unchanged
- if state.showCores != cfg.ShowCores || state.showMem != cfg.ShowMem || state.showNet != cfg.ShowNet {
+ if state.cpuMode != cfg.CPUMode || state.showMem != cfg.ShowMem || state.showNet != cfg.ShowNet {
t.Error("unknown key should not change state")
}
}
func TestHandleKey_ToggleCores(t *testing.T) {
- renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
+ renderer, surface, cfg, state, src := newHotkeyTestEnv(t, constants.CPUModeAverage, false, false)
defer renderer.Destroy()
defer surface.Free()
- // Before: showCores=false → 1 CPU bar (aggregate)
+ // State 0 (CPUModeAverage): single aggregate bar spans full width
drawFrame(renderer, src, cfg, state)
- // The single bar spans full width; check it has color at x=100
- assertPixelColor(t, surface, 100, 95, constants.Blue, 5, "aggregate CPU bar before toggle")
+ assertPixelColor(t, surface, 100, 95, constants.Blue, 5, "aggregate CPU bar in average mode")
- // Press '1' to toggle cores on
+ // Press '1': CPUModeAverage → CPUModeCores
handleKey(sdl.K_1, nil, cfg, state)
- if !state.showCores {
- t.Fatal("expected showCores=true after pressing 1")
+ if state.cpuMode != constants.CPUModeCores {
+ t.Fatalf("expected cpuMode=CPUModeCores after first press, got %d", state.cpuMode)
}
- // After: showCores=true → 3 CPU bars (cpu + cpu0 + cpu1)
+ // State 1 (CPUModeCores): 3 CPU bars (cpu + cpu0 + cpu1), each ~66px wide at 200px window
drawFrame(renderer, src, cfg, state)
- // With 3 bars at width 200: barWidth=66, bars at x=0, x=66, x=132
- // Third bar (cpu1) should have color
- assertPixelColor(t, surface, 140, 95, constants.Blue, 5, "cpu1 bar after toggle")
+ // Third bar (cpu1) starts at x=133; check it has color at x=140
+ assertPixelColor(t, surface, 140, 95, constants.Blue, 5, "cpu1 bar visible in cores mode")
+
+ // Press '1': CPUModeCores → CPUModeOff
+ handleKey(sdl.K_1, nil, cfg, state)
+ if state.cpuMode != constants.CPUModeOff {
+ t.Fatalf("expected cpuMode=CPUModeOff after second press, got %d", state.cpuMode)
+ }
+
+ // State 2 (CPUModeOff): no CPU bars; countBars returns 1 (floor) so window is still drawn
+ nOff := countBars(src.Snapshot(), constants.CPUModeOff, false, false)
+ if nOff != 1 {
+ t.Errorf("CPUModeOff: expected countBars=1 (floor), got %d", nOff)
+ }
+
+ // Press '1': CPUModeOff → CPUModeAverage (wraps around)
+ handleKey(sdl.K_1, nil, cfg, state)
+ if state.cpuMode != constants.CPUModeAverage {
+ t.Fatalf("expected cpuMode=CPUModeAverage after third press, got %d", state.cpuMode)
+ }
}
func TestHandleKey_ToggleMem(t *testing.T) {
- renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
+ renderer, surface, cfg, state, src := newHotkeyTestEnv(t, constants.CPUModeAverage, false, false)
defer renderer.Destroy()
defer surface.Free()
@@ -728,7 +750,7 @@ func TestHandleKey_ToggleMemAlias(t *testing.T) {
}
func TestHandleKey_ToggleNet(t *testing.T) {
- renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
+ renderer, surface, cfg, state, src := newHotkeyTestEnv(t, constants.CPUModeAverage, false, false)
defer renderer.Destroy()
defer surface.Free()
@@ -764,7 +786,7 @@ func TestHandleKey_ToggleNetAlias(t *testing.T) {
}
func TestHandleKey_ToggleExtended(t *testing.T) {
- renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
+ renderer, surface, cfg, state, src := newHotkeyTestEnv(t, constants.CPUModeAverage, false, false)
defer renderer.Destroy()
defer surface.Free()
@@ -852,7 +874,7 @@ func TestHandleKey_WriteConfig(t *testing.T) {
state := newRunState(cfg, 200, 100)
// Modify state values that should be copied to config
state.showAvgLine = true
- state.showCores = true
+ state.cpuMode = constants.CPUModeCores
state.showMem = true
state.showNet = true
state.extended = true
@@ -862,8 +884,8 @@ func TestHandleKey_WriteConfig(t *testing.T) {
if !cfg.ShowAvgLine {
t.Error("expected ShowAvgLine=true in config after 'w'")
}
- if !cfg.ShowCores {
- t.Error("expected ShowCores=true in config after 'w'")
+ if cfg.CPUMode != constants.CPUModeCores {
+ t.Errorf("expected CPUMode=CPUModeCores in config after 'w', got %d", cfg.CPUMode)
}
if !cfg.ShowMem {
t.Error("expected ShowMem=true in config after 'w'")
@@ -877,7 +899,7 @@ func TestHandleKey_WriteConfig(t *testing.T) {
}
func TestHandleKey_LinkScaleUp(t *testing.T) {
- renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, true)
+ renderer, surface, cfg, state, src := newHotkeyTestEnv(t, constants.CPUModeAverage, false, true)
defer renderer.Destroy()
defer surface.Free()
@@ -950,7 +972,7 @@ func TestGlobalAvgLine_SingleHost(t *testing.T) {
prev, cur := makeCPUPair(40, 40, 20) // 80% used (40 sys + 40 user)
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
@@ -988,7 +1010,7 @@ func TestGlobalAvgLine_MultiHost(t *testing.T) {
prev2, cur2 := makeCPUPair(20, 20, 60) // 40% used
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
@@ -1023,7 +1045,7 @@ func TestGlobalAvgLine_Disabled(t *testing.T) {
prev, cur := makeCPUPair(40, 40, 20) // 80% used
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
@@ -1284,7 +1306,7 @@ func TestHandleKey_ToggleSeparators(t *testing.T) {
}
func TestSeparator_TwoHosts_Enabled(t *testing.T) {
- // Two hosts (100% system = blue) with separators enabled: yellow pixel at boundary
+ // Two hosts (100% system = blue) with separators enabled: red pixel at boundary
const w, h int32 = 200, 100
renderer, surface, err := createTestRenderer(w, h)
@@ -1298,7 +1320,7 @@ func TestSeparator_TwoHosts_Enabled(t *testing.T) {
prev2, cur2 := makeCPUPair(100, 0, 0)
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
cfg.ShowSeparators = true
@@ -1321,7 +1343,7 @@ func TestSeparator_TwoHosts_Enabled(t *testing.T) {
}
func TestSeparator_TwoHosts_Disabled(t *testing.T) {
- // Two hosts (100% system = blue) with separators disabled: no yellow at boundary
+ // Two hosts (100% system = blue) with separators disabled: no red at boundary
const w, h int32 = 200, 100
renderer, surface, err := createTestRenderer(w, h)
@@ -1335,7 +1357,7 @@ func TestSeparator_TwoHosts_Disabled(t *testing.T) {
prev2, cur2 := makeCPUPair(100, 0, 0)
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
cfg.ShowSeparators = false
@@ -1370,7 +1392,7 @@ func TestSeparator_SingleHost(t *testing.T) {
prev, cur := makeCPUPair(50, 30, 20)
cfg := defaultTestConfig()
- cfg.ShowCores = false
+ cfg.CPUMode = constants.CPUModeAverage
cfg.ShowMem = false
cfg.ShowNet = false
cfg.ShowSeparators = true
diff --git a/internal/display/font.go b/internal/display/font.go
new file mode 100644
index 0000000..d6dae18
--- /dev/null
+++ b/internal/display/font.go
@@ -0,0 +1,248 @@
+package display
+
+import "github.com/veandco/go-sdl2/sdl"
+
+// Bitmap font: 5x7 pixel glyphs for ASCII 32–126, rendered via FillRect.
+// Each glyph is 7 rows of 5 bits (MSB = leftmost pixel).
+
+const (
+ glyphW = 5 // pixels per character width
+ glyphH = 7 // pixels per character height
+ charGap = 1 // horizontal gap between characters
+)
+
+// font5x7 maps ASCII 32–126 to 7-byte bitmaps (one byte per row, top 5 bits used).
+// Index 0 = space (0x20), index 94 = tilde (0x7E).
+var font5x7 = [95][7]byte{
+ // space (0x20)
+ {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
+ // ! (0x21)
+ {0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x20},
+ // " (0x22)
+ {0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00},
+ // # (0x23)
+ {0x50, 0xF8, 0x50, 0x50, 0x50, 0xF8, 0x50},
+ // $ (0x24)
+ {0x20, 0x70, 0xA0, 0x70, 0x28, 0x70, 0x20},
+ // % (0x25)
+ {0xC8, 0xC8, 0x10, 0x20, 0x40, 0x98, 0x98},
+ // & (0x26)
+ {0x40, 0xA0, 0xA0, 0x40, 0xA8, 0x90, 0x68},
+ // ' (0x27)
+ {0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00},
+ // ( (0x28)
+ {0x10, 0x20, 0x40, 0x40, 0x40, 0x20, 0x10},
+ // ) (0x29)
+ {0x40, 0x20, 0x10, 0x10, 0x10, 0x20, 0x40},
+ // * (0x2A)
+ {0x00, 0x20, 0xA8, 0x70, 0xA8, 0x20, 0x00},
+ // + (0x2B)
+ {0x00, 0x20, 0x20, 0xF8, 0x20, 0x20, 0x00},
+