From 971928faff0c100ef591c2d0e92e94b9f46ae71a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 16 Feb 2026 22:39:30 +0200 Subject: Add global average CPU line toggled with hotkey g Draw a 1px red horizontal line spanning the full window width showing the mean CPU usage across all monitored hosts. Toggled with 'g' hotkey and persistable to ~/.loadbarsrc via 'w'. Bump version to 0.10.0. Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + internal/config/config.go | 12 ++-- internal/config/config_test.go | 37 +++++++++++ internal/display/display.go | 53 +++++++++++++++- internal/display/display_test.go | 129 +++++++++++++++++++++++++++++++++++++++ internal/version/version.go | 2 +- 6 files changed, 228 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3c2c6cf..836b8df 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Press these keys while loadbars is running (see also `h` for a short list on std | **2** | Toggle memory bars (RAM left, Swap right per host) | | **3** | Toggle network bars (RX/TX summed across all non-lo interfaces per host) | | **e** | Toggle extended display (1px peak line on CPU bars: max system+user over last samples) | +| **g** | Toggle global average CPU line (1px red line showing mean CPU usage across all hosts) | | **h** | Print hotkey list to stdout | | **q** | Quit | | **w** | Write current settings to ~/.loadbarsrc | diff --git a/internal/config/config.go b/internal/config/config.go index 0fc80d4..036d081 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,9 +24,10 @@ type Config struct { MaxWidth int NetAverage int NetLink string - ShowCores bool - ShowMem bool - ShowNet bool + ShowAvgLine bool + ShowCores bool + ShowMem bool + ShowNet bool SSHOpts string Cluster string } @@ -104,7 +105,7 @@ func (c *Config) parseReader(f *os.File) error { "title": true, "barwidth": true, "cpuaverage": true, "extended": true, "hasagent": true, "height": true, "maxwidth": true, "netaverage": true, "netlink": true, "showcores": true, "showmem": true, - "shownet": true, "sshopts": true, "cluster": true, + "showavgline": true, "shownet": true, "sshopts": true, "cluster": true, } scanner := bufio.NewScanner(f) for scanner.Scan() { @@ -159,6 +160,8 @@ func (c *Config) set(key, val string) { } case "netlink": c.NetLink = val + case "showavgline": + c.ShowAvgLine = parseBool(val) case "showcores": c.ShowCores = parseBool(val) case "showmem": @@ -196,6 +199,7 @@ func (c *Config) writeTo(f *os.File) error { writeInt("maxwidth", c.MaxWidth) writeInt("netaverage", c.NetAverage) writeStr("netlink", c.NetLink) + writeBool("showavgline", c.ShowAvgLine) writeBool("showcores", c.ShowCores) writeBool("showmem", c.ShowMem) writeBool("shownet", c.ShowNet) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dfa499c..db0e068 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -78,6 +78,43 @@ func TestConfig_writeTo(t *testing.T) { } } +func TestConfig_showAvgLineRoundTrip(t *testing.T) { + // Write a config with showavgline=1, read it back, verify round-trip + c := Default() + c.ShowAvgLine = true + + dir := t.TempDir() + path := filepath.Join(dir, "rc") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := c.writeTo(f); err != nil { + f.Close() + t.Fatal(err) + } + f.Close() + + data, _ := os.ReadFile(path) + if !bytes.Contains(data, []byte("showavgline=1")) { + t.Errorf("expected showavgline=1 in output, got:\n%s", data) + } + + // Read it back + c2 := Default() + f2, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f2.Close() + if err := c2.parseReader(f2); err != nil { + t.Fatal(err) + } + if !c2.ShowAvgLine { + t.Error("expected ShowAvgLine=true after round-trip") + } +} + func TestGetClusterHostsFromFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "clusters") diff --git a/internal/display/display.go b/internal/display/display.go index 5fffb2b..649f636 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -21,6 +21,7 @@ const smoothFactor = 0.12 // blend toward target each frame; lower = smoother // runState holds mutable state across the display loop (hotkeys, window size, smoothed data). type runState struct { + showAvgLine bool showCores bool showMem bool showNet bool @@ -38,6 +39,7 @@ type runState struct { // newRunState builds initial run state from config. func newRunState(cfg *config.Config, winW, winH int32) *runState { return &runState{ + showAvgLine: cfg.ShowAvgLine, showCores: cfg.ShowCores, showMem: cfg.ShowMem, showNet: cfg.ShowNet, @@ -150,6 +152,9 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r case sdl.K_e: state.extended = !state.extended fmt.Println("==> Toggled extended (peak line):", state.extended) + case sdl.K_g: + state.showAvgLine = !state.showAvgLine + fmt.Println("==> Toggled global avg line:", state.showAvgLine) case sdl.K_a: cfg.CPUAverage++ fmt.Println("==> CPU average samples:", cfg.CPUAverage) @@ -173,6 +178,7 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r case sdl.K_h: printHotkeys() case sdl.K_w: + cfg.ShowAvgLine = state.showAvgLine cfg.ShowCores = state.showCores cfg.ShowMem = state.showMem cfg.ShowNet = state.showNet @@ -220,6 +226,7 @@ func barBounds(winW int32, numBars int, barIndex int) (x int32, width int32) { } // drawFrame updates state from snapshot, clears if layout changed, and draws all bars. +// When showAvgLine is enabled, a global average CPU line is 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) @@ -228,6 +235,9 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta renderer.SetDrawColor(0, 0, 0, 255) renderer.Clear() drawBars(renderer, snap, cfg, state, numBars) + if state.showAvgLine { + drawGlobalAvgLine(renderer, snap, state) + } } func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet bool) int { @@ -261,6 +271,47 @@ func drawBars(renderer *sdl.Renderer, snap map[string]*stats.HostStats, cfg *con } } +// drawGlobalAvgLine draws a 1px red horizontal line spanning the full window width +// at the Y position corresponding to the mean CPU usage across all hosts. +// CPU usage per host is the sum of all smoothed segments except idle (index 3). +func drawGlobalAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStats, state *runState) { + var totalUsage float64 + var hostCount int + for _, host := range sortedHosts(snap) { + h := snap[host] + if h == nil { + continue + } + key := host + ";cpu" + s := state.smoothedCPU[key] + if s == nil { + continue + } + // Sum all segments except idle (index 3) to get total CPU usage + var usage float64 + for i := 0; i < 9; i++ { + if i != 3 { + usage += (*s)[i] + } + } + totalUsage += usage + hostCount++ + } + if hostCount == 0 { + return + } + avgPct := totalUsage / float64(hostCount) + lineY := state.winH - int32(avgPct*float64(state.winH)/100) + if lineY < 0 { + lineY = 0 + } + if lineY >= state.winH { + lineY = state.winH - 1 + } + renderer.SetDrawColor(constants.Red.R, constants.Red.G, constants.Red.B, 255) + renderer.FillRect(&sdl.Rect{X: 0, Y: lineY, W: state.winW, H: 1}) +} + // drawHostBars draws CPU, mem, and net bars for one host and advances barIndex. func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *config.Config, state *runState, numBars int, barIndex *int) { winH := state.winH @@ -513,7 +564,7 @@ func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *st } func printHotkeys() { - fmt.Println("=> Hotkeys: 1=cores 2=mem 3=net e=extended h=help q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize") + fmt.Println("=> Hotkeys: 1=cores 2=mem 3=net e=extended g=avg line h=help q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize") } diff --git a/internal/display/display_test.go b/internal/display/display_test.go index 5417637..f0377b5 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -817,6 +817,7 @@ func TestHandleKey_WriteConfig(t *testing.T) { cfg := defaultTestConfig() state := newRunState(cfg, 200, 100) // Modify state values that should be copied to config + state.showAvgLine = true state.showCores = true state.showMem = true state.showNet = true @@ -824,6 +825,9 @@ func TestHandleKey_WriteConfig(t *testing.T) { handleKey(sdl.K_w, nil, cfg, state) + if !cfg.ShowAvgLine { + t.Error("expected ShowAvgLine=true in config after 'w'") + } if !cfg.ShowCores { t.Error("expected ShowCores=true in config after 'w'") } @@ -883,6 +887,131 @@ func TestHandleKey_LinkScaleDown(t *testing.T) { } } +func TestHandleKey_ToggleAvgLine(t *testing.T) { + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + if state.showAvgLine { + t.Fatal("expected showAvgLine=false initially") + } + handleKey(sdl.K_g, nil, cfg, state) + if !state.showAvgLine { + t.Fatal("expected showAvgLine=true after pressing g") + } + handleKey(sdl.K_g, nil, cfg, state) + if state.showAvgLine { + t.Fatal("expected showAvgLine=false after pressing g again") + } +} + +func TestGlobalAvgLine_SingleHost(t *testing.T) { + // One host at 80% CPU → red line at y = 100 - 80 = 20 + const w, h int32 = 100, 100 + + renderer, surface, err := createTestRenderer(w, h) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + prev, cur := makeCPUPair(40, 40, 20) // 80% used (40 sys + 40 user) + cfg := defaultTestConfig() + cfg.ShowCores = false + cfg.ShowMem = false + cfg.ShowNet = false + + src := &mockSource{ + data: map[string]*stats.HostStats{ + "host1": {CPU: map[string]collector.CPULine{"cpu": cur}}, + }, + } + + state := newRunState(cfg, w, h) + state.showAvgLine = true + state.prevCPU["host1;cpu"] = prev + + drawFrame(renderer, src, cfg, state) + + // Red line at y=20 (100 - 80%) + assertPixelColor(t, surface, 50, 20, constants.Red, 3, "avg line at y=20") + // Check it spans the full width + assertPixelColor(t, surface, 0, 20, constants.Red, 3, "avg line at x=0") + assertPixelColor(t, surface, 99, 20, constants.Red, 3, "avg line at x=99") +} + +func TestGlobalAvgLine_MultiHost(t *testing.T) { + // Two hosts: 80% + 40% → average 60% → red line at y = 100 - 60 = 40 + const w, h int32 = 100, 100 + + renderer, surface, err := createTestRenderer(w, h) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + prev1, cur1 := makeCPUPair(40, 40, 20) // 80% used + prev2, cur2 := makeCPUPair(20, 20, 60) // 40% used + + cfg := defaultTestConfig() + cfg.ShowCores = false + cfg.ShowMem = false + cfg.ShowNet = false + + src := &mockSource{ + data: map[string]*stats.HostStats{ + "alpha": {CPU: map[string]collector.CPULine{"cpu": cur1}}, + "beta": {CPU: map[string]collector.CPULine{"cpu": cur2}}, + }, + } + + state := newRunState(cfg, w, h) + state.showAvgLine = true + state.prevCPU["alpha;cpu"] = prev1 + state.prevCPU["beta;cpu"] = prev2 + + drawFrame(renderer, src, cfg, state) + + // Average 60% → line at y=40 + assertPixelColor(t, surface, 50, 40, constants.Red, 3, "avg line at y=40") +} + +func TestGlobalAvgLine_Disabled(t *testing.T) { + // With showAvgLine=false, the line position should remain black (background) + const w, h int32 = 100, 100 + + renderer, surface, err := createTestRenderer(w, h) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + prev, cur := makeCPUPair(40, 40, 20) // 80% used + cfg := defaultTestConfig() + cfg.ShowCores = false + cfg.ShowMem = false + cfg.ShowNet = false + + src := &mockSource{ + data: map[string]*stats.HostStats{ + "host1": {CPU: map[string]collector.CPULine{"cpu": cur}}, + }, + } + + state := newRunState(cfg, w, h) + state.showAvgLine = false + state.prevCPU["host1;cpu"] = prev + + drawFrame(renderer, src, cfg, state) + + // At y=20, the CPU bar has user color (yellow), not red + r, g, b := getPixelColor(surface, 50, 20) + if r == constants.Red.R && g == constants.Red.G && b == constants.Red.B { + t.Errorf("expected no red avg line at y=20 when disabled, got RGB(%d,%d,%d)", r, g, b) + } +} + func TestHandleKey_ArrowResize(t *testing.T) { // Arrow keys require a window for SetSize. Create a real dummy SDL window. window, err := sdl.CreateWindow("test", 0, 0, 200, 100, sdl.WINDOW_HIDDEN) diff --git a/internal/version/version.go b/internal/version/version.go index 01de152..1cb63c3 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,4 +1,4 @@ package version // Version is the application version (set at build time or here for development). -const Version = "0.9.1" +const Version = "0.10.0" -- cgit v1.2.3