package display import ( "context" "fmt" "os" "sort" "strconv" "strings" "time" "codeberg.org/snonux/loadbars/internal/collector" "codeberg.org/snonux/loadbars/internal/config" "codeberg.org/snonux/loadbars/internal/constants" "codeberg.org/snonux/loadbars/internal/stats" "codeberg.org/snonux/loadbars/internal/version" "github.com/veandco/go-sdl2/sdl" ) 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 { showCores bool showMem bool showNet bool extended bool winW int32 winH int32 lastNumBars int lastWinW int32 lastWinH int32 prevCPU map[string]collector.CPULine smoothedCPU map[string]*[9]float64 smoothedMem map[string]*struct{ ramUsed, swapUsed float64 } smoothedNet map[string]*struct{ rxPct, txPct float64 } prevNet map[string]stats.NetStamp netIntIndex map[string]int cycleNetNext bool printNetInfoOnce bool peakHistory map[string][]float64 } // newRunState builds initial run state from config. func newRunState(cfg *config.Config, winW, winH int32) *runState { return &runState{ showCores: cfg.ShowCores, showMem: cfg.ShowMem, showNet: cfg.ShowNet, extended: cfg.Extended, winW: winW, winH: winH, lastNumBars: -1, prevCPU: make(map[string]collector.CPULine), smoothedCPU: make(map[string]*[9]float64), smoothedMem: make(map[string]*struct{ ramUsed, swapUsed float64 }), smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }), prevNet: make(map[string]stats.NetStamp), netIntIndex: make(map[string]int), printNetInfoOnce: cfg.ShowNet, peakHistory: make(map[string][]float64), } } // Run runs the SDL display loop until ctx is cancelled or user presses 'q'. func Run(ctx context.Context, cfg *config.Config, src stats.Source) error { if err := sdl.Init(sdl.INIT_VIDEO); err != nil { return fmt.Errorf("sdl init: %w", err) } defer sdl.Quit() const minWindowWidth = 800 width := clampInt(cfg.BarWidth, minWindowWidth, cfg.MaxWidth) height := cfg.Height if height < 1 { height = 1 } title := cfg.Title if title == "" { title = "Loadbars " + version.Version + " (press h for help on stdout)" } window, renderer, err := sdl.CreateWindowAndRenderer(int32(width), int32(height), sdl.WINDOW_RESIZABLE) if err != nil { return fmt.Errorf("create window: %w", err) } defer window.Destroy() defer renderer.Destroy() window.SetTitle(title) // On macOS, bring the window to the foreground activateWindow() state := newRunState(cfg, int32(width), int32(height)) ticker := time.NewTicker(time.Duration(constants.IntervalSDL * float64(time.Second))) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() default: } if handleEvents(window, cfg, state) { return nil } drawFrame(renderer, src, cfg, state) renderer.Present() sdl.Delay(10) <-ticker.C } } func clampInt(v, min, max int) int { if v < min { return min } if v > max { return max } return v } // handleEvents processes all pending SDL events and updates state. Returns true if the user quit. func handleEvents(window *sdl.Window, cfg *config.Config, state *runState) bool { for e := sdl.PollEvent(); e != nil; e = sdl.PollEvent() { switch ev := e.(type) { case *sdl.QuitEvent: return true case *sdl.KeyboardEvent: if ev.Type != sdl.KEYDOWN || ev.Repeat != 0 { continue } if handleKey(ev.Keysym.Sym, window, cfg, state) { return true } case *sdl.WindowEvent: if ev.Event == sdl.WINDOWEVENT_RESIZED { state.winW, state.winH = ev.Data1, ev.Data2 } } } return false } // handleKey handles one key press; returns true to quit. func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *runState) bool { switch sym { case sdl.K_q: return true case sdl.K_1: state.showCores = !state.showCores fmt.Println("==> Toggled show cores:", state.showCores) case sdl.K_2: state.showMem = !state.showMem fmt.Println("==> Toggled show mem:", state.showMem) case sdl.K_3: state.showNet = !state.showNet fmt.Println("==> Toggled show net:", state.showNet) if state.showNet { state.printNetInfoOnce = true } case sdl.K_e: state.extended = !state.extended fmt.Println("==> Toggled extended (peak line):", state.extended) case sdl.K_a: cfg.CPUAverage++ fmt.Println("==> CPU average samples:", cfg.CPUAverage) case sdl.K_y: if cfg.CPUAverage > 1 { cfg.CPUAverage-- } fmt.Println("==> CPU average samples:", cfg.CPUAverage) case sdl.K_d: cfg.NetAverage++ fmt.Println("==> Net average samples:", cfg.NetAverage) case sdl.K_c: if cfg.NetAverage > 1 { cfg.NetAverage-- } fmt.Println("==> Net average samples:", cfg.NetAverage) case sdl.K_h: printHotkeys() case sdl.K_n: state.cycleNetNext = true if state.showNet { fmt.Println("==> Cycling to next network interface (per host)") } case sdl.K_w: cfg.ShowCores = state.showCores cfg.ShowMem = state.showMem cfg.ShowNet = state.showNet cfg.Extended = state.extended if err := cfg.Write(); err != nil { fmt.Fprintf(os.Stderr, "!!! Write config: %v\n", err) } else { fmt.Println("==> Config written to ~/.loadbarsrc") } case sdl.K_LEFT: state.winW -= 100 if state.winW < 1 { state.winW = 1 } window.SetSize(state.winW, state.winH) case sdl.K_RIGHT: state.winW += 100 if state.winW > int32(cfg.MaxWidth) { state.winW = int32(cfg.MaxWidth) } window.SetSize(state.winW, state.winH) case sdl.K_UP: state.winH -= 100 if state.winH < 1 { state.winH = 1 } window.SetSize(state.winW, state.winH) case sdl.K_DOWN: state.winH += 100 window.SetSize(state.winW, state.winH) } return false } // drawFrame updates state from snapshot, clears if layout changed, and draws all bars. func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, state *runState) { snap := src.Snapshot() if state.cycleNetNext { for _, host := range sortedHosts(snap) { state.netIntIndex[host]++ } state.cycleNetNext = false } if state.printNetInfoOnce && state.showNet { state.printNetInfoOnce = false printNetInterfaceHelp(snap, cfg, state.netIntIndex) } numBars := countBars(snap, state.showCores, state.showMem, state.showNet) barWidth := state.winW / int32(numBars) if barWidth < 1 { barWidth = 1 } if numBars != state.lastNumBars || state.winW != state.lastWinW || state.winH != state.lastWinH { renderer.SetDrawColor(0, 0, 0, 255) renderer.Clear() state.lastNumBars = numBars state.lastWinW, state.lastWinH = state.winW, state.winH } drawBars(renderer, snap, cfg, state, barWidth) } func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet bool) int { n := 0 for _, host := range sortedHosts(snap) { if h := snap[host]; h != nil { n += len(sortedCPUNames(h.CPU, showCores)) if showMem { n++ } if showNet { n++ } } } if n == 0 { n = 1 } return n } // 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, barWidth int32) { x := int32(0) for _, host := range sortedHosts(snap) { h := snap[host] if h == nil { continue } drawHostBars(renderer, h, host, cfg, state, barWidth, &x) } } // drawHostBars draws CPU, mem, and net bars for one host and advances x. func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *config.Config, state *runState, barWidth int32, x *int32) { winH := state.winH cpuNames := sortedCPUNames(h.CPU, state.showCores) for _, name := range cpuNames { key := host + ";" + name cur := h.CPU[name] prev := state.prevCPU[key] state.prevCPU[key] = cur target, ok := cpuBarTargetPcts(cur, prev) s := state.smoothedCPU[key] if s == nil { s = &[9]float64{} state.smoothedCPU[key] = s if ok { *s = target } } else if ok { for i := 0; i < 9; i++ { (*s)[i] += (target[i] - (*s)[i]) * smoothFactor } normalizePcts9(s) } peakPct := peakPctForBar(state, key, cfg.CPUAverage, s) drawCPUBarFromPcts(renderer, s, barWidth, x, winH, state.extended, peakPct) } if state.showMem { if state.smoothedMem[host] == nil { state.smoothedMem[host] = &struct{ ramUsed, swapUsed float64 }{} } drawMemBarSmoothed(renderer, h, state.smoothedMem[host], smoothFactor, barWidth, x, winH) } if state.showNet { if state.smoothedNet[host] == nil { state.smoothedNet[host] = &struct{ rxPct, txPct float64 }{} } state.prevNet[host] = drawNetBarSmoothed(renderer, h, cfg, state.smoothedNet[host], state.prevNet[host], state.netIntIndex, host, smoothFactor, barWidth, x, winH) } } func peakPctForBar(state *runState, key string, cpuAvg int, s *[9]float64) float64 { if !state.extended || s == nil { return 0 } userSys := (*s)[0] + (*s)[1] hist := state.peakHistory[key] hist = append(hist, userSys) n := cpuAvg if n < 1 { n = 1 } for len(hist) > n { hist = hist[1:] } state.peakHistory[key] = hist var max float64 for _, v := range hist { if v > max { max = v } } return max } func sortedHosts(snap map[string]*stats.HostStats) []string { out := make([]string, 0, len(snap)) for h := range snap { out = append(out, h) } sort.Strings(out) return out } func sortedCPUNames(cpu map[string]collector.CPULine, showCores bool) []string { var names []string for name := range cpu { if name == "cpu" { names = append(names, "cpu") continue } if showCores { names = append(names, name) } } sort.Slice(names, func(i, j int) bool { if names[i] == "cpu" { return true } if names[j] == "cpu" { return false } return names[i] < names[j] }) return names } // cpuBarTargetPcts returns the 9 segment percentages (system, user, nice, idle, iowait, irq, softirq, guest, steal) from cur/prev delta. ok is false if no valid sample. func cpuBarTargetPcts(cur, prev collector.CPULine) (out [9]float64, ok bool) { totalCur := cur.Total() totalPrev := prev.Total() if totalPrev == 0 || totalCur <= totalPrev { return out, false } scale := float64(totalCur-totalPrev) / 100.0 if scale <= 0 { return out, false } out[0] = float64(cur.System-prev.System) / scale out[1] = float64(cur.User-prev.User) / scale out[2] = float64(cur.Nice-prev.Nice) / scale out[3] = float64(cur.Idle-prev.Idle) / scale out[4] = float64(cur.Iowait-prev.Iowait) / scale out[5] = float64(cur.IRQ-prev.IRQ) / scale out[6] = float64(cur.SoftIRQ-prev.SoftIRQ) / scale out[7] = float64(cur.Guest-prev.Guest) / scale out[8] = float64(cur.Steal-prev.Steal) / scale for i := range out { if out[i] < 0 { out[i] = 0 } if out[i] > 100 { out[i] = 100 } } return out, true } func normalizePcts9(s *[9]float64) { var sum float64 for i := 0; i < 9; i++ { sum += (*s)[i] } if sum <= 0 { return } for i := 0; i < 9; i++ { (*s)[i] = (*s)[i] * 100 / sum } } // drawCPUBarFromPcts draws one CPU bar from 9 smoothed segment percentages. If s is nil, advances x only. // When extended is true and peakPct > 0, draws a 1px peak line (max system+user over history). func drawCPUBarFromPcts(renderer *sdl.Renderer, s *[9]float64, barW int32, x *int32, winH int32, extended bool, peakPct float64) { defer func() { *x += barW }() // Clear this slot so we never leave previous (e.g. mem/net) content visible renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: barW, H: winH}) if s == nil { return } barH := float64(winH) / 100.0 y := float64(winH) fill := func(r, g, b uint8, pct float64) { hh := int32(pct * barH) if hh < 1 && pct > 0 { hh = 1 } y -= float64(hh) renderer.SetDrawColor(r, g, b, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: int32(y), W: barW, H: hh}) } fill(constants.Blue.R, constants.Blue.G, constants.Blue.B, (*s)[0]) // system fill(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, (*s)[1]) // user fill(constants.Green.R, constants.Green.G, constants.Green.B, (*s)[2]) // nice fill(constants.Black.R, constants.Black.G, constants.Black.B, (*s)[3]) // idle fill(constants.Purple.R, constants.Purple.G, constants.Purple.B, (*s)[4]) // iowait fill(constants.White.R, constants.White.G, constants.White.B, (*s)[5]) // irq fill(constants.White.R, constants.White.G, constants.White.B, (*s)[6]) // softirq fill(constants.Red.R, constants.Red.G, constants.Red.B, (*s)[7]) // guest fill(constants.Red.R, constants.Red.G, constants.Red.B, (*s)[8]) // steal // Extended: 1px peak line at max (system+user) over history if extended && peakPct > 0 { peakY := winH - int32(peakPct*barH) if peakY < 0 { peakY = 0 } if peakY >= winH { peakY = winH - 1 } if peakPct > float64(constants.UserOrangeThreshold) { renderer.SetDrawColor(constants.Orange.R, constants.Orange.G, constants.Orange.B, 255) } else if peakPct > float64(constants.UserYellowThreshold) { renderer.SetDrawColor(constants.Yellow0.R, constants.Yellow0.G, constants.Yellow0.B, 255) } else { renderer.SetDrawColor(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, 255) } renderer.FillRect(&sdl.Rect{X: *x, Y: peakY, W: barW, H: 1}) } } // drawMemBarSmoothed blends mem stats toward target and draws one memory bar (RAM left, Swap right). func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *struct{ ramUsed, swapUsed float64 }, factor float64, barW int32, x *int32, winH int32) { defer func() { *x += barW }() // Clear this slot so we never leave previous (e.g. CPU/net) content visible renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: barW, H: winH}) if h.Mem == nil { return } var targetRam, targetSwap float64 if memTotal := h.Mem["MemTotal"]; memTotal > 0 { targetRam = 100 - 100*float64(h.Mem["MemFree"])/float64(memTotal) if targetRam < 0 { targetRam = 0 } if targetRam > 100 { targetRam = 100 } } if swapTotal := h.Mem["SwapTotal"]; swapTotal > 0 { targetSwap = 100 - 100*float64(h.Mem["SwapFree"])/float64(swapTotal) if targetSwap < 0 { targetSwap = 0 } if targetSwap > 100 { targetSwap = 100 } } smoothed.ramUsed += (targetRam - smoothed.ramUsed) * factor smoothed.swapUsed += (targetSwap - smoothed.swapUsed) * factor halfW := barW / 2 barH := float64(winH) / 100.0 // RAM: used (dark grey) from bottom, free (black) on top ramUsedH := int32(smoothed.ramUsed * barH) if ramUsedH > 0 { renderer.SetDrawColor(constants.DarkGrey.R, constants.DarkGrey.G, constants.DarkGrey.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: winH - ramUsedH, W: halfW, H: ramUsedH}) } if ramFreeH := winH - ramUsedH; ramFreeH > 0 { renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: halfW, H: ramFreeH}) } // Swap: used (grey) from bottom, free (black) on top swapUsedH := int32(smoothed.swapUsed * barH) if swapUsedH > 0 { renderer.SetDrawColor(constants.Grey.R, constants.Grey.G, constants.Grey.B, 255) renderer.FillRect(&sdl.Rect{X: *x + halfW, Y: winH - swapUsedH, W: halfW, H: swapUsedH}) } if swapFreeH := winH - swapUsedH; swapFreeH > 0 { renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x + halfW, Y: 0, W: halfW, H: swapFreeH}) } } func printHotkeys() { fmt.Println("=> Hotkeys: 1=cores 2=mem 3=net e=extended h=help n=next net q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize") } // printNetInterfaceHelp prints which interface is used per host and how to set netint (when net view is toggled on). func printNetInterfaceHelp(snap map[string]*stats.HostStats, cfg *config.Config, netIntIndex map[string]int) { for _, host := range sortedHosts(snap) { h := snap[host] if h == nil || h.Net == nil || len(h.Net) == 0 { fmt.Printf("Net: %s => (no interfaces yet, wait for data)\n", host) continue } iface := chooseNetIface(h, cfg, host, netIntIndex) all := make([]string, 0, len(h.Net)) for name := range h.Net { all = append(all, name) } sort.Strings(all) if iface == "" { fmt.Printf("Net: %s => (no non-lo interface; seen: %s)\n", host, strings.Join(all, ", ")) continue } hint := "set netint=IFACE in ~/.loadbarsrc or --netint IFACE" if cfg.NetInt != "" { hint = "using netint=" + cfg.NetInt + " from config" } fmt.Printf("Net: %s => %s (all: %s). %s\n", host, iface, strings.Join(all, ", "), hint) } fmt.Println("=> Link speed: netlink=" + cfg.NetLink + " (gbit/mbit/10mbit/100mbit/10gbit or number). Change in ~/.loadbarsrc or --netlink") } // netLinkBytesPerSec returns link speed in bytes/sec from cfg.NetLink (e.g. "gbit", "10gbit", "100mbit", or numeric mbit). func netLinkBytesPerSec(cfg *config.Config) int64 { s := strings.ToLower(strings.TrimSpace(cfg.NetLink)) switch s { case "gbit", "1gbit": return int64(constants.BytesGbit) case "10gbit": return int64(constants.Bytes10Gbit) case "mbit", "1mbit": return int64(constants.BytesMbit) case "10mbit": return int64(constants.Bytes10Mbit) case "100mbit": return int64(constants.Bytes100Mbit) case "": return int64(constants.BytesGbit) } if n, err := strconv.ParseInt(s, 10, 64); err == nil { return n * int64(constants.BytesMbit) } return int64(constants.BytesGbit) } // chooseNetIface returns the interface name to use for this host: cfg.NetInt if set and present, else first non-lo, cycling with n key. func chooseNetIface(h *stats.HostStats, cfg *config.Config, host string, netIntIndex map[string]int) string { if h.Net == nil || len(h.Net) == 0 { return "" } if cfg.NetInt != "" { if _, ok := h.Net[cfg.NetInt]; ok { return cfg.NetInt } } names := make([]string, 0, len(h.Net)) for iface := range h.Net { if iface == "lo" { continue } names = append(names, iface) } sort.Strings(names) if len(names) == 0 { return "" } idx := netIntIndex[host] % len(names) if idx < 0 { idx += len(names) } return names[idx] } func drawNetBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, cfg *config.Config, smoothed *struct{ rxPct, txPct float64 }, prev stats.NetStamp, netIntIndex map[string]int, host string, factor float64, barW int32, x *int32, winH int32) stats.NetStamp { defer func() { *x += barW }() // Clear this slot so we never leave previous (e.g. CPU/mem) content visible renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: barW, H: winH}) iface := chooseNetIface(h, cfg, host, netIntIndex) if iface == "" { renderer.SetDrawColor(constants.Red.R, constants.Red.G, constants.Red.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: barW, H: winH}) return prev } cur, ok := h.Net[iface] if !ok { renderer.SetDrawColor(constants.Red.R, constants.Red.G, constants.Red.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: barW, H: winH}) return prev } linkBps := netLinkBytesPerSec(cfg) if linkBps <= 0 { linkBps = int64(constants.BytesGbit) } var targetRx, targetTx float64 if prev.Stamp > 0 && cur.Stamp > prev.Stamp { dt := float64(cur.Stamp-prev.Stamp) / 1e9 if dt > 0 { deltaB := cur.B - prev.B deltaTb := cur.Tb - prev.Tb if deltaB < 0 { deltaB = 0 } if deltaTb < 0 { deltaTb = 0 } targetRx = 100 * float64(deltaB) / (float64(linkBps) * dt) targetTx = 100 * float64(deltaTb) / (float64(linkBps) * dt) } } smoothed.rxPct += (targetRx - smoothed.rxPct) * factor smoothed.txPct += (targetTx - smoothed.txPct) * factor halfW := barW / 2 barH := float64(winH) / 100.0 // Left half: RX from top (light green = used) rxH := int32(smoothed.rxPct * barH) if rxH > winH/2 { rxH = winH / 2 } if rxH > 0 { renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: 0, W: halfW, H: rxH}) } if halfW > 0 && winH/2-rxH > 0 { renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: rxH, W: halfW, H: winH/2 - rxH}) } // Right half: TX from bottom (light green = used) txH := int32(smoothed.txPct * barH) if txH > winH/2 { txH = winH / 2 } if txH > 0 { renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) renderer.FillRect(&sdl.Rect{X: *x + halfW, Y: winH - txH, W: halfW, H: txH}) } if halfW > 0 && (winH-txH) > 0 { renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x + halfW, Y: 0, W: halfW, H: winH - txH}) } return cur }