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/display | |
| 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/display')
| -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 |
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.Sp |
