1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 15:04:42 +00:00

feat: display macOS battery capacity and cached memory, and refine status view presentation

This commit is contained in:
Tw93
2026-01-02 19:59:07 +08:00
parent 6c8c87bef6
commit cc0cbef8d9
4 changed files with 127 additions and 32 deletions

View File

@@ -83,7 +83,8 @@ type MemoryStatus struct {
UsedPercent float64 UsedPercent float64
SwapUsed uint64 SwapUsed uint64
SwapTotal uint64 SwapTotal uint64
Pressure string // macOS memory pressure: normal/warn/critical Cached uint64 // File cache that can be freed if needed
Pressure string // macOS memory pressure: normal/warn/critical
} }
type DiskStatus struct { type DiskStatus struct {
@@ -115,6 +116,7 @@ type BatteryStatus struct {
TimeLeft string TimeLeft string
Health string Health string
CycleCount int CycleCount int
Capacity int // Maximum capacity percentage (e.g., 85 means 85% of original)
} }
type ThermalStatus struct { type ThermalStatus struct {

View File

@@ -32,9 +32,9 @@ func collectBatteries() (batts []BatteryStatus, err error) {
// macOS: pmset for real-time percentage/status. // macOS: pmset for real-time percentage/status.
if runtime.GOOS == "darwin" && commandExists("pmset") { if runtime.GOOS == "darwin" && commandExists("pmset") {
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
// Health/cycles from cached system_profiler. // Health/cycles/capacity from cached system_profiler.
health, cycles := getCachedPowerData() health, cycles, capacity := getCachedPowerData()
if batts := parsePMSet(out, health, cycles); len(batts) > 0 { if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 {
return batts, nil return batts, nil
} }
} }
@@ -67,7 +67,7 @@ func collectBatteries() (batts []BatteryStatus, err error) {
return nil, errors.New("no battery data found") return nil, errors.New("no battery data found")
} }
func parsePMSet(raw string, health string, cycles int) []BatteryStatus { func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus {
lines := strings.Split(raw, "\n") lines := strings.Split(raw, "\n")
var out []BatteryStatus var out []BatteryStatus
var timeLeft string var timeLeft string
@@ -115,16 +115,17 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus {
TimeLeft: timeLeft, TimeLeft: timeLeft,
Health: health, Health: health,
CycleCount: cycles, CycleCount: cycles,
Capacity: capacity,
}) })
} }
return out return out
} }
// getCachedPowerData returns condition and cycles from cached system_profiler. // getCachedPowerData returns condition, cycles, and capacity from cached system_profiler.
func getCachedPowerData() (health string, cycles int) { func getCachedPowerData() (health string, cycles int, capacity int) {
out := getSystemPowerOutput() out := getSystemPowerOutput()
if out == "" { if out == "" {
return "", 0 return "", 0, 0
} }
lines := strings.Split(out, "\n") lines := strings.Split(out, "\n")
@@ -140,8 +141,15 @@ func getCachedPowerData() (health string, cycles int) {
health = strings.TrimSpace(after) health = strings.TrimSpace(after)
} }
} }
if strings.Contains(lower, "maximum capacity") {
if _, after, found := strings.Cut(line, ":"); found {
capacityStr := strings.TrimSpace(after)
capacityStr = strings.TrimSuffix(capacityStr, "%")
capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr))
}
}
} }
return health, cycles return health, cycles, capacity
} }
func getSystemPowerOutput() string { func getSystemPowerOutput() string {

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"runtime" "runtime"
"strconv"
"strings" "strings"
"time" "time"
@@ -18,16 +19,62 @@ func collectMemory() (MemoryStatus, error) {
swap, _ := mem.SwapMemory() swap, _ := mem.SwapMemory()
pressure := getMemoryPressure() pressure := getMemoryPressure()
// On macOS, vm.Cached is 0, so we calculate from file-backed pages.
cached := vm.Cached
if runtime.GOOS == "darwin" && cached == 0 {
cached = getFileBackedMemory()
}
return MemoryStatus{ return MemoryStatus{
Used: vm.Used, Used: vm.Used,
Total: vm.Total, Total: vm.Total,
UsedPercent: vm.UsedPercent, UsedPercent: vm.UsedPercent,
SwapUsed: swap.Used, SwapUsed: swap.Used,
SwapTotal: swap.Total, SwapTotal: swap.Total,
Cached: cached,
Pressure: pressure, Pressure: pressure,
}, nil }, nil
} }
func getFileBackedMemory() uint64 {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "vm_stat")
if err != nil {
return 0
}
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
var pageSize uint64 = 4096 // Default
lines := strings.Split(out, "\n")
if len(lines) > 0 {
firstLine := lines[0]
if strings.Contains(firstLine, "page size of") {
if _, after, found := strings.Cut(firstLine, "page size of "); found {
if before, _, found := strings.Cut(after, " bytes"); found {
if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil {
pageSize = size
}
}
}
}
}
// Parse "File-backed pages: 388975."
for _, line := range lines {
if strings.Contains(line, "File-backed pages:") {
if _, after, found := strings.Cut(line, ":"); found {
numStr := strings.TrimSpace(after)
numStr = strings.TrimSuffix(numStr, ".")
if pages, err := strconv.ParseUint(numStr, 10, 64); err == nil {
return pages * pageSize
}
}
}
}
return 0
}
func getMemoryPressure() string { func getMemoryPressure() string {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return "" return ""

View File

@@ -129,7 +129,7 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
infoParts = append(infoParts, m.Hardware.OSVersion) infoParts = append(infoParts, m.Hardware.OSVersion)
} }
headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · ")) headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
mole := getMoleFrame(animFrame, termWidth) mole := getMoleFrame(animFrame, termWidth)
@@ -181,14 +181,6 @@ func renderCPUCard(cpu CPUStatus) cardData {
var lines []string var lines []string
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) 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 { if cpu.PerCoreEstimated {
lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)"))
} else if len(cpu.PerCore) > 0 { } else if len(cpu.PerCore) > 0 {
@@ -212,6 +204,15 @@ func renderCPUCard(cpu CPUStatus) cardData {
} }
} }
// Load line at the end
if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)",
cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount))
} else {
lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)",
cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))
}
return cardData{icon: iconCPU, title: "CPU", lines: lines} return cardData{icon: iconCPU, title: "CPU", lines: lines}
} }
@@ -226,9 +227,9 @@ func renderGPUCard(gpus []GPUStatus) cardData {
} }
coreInfo := "" coreInfo := ""
if g.CoreCount > 0 { if g.CoreCount > 0 {
coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount)
} }
lines = append(lines, subtleStyle.Render(g.Name+coreInfo)) lines = append(lines, g.Name+coreInfo)
if g.Usage < 0 { if g.Usage < 0 {
lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics")) lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics"))
} }
@@ -238,22 +239,48 @@ func renderGPUCard(gpus []GPUStatus) cardData {
} }
func renderMemoryCard(mem MemoryStatus) cardData { func renderMemoryCard(mem MemoryStatus) cardData {
// Check if swap is being used (or at least allocated).
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
var lines []string var lines []string
// Line 1: Used
lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent)) 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 // Line 2: Free
freePercent := 100 - mem.UsedPercent freePercent := 100 - mem.UsedPercent
lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent)) 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 { if hasSwap {
// Layout with Swap:
// 3. Swap (progress bar + text)
// 4. Total
// 5. Avail
var swapPercent float64 var swapPercent float64
if mem.SwapTotal > 0 { if mem.SwapTotal > 0 {
swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0
} }
swapText := subtleStyle.Render(fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal))) swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal))
lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText)) lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText))
lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total)))
lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency
} else { } else {
lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use"))) // Layout without Swap:
// 3. Total
// 4. Cached (if > 0)
// 5. Avail
lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total)))
if mem.Cached > 0 {
lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached)))
}
// Calculate available if not provided directly, or use Total-Used as proxy if needed,
// but typically available is more nuanced. Using what we have.
// Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail"
// in simple terms for this view or we could use the passed definition.
// Original code calculated: available := mem.Total - mem.Used
available := mem.Total - mem.Used
lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available)))
} }
// Memory pressure status. // Memory pressure status.
if mem.Pressure != "" { if mem.Pressure != "" {
@@ -400,7 +427,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
infoParts = append(infoParts, primaryIP) infoParts = append(infoParts, primaryIP)
} }
if len(infoParts) > 0 { if len(infoParts) > 0 {
lines = append(lines, subtleStyle.Render(strings.Join(infoParts, " · "))) lines = append(lines, strings.Join(infoParts, " · "))
} }
} }
return cardData{icon: iconNetwork, title: "Network", lines: lines} return cardData{icon: iconNetwork, title: "Network", lines: lines}
@@ -437,6 +464,17 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} }
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText)) lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
// Add capacity line if available.
if b.Capacity > 0 {
capacityText := fmt.Sprintf("%5d%%", b.Capacity)
if b.Capacity < 70 {
capacityText = dangerStyle.Render(capacityText)
} else if b.Capacity < 85 {
capacityText = warnStyle.Render(capacityText)
}
lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText))
}
statusIcon := "" statusIcon := ""
statusStyle := subtleStyle statusStyle := subtleStyle
if statusLower == "charging" || statusLower == "charged" { if statusLower == "charging" || statusLower == "charged" {
@@ -473,13 +511,13 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} }
if thermal.CPUTemp > 0 { if thermal.CPUTemp > 0 {
tempStyle := subtleStyle tempText := fmt.Sprintf("%.0f°C", thermal.CPUTemp)
if thermal.CPUTemp > 80 { if thermal.CPUTemp > 80 {
tempStyle = dangerStyle tempText = dangerStyle.Render(tempText)
} else if thermal.CPUTemp > 60 { } else if thermal.CPUTemp > 60 {
tempStyle = warnStyle tempText = warnStyle.Render(tempText)
} }
healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp))) healthParts = append(healthParts, tempText)
} }
if thermal.FanSpeed > 0 { if thermal.FanSpeed > 0 {
@@ -487,7 +525,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} }
if len(healthParts) > 0 { if len(healthParts) > 0 {
lines = append(lines, subtleStyle.Render(strings.Join(healthParts, " · "))) lines = append(lines, strings.Join(healthParts, " · "))
} }
} }