summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-16 22:39:30 +0200
committerPaul Buetow <paul@buetow.org>2026-02-16 22:39:30 +0200
commit971928faff0c100ef591c2d0e92e94b9f46ae71a (patch)
treeaad62242e6107a8deefa310112c88d0409535a86 /internal
parent96b15cd512f6f38603e1e3d20afabd8c6f303079 (diff)
Add global average CPU line toggled with hotkey gv0.10.0
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 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go12
-rw-r--r--internal/config/config_test.go37
-rw-r--r--internal/display/display.go53
-rw-r--r--internal/display/display_test.go129
-rw-r--r--internal/version/version.go2
5 files changed, 227 insertions, 6 deletions
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"