mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 19:44:44 +00:00
786 lines
20 KiB
Go
786 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
|
|
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9E9E9E"))
|
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
|
|
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
|
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787"))
|
|
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5A5A5A"))
|
|
hatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000"))
|
|
)
|
|
|
|
const (
|
|
colWidth = 38
|
|
iconCPU = "⚙"
|
|
iconMemory = "▦"
|
|
iconGPU = "▣"
|
|
iconDisk = "▤"
|
|
iconNetwork = "⇅"
|
|
iconBattery = "▮"
|
|
iconSensors = "♨"
|
|
iconProcs = "▶"
|
|
)
|
|
|
|
// Check if it's Christmas season (Dec 10-31)
|
|
func isChristmasSeason() bool {
|
|
now := time.Now()
|
|
month := now.Month()
|
|
day := now.Day()
|
|
return month == time.December && day >= 10 && day <= 31
|
|
}
|
|
|
|
// Mole body frames (legs animate)
|
|
var moleBody = [][]string{
|
|
{
|
|
` /\_/\`,
|
|
` ___/ o o \`,
|
|
`/___ =-= /`,
|
|
`\____)-m-m)`,
|
|
},
|
|
{
|
|
` /\_/\`,
|
|
` ___/ o o \`,
|
|
`/___ =-= /`,
|
|
`\____)mm__)`,
|
|
},
|
|
{
|
|
` /\_/\`,
|
|
` ___/ · · \`,
|
|
`/___ =-= /`,
|
|
`\___)-m__m)`,
|
|
},
|
|
{
|
|
` /\_/\`,
|
|
` ___/ o o \`,
|
|
`/___ =-= /`,
|
|
`\____)-mm-)`,
|
|
},
|
|
}
|
|
|
|
// Mole body frames with Christmas hat
|
|
var moleBodyWithHat = [][]string{
|
|
{
|
|
` *`,
|
|
` /o\`,
|
|
` {/\_/\}`,
|
|
` ___/ o o \`,
|
|
` /___ =-= /`,
|
|
` \____)-m-m)`,
|
|
},
|
|
{
|
|
` *`,
|
|
` /o\`,
|
|
` {/\_/\}`,
|
|
` ___/ o o \`,
|
|
` /___ =-= /`,
|
|
` \____)mm__)`,
|
|
},
|
|
{
|
|
` *`,
|
|
` /o\`,
|
|
` {/\_/\}`,
|
|
` ___/ · · \`,
|
|
` /___ =-= /`,
|
|
` \___)-m__m)`,
|
|
},
|
|
{
|
|
` *`,
|
|
` /o\`,
|
|
` {/\_/\}`,
|
|
` ___/ o o \`,
|
|
` /___ =-= /`,
|
|
` \____)-mm-)`,
|
|
},
|
|
}
|
|
|
|
// Generate frames with horizontal movement
|
|
func getMoleFrame(animFrame int, termWidth int) string {
|
|
var body []string
|
|
var bodyIdx int
|
|
isChristmas := isChristmasSeason()
|
|
|
|
if isChristmas {
|
|
bodyIdx = animFrame % len(moleBodyWithHat)
|
|
body = moleBodyWithHat[bodyIdx]
|
|
} else {
|
|
bodyIdx = animFrame % len(moleBody)
|
|
body = moleBody[bodyIdx]
|
|
}
|
|
|
|
// Calculate mole width (approximate)
|
|
moleWidth := 15
|
|
// Move across terminal width
|
|
maxPos := termWidth - moleWidth
|
|
if maxPos < 0 {
|
|
maxPos = 0
|
|
}
|
|
|
|
// Move position: 0 -> maxPos -> 0
|
|
cycleLength := maxPos * 2
|
|
if cycleLength == 0 {
|
|
cycleLength = 1
|
|
}
|
|
pos := animFrame % cycleLength
|
|
if pos > maxPos {
|
|
pos = cycleLength - pos
|
|
}
|
|
|
|
padding := strings.Repeat(" ", pos)
|
|
var lines []string
|
|
|
|
if isChristmas {
|
|
// Render with red hat on first 3 lines
|
|
for i, line := range body {
|
|
if i < 3 {
|
|
lines = append(lines, padding+hatStyle.Render(line))
|
|
} else {
|
|
lines = append(lines, padding+line)
|
|
}
|
|
}
|
|
} else {
|
|
for _, line := range body {
|
|
lines = append(lines, padding+line)
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
type cardData struct {
|
|
icon string
|
|
title string
|
|
lines []string
|
|
}
|
|
|
|
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
|
|
// Title
|
|
title := titleStyle.Render("Mole Status")
|
|
|
|
// Health Score with color and label
|
|
scoreStyle := getScoreStyle(m.HealthScore)
|
|
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
|
|
|
|
// Hardware info
|
|
infoParts := []string{}
|
|
if m.Hardware.Model != "" {
|
|
infoParts = append(infoParts, m.Hardware.Model)
|
|
}
|
|
if m.Hardware.CPUModel != "" {
|
|
cpuInfo := m.Hardware.CPUModel
|
|
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
|
|
cpuInfo += fmt.Sprintf(" (%d GPU cores)", m.GPU[0].CoreCount)
|
|
}
|
|
infoParts = append(infoParts, cpuInfo)
|
|
}
|
|
if m.Hardware.TotalRAM != "" {
|
|
infoParts = append(infoParts, m.Hardware.TotalRAM)
|
|
}
|
|
if m.Hardware.DiskSize != "" {
|
|
infoParts = append(infoParts, m.Hardware.DiskSize)
|
|
}
|
|
if m.Hardware.OSVersion != "" {
|
|
infoParts = append(infoParts, m.Hardware.OSVersion)
|
|
}
|
|
|
|
headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · "))
|
|
|
|
// Running mole animation
|
|
mole := getMoleFrame(animFrame, termWidth)
|
|
|
|
if errMsg != "" {
|
|
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render(errMsg), "")
|
|
}
|
|
return headerLine + "\n" + mole
|
|
}
|
|
|
|
func getScoreStyle(score int) lipgloss.Style {
|
|
if score >= 90 {
|
|
// Excellent - Bright Green
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
|
|
} else if score >= 75 {
|
|
// Good - Green
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
|
|
} else if score >= 60 {
|
|
// Fair - Yellow
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
|
|
} else if score >= 40 {
|
|
// Poor - Orange
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
|
|
} else {
|
|
// Critical - Red
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
|
|
}
|
|
}
|
|
|
|
func buildCards(m MetricsSnapshot, _ int) []cardData {
|
|
cards := []cardData{
|
|
renderCPUCard(m.CPU),
|
|
renderMemoryCard(m.Memory),
|
|
renderDiskCard(m.Disks, m.DiskIO),
|
|
renderBatteryCard(m.Batteries, m.Thermal),
|
|
renderProcessCard(m.TopProcesses),
|
|
renderNetworkCard(m.Network, m.Proxy),
|
|
}
|
|
// Only show sensors if we have valid temperature readings
|
|
if hasSensorData(m.Sensors) {
|
|
cards = append(cards, renderSensorsCard(m.Sensors))
|
|
}
|
|
return cards
|
|
}
|
|
|
|
func hasSensorData(sensors []SensorReading) bool {
|
|
for _, s := range sensors {
|
|
if s.Note == "" && s.Value > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func renderCPUCard(cpu CPUStatus) cardData {
|
|
var lines []string
|
|
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage))
|
|
|
|
if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
|
|
lines = append(lines, subtleStyle.Render(fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)",
|
|
cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount)))
|
|
} else {
|
|
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)",
|
|
cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)))
|
|
}
|
|
|
|
if cpu.PerCoreEstimated {
|
|
lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)"))
|
|
} else if len(cpu.PerCore) > 0 {
|
|
type coreUsage struct {
|
|
idx int
|
|
val float64
|
|
}
|
|
var cores []coreUsage
|
|
for i, v := range cpu.PerCore {
|
|
cores = append(cores, coreUsage{i, v})
|
|
}
|
|
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
|
|
|
|
maxCores := 3
|
|
if len(cores) < maxCores {
|
|
maxCores = len(cores)
|
|
}
|
|
for i := 0; i < maxCores; i++ {
|
|
c := cores[i]
|
|
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val))
|
|
}
|
|
}
|
|
|
|
return cardData{icon: iconCPU, title: "CPU", lines: lines}
|
|
}
|
|
|
|
func renderGPUCard(gpus []GPUStatus) cardData {
|
|
var lines []string
|
|
if len(gpus) == 0 {
|
|
lines = append(lines, subtleStyle.Render("No GPU detected"))
|
|
} else {
|
|
for _, g := range gpus {
|
|
if g.Usage >= 0 {
|
|
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage))
|
|
}
|
|
coreInfo := ""
|
|
if g.CoreCount > 0 {
|
|
coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount)
|
|
}
|
|
lines = append(lines, subtleStyle.Render(g.Name+coreInfo))
|
|
if g.Usage < 0 {
|
|
lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics"))
|
|
}
|
|
}
|
|
}
|
|
return cardData{icon: iconGPU, title: "GPU", lines: lines}
|
|
}
|
|
|
|
func renderMemoryCard(mem MemoryStatus) cardData {
|
|
var lines []string
|
|
lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent))
|
|
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s / %s total", humanBytes(mem.Used), humanBytes(mem.Total))))
|
|
available := mem.Total - mem.Used
|
|
freePercent := 100 - mem.UsedPercent
|
|
lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent))
|
|
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s available", humanBytes(available))))
|
|
if mem.SwapTotal > 0 || mem.SwapUsed > 0 {
|
|
var swapPercent float64
|
|
if mem.SwapTotal > 0 {
|
|
swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0
|
|
}
|
|
swapText := subtleStyle.Render(fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)))
|
|
lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText))
|
|
} else {
|
|
lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use")))
|
|
}
|
|
// Memory pressure
|
|
if mem.Pressure != "" {
|
|
pressureStyle := okStyle
|
|
pressureText := "Status " + mem.Pressure
|
|
if mem.Pressure == "warn" {
|
|
pressureStyle = warnStyle
|
|
} else if mem.Pressure == "critical" {
|
|
pressureStyle = dangerStyle
|
|
}
|
|
lines = append(lines, pressureStyle.Render(pressureText))
|
|
}
|
|
return cardData{icon: iconMemory, title: "Memory", lines: lines}
|
|
}
|
|
|
|
func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData {
|
|
var lines []string
|
|
if len(disks) == 0 {
|
|
lines = append(lines, subtleStyle.Render("Collecting..."))
|
|
} else {
|
|
internal, external := splitDisks(disks)
|
|
addGroup := func(prefix string, list []DiskStatus) {
|
|
if len(list) == 0 {
|
|
return
|
|
}
|
|
for i, d := range list {
|
|
label := diskLabel(prefix, i, len(list))
|
|
lines = append(lines, formatDiskLine(label, d))
|
|
}
|
|
}
|
|
addGroup("INTR", internal)
|
|
addGroup("EXTR", external)
|
|
if len(lines) == 0 {
|
|
lines = append(lines, subtleStyle.Render("No disks detected"))
|
|
}
|
|
}
|
|
readBar := ioBar(io.ReadRate)
|
|
writeBar := ioBar(io.WriteRate)
|
|
lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate))
|
|
lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate))
|
|
return cardData{icon: iconDisk, title: "Disk", lines: lines}
|
|
}
|
|
|
|
func splitDisks(disks []DiskStatus) (internal, external []DiskStatus) {
|
|
for _, d := range disks {
|
|
if d.External {
|
|
external = append(external, d)
|
|
} else {
|
|
internal = append(internal, d)
|
|
}
|
|
}
|
|
return internal, external
|
|
}
|
|
|
|
func diskLabel(prefix string, index int, total int) string {
|
|
if total <= 1 {
|
|
return prefix
|
|
}
|
|
return fmt.Sprintf("%s%d", prefix, index+1)
|
|
}
|
|
|
|
func formatDiskLine(label string, d DiskStatus) string {
|
|
if label == "" {
|
|
label = "DISK"
|
|
}
|
|
bar := progressBar(d.UsedPercent)
|
|
used := humanBytesShort(d.Used)
|
|
total := humanBytesShort(d.Total)
|
|
return fmt.Sprintf("%-6s %s %5.1f%% (%s/%s)", label, bar, d.UsedPercent, used, total)
|
|
}
|
|
|
|
func ioBar(rate float64) string {
|
|
// Scale: 0-50 MB/s maps to 0-5 blocks
|
|
filled := int(rate / 10.0)
|
|
if filled > 5 {
|
|
filled = 5
|
|
}
|
|
if filled < 0 {
|
|
filled = 0
|
|
}
|
|
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
|
|
if rate > 80 {
|
|
return dangerStyle.Render(bar)
|
|
}
|
|
if rate > 30 {
|
|
return warnStyle.Render(bar)
|
|
}
|
|
return okStyle.Render(bar)
|
|
}
|
|
|
|
func renderProcessCard(procs []ProcessInfo) cardData {
|
|
var lines []string
|
|
maxProcs := 3
|
|
for i, p := range procs {
|
|
if i >= maxProcs {
|
|
break
|
|
}
|
|
name := shorten(p.Name, 12)
|
|
cpuBar := miniBar(p.CPU)
|
|
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU))
|
|
}
|
|
if len(lines) == 0 {
|
|
lines = append(lines, subtleStyle.Render("No data"))
|
|
}
|
|
return cardData{icon: iconProcs, title: "Processes", lines: lines}
|
|
}
|
|
|
|
func miniBar(percent float64) string {
|
|
filled := int(percent / 20) // 5 chars max for 100%
|
|
if filled > 5 {
|
|
filled = 5
|
|
}
|
|
if filled < 0 {
|
|
filled = 0
|
|
}
|
|
return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled))
|
|
}
|
|
|
|
func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
|
var lines []string
|
|
var totalRx, totalTx float64
|
|
var primaryIP string
|
|
|
|
for _, n := range netStats {
|
|
totalRx += n.RxRateMBs
|
|
totalTx += n.TxRateMBs
|
|
if primaryIP == "" && n.IP != "" && n.Name == "en0" {
|
|
primaryIP = n.IP
|
|
}
|
|
}
|
|
|
|
if len(netStats) == 0 {
|
|
lines = []string{subtleStyle.Render("Collecting...")}
|
|
} else {
|
|
rxBar := netBar(totalRx)
|
|
txBar := netBar(totalTx)
|
|
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
|
|
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
|
|
// Show proxy and IP in one line
|
|
var infoParts []string
|
|
if proxy.Enabled {
|
|
infoParts = append(infoParts, "Proxy "+proxy.Type)
|
|
}
|
|
if primaryIP != "" {
|
|
infoParts = append(infoParts, primaryIP)
|
|
}
|
|
if len(infoParts) > 0 {
|
|
lines = append(lines, subtleStyle.Render(strings.Join(infoParts, " · ")))
|
|
}
|
|
}
|
|
return cardData{icon: iconNetwork, title: "Network", lines: lines}
|
|
}
|
|
|
|
func netBar(rate float64) string {
|
|
// Scale: 0-10 MB/s maps to 0-5 blocks
|
|
filled := int(rate / 2.0)
|
|
if filled > 5 {
|
|
filled = 5
|
|
}
|
|
if filled < 0 {
|
|
filled = 0
|
|
}
|
|
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
|
|
if rate > 8 {
|
|
return dangerStyle.Render(bar)
|
|
}
|
|
if rate > 3 {
|
|
return warnStyle.Render(bar)
|
|
}
|
|
return okStyle.Render(bar)
|
|
}
|
|
|
|
func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
|
var lines []string
|
|
if len(batts) == 0 {
|
|
lines = append(lines, subtleStyle.Render("No battery"))
|
|
} else {
|
|
b := batts[0]
|
|
// Line 1: label + bar + percentage (consistent with other cards)
|
|
// Only show red when battery is critically low
|
|
statusLower := strings.ToLower(b.Status)
|
|
percentText := fmt.Sprintf("%5.1f%%", b.Percent)
|
|
if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" {
|
|
percentText = dangerStyle.Render(percentText)
|
|
}
|
|
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
|
|
|
|
// Line 2: status
|
|
statusIcon := ""
|
|
statusStyle := subtleStyle
|
|
if statusLower == "charging" || statusLower == "charged" {
|
|
statusIcon = " ⚡"
|
|
statusStyle = okStyle
|
|
} else if b.Percent < 20 {
|
|
statusStyle = dangerStyle
|
|
}
|
|
// Capitalize first letter
|
|
statusText := b.Status
|
|
if len(statusText) > 0 {
|
|
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
|
|
}
|
|
if b.TimeLeft != "" {
|
|
statusText += " · " + b.TimeLeft
|
|
}
|
|
lines = append(lines, statusStyle.Render(statusText+statusIcon))
|
|
|
|
// Line 3: Health + cycles + temp
|
|
healthParts := []string{}
|
|
if b.Health != "" {
|
|
healthParts = append(healthParts, b.Health)
|
|
}
|
|
if b.CycleCount > 0 {
|
|
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
|
|
}
|
|
|
|
// Add temperature if available
|
|
if thermal.CPUTemp > 0 {
|
|
tempStyle := subtleStyle
|
|
if thermal.CPUTemp > 80 {
|
|
tempStyle = dangerStyle
|
|
} else if thermal.CPUTemp > 60 {
|
|
tempStyle = warnStyle
|
|
}
|
|
healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp)))
|
|
}
|
|
|
|
// Add fan speed if available
|
|
if thermal.FanSpeed > 0 {
|
|
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
|
|
}
|
|
|
|
if len(healthParts) > 0 {
|
|
lines = append(lines, subtleStyle.Render(strings.Join(healthParts, " · ")))
|
|
}
|
|
}
|
|
|
|
return cardData{icon: iconBattery, title: "Power", lines: lines}
|
|
}
|
|
|
|
func renderSensorsCard(sensors []SensorReading) cardData {
|
|
var lines []string
|
|
for _, s := range sensors {
|
|
if s.Note != "" {
|
|
continue
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit))
|
|
}
|
|
if len(lines) == 0 {
|
|
lines = append(lines, subtleStyle.Render("No sensors"))
|
|
}
|
|
return cardData{icon: iconSensors, title: "Sensors", lines: lines}
|
|
}
|
|
|
|
func renderCard(data cardData, width int, height int) string {
|
|
titleText := data.icon + " " + data.title
|
|
lineLen := width - lipgloss.Width(titleText) - 1
|
|
if lineLen < 4 {
|
|
lineLen = 4
|
|
}
|
|
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("─", lineLen))
|
|
content := header + "\n" + strings.Join(data.lines, "\n")
|
|
|
|
// Pad to target height
|
|
lines := strings.Split(content, "\n")
|
|
for len(lines) < height {
|
|
lines = append(lines, "")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func progressBar(percent float64) string {
|
|
total := 18
|
|
if percent < 0 {
|
|
percent = 0
|
|
}
|
|
if percent > 100 {
|
|
percent = 100
|
|
}
|
|
filled := int(percent / 100 * float64(total))
|
|
if filled > total {
|
|
filled = total
|
|
}
|
|
|
|
var builder strings.Builder
|
|
for i := 0; i < total; i++ {
|
|
if i < filled {
|
|
builder.WriteString("█")
|
|
} else {
|
|
builder.WriteString("░")
|
|
}
|
|
}
|
|
return colorizePercent(percent, builder.String())
|
|
}
|
|
|
|
func batteryProgressBar(percent float64) string {
|
|
total := 18
|
|
if percent < 0 {
|
|
percent = 0
|
|
}
|
|
if percent > 100 {
|
|
percent = 100
|
|
}
|
|
filled := int(percent / 100 * float64(total))
|
|
if filled > total {
|
|
filled = total
|
|
}
|
|
|
|
var builder strings.Builder
|
|
for i := 0; i < total; i++ {
|
|
if i < filled {
|
|
builder.WriteString("█")
|
|
} else {
|
|
builder.WriteString("░")
|
|
}
|
|
}
|
|
return colorizeBattery(percent, builder.String())
|
|
}
|
|
|
|
func colorizePercent(percent float64, s string) string {
|
|
switch {
|
|
case percent >= 90:
|
|
return dangerStyle.Render(s)
|
|
case percent >= 70:
|
|
return warnStyle.Render(s)
|
|
default:
|
|
return okStyle.Render(s)
|
|
}
|
|
}
|
|
|
|
func colorizeBattery(percent float64, s string) string {
|
|
switch {
|
|
case percent < 20:
|
|
return dangerStyle.Render(s)
|
|
case percent < 50:
|
|
return warnStyle.Render(s)
|
|
default:
|
|
return okStyle.Render(s)
|
|
}
|
|
}
|
|
|
|
func colorizeTemp(t float64) string {
|
|
switch {
|
|
case t >= 85:
|
|
return dangerStyle.Render(fmt.Sprintf("%.1f", t))
|
|
case t >= 70:
|
|
return warnStyle.Render(fmt.Sprintf("%.1f", t))
|
|
default:
|
|
return subtleStyle.Render(fmt.Sprintf("%.1f", t))
|
|
}
|
|
}
|
|
|
|
func formatRate(mb float64) string {
|
|
if mb < 0.01 {
|
|
return "0 MB/s"
|
|
}
|
|
if mb < 1 {
|
|
return fmt.Sprintf("%.2f MB/s", mb)
|
|
}
|
|
if mb < 10 {
|
|
return fmt.Sprintf("%.1f MB/s", mb)
|
|
}
|
|
return fmt.Sprintf("%.0f MB/s", mb)
|
|
}
|
|
|
|
func humanBytes(v uint64) string {
|
|
switch {
|
|
case v > 1<<40:
|
|
return fmt.Sprintf("%.1f TB", float64(v)/(1<<40))
|
|
case v > 1<<30:
|
|
return fmt.Sprintf("%.1f GB", float64(v)/(1<<30))
|
|
case v > 1<<20:
|
|
return fmt.Sprintf("%.1f MB", float64(v)/(1<<20))
|
|
case v > 1<<10:
|
|
return fmt.Sprintf("%.1f KB", float64(v)/(1<<10))
|
|
default:
|
|
return strconv.FormatUint(v, 10) + " B"
|
|
}
|
|
}
|
|
|
|
func humanBytesShort(v uint64) string {
|
|
switch {
|
|
case v >= 1<<40:
|
|
return fmt.Sprintf("%.0fT", float64(v)/(1<<40))
|
|
case v >= 1<<30:
|
|
return fmt.Sprintf("%.0fG", float64(v)/(1<<30))
|
|
case v >= 1<<20:
|
|
return fmt.Sprintf("%.0fM", float64(v)/(1<<20))
|
|
case v >= 1<<10:
|
|
return fmt.Sprintf("%.0fK", float64(v)/(1<<10))
|
|
default:
|
|
return strconv.FormatUint(v, 10)
|
|
}
|
|
}
|
|
|
|
func humanBytesCompact(v uint64) string {
|
|
switch {
|
|
case v >= 1<<40:
|
|
return fmt.Sprintf("%.1fT", float64(v)/(1<<40))
|
|
case v >= 1<<30:
|
|
return fmt.Sprintf("%.1fG", float64(v)/(1<<30))
|
|
case v >= 1<<20:
|
|
return fmt.Sprintf("%.1fM", float64(v)/(1<<20))
|
|
case v >= 1<<10:
|
|
return fmt.Sprintf("%.1fK", float64(v)/(1<<10))
|
|
default:
|
|
return strconv.FormatUint(v, 10)
|
|
}
|
|
}
|
|
|
|
func shorten(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max-1] + "…"
|
|
}
|
|
|
|
func renderTwoColumns(cards []cardData, width int) string {
|
|
if len(cards) == 0 {
|
|
return ""
|
|
}
|
|
cw := colWidth
|
|
if width > 0 && width/2-2 > cw {
|
|
cw = width/2 - 2
|
|
}
|
|
var rows []string
|
|
for i := 0; i < len(cards); i += 2 {
|
|
left := renderCard(cards[i], cw, 0)
|
|
right := ""
|
|
if i+1 < len(cards) {
|
|
right = renderCard(cards[i+1], cw, 0)
|
|
}
|
|
targetHeight := maxInt(lipgloss.Height(left), lipgloss.Height(right))
|
|
left = renderCard(cards[i], cw, targetHeight)
|
|
if right != "" {
|
|
right = renderCard(cards[i+1], cw, targetHeight)
|
|
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right))
|
|
} else {
|
|
rows = append(rows, left)
|
|
}
|
|
}
|
|
|
|
// Add empty lines between rows for separation
|
|
var spacedRows []string
|
|
for i, r := range rows {
|
|
if i > 0 {
|
|
spacedRows = append(spacedRows, "")
|
|
}
|
|
spacedRows = append(spacedRows, r)
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, spacedRows...)
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|