diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-18 09:00:35 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-18 09:00:35 +0200 |
| commit | 88f4e239a7521112a4db8c7842e3a05db4446cd4 (patch) | |
| tree | 8c331f9f2e23ad9c9319d6dc8275205b23ce811a | |
| parent | 11204092b5ab5dc0f71515adfcaa6f07111363e5 (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.go | 4 | ||||
| -rw-r--r-- | cmd/loadbars/main.go | 2 | ||||
| -rw-r--r-- | internal/collector/parse_test.go | 34 | ||||
| -rw-r--r-- | internal/collector/script_embed.go | 9 | ||||
| -rw-r--r-- | internal/collector/scriptdata/loadbars-remote.sh | 35 | ||||
| -rw-r--r-- | internal/collector/types.go | 2 | ||||
| -rw-r--r-- | internal/config/config.go | 20 | ||||
| -rw-r--r-- | internal/config/config_test.go | 9 | ||||
| -rw-r--r-- | internal/constants/constants.go | 10 | ||||
| -rw-r--r-- | internal/display/activate.go | 2 | ||||
| -rw-r--r-- | internal/display/activate_darwin.go | 2 | ||||
| -rw-r--r-- | internal/display/display.go | 90 | ||||
| -rw-r--r-- | internal/display/display_test.go | 114 | ||||
| -rw-r--r-- | internal/display/font.go | 248 | ||||
| -rw-r--r-- | internal/display/hittest.go | 82 | ||||
| -rw-r--r-- | internal/display/tooltip.go | 194 | ||||
| -rw-r--r-- | internal/display/tooltip_test.go | 587 |
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) + } +} |
