diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | internal/config/config.go | 11 | ||||
| -rw-r--r-- | internal/config/config_test.go | 37 | ||||
| -rw-r--r-- | internal/display/display.go | 31 | ||||
| -rw-r--r-- | internal/display/display_test.go | 148 |
5 files changed, 219 insertions, 9 deletions
@@ -113,6 +113,7 @@ Press these keys while loadbars is running (see also `h` for a short list on std | **e** | Toggle extended display (1px peak line on CPU bars: max system+user over last samples) | | **g** | Toggle global average CPU line (1px red line showing mean CPU usage across all hosts) | | **i** | Toggle global I/O average line (1px pink line showing mean iowait+IRQ across all hosts) | +| **s** | Toggle host separator lines (1px yellow vertical line between hosts) | | **h** | Print hotkey list to stdout | | **q** | Quit | | **w** | Write current settings to ~/.loadbarsrc | diff --git a/internal/config/config.go b/internal/config/config.go index d53fd7f..1b4c9e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,8 +28,9 @@ type Config struct { ShowIOAvgLine bool ShowCores bool ShowMem bool - ShowNet bool - SSHOpts string + ShowNet bool + ShowSeparators bool + SSHOpts string Cluster string } @@ -106,7 +107,8 @@ 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, - "showavgline": true, "showioavgline": true, "shownet": true, "sshopts": true, "cluster": true, + "showavgline": true, "showioavgline": true, "shownet": true, "showseparators": true, + "sshopts": true, "cluster": true, } scanner := bufio.NewScanner(f) for scanner.Scan() { @@ -171,6 +173,8 @@ func (c *Config) set(key, val string) { c.ShowMem = parseBool(val) case "shownet": c.ShowNet = parseBool(val) + case "showseparators": + c.ShowSeparators = parseBool(val) case "sshopts": c.SSHOpts = val case "cluster": @@ -207,6 +211,7 @@ func (c *Config) writeTo(f *os.File) error { writeBool("showcores", c.ShowCores) writeBool("showmem", c.ShowMem) writeBool("shownet", c.ShowNet) + writeBool("showseparators", c.ShowSeparators) writeStr("sshopts", c.SSHOpts) writeStr("cluster", c.Cluster) return w.Flush() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a8535be..630fa47 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -152,6 +152,43 @@ func TestConfig_showIOAvgLineRoundTrip(t *testing.T) { } } +func TestConfig_showSeparatorsRoundTrip(t *testing.T) { + // Write a config with showseparators=1, read it back, verify round-trip + c := Default() + c.ShowSeparators = 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("showseparators=1")) { + t.Errorf("expected showseparators=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.ShowSeparators { + t.Error("expected ShowSeparators=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 54c5c51..d3f27ca 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -25,8 +25,9 @@ type runState struct { showIOAvgLine bool showCores bool showMem bool - showNet bool - extended bool + showNet bool + showSeparators bool + extended bool winW int32 winH int32 prevCPU map[string]collector.CPULine @@ -44,8 +45,9 @@ func newRunState(cfg *config.Config, winW, winH int32) *runState { showIOAvgLine: cfg.ShowIOAvgLine, showCores: cfg.ShowCores, showMem: cfg.ShowMem, - showNet: cfg.ShowNet, - extended: cfg.Extended, + showNet: cfg.ShowNet, + showSeparators: cfg.ShowSeparators, + extended: cfg.Extended, winW: winW, winH: winH, prevCPU: make(map[string]collector.CPULine), @@ -160,6 +162,9 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r case sdl.K_i: state.showIOAvgLine = !state.showIOAvgLine fmt.Println("==> Toggled global I/O avg line:", state.showIOAvgLine) + case sdl.K_s: + state.showSeparators = !state.showSeparators + fmt.Println("==> Toggled host separators:", state.showSeparators) case sdl.K_a: cfg.CPUAverage++ fmt.Println("==> CPU average samples:", cfg.CPUAverage) @@ -188,6 +193,7 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r cfg.ShowCores = state.showCores cfg.ShowMem = state.showMem cfg.ShowNet = state.showNet + cfg.ShowSeparators = state.showSeparators cfg.Extended = state.extended if err := cfg.Write(); err != nil { fmt.Fprintf(os.Stderr, "!!! Write config: %v\n", err) @@ -271,12 +277,25 @@ func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet boo // drawBars draws CPU, memory, and network bars for all hosts in snap. func drawBars(renderer *sdl.Renderer, snap map[string]*stats.HostStats, cfg *config.Config, state *runState, numBars int) { barIndex := 0 - for _, host := range sortedHosts(snap) { + hosts := sortedHosts(snap) + // Track where each host's bars end so we can draw separators after all bars + var separatorXs []int32 + for i, host := range hosts { h := snap[host] if h == nil { continue } drawHostBars(renderer, h, host, cfg, state, numBars, &barIndex) + // Record separator position between hosts (not after the last one) + if state.showSeparators && i < len(hosts)-1 { + sepX, _ := barBounds(state.winW, numBars, barIndex) + separatorXs = append(separatorXs, sepX) + } + } + // Draw 1px yellow vertical separators on top of all bars + for _, sepX := range separatorXs { + renderer.SetDrawColor(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, 255) + renderer.FillRect(&sdl.Rect{X: sepX, Y: 0, W: 1, H: state.winH}) } } @@ -611,7 +630,7 @@ func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *st } func printHotkeys() { - 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") + fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net e=extended g=avg line i=io avg s=separators 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 734e144..f5f2e39 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -1266,3 +1266,151 @@ func TestHandleKey_WriteConfig_IOAvgLine(t *testing.T) { t.Error("expected ShowIOAvgLine=true in config after 'w'") } } + +func TestHandleKey_ToggleSeparators(t *testing.T) { + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + if state.showSeparators { + t.Fatal("expected showSeparators=false initially") + } + handleKey(sdl.K_s, nil, cfg, state) + if !state.showSeparators { + t.Fatal("expected showSeparators=true after pressing s") + } + handleKey(sdl.K_s, nil, cfg, state) + if state.showSeparators { + t.Fatal("expected showSeparators=false after pressing s again") + } +} + +func TestSeparator_TwoHosts_Enabled(t *testing.T) { + // Two hosts (100% system = blue) with separators enabled: yellow pixel at boundary + const w, h int32 = 200, 100 + + renderer, surface, err := createTestRenderer(w, h) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + prev1, cur1 := makeCPUPair(100, 0, 0) // all system → blue + prev2, cur2 := makeCPUPair(100, 0, 0) + + cfg := defaultTestConfig() + cfg.ShowCores = false + cfg.ShowMem = false + cfg.ShowNet = false + cfg.ShowSeparators = true + + 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.prevCPU["alpha;cpu"] = prev1 + state.prevCPU["beta;cpu"] = prev2 + + drawFrame(renderer, src, cfg, state) + + // 2 bars at 200px → each 100px. Separator at x=100 (start of second host's bars) + assertPixelColor(t, surface, 100, 50, constants.Yellow, 3, "separator yellow at x=100") +} + +func TestSeparator_TwoHosts_Disabled(t *testing.T) { + // Two hosts (100% system = blue) with separators disabled: no yellow at boundary + const w, h int32 = 200, 100 + + renderer, surface, err := createTestRenderer(w, h) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + prev1, cur1 := makeCPUPair(100, 0, 0) // all system → blue + prev2, cur2 := makeCPUPair(100, 0, 0) + + cfg := defaultTestConfig() + cfg.ShowCores = false + cfg.ShowMem = false + cfg.ShowNet = false + cfg.ShowSeparators = 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.prevCPU["alpha;cpu"] = prev1 + state.prevCPU["beta;cpu"] = prev2 + + drawFrame(renderer, src, cfg, state) + + // At x=100, should be blue (second host's system bar), NOT yellow separator + assertPixelColor(t, surface, 100, 50, constants.Blue, 3, "no separator, should be blue") +} + +func TestSeparator_SingleHost(t *testing.T) { + // Single host: no separator should be drawn even when enabled + 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(50, 30, 20) + cfg := defaultTestConfig() + cfg.ShowCores = false + cfg.ShowMem = false + cfg.ShowNet = false + cfg.ShowSeparators = true + + src := &mockSource{ + data: map[string]*stats.HostStats{ + "host1": {CPU: map[string]collector.CPULine{"cpu": cur}}, + }, + } + + state := newRunState(cfg, w, h) + state.prevCPU["host1;cpu"] = prev + + drawFrame(renderer, src, cfg, state) + + // No separator at the edges — just verify no yellow at x=0 or x=99 + r, g, b := getPixelColor(surface, 0, 50) + if r == constants.Yellow.R && g == constants.Yellow.G && b == constants.Yellow.B { + t.Errorf("unexpected yellow separator at x=0 with single host") + } + r, g, b = getPixelColor(surface, 99, 50) + if r == constants.Yellow.R && g == constants.Yellow.G && b == constants.Yellow.B { + t.Errorf("unexpected yellow separator at x=99 with single host") + } +} + +func TestHandleKey_WriteConfig_Separators(t *testing.T) { + // Verify that 'w' hotkey persists showSeparators 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.showSeparators = true + + handleKey(sdl.K_w, nil, cfg, state) + + if !cfg.ShowSeparators { + t.Error("expected ShowSeparators=true in config after 'w'") + } +} |
