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 /internal | |
| 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>
Diffstat (limited to 'internal')
| -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 |
15 files changed, 1329 insertions, 109 deletions
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}, + |
