summaryrefslogtreecommitdiff
path: root/internal/display
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-16 22:53:17 +0200
committerPaul Buetow <paul@buetow.org>2026-02-16 22:53:17 +0200
commit6798b669464d828c241554647b4fff68a62ca91d (patch)
tree706fc2da37b27b89649afe650ec699170191ecb1 /internal/display
parent971928faff0c100ef591c2d0e92e94b9f46ae71a (diff)
Add global I/O avg line (hotkey i) and m/n hotkey aliases
Add a pink 1px line drawn from the top showing mean iowait+IRQ+softIRQ across all hosts, toggled with hotkey i and persistable to ~/.loadbarsrc. Also add m as alias for 2 (memory toggle) and n as alias for 3 (network toggle) for easier single-hand operation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/display')
-rw-r--r--internal/display/display.go61
-rw-r--r--internal/display/display_test.go196
2 files changed, 249 insertions, 8 deletions
diff --git a/internal/display/display.go b/internal/display/display.go
index 649f636..fe5340e 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -21,8 +21,9 @@ 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
+ showAvgLine bool
+ showIOAvgLine bool
+ showCores bool
showMem bool
showNet bool
extended bool
@@ -39,8 +40,9 @@ 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,
+ showAvgLine: cfg.ShowAvgLine,
+ showIOAvgLine: cfg.ShowIOAvgLine,
+ showCores: cfg.ShowCores,
showMem: cfg.ShowMem,
showNet: cfg.ShowNet,
extended: cfg.Extended,
@@ -143,10 +145,10 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_1:
state.showCores = !state.showCores
fmt.Println("==> Toggled show cores:", state.showCores)
- case sdl.K_2:
+ case sdl.K_2, sdl.K_m:
state.showMem = !state.showMem
fmt.Println("==> Toggled show mem:", state.showMem)
- case sdl.K_3:
+ case sdl.K_3, sdl.K_n:
state.showNet = !state.showNet
fmt.Println("==> Toggled show net:", state.showNet)
case sdl.K_e:
@@ -155,6 +157,9 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_g:
state.showAvgLine = !state.showAvgLine
fmt.Println("==> Toggled global avg line:", state.showAvgLine)
+ case sdl.K_i:
+ state.showIOAvgLine = !state.showIOAvgLine
+ fmt.Println("==> Toggled global I/O avg line:", state.showIOAvgLine)
case sdl.K_a:
cfg.CPUAverage++
fmt.Println("==> CPU average samples:", cfg.CPUAverage)
@@ -179,6 +184,7 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
printHotkeys()
case sdl.K_w:
cfg.ShowAvgLine = state.showAvgLine
+ cfg.ShowIOAvgLine = state.showIOAvgLine
cfg.ShowCores = state.showCores
cfg.ShowMem = state.showMem
cfg.ShowNet = state.showNet
@@ -226,7 +232,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.
+// 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)
@@ -238,6 +244,9 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta
if state.showAvgLine {
drawGlobalAvgLine(renderer, snap, state)
}
+ if state.showIOAvgLine {
+ drawGlobalIOAvgLine(renderer, snap, state)
+ }
}
func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet bool) int {
@@ -312,6 +321,42 @@ func drawGlobalAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStats,
renderer.FillRect(&sdl.Rect{X: 0, Y: lineY, W: state.winW, H: 1})
}
+// drawGlobalIOAvgLine draws a 1px pink horizontal line from the top of the window
+// at the Y position corresponding to the mean I/O overhead (iowait + IRQ + softIRQ,
+// indices 4, 5, 6 in the smoothed CPU array) across all hosts.
+func drawGlobalIOAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStats, state *runState) {
+ var totalIO 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 iowait (4) + IRQ (5) + softIRQ (6) for I/O overhead
+ totalIO += (*s)[4] + (*s)[5] + (*s)[6]
+ hostCount++
+ }
+ if hostCount == 0 {
+ return
+ }
+ avgPct := totalIO / float64(hostCount)
+ // Draw from top: lineY = percentage of window height from the top
+ lineY := int32(avgPct * float64(state.winH) / 100)
+ if lineY < 0 {
+ lineY = 0
+ }
+ if lineY >= state.winH {
+ lineY = state.winH - 1
+ }
+ renderer.SetDrawColor(constants.Pink.R, constants.Pink.G, constants.Pink.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
@@ -564,7 +609,7 @@ func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *st
}
func printHotkeys() {
- 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")
+ fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net e=extended g=avg line i=io avg 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 f0377b5..734e144 100644
--- a/internal/display/display_test.go
+++ b/internal/display/display_test.go
@@ -710,6 +710,23 @@ func TestHandleKey_ToggleMem(t *testing.T) {
assertPixelColor(t, surface, 110, 95, constants.DarkGrey, 5, "mem bar RAM after toggle")
}
+func TestHandleKey_ToggleMemAlias(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ if state.showMem {
+ t.Fatal("expected showMem=false initially")
+ }
+ // 'm' should toggle mem just like '2'
+ handleKey(sdl.K_m, nil, cfg, state)
+ if !state.showMem {
+ t.Fatal("expected showMem=true after pressing m")
+ }
+ handleKey(sdl.K_m, nil, cfg, state)
+ if state.showMem {
+ t.Fatal("expected showMem=false after pressing m again")
+ }
+}
+
func TestHandleKey_ToggleNet(t *testing.T) {
renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
defer renderer.Destroy()
@@ -729,6 +746,23 @@ func TestHandleKey_ToggleNet(t *testing.T) {
assertPixelColor(t, surface, 110, 2, constants.LightGreen, 5, "net bar RX after toggle")
}
+func TestHandleKey_ToggleNetAlias(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ if state.showNet {
+ t.Fatal("expected showNet=false initially")
+ }
+ // 'n' should toggle net just like '3'
+ handleKey(sdl.K_n, nil, cfg, state)
+ if !state.showNet {
+ t.Fatal("expected showNet=true after pressing n")
+ }
+ handleKey(sdl.K_n, nil, cfg, state)
+ if state.showNet {
+ t.Fatal("expected showNet=false after pressing n again")
+ }
+}
+
func TestHandleKey_ToggleExtended(t *testing.T) {
renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
defer renderer.Destroy()
@@ -1070,3 +1104,165 @@ func TestHandleKey_ArrowResize(t *testing.T) {
t.Errorf("expected winH=1 (clamped), got %d", state.winH)
}
}
+
+// makeCPUPairWithIO creates a (prev, cur) pair where the delta yields the desired
+// system, user, idle, iowait, irq, and softirq percentages.
+func makeCPUPairWithIO(systemPct, userPct, idlePct, iowaitPct, irqPct, softirqPct float64) (prev, cur collector.CPULine) {
+ const base = 1000
+ const delta = 1000
+ prev = collector.CPULine{Idle: base}
+ dSys := int64(systemPct * float64(delta) / 100)
+ dUser := int64(userPct * float64(delta) / 100)
+ dIdle := int64(idlePct * float64(delta) / 100)
+ dIowait := int64(iowaitPct * float64(delta) / 100)
+ dIRQ := int64(irqPct * float64(delta) / 100)
+ dSoftIRQ := int64(softirqPct * float64(delta) / 100)
+ dNice := delta - dSys - dUser - dIdle - dIowait - dIRQ - dSoftIRQ
+ if dNice < 0 {
+ dNice = 0
+ }
+ cur = collector.CPULine{
+ System: prev.System + dSys,
+ User: prev.User + dUser,
+ Idle: prev.Idle + dIdle,
+ Nice: prev.Nice + dNice,
+ Iowait: prev.Iowait + dIowait,
+ IRQ: prev.IRQ + dIRQ,
+ SoftIRQ: prev.SoftIRQ + dSoftIRQ,
+ }
+ return prev, cur
+}
+
+func TestHandleKey_ToggleIOAvgLine(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ if state.showIOAvgLine {
+ t.Fatal("expected showIOAvgLine=false initially")
+ }
+ handleKey(sdl.K_i, nil, cfg, state)
+ if !state.showIOAvgLine {
+ t.Fatal("expected showIOAvgLine=true after pressing i")
+ }
+ handleKey(sdl.K_i, nil, cfg, state)
+ if state.showIOAvgLine {
+ t.Fatal("expected showIOAvgLine=false after pressing i again")
+ }
+}
+
+func TestGlobalIOAvgLine_SingleHost(t *testing.T) {
+ // One host with 20% iowait + 5% irq + 5% softirq = 30% → pink line at y=30 from top
+ 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 := makeCPUPairWithIO(10, 10, 20, 20, 5, 5)
+ cfg := defaultTestConfig()
+
+ src := &mockSource{
+ data: map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": cur}},
+ },
+ }
+
+ state := newRunState(cfg, w, h)
+ state.showIOAvgLine = true
+ state.prevCPU["host1;cpu"] = prev
+
+ drawFrame(renderer, src, cfg, state)
+
+ // Pink line at y=30 (30% from top in a 100px window)
+ assertPixelColor(t, surface, 50, 30, constants.Pink, 3, "IO avg line at y=30")
+ // Spans full width
+ assertPixelColor(t, surface, 0, 30, constants.Pink, 3, "IO avg line at x=0")
+ assertPixelColor(t, surface, 99, 30, constants.Pink, 3, "IO avg line at x=99")
+}
+
+func TestGlobalIOAvgLine_MultiHost(t *testing.T) {
+ // Two hosts: host1=30% IO, host2=0% IO → average 15% → pink line at y=15
+ 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 := makeCPUPairWithIO(10, 10, 20, 20, 5, 5) // 30% IO
+ prev2, cur2 := makeCPUPair(40, 40, 20) // 0% IO
+
+ cfg := defaultTestConfig()
+
+ 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.showIOAvgLine = true
+ state.prevCPU["alpha;cpu"] = prev1
+ state.prevCPU["beta;cpu"] = prev2
+
+ drawFrame(renderer, src, cfg, state)
+
+ // Average 15% → pink line at y=15
+ assertPixelColor(t, surface, 50, 15, constants.Pink, 3, "IO avg line at y=15")
+}
+
+func TestGlobalIOAvgLine_Disabled(t *testing.T) {
+ // With showIOAvgLine=false, no pink line should appear
+ 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 := makeCPUPairWithIO(10, 10, 20, 20, 5, 5) // 30% IO
+ cfg := defaultTestConfig()
+
+ src := &mockSource{
+ data: map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": cur}},
+ },
+ }
+
+ state := newRunState(cfg, w, h)
+ state.showIOAvgLine = false
+ state.prevCPU["host1;cpu"] = prev
+
+ drawFrame(renderer, src, cfg, state)
+
+ // At y=30, there should be no pink line
+ r, g, b := getPixelColor(surface, 50, 30)
+ if r == constants.Pink.R && g == constants.Pink.G && b == constants.Pink.B {
+ t.Errorf("expected no pink IO avg line at y=30 when disabled, got RGB(%d,%d,%d)", r, g, b)
+ }
+}
+
+func TestHandleKey_WriteConfig_IOAvgLine(t *testing.T) {
+ // Verify that 'w' hotkey persists showIOAvgLine to config
+ tmpDir := t.TempDir()
+ origHome := os.Getenv("HOME")
+ os.Setenv("HOME", tmpDir)
+ defer os.Setenv("HOME", origHome)
+
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ state.showIOAvgLine = true
+
+ handleKey(sdl.K_w, nil, cfg, state)
+
+ if !cfg.ShowIOAvgLine {
+ t.Error("expected ShowIOAvgLine=true in config after 'w'")
+ }
+}