1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 21:20:09 +00:00
Files
Mole/cmd/status/view.go
tw93 8e8059b0aa fix(status): resolve layout issue when stretching terminal window (#467)
When the terminal is stretched wide, the header info line may wrap to
multiple lines but the mole position was calculated independently based
on terminal width, causing vertical misalignment.

Separate header and mole rendering so mole always appears on dedicated
lines below the header regardless of terminal width.
2026-02-16 19:07:42 +08:00

785 lines
20 KiB
Go

package main
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040"))
primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9"))
)
const (
colWidth = 38
iconCPU = "◉"
iconMemory = "◫"
iconGPU = "◧"
iconDisk = "▥"
iconNetwork = "⇅"
iconBattery = "◪"
iconSensors = "◈"
iconProcs = "❊"
)
// Mole body frames (facing right).
var moleBody = [][]string{
{
` /\_/\`,
` ___/ o o \`,
`/___ =-= /`,
`\____)-m-m)`,
},
{
` /\_/\`,
` ___/ o o \`,
`/___ =-= /`,
`\____)mm__)`,
},
{
` /\_/\`,
` ___/ · · \`,
`/___ =-= /`,
`\___)-m__m)`,
},
{
` /\_/\`,
` ___/ o o \`,
`/___ =-= /`,
`\____)-mm-)`,
},
}
// Mirror mole body frames (facing left).
var moleBodyMirror = [][]string{
{
` /\_/\`,
` / o o \___`,
` \ =-= ___\`,
` (m-m-(____/`,
},
{
` /\_/\`,
` / o o \___`,
` \ =-= ___\`,
` (__mm(____/`,
},
{
` /\_/\`,
` / · · \___`,
` \ =-= ___\`,
` (m__m-(___/`,
},
{
` /\_/\`,
` / o o \___`,
` \ =-= ___\`,
` (-mm-(____/`,
},
}
// getMoleFrame renders the animated mole.
func getMoleFrame(animFrame int, termWidth int) string {
moleWidth := 15
maxPos := max(termWidth-moleWidth, 0)
cycleLength := maxPos * 2
if cycleLength == 0 {
cycleLength = 1
}
pos := animFrame % cycleLength
movingLeft := pos > maxPos
if movingLeft {
pos = cycleLength - pos
}
// Use mirror frames when moving left
var frames [][]string
if movingLeft {
frames = moleBodyMirror
} else {
frames = moleBody
}
bodyIdx := animFrame % len(frames)
body := frames[bodyIdx]
padding := strings.Repeat(" ", pos)
var lines []string
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, catHidden bool) (string, string) {
title := titleStyle.Render("Status")
scoreStyle := getScoreStyle(m.HealthScore)
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
// Hardware info for a single line.
infoParts := []string{}
if m.Hardware.Model != "" {
infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model))
}
if m.Hardware.CPUModel != "" {
cpuInfo := m.Hardware.CPUModel
// Append GPU core count when available.
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
cpuInfo += fmt.Sprintf(", %dGPU", m.GPU[0].CoreCount)
}
infoParts = append(infoParts, cpuInfo)
}
var specs []string
if m.Hardware.TotalRAM != "" {
specs = append(specs, m.Hardware.TotalRAM)
}
if m.Hardware.DiskSize != "" {
specs = append(specs, m.Hardware.DiskSize)
}
if len(specs) > 0 {
infoParts = append(infoParts, strings.Join(specs, "/"))
}
if m.Hardware.RefreshRate != "" {
infoParts = append(infoParts, m.Hardware.RefreshRate)
}
if m.Hardware.OSVersion != "" {
infoParts = append(infoParts, m.Hardware.OSVersion)
}
if m.Uptime != "" {
infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime))
}
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
// Show cat unless hidden - render mole centered below header
var mole string
if !catHidden {
mole = getMoleFrame(animFrame, termWidth)
}
if errMsg != "" {
if mole == "" {
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", dangerStyle.Render("ERROR: "+errMsg)), ""
}
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg)), mole
}
if mole == "" {
return headerLine, ""
}
return headerLine, mole
}
func getScoreStyle(score int) lipgloss.Style {
switch {
case score >= 90:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
case score >= 75:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
case score >= 60:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
case score >= 40:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
}
}
func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData {
var lines []string
// Line 1: Usage + Temp (Format: 15% @ 30.4°C)
usageBar := progressBar(cpu.Usage)
headerText := fmt.Sprintf("%5.1f%%", cpu.Usage)
if thermal.CPUTemp > 0 {
headerText += fmt.Sprintf(" @ %s°C", colorizeTemp(thermal.CPUTemp))
}
lines = append(lines, fmt.Sprintf("Total %s %s", usageBar, headerText))
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 := min(len(cores), 3)
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))
}
}
// 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}
}
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
// Line 1: Used
lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent))
// Line 2: Free
freePercent := 100 - mem.UsedPercent
lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent))
if hasSwap {
// Layout with Swap:
// 3. Swap (progress bar + text)
// 4. Total
// 5. Avail
var swapPercent float64
if mem.SwapTotal > 0 {
swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0
}
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("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 {
// 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.
if mem.Pressure != "" {
pressureStyle := okStyle
pressureText := "Status " + mem.Pressure
switch mem.Pressure {
case "warn":
pressureStyle = warnStyle
case "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 {
filled := min(int(rate/10.0), 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 buildCards(m MetricsSnapshot, width int) []cardData {
cards := []cardData{
renderCPUCard(m.CPU, m.Thermal),
renderMemoryCard(m.Memory),
renderDiskCard(m.Disks, m.DiskIO),
renderBatteryCard(m.Batteries, m.Thermal),
renderProcessCard(m.TopProcesses),
renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width),
}
// Sensors card disabled - redundant with CPU temp
// if hasSensorData(m.Sensors) {
// cards = append(cards, renderSensorsCard(m.Sensors))
// }
return cards
}
func miniBar(percent float64) string {
filled := min(int(percent/20), 5)
if filled < 0 {
filled = 0
}
return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled))
}
func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy ProxyStatus, cardWidth int) 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 {
// Calculate dynamic width
// Layout: "Down " (7) + graph + " " (2) + rate (approx 10-12)
// Safe margin: 22 chars.
// We target 16 chars to match progressBar implementation for visual consistency.
graphWidth := cardWidth - 22
if graphWidth < 5 {
graphWidth = 5
}
if graphWidth > 16 {
graphWidth = 16 // Match progressBar fixed width
}
// sparkline graphs
rxSparkline := sparkline(history.RxHistory, totalRx, graphWidth)
txSparkline := sparkline(history.TxHistory, totalTx, graphWidth)
lines = append(lines, fmt.Sprintf("Down %s %s", rxSparkline, formatRate(totalRx)))
lines = append(lines, fmt.Sprintf("Up %s %s", txSparkline, formatRate(totalTx)))
// Show proxy and IP on 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, strings.Join(infoParts, " · "))
}
}
return cardData{icon: iconNetwork, title: "Network", lines: lines}
}
// 8 levels: ▁▂▃▄▅▆▇█
func sparkline(history []float64, current float64, width int) string {
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
data := make([]float64, 0, width)
if len(history) > 0 {
// Take the most recent points.
start := 0
if len(history) > width {
start = len(history) - width
}
data = append(data, history[start:]...)
}
// padding with zeros at the start
for len(data) < width {
data = append([]float64{0}, data...)
}
if len(data) > width {
data = data[len(data)-width:]
}
maxVal := 0.1
for _, v := range data {
if v > maxVal {
maxVal = v
}
}
var builder strings.Builder
for _, v := range data {
level := int((v / maxVal) * float64(len(blocks)-1))
if level < 0 {
level = 0
}
if level >= len(blocks) {
level = len(blocks) - 1
}
builder.WriteRune(blocks[level])
}
result := builder.String()
if current > 8 {
return dangerStyle.Render(result)
}
if current > 3 {
return warnStyle.Render(result)
}
return okStyle.Render(result)
}
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]
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))
// 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 := ""
statusStyle := subtleStyle
if statusLower == "charging" || statusLower == "charged" {
statusIcon = " ⚡"
statusStyle = okStyle
} else if b.Percent < 20 {
statusStyle = dangerStyle
}
statusText := b.Status
if len(statusText) > 0 {
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
}
if b.TimeLeft != "" {
statusText += " · " + b.TimeLeft
}
// Add power info.
if statusLower == "charging" || statusLower == "charged" {
if thermal.SystemPower > 0 {
statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower)
} else if thermal.AdapterPower > 0 {
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
}
} else if thermal.BatteryPower > 0 {
// Only show battery power when discharging (positive value)
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
}
lines = append(lines, statusStyle.Render(statusText+statusIcon))
healthParts := []string{}
if b.Health != "" {
healthParts = append(healthParts, b.Health)
}
if b.CycleCount > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
}
if thermal.CPUTemp > 0 {
tempText := colorizeTemp(thermal.CPUTemp) + "°C" // Reuse common color logic
healthParts = append(healthParts, tempText)
}
if thermal.FanSpeed > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
}
if len(healthParts) > 0 {
lines = append(lines, strings.Join(healthParts, " · "))
}
}
return cardData{icon: iconBattery, title: "Power", lines: lines}
}
func renderCard(data cardData, width int, height int) string {
titleText := data.icon + " " + data.title
lineLen := max(width-lipgloss.Width(titleText)-2, 4)
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
content := header + "\n" + strings.Join(data.lines, "\n")
lines := strings.Split(content, "\n")
for len(lines) < height {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func progressBar(percent float64) string {
total := 16
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100 * float64(total))
var builder strings.Builder
for i := range total {
if i < filled {
builder.WriteString("█")
} else {
builder.WriteString("░")
}
}
return colorizePercent(percent, builder.String())
}
func batteryProgressBar(percent float64) string {
total := 16
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100 * float64(total))
var builder strings.Builder
for i := range total {
if i < filled {
builder.WriteString("█")
} else {
builder.WriteString("░")
}
}
return colorizeBattery(percent, builder.String())
}
func colorizePercent(percent float64, s string) string {
switch {
case percent >= 85:
return dangerStyle.Render(s)
case percent >= 60:
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 >= 76:
return dangerStyle.Render(fmt.Sprintf("%.1f", t))
case t >= 56:
return warnStyle.Render(fmt.Sprintf("%.1f", t))
default:
return okStyle.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, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-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 := max(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)
}
}
var spacedRows []string
for i, r := range rows {
if i > 0 {
spacedRows = append(spacedRows, "")
}
spacedRows = append(spacedRows, r)
}
return lipgloss.JoinVertical(lipgloss.Left, spacedRows...)
}