summaryrefslogtreecommitdiff
path: root/internal/display
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-18 09:00:35 +0200
committerPaul Buetow <paul@buetow.org>2026-02-18 09:00:35 +0200
commit88f4e239a7521112a4db8c7842e3a05db4446cd4 (patch)
tree8c331f9f2e23ad9c9319d6dc8275205b23ce811a /internal/display
parent11204092b5ab5dc0f71515adfcaa6f07111363e5 (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/display')
-rw-r--r--internal/display/activate.go2
-rw-r--r--internal/display/activate_darwin.go2
-rw-r--r--internal/display/display.go90
-rw-r--r--internal/display/display_test.go114
-rw-r--r--internal/display/font.go248
-rw-r--r--internal/display/hittest.go82
-rw-r--r--internal/display/tooltip.go194
-rw-r--r--internal/display/tooltip_test.go587
8 files changed, 1239 insertions, 80 deletions
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
+