summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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},
+ // , (0x2C)
+ {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x40},
+ // - (0x2D)
+ {0x00, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x00},
+ // . (0x2E)
+ {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20},
+ // / (0x2F)
+ {0x08, 0x08, 0x10, 0x20, 0x40, 0x80, 0x80},
+ // 0 (0x30)
+ {0x70, 0x88, 0x98, 0xA8, 0xC8, 0x88, 0x70},
+ // 1 (0x31)
+ {0x20, 0x60, 0x20, 0x20, 0x20, 0x20, 0x70},
+ // 2 (0x32)
+ {0x70, 0x88, 0x08, 0x10, 0x20, 0x40, 0xF8},
+ // 3 (0x33)
+ {0x70, 0x88, 0x08, 0x30, 0x08, 0x88, 0x70},
+ // 4 (0x34)
+ {0x10, 0x30, 0x50, 0x90, 0xF8, 0x10, 0x10},
+ // 5 (0x35)
+ {0xF8, 0x80, 0xF0, 0x08, 0x08, 0x88, 0x70},
+ // 6 (0x36)
+ {0x30, 0x40, 0x80, 0xF0, 0x88, 0x88, 0x70},
+ // 7 (0x37)
+ {0xF8, 0x08, 0x10, 0x20, 0x40, 0x40, 0x40},
+ // 8 (0x38)
+ {0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70},
+ // 9 (0x39)
+ {0x70, 0x88, 0x88, 0x78, 0x08, 0x10, 0x60},
+ // : (0x3A)
+ {0x00, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00},
+ // ; (0x3B)
+ {0x00, 0x00, 0x20, 0x00, 0x00, 0x20, 0x40},
+ // < (0x3C)
+ {0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08},
+ // = (0x3D)
+ {0x00, 0x00, 0xF8, 0x00, 0xF8, 0x00, 0x00},
+ // > (0x3E)
+ {0x80, 0x40, 0x20, 0x10, 0x20, 0x40, 0x80},
+ // ? (0x3F)
+ {0x70, 0x88, 0x08, 0x10, 0x20, 0x00, 0x20},
+ // @ (0x40)
+ {0x70, 0x88, 0xB8, 0xA8, 0xB8, 0x80, 0x70},
+ // A (0x41)
+ {0x70, 0x88, 0x88, 0xF8, 0x88, 0x88, 0x88},
+ // B (0x42)
+ {0xF0, 0x88, 0x88, 0xF0, 0x88, 0x88, 0xF0},
+ // C (0x43)
+ {0x70, 0x88, 0x80, 0x80, 0x80, 0x88, 0x70},
+ // D (0x44)
+ {0xF0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xF0},
+ // E (0x45)
+ {0xF8, 0x80, 0x80, 0xF0, 0x80, 0x80, 0xF8},
+ // F (0x46)
+ {0xF8, 0x80, 0x80, 0xF0, 0x80, 0x80, 0x80},
+ // G (0x47)
+ {0x70, 0x88, 0x80, 0xB8, 0x88, 0x88, 0x70},
+ // H (0x48)
+ {0x88, 0x88, 0x88, 0xF8, 0x88, 0x88, 0x88},
+ // I (0x49)
+ {0x70, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70},
+ // J (0x4A)
+ {0x38, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60},
+ // K (0x4B)
+ {0x88, 0x90, 0xA0, 0xC0, 0xA0, 0x90, 0x88},
+ // L (0x4C)
+ {0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xF8},
+ // M (0x4D)
+ {0x88, 0xD8, 0xA8, 0xA8, 0x88, 0x88, 0x88},
+ // N (0x4E)
+ {0x88, 0xC8, 0xA8, 0x98, 0x88, 0x88, 0x88},
+ // O (0x4F)
+ {0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70},
+ // P (0x50)
+ {0xF0, 0x88, 0x88, 0xF0, 0x80, 0x80, 0x80},
+ // Q (0x51)
+ {0x70, 0x88, 0x88, 0x88, 0xA8, 0x90, 0x68},
+ // R (0x52)
+ {0xF0, 0x88, 0x88, 0xF0, 0xA0, 0x90, 0x88},
+ // S (0x53)
+ {0x70, 0x88, 0x80, 0x70, 0x08, 0x88, 0x70},
+ // T (0x54)
+ {0xF8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20},
+ // U (0x55)
+ {0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70},
+ // V (0x56)
+ {0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20},
+ // W (0x57)
+ {0x88, 0x88, 0x88, 0xA8, 0xA8, 0xD8, 0x88},
+ // X (0x58)
+ {0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88},
+ // Y (0x59)
+ {0x88, 0x88, 0x50, 0x20, 0x20, 0x20, 0x20},
+ // Z (0x5A)
+ {0xF8, 0x08, 0x10, 0x20, 0x40, 0x80, 0xF8},
+ // [ (0x5B)
+ {0x70, 0x40, 0x40, 0x40, 0x40, 0x40, 0x70},
+ // \ (0x5C)
+ {0x80, 0x80, 0x40, 0x20, 0x10, 0x08, 0x08},
+ // ] (0x5D)
+ {0x70, 0x10, 0x10, 0x10, 0x10, 0x10, 0x70},
+ // ^ (0x5E)
+ {0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00},
+ // _ (0x5F)
+ {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8},
+ // ` (0x60)
+ {0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00},
+ // a (0x61)
+ {0x00, 0x00, 0x70, 0x08, 0x78, 0x88, 0x78},
+ // b (0x62)
+ {0x80, 0x80, 0xF0, 0x88, 0x88, 0x88, 0xF0},
+ // c (0x63)
+ {0x00, 0x00, 0x70, 0x80, 0x80, 0x80, 0x70},
+ // d (0x64)
+ {0x08, 0x08, 0x78, 0x88, 0x88, 0x88, 0x78},
+ // e (0x65)
+ {0x00, 0x00, 0x70, 0x88, 0xF8, 0x80, 0x70},
+ // f (0x66)
+ {0x30, 0x48, 0x40, 0xE0, 0x40, 0x40, 0x40},
+ // g (0x67)
+ {0x00, 0x00, 0x78, 0x88, 0x78, 0x08, 0x70},
+ // h (0x68)
+ {0x80, 0x80, 0xF0, 0x88, 0x88, 0x88, 0x88},
+ // i (0x69)
+ {0x20, 0x00, 0x60, 0x20, 0x20, 0x20, 0x70},
+ // j (0x6A)
+ {0x10, 0x00, 0x30, 0x10, 0x10, 0x90, 0x60},
+ // k (0x6B)
+ {0x80, 0x80, 0x90, 0xA0, 0xC0, 0xA0, 0x90},
+ // l (0x6C)
+ {0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70},
+ // m (0x6D)
+ {0x00, 0x00, 0xD0, 0xA8, 0xA8, 0xA8, 0xA8},
+ // n (0x6E)
+ {0x00, 0x00, 0xF0, 0x88, 0x88, 0x88, 0x88},
+ // o (0x6F)
+ {0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x70},
+ // p (0x70)
+ {0x00, 0x00, 0xF0, 0x88, 0xF0, 0x80, 0x80},
+ // q (0x71)
+ {0x00, 0x00, 0x78, 0x88, 0x78, 0x08, 0x08},
+ // r (0x72)
+ {0x00, 0x00, 0xB0, 0xC8, 0x80, 0x80, 0x80},
+ // s (0x73)
+ {0x00, 0x00, 0x78, 0x80, 0x70, 0x08, 0xF0},
+ // t (0x74)
+ {0x40, 0x40, 0xE0, 0x40, 0x40, 0x48, 0x30},
+ // u (0x75)
+ {0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x78},
+ // v (0x76)
+ {0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x20},
+ // w (0x77)
+ {0x00, 0x00, 0x88, 0x88, 0xA8, 0xA8, 0x50},
+ // x (0x78)
+ {0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88},
+ // y (0x79)
+ {0x00, 0x00, 0x88, 0x88, 0x78, 0x08, 0x70},
+ // z (0x7A)
+ {0x00, 0x00, 0xF8, 0x10, 0x20, 0x40, 0xF8},
+ // { (0x7B)
+ {0x10, 0x20, 0x20, 0x40, 0x20, 0x20, 0x10},
+ // | (0x7C)
+ {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20},
+ // } (0x7D)
+ {0x40, 0x20, 0x20, 0x10, 0x20, 0x20, 0x40},
+ // ~ (0x7E)
+ {0x00, 0x00, 0x40, 0xA8, 0x10, 0x00, 0x00},
+}
+
+// drawChar renders a single ASCII character at (x, y) using filled rectangles.
+// Each pixel of the 5x7 glyph is drawn as a scale×scale block.
+func drawChar(renderer *sdl.Renderer, ch byte, x, y, scale int32) {
+ if ch < 0x20 || ch > 0x7E {
+ ch = '?' // replace unprintable characters
+ }
+ glyph := font5x7[ch-0x20]
+ for row := 0; row < glyphH; row++ {
+ bits := glyph[row]
+ for col := 0; col < glyphW; col++ {
+ if bits&(0x80>>uint(col)) != 0 {
+ renderer.FillRect(&sdl.Rect{
+ X: x + int32(col)*scale,
+ Y: y + int32(row)*scale,
+ W: scale,
+ H: scale,
+ })
+ }
+ }
+ }
+}
+
+// drawString renders a string at (x, y) with the given pixel scale.
+// Returns the total width in pixels consumed by the string.
+func drawString(renderer *sdl.Renderer, s string, x, y, scale int32) int32 {
+ cx := x
+ for i := 0; i < len(s); i++ {
+ drawChar(renderer, s[i], cx, y, scale)
+ cx += (glyphW + charGap) * scale
+ }
+ return cx - x
+}
+
+// stringWidth returns the pixel width of a string at the given scale.
+func stringWidth(s string, scale int32) int32 {
+ if len(s) == 0 {
+ return 0
+ }
+ return int32(len(s))*(glyphW+charGap)*scale - charGap*scale
+}
diff --git a/internal/display/hittest.go b/internal/display/hittest.go
new file mode 100644
index 0000000..fe88471
--- /dev/null
+++ b/internal/display/hittest.go
@@ -0,0 +1,82 @@
+package display
+
+import (
+ "codeberg.org/snonux/loadbars/internal/config"
+ "codeberg.org/snonux/loadbars/internal/stats"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+// barKind identifies what type of data a bar represents.
+type barKind int
+
+const (
+ barCPU barKind = iota
+ barMem
+ barNet
+)
+
+// barDescriptor describes a single bar's position, host, and type.
+type barDescriptor struct {
+ host string // hostname this bar belongs to
+ kind barKind // CPU, mem, or net
+ cpuName string // CPU name (e.g. "cpu", "cpu0"); only set for barCPU
+ rect sdl.Rect
+}
+
+// buildBarMap replays the same host/bar iteration as drawBars to produce
+// a slice of bar descriptors with their screen rectangles.
+func buildBarMap(snap map[string]*stats.HostStats, cfg *config.Config, state *runState) []barDescriptor {
+ numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet)
+ maxPerRow := cfg.MaxBarsPerRow
+ hosts := sortedHosts(snap)
+
+ bars := make([]barDescriptor, 0, numBars)
+ barIndex := 0
+ for _, host := range hosts {
+ h := snap[host]
+ if h == nil {
+ continue
+ }
+ cpuNames := sortedCPUNames(h.CPU, state.cpuMode)
+ for _, name := range cpuNames {
+ x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex)
+ bars = append(bars, barDescriptor{
+ host: host,
+ kind: barCPU,
+ cpuName: name,
+ rect: sdl.Rect{X: x, Y: y, W: w, H: bh},
+ })
+ barIndex++
+ }
+ if state.showMem {
+ x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex)
+ bars = append(bars, barDescriptor{
+ host: host,
+ kind: barMem,
+ rect: sdl.Rect{X: x, Y: y, W: w, H: bh},
+ })
+ barIndex++
+ }
+ if state.showNet {
+ x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex)
+ bars = append(bars, barDescriptor{
+ host: host,
+ kind: barNet,
+ rect: sdl.Rect{X: x, Y: y, W: w, H: bh},
+ })
+ barIndex++
+ }
+ }
+ return bars
+}
+
+// hitTest returns the bar descriptor under the given point, or nil if none.
+func hitTest(bars []barDescriptor, mx, my int32) *barDescriptor {
+ for i := range bars {
+ r := &bars[i].rect
+ if mx >= r.X && mx < r.X+r.W && my >= r.Y && my < r.Y+r.H {
+ return &bars[i]
+ }
+ }
+ return nil
+}
diff --git a/internal/display/tooltip.go b/internal/display/tooltip.go
new file mode 100644
index 0000000..42075e0
--- /dev/null
+++ b/internal/display/tooltip.go
@@ -0,0 +1,194 @@
+package display
+
+import (
+ "fmt"
+ "time"
+
+ "codeberg.org/snonux/loadbars/internal/config"
+ "codeberg.org/snonux/loadbars/internal/stats"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+// mouseIdleTimeout is how long the mouse must be idle before the tooltip
+// and host inversion overlay are hidden.
+const mouseIdleTimeout = 3 * time.Second
+
+const (
+ tooltipScale int32 = 2 // pixel scale for bitmap font
+ tooltipPadX int32 = 6 // horizontal padding inside tooltip box
+ tooltipPadY int32 = 4 // vertical padding inside tooltip box
+ tooltipOffsetX int32 = 12 // offset from cursor to tooltip
+ tooltipOffsetY int32 = 12
+)
+
+// tooltipLines builds the text lines to display for the hovered bar.
+func tooltipLines(bar *barDescriptor, snap map[string]*stats.HostStats, cfg *config.Config, state *runState) []string {
+ h := snap[bar.host]
+ if h == nil {
+ return []string{bar.host}
+ }
+ switch bar.kind {
+ case barCPU:
+ return cpuTooltipLines(bar, h, state)
+ case barMem:
+ return memTooltipLines(bar, h, state)
+ case barNet:
+ return netTooltipLines(bar, cfg, state)
+ }
+ return nil
+}
+
+// cpuTooltipLines returns tooltip text for a CPU bar showing smoothed percentages.
+func cpuTooltipLines(bar *barDescriptor, h *stats.HostStats, state *runState) []string {
+ lines := []string{fmt.Sprintf("%s [%s]", bar.host, bar.cpuName)}
+ key := bar.host + ";" + bar.cpuName
+ s := state.smoothedCPU[key]
+ if s == nil {
+ lines = append(lines, "No data yet")
+ return lines
+ }
+ // Indices match cpuBarTargetPcts: 0=sys 1=usr 2=nice 3=idle 4=io 5=irq 6=softirq 7=guest 8=steal 9=guestnice
+ lines = append(lines,
+ fmt.Sprintf("Sys: %5.1f%%", s[0]),
+ fmt.Sprintf("Usr: %5.1f%%", s[1]),
+ fmt.Sprintf("Nice: %5.1f%%", s[2]),
+ fmt.Sprintf("IO: %5.1f%%", s[4]),
+ fmt.Sprintf("Steal: %5.1f%%", s[8]),
+ fmt.Sprintf("Idle: %5.1f%%", s[3]),
+ )
+ return lines
+}
+
+// memTooltipLines returns tooltip text for a memory bar.
+func memTooltipLines(bar *barDescriptor, h *stats.HostStats, state *runState) []string {
+ lines := []string{fmt.Sprintf("%s [mem]", bar.host)}
+ sm := state.smoothedMem[bar.host]
+ if sm == nil || h.Mem == nil {
+ lines = append(lines, "No data yet")
+ return lines
+ }
+ memTotal := h.Mem["MemTotal"]
+ memFree := h.Mem["MemFree"]
+ swapTotal := h.Mem["SwapTotal"]
+ swapFree := h.Mem["SwapFree"]
+ lines = append(lines,
+ fmt.Sprintf("RAM: %s / %s", formatKB(memTotal-memFree), formatKB(memTotal)),
+ fmt.Sprintf(" %5.1f%%", sm.ramUsed),
+ fmt.Sprintf("Swap: %s / %s", formatKB(swapTotal-swapFree), formatKB(swapTotal)),
+ fmt.Sprintf(" %5.1f%%", sm.swapUsed),
+ )
+ return lines
+}
+
+// netTooltipLines returns tooltip text for a network bar.
+func netTooltipLines(bar *barDescriptor, cfg *config.Config, state *runState) []string {
+ lines := []string{fmt.Sprintf("%s [net]", bar.host)}
+ sm := state.smoothedNet[bar.host]
+ if sm == nil {
+ lines = append(lines, "No data yet")
+ return lines
+ }
+ lines = append(lines,
+ fmt.Sprintf("RX: %5.1f%%", sm.rxPct),
+ fmt.Sprintf("TX: %5.1f%%", sm.txPct),
+ fmt.Sprintf("Link: %s", cfg.NetLink),
+ )
+ return lines
+}
+
+// formatKB formats a value in KB as a human-readable string (KB, MB, or GB).
+func formatKB(kb int64) string {
+ switch {
+ case kb >= 1024*1024:
+ return fmt.Sprintf("%.1fG", float64(kb)/(1024*1024))
+ case kb >= 1024:
+ return fmt.Sprintf("%.1fM", float64(kb)/1024)
+ default:
+ return fmt.Sprintf("%dK", kb)
+ }
+}
+
+// drawTooltip renders the tooltip box with text lines near the cursor position,
+// clamped to stay within the window.
+func drawTooltip(renderer *sdl.Renderer, lines []string, mx, my, winW, winH int32) {
+ if len(lines) == 0 {
+ return
+ }
+ // Compute box dimensions from the longest line
+ lineH := glyphH*tooltipScale + 2 // 2px spacing between lines
+ maxW := int32(0)
+ for _, l := range lines {
+ w := stringWidth(l, tooltipScale)
+ if w > maxW {
+ maxW = w
+ }
+ }
+ boxW := maxW + tooltipPadX*2
+ boxH := int32(len(lines))*lineH + tooltipPadY*2 - 2 // subtract trailing spacing
+
+ // Position: prefer below-right of cursor, clamp to window
+ bx := mx + tooltipOffsetX
+ by := my + tooltipOffsetY
+ if bx+boxW > winW {
+ bx = mx - tooltipOffsetX - boxW
+ }
+ if by+boxH > winH {
+ by = my - tooltipOffsetY - boxH
+ }
+ if bx < 0 {
+ bx = 0
+ }
+ if by < 0 {
+ by = 0
+ }
+
+ // Dark background
+ renderer.SetDrawColor(0x18, 0x18, 0x18, 240)
+ renderer.FillRect(&sdl.Rect{X: bx, Y: by, W: boxW, H: boxH})
+ // Grey border (1px)
+ renderer.SetDrawColor(0x60, 0x60, 0x60, 255)
+ renderer.DrawRect(&sdl.Rect{X: bx, Y: by, W: boxW, H: boxH})
+
+ // Light text
+ renderer.SetDrawColor(0xE0, 0xE0, 0xE0, 255)
+ ty := by + tooltipPadY
+ for _, l := range lines {
+ drawString(renderer, l, bx+tooltipPadX, ty, tooltipScale)
+ ty += lineH
+ }
+}
+
+// invertHostBars draws a color-inversion overlay on all bars belonging to the given host.
+// Uses SDL custom blend mode: src factor = ONE_MINUS_DST_COLOR, so drawing white
+// produces (1 - dst) = inverted colors.
+func invertHostBars(renderer *sdl.Renderer, bars []barDescriptor, host string) {
+ invertBlend := sdl.ComposeCustomBlendMode(
+ sdl.BLENDFACTOR_ONE_MINUS_DST_COLOR, sdl.BLENDFACTOR_ZERO, sdl.BLENDOPERATION_ADD,
+ sdl.BLENDFACTOR_ONE, sdl.BLENDFACTOR_ZERO, sdl.BLENDOPERATION_ADD,
+ )
+ renderer.SetDrawBlendMode(invertBlend)
+ renderer.SetDrawColor(255, 255, 255, 255)
+ for i := range bars {
+ if bars[i].host == host {
+ renderer.FillRect(&bars[i].rect)
+ }
+ }
+ renderer.SetDrawBlendMode(sdl.BLENDMODE_NONE)
+}
+
+// drawOverlay handles the full mouse-over effect: builds the bar map, performs
+// hit testing, inverts the hovered host's bars, and draws the tooltip.
+func drawOverlay(renderer *sdl.Renderer, snap map[string]*stats.HostStats, cfg *config.Config, state *runState) {
+ // Hide tooltip and inversion when mouse has been idle for 3 seconds
+ if state.mouseLastMove.IsZero() || time.Since(state.mouseLastMove) > mouseIdleTimeout {
+ return
+ }
+ bars := buildBarMap(snap, cfg, state)
+ hit := hitTest(bars, state.mouseX, state.mouseY)
+ if hit == nil {
+ return
+ }
+ invertHostBars(renderer, bars, hit.host)
+ lines := tooltipLines(hit, snap, cfg, state)
+ drawTooltip(renderer, lines, state.mouseX, state.mouseY, state.winW, state.winH)
+}
diff --git a/internal/display/tooltip_test.go b/internal/display/tooltip_test.go
new file mode 100644
index 0000000..e4f6afc
--- /dev/null
+++ b/internal/display/tooltip_test.go
@@ -0,0 +1,587 @@
+package display
+
+import (
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/loadbars/internal/collector"
+ "codeberg.org/snonux/loadbars/internal/constants"
+ "codeberg.org/snonux/loadbars/internal/stats"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+// --- font tests ---
+
+func TestStringWidth(t *testing.T) {
+ // Single character: 5 pixels wide at scale 1
+ if w := stringWidth("A", 1); w != 5 {
+ t.Errorf("stringWidth(A, 1) = %d, want 5", w)
+ }
+ // Two characters at scale 1: 5 + gap(1) + 5 = 11, minus trailing gap = 11
+ if w := stringWidth("AB", 1); w != 11 {
+ t.Errorf("stringWidth(AB, 1) = %d, want 11", w)
+ }
+ // Scale 2: single char = 5*2 = 10
+ if w := stringWidth("A", 2); w != 10 {
+ t.Errorf("stringWidth(A, 2) = %d, want 10", w)
+ }
+ // Empty string
+ if w := stringWidth("", 1); w != 0 {
+ t.Errorf("stringWidth('', 1) = %d, want 0", w)
+ }
+}
+
+func TestDrawChar_RendersPixels(t *testing.T) {
+ // Draw the letter 'I' which has a recognizable pattern (center column lit)
+ renderer, surface, err := createTestRenderer(20, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ renderer.SetDrawColor(0, 0, 0, 255)
+ renderer.Clear()
+ renderer.SetDrawColor(255, 255, 255, 255)
+ drawChar(renderer, 'I', 0, 0, 1)
+ renderer.Present()
+
+ // 'I' glyph row 0 = 0x70 = 01110000 → pixels at columns 1,2,3 should be lit
+ r, g, b := getPixelColor(surface, 1, 0)
+ if r < 200 || g < 200 || b < 200 {
+ t.Errorf("expected white pixel at (1,0) for 'I' glyph, got RGB(%d,%d,%d)", r, g, b)
+ }
+ // Column 0 should be black (not part of 'I' top row)
+ r, g, b = getPixelColor(surface, 0, 0)
+ if r > 50 || g > 50 || b > 50 {
+ t.Errorf("expected black pixel at (0,0) for 'I' glyph, got RGB(%d,%d,%d)", r, g, b)
+ }
+}
+
+func TestDrawString_MultipleChars(t *testing.T) {
+ renderer, surface, err := createTestRenderer(40, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ renderer.SetDrawColor(0, 0, 0, 255)
+ renderer.Clear()
+ renderer.SetDrawColor(255, 255, 255, 255)
+ totalW := drawString(renderer, "Hi", 0, 0, 1)
+ renderer.Present()
+
+ // "Hi" at scale 1: 2 chars × (5+1) = 12 pixels total advance
+ if totalW != 12 {
+ t.Errorf("drawString returned width %d, want 12", totalW)
+ }
+ // Second character starts at x=6, verify it has some lit pixels
+ // 'i' glyph row 0 = 0x20 = col 2 → pixel at x=6+2=8
+ r, _, _ := getPixelColor(surface, 8, 0)
+ if r < 200 {
+ t.Errorf("expected lit pixel from second char at x=8, got R=%d", r)
+ }
+}
+
+// --- hit test tests ---
+
+func TestBuildBarMap_SingleHost(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "host1": {
+ CPU: map[string]collector.CPULine{"cpu": {}},
+ },
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+
+ bars := buildBarMap(snap, cfg, state)
+ if len(bars) != 1 {
+ t.Fatalf("expected 1 bar, got %d", len(bars))
+ }
+ if bars[0].host != "host1" || bars[0].kind != barCPU || bars[0].cpuName != "cpu" {
+ t.Errorf("unexpected bar: %+v", bars[0])
+ }
+ // Single bar should fill the whole window
+ if bars[0].rect.W != 200 || bars[0].rect.H != 100 {
+ t.Errorf("expected bar to fill window (200x100), got %dx%d", bars[0].rect.W, bars[0].rect.H)
+ }
+}
+
+func TestBuildBarMap_WithMemAndNet(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "alpha": {
+ CPU: map[string]collector.CPULine{"cpu": {}},
+ Mem: map[string]int64{"MemTotal": 1024},
+ Net: map[string]stats.NetStamp{"eth0": {}},
+ },
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 300, 100)
+ state.showMem = true
+ state.showNet = true
+
+ bars := buildBarMap(snap, cfg, state)
+ if len(bars) != 3 {
+ t.Fatalf("expected 3 bars (cpu+mem+net), got %d", len(bars))
+ }
+ if bars[0].kind != barCPU {
+ t.Errorf("bar 0 should be CPU, got %d", bars[0].kind)
+ }
+ if bars[1].kind != barMem {
+ t.Errorf("bar 1 should be Mem, got %d", bars[1].kind)
+ }
+ if bars[2].kind != barNet {
+ t.Errorf("bar 2 should be Net, got %d", bars[2].kind)
+ }
+}
+
+func TestBuildBarMap_MultiHost(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "alpha": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ "beta": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+
+ bars := buildBarMap(snap, cfg, state)
+ if len(bars) != 2 {
+ t.Fatalf("expected 2 bars, got %d", len(bars))
+ }
+ // Sorted order: alpha first
+ if bars[0].host != "alpha" || bars[1].host != "beta" {
+ t.Errorf("expected alpha then beta, got %s then %s", bars[0].host, bars[1].host)
+ }
+}
+
+func TestBuildBarMap_WithCores(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "host1": {
+ CPU: map[string]collector.CPULine{
+ "cpu": {},
+ "cpu0": {},
+ "cpu1": {},
+ },
+ },
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 300, 100)
+ state.cpuMode = constants.CPUModeCores
+
+ bars := buildBarMap(snap, cfg, state)
+ if len(bars) != 3 {
+ t.Fatalf("expected 3 bars (cpu + cpu0 + cpu1), got %d", len(bars))
+ }
+ if bars[0].cpuName != "cpu" {
+ t.Errorf("first CPU bar should be aggregate 'cpu', got %s", bars[0].cpuName)
+ }
+}
+
+func TestHitTest_Hit(t *testing.T) {
+ bars := []barDescriptor{
+ {host: "h1", kind: barCPU, rect: sdl.Rect{X: 0, Y: 0, W: 100, H: 100}},
+ {host: "h2", kind: barCPU, rect: sdl.Rect{X: 100, Y: 0, W: 100, H: 100}},
+ }
+ hit := hitTest(bars, 50, 50)
+ if hit == nil || hit.host != "h1" {
+ t.Errorf("expected hit on h1 at (50,50), got %v", hit)
+ }
+ hit = hitTest(bars, 150, 50)
+ if hit == nil || hit.host != "h2" {
+ t.Errorf("expected hit on h2 at (150,50), got %v", hit)
+ }
+}
+
+func TestHitTest_Miss(t *testing.T) {
+ bars := []barDescriptor{
+ {host: "h1", kind: barCPU, rect: sdl.Rect{X: 0, Y: 0, W: 100, H: 100}},
+ }
+ hit := hitTest(bars, -1, -1)
+ if hit != nil {
+ t.Errorf("expected nil for off-screen coords, got %v", hit)
+ }
+ hit = hitTest(bars, 200, 50)
+ if hit != nil {
+ t.Errorf("expected nil for point outside bars, got %v", hit)
+ }
+}
+
+func TestHitTest_EdgeCases(t *testing.T) {
+ bars := []barDescriptor{
+ {host: "h1", kind: barCPU, rect: sdl.Rect{X: 10, Y: 10, W: 50, H: 50}},
+ }
+ // Top-left corner (inclusive)
+ hit := hitTest(bars, 10, 10)
+ if hit == nil {
+ t.Error("expected hit at top-left corner (10,10)")
+ }
+ // Bottom-right edge (exclusive)
+ hit = hitTest(bars, 60, 60)
+ if hit != nil {
+ t.Error("expected miss at bottom-right edge (60,60)")
+ }
+}
+
+// --- tooltip content tests ---
+
+func TestTooltipLines_CPU(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "myhost": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ // Pre-populate smoothed data
+ state.smoothedCPU["myhost;cpu"] = &[10]float64{10.0, 20.0, 5.0, 60.0, 3.0, 0, 0, 0, 2.0, 0}
+
+ bar := &barDescriptor{host: "myhost", kind: barCPU, cpuName: "cpu"}
+ lines := tooltipLines(bar, snap, cfg, state)
+
+ if len(lines) < 2 {
+ t.Fatalf("expected at least 2 lines, got %d", len(lines))
+ }
+ if lines[0] != "myhost [cpu]" {
+ t.Errorf("first line = %q, want %q", lines[0], "myhost [cpu]")
+ }
+ // Check that sys/usr/idle lines are present
+ found := map[string]bool{}
+ for _, l := range lines {
+ if len(l) >= 3 {
+ found[l[:3]] = true
+ }
+ }
+ for _, prefix := range []string{"Sys", "Usr", "Nic", "IO:", "Ste", "Idl"} {
+ if !found[prefix] {
+ t.Errorf("missing line with prefix %q in tooltip", prefix)
+ }
+ }
+}
+
+func TestTooltipLines_Mem(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "myhost": {
+ CPU: map[string]collector.CPULine{"cpu": {}},
+ Mem: map[string]int64{
+ "MemTotal": 8*1024*1024, // 8 GB in KB
+ "MemFree": 2*1024*1024,
+ "SwapTotal": 4*1024*1024,
+ "SwapFree": 3*1024*1024,
+ },
+ },
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ state.smoothedMem["myhost"] = &struct{ ramUsed, swapUsed float64 }{75.0, 25.0}
+
+ bar := &barDescriptor{host: "myhost", kind: barMem}
+ lines := tooltipLines(bar, snap, cfg, state)
+
+ if len(lines) < 3 {
+ t.Fatalf("expected at least 3 lines, got %d", len(lines))
+ }
+ if lines[0] != "myhost [mem]" {
+ t.Errorf("first line = %q, want %q", lines[0], "myhost [mem]")
+ }
+}
+
+func TestTooltipLines_Net(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "myhost": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ cfg.NetLink = "gbit"
+ state := newRunState(cfg, 200, 100)
+ state.smoothedNet["myhost"] = &struct{ rxPct, txPct float64 }{12.5, 3.2}
+
+ bar := &barDescriptor{host: "myhost", kind: barNet}
+ lines := tooltipLines(bar, snap, cfg, state)
+
+ if len(lines) < 3 {
+ t.Fatalf("expected at least 3 lines, got %d", len(lines))
+ }
+ if lines[0] != "myhost [net]" {
+ t.Errorf("first line = %q, want %q", lines[0], "myhost [net]")
+ }
+}
+
+func TestTooltipLines_NoData(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "myhost": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ // Don't populate smoothedCPU → should get "No data yet"
+
+ bar := &barDescriptor{host: "myhost", kind: barCPU, cpuName: "cpu"}
+ lines := tooltipLines(bar, snap, cfg, state)
+
+ if len(lines) < 2 {
+ t.Fatalf("expected at least 2 lines, got %d", len(lines))
+ }
+ if lines[1] != "No data yet" {
+ t.Errorf("expected 'No data yet', got %q", lines[1])
+ }
+}
+
+// --- formatKB tests ---
+
+func TestFormatKB(t *testing.T) {
+ tests := []struct {
+ kb int64
+ want string
+ }{
+ {500, "500K"},
+ {2048, "2.0M"},
+ {1024 * 1024, "1.0G"},
+ {8 * 1024 * 1024, "8.0G"},
+ }
+ for _, tc := range tests {
+ got := formatKB(tc.kb)
+ if got != tc.want {
+ t.Errorf("formatKB(%d) = %q, want %q", tc.kb, got, tc.want)
+ }
+ }
+}
+
+// --- drawTooltip rendering test ---
+
+func TestDrawTooltip_RendersBox(t *testing.T) {
+ renderer, surface, err := createTestRenderer(200, 200)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ renderer.SetDrawColor(0, 0, 0, 255)
+ renderer.Clear()
+
+ lines := []string{"Host: test", "CPU: 50%"}
+ drawTooltip(renderer, lines, 10, 10, 200, 200)
+ renderer.Present()
+
+ // The tooltip background (#181818) should be visible near cursor + offset
+ bx := int32(10 + tooltipOffsetX + 1)
+ by := int32(10 + tooltipOffsetY + 1)
+ r, g, b := getPixelColor(surface, bx, by)
+ // Should be near #181818 (dark grey background) or text color
+ if r > 0x30 && g > 0x30 && b > 0x30 {
+ // If it's bright, it might be text — that's also fine
+ }
+ // Just verify it's not pure black (meaning something was drawn)
+ if r == 0 && g == 0 && b == 0 {
+ t.Errorf("expected tooltip content at (%d,%d), but pixel is pure black", bx, by)
+ }
+}
+
+func TestDrawTooltip_ClampsToWindow(t *testing.T) {
+ renderer, surface, err := createTestRenderer(100, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ renderer.SetDrawColor(0, 0, 0, 255)
+ renderer.Clear()
+
+ // Place cursor near bottom-right corner; tooltip should flip to stay in bounds
+ lines := []string{"Very long text line here"}
+ drawTooltip(renderer, lines, 90, 90, 100, 100)
+ renderer.Present()
+
+ // Tooltip should be positioned to the left/above cursor
+ // Check that something was drawn in the upper area (not just bottom-right)
+ foundDrawn := false
+ for y := int32(0); y < 80; y += 10 {
+ for x := int32(0); x < 80; x += 10 {
+ r, g, b := getPixelColor(surface, x, y)
+ if r != 0 || g != 0 || b != 0 {
+ foundDrawn = true
+ break
+ }
+ }
+ if foundDrawn {
+ break
+ }
+ }
+ if !foundDrawn {
+ t.Error("expected tooltip to be clamped and visible in upper area, but found nothing drawn")
+ }
+}
+
+// --- invertHostBars test ---
+
+func TestInvertHostBars_InvertsCorrectHost(t *testing.T) {
+ renderer, surface, err := createTestRenderer(200, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ // Draw a blue bar for host1 (left half) and a green bar for host2 (right half)
+ renderer.SetDrawColor(0, 0, 200, 255)
+ renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 100, H: 100})
+ renderer.SetDrawColor(0, 200, 0, 255)
+ renderer.FillRect(&sdl.Rect{X: 100, Y: 0, W: 100, H: 100})
+
+ bars := []barDescriptor{
+ {host: "host1", kind: barCPU, rect: sdl.Rect{X: 0, Y: 0, W: 100, H: 100}},
+ {host: "host2", kind: barCPU, rect: sdl.Rect{X: 100, Y: 0, W: 100, H: 100}},
+ }
+
+ // Invert host1 only
+ invertHostBars(renderer, bars, "host1")
+ renderer.Present()
+
+ // host1 area: blue (0,0,200) should become inverted (255, 255, 55)
+ r1, g1, b1 := getPixelColor(surface, 50, 50)
+ // host2 area: green (0,200,0) should remain unchanged
+ r2, g2, b2 := getPixelColor(surface, 150, 50)
+
+ // Host1 blue was inverted: R should jump from 0 to ~255
+ if r1 < 200 {
+ t.Errorf("host1 after inversion: expected R>200 (inverted blue), got R=%d (full: %d,%d,%d)", r1, r1, g1, b1)
+ }
+ // Host2 green should stay green (R near 0)
+ if r2 > 50 {
+ t.Errorf("host2 should not be inverted: expected R<50, got R=%d (full: %d,%d,%d)", r2, r2, g2, b2)
+ }
+ _ = g1
+ _ = b1
+ _ = g2
+ _ = b2
+}
+
+// --- drawOverlay integration test ---
+
+func TestDrawOverlay_NoTooltipWhenMouseOffScreen(t *testing.T) {
+ renderer, surface, err := createTestRenderer(100, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ snap := map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 100, 100)
+ // mouseX/mouseY default to -1 (off-screen)
+
+ // Draw a solid blue background to detect any overlay changes
+ renderer.SetDrawColor(0, 0, constants.Blue.B, 255)
+ renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 100, H: 100})
+
+ drawOverlay(renderer, snap, cfg, state)
+ renderer.Present()
+
+ // Center pixel should still be blue (no inversion or tooltip drawn)
+ r, g, b := getPixelColor(surface, 50, 50)
+ if r > 10 || g > 10 {
+ t.Errorf("expected blue pixel (no overlay) at (50,50), got RGB(%d,%d,%d)", r, g, b)
+ }
+}
+
+func TestDrawOverlay_TooltipWhenMouseOnBar(t *testing.T) {
+ renderer, surface, err := createTestRenderer(200, 200)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ snap := map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 200)
+ state.mouseX = 50
+ state.mouseY = 50
+ state.mouseLastMove = time.Now() // simulate recent mouse activity
+ state.smoothedCPU["host1;cpu"] = &[10]float64{10, 20, 5, 60, 3, 0, 0, 0, 2, 0}
+
+ // Draw a solid blue background
+ renderer.SetDrawColor(0, 0, 200, 255)
+ renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 200, H: 200})
+
+ drawOverlay(renderer, snap, cfg, state)
+ renderer.Present()
+
+ // Tooltip area should have tooltip background or text drawn.
+ // Note: custom blend modes (used for host inversion) may not work with
+ // software renderers, so we only verify the tooltip box was rendered.
+ tx := int32(50 + tooltipOffsetX + 2)
+ ty := int32(50 + tooltipOffsetY + 2)
+ if tx < 200 && ty < 200 {
+ tr, tg, tb := getPixelColor(surface, tx, ty)
+ // Should be tooltip background ~(0x18,0x18,0x18) or text color, not the original blue
+ isOriginalBlue := tr < 10 && tg < 10 && tb > 150
+ if isOriginalBlue {
+ t.Errorf("tooltip pixel at (%d,%d): still original blue RGB(%d,%d,%d), expected tooltip content", tx, ty, tr, tg, tb)
+ }
+ }
+}
+
+// --- mouse idle timeout test ---
+
+func TestDrawOverlay_HiddenAfterIdleTimeout(t *testing.T) {
+ renderer, surface, err := createTestRenderer(200, 200)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ snap := map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 200)
+ state.mouseX = 50
+ state.mouseY = 50
+ // Set mouseLastMove to 4 seconds ago (beyond the 3s idle timeout)
+ state.mouseLastMove = time.Now().Add(-4 * time.Second)
+ state.smoothedCPU["host1;cpu"] = &[10]float64{10, 20, 5, 60, 3, 0, 0, 0, 2, 0}
+
+ // Draw a solid blue background
+ renderer.SetDrawColor(0, 0, 200, 255)
+ renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 200, H: 200})
+
+ drawOverlay(renderer, snap, cfg, state)
+ renderer.Present()
+
+ // Mouse is idle > 3s, so no tooltip or inversion should be drawn.
+ // The pixel should remain the original blue.
+ r, g, b := getPixelColor(surface, 50, 50)
+ if r > 10 || g > 10 {
+ t.Errorf("expected original blue at (50,50) after idle timeout, got RGB(%d,%d,%d)", r, g, b)
+ }
+}
+
+// --- multi-row hit test ---
+
+func TestBuildBarMap_MultiRow(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "a": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ "b": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ "c": {CPU: map[string]collector.CPULine{"cpu": {}}},
+ }
+ cfg := defaultTestConfig()
+ cfg.MaxBarsPerRow = 2
+ state := newRunState(cfg, 200, 200)
+
+ bars := buildBarMap(snap, cfg, state)
+ if len(bars) != 3 {
+ t.Fatalf("expected 3 bars, got %d", len(bars))
+ }
+ // First row: bars 0 and 1 (hosts a, b) should have Y=0
+ if bars[0].rect.Y != 0 || bars[1].rect.Y != 0 {
+ t.Errorf("first row bars should start at Y=0, got Y=%d and Y=%d", bars[0].rect.Y, bars[1].rect.Y)
+ }
+ // Second row: bar 2 (host c) should have Y > 0
+ if bars[2].rect.Y <= 0 {
+ t.Errorf("second row bar should have Y>0, got Y=%d", bars[2].rect.Y)
+ }
+}