1352 lines
46 KiB
Swift
1352 lines
46 KiB
Swift
import Foundation
|
||
import AppKit
|
||
import SwiftUI
|
||
import MacToolsPluginKit
|
||
|
||
private enum SystemStatusHUDLayout {
|
||
static let outerPadding: CGFloat = 0
|
||
static let sectionSpacing: CGFloat = SystemStatusComponentLayout.dashboardSectionSpacing
|
||
static let metricSpacing: CGFloat = SystemStatusComponentLayout.cardSpacing
|
||
static let metricTileHeight: CGFloat = SystemStatusComponentLayout.dashboardMetricTileHeight
|
||
static let metricInternalSpacing: CGFloat = 3
|
||
static let metricTitleHeight: CGFloat = 20
|
||
static let metricValueHeight: CGFloat = 19
|
||
static let metricVisualHeight: CGFloat = 22
|
||
static let metricFootnoteHeight: CGFloat = 11
|
||
static let lowerTileHeight: CGFloat = SystemStatusComponentLayout.dashboardLowerTileHeight
|
||
static let processRowHeight: CGFloat = 17
|
||
static let processListSpacing: CGFloat = metricInternalSpacing
|
||
static let processLimit = 3
|
||
static let processListHeight = CGFloat(processLimit) * processRowHeight
|
||
+ CGFloat(max(processLimit - 1, 0)) * processListSpacing
|
||
static let chartDisplayInterval: TimeInterval = 30 * 60
|
||
static let chartSampleLimit = 120
|
||
}
|
||
|
||
struct SystemStatusDashboardView: View {
|
||
let snapshot: SystemStatusSnapshot
|
||
let visibleKinds: [SystemStatusMetricKind]
|
||
let localization: PluginLocalization
|
||
|
||
private var metricColumns: [GridItem] {
|
||
[
|
||
GridItem(.flexible(), spacing: SystemStatusHUDLayout.metricSpacing),
|
||
GridItem(.flexible(), spacing: SystemStatusHUDLayout.metricSpacing)
|
||
]
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if visibleRows.isEmpty {
|
||
emptyState
|
||
} else {
|
||
VStack(alignment: .leading, spacing: SystemStatusHUDLayout.sectionSpacing) {
|
||
ForEach(Array(visibleRows.enumerated()), id: \.offset) { _, row in
|
||
rowView(row)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(SystemStatusHUDLayout.outerPadding)
|
||
.frame(
|
||
height: SystemStatusComponentLayout.contentHeight(for: visibleKinds),
|
||
alignment: .topLeading
|
||
)
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
}
|
||
|
||
private var visibleRows: [SystemStatusComponentRow] {
|
||
SystemStatusComponentLayout.rows(for: visibleKinds)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func rowView(_ row: SystemStatusComponentRow) -> some View {
|
||
switch row {
|
||
case let .metrics(kinds):
|
||
metricRow(kinds)
|
||
case .topProcesses:
|
||
topProcessesSection
|
||
}
|
||
}
|
||
|
||
private func metricRow(_ kinds: [SystemStatusMetricKind]) -> some View {
|
||
let visibleHistory = chartHistory
|
||
return LazyVGrid(columns: metricColumns, spacing: SystemStatusHUDLayout.metricSpacing) {
|
||
ForEach(kinds) { kind in
|
||
metricTile(kind, history: visibleHistory)
|
||
}
|
||
}
|
||
.frame(height: SystemStatusComponentLayout.dashboardMetricTileHeight, alignment: .top)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func metricTile(_ kind: SystemStatusMetricKind, history: [SystemStatusHistoryPoint]) -> some View {
|
||
switch kind {
|
||
case .cpu:
|
||
cpuTile(history: history)
|
||
case .gpu:
|
||
gpuTile(history: history)
|
||
case .network:
|
||
networkTile(history: history)
|
||
case .disk:
|
||
diskTile(history: history)
|
||
case .memory:
|
||
memoryTile(history: history)
|
||
case .battery:
|
||
batteryTile(history: history)
|
||
case .topProcesses:
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "slider.horizontal.3")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(SystemStatusHUDPalette.textSecondary)
|
||
|
||
Text(localization.string("component.empty.title", defaultValue: "未选择显示内容"))
|
||
.font(SystemStatusHUDFont.sans(11, .semibold))
|
||
.foregroundStyle(SystemStatusHUDPalette.textSecondary)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: SystemStatusComponentLayout.emptyContentHeight)
|
||
.background(SystemStatusHUDCardBackground())
|
||
}
|
||
|
||
private func cpuTile(history: [SystemStatusHistoryPoint]) -> some View {
|
||
let value = percentParts(snapshot.cpu.usage)
|
||
return SystemStatusHUDValueTile(
|
||
eyebrow: "CPU",
|
||
glyph: "cpu",
|
||
accent: SystemStatusHUDPalette.green,
|
||
chartColor: SystemStatusHUDPalette.green,
|
||
value: value.value,
|
||
unit: value.unit,
|
||
chip: temperatureChip(snapshot.cpu.temperatureCelsius),
|
||
values: percentHistory(\.cpuUsage, fallback: snapshot.cpu.usage, history: history),
|
||
chartStyle: .bars,
|
||
footnote: cpuFootnote
|
||
)
|
||
}
|
||
|
||
private func gpuTile(history: [SystemStatusHistoryPoint]) -> some View {
|
||
let value = snapshot.gpu.isAvailable
|
||
? percentParts(snapshot.gpu.usage)
|
||
: (value: "—", unit: "")
|
||
return SystemStatusHUDValueTile(
|
||
eyebrow: "GPU",
|
||
glyph: "cpu.fill",
|
||
accent: SystemStatusHUDPalette.gpu,
|
||
chartColor: SystemStatusHUDPalette.gpu,
|
||
value: value.value,
|
||
unit: value.unit,
|
||
chip: temperatureChip(snapshot.gpu.temperatureCelsius),
|
||
values: percentHistory(\.gpuUsage, fallback: snapshot.gpu.usage, history: history),
|
||
chartStyle: .bars,
|
||
footnote: gpuFootnote
|
||
)
|
||
}
|
||
|
||
private func memoryTile(history: [SystemStatusHistoryPoint]) -> some View {
|
||
let value = percentParts(snapshot.memory.usage)
|
||
return SystemStatusHUDValueTile(
|
||
eyebrow: SystemStatusMetricKind.memory.title(localization: localization),
|
||
glyph: "memorychip",
|
||
accent: SystemStatusHUDPalette.amber,
|
||
chartColor: SystemStatusHUDPalette.amber,
|
||
value: value.value,
|
||
unit: value.unit,
|
||
chip: memoryChip,
|
||
values: percentHistory(\.memoryUsage, fallback: snapshot.memory.usage, history: history),
|
||
chartStyle: .area,
|
||
footnote: memoryFootnote
|
||
)
|
||
}
|
||
|
||
private func diskTile(history: [SystemStatusHistoryPoint]) -> some View {
|
||
let rate = rateParts(totalDiskBytesPerSecond)
|
||
let free = bytesParts(diskFreeBytes)
|
||
return SystemStatusHUDDiskTile(
|
||
title: SystemStatusMetricKind.disk.title(localization: localization),
|
||
freeValue: free.value,
|
||
freeUnit: diskAvailableUnit(free.unit),
|
||
totalText: SystemStatusFormatter.bytes(snapshot.disk.totalBytes),
|
||
readText: SystemStatusFormatter.speed(snapshot.disk.readBytesPerSecond),
|
||
writeText: SystemStatusFormatter.speed(snapshot.disk.writeBytesPerSecond),
|
||
readValues: diskReadHistory(history),
|
||
writeValues: diskWriteHistory(history),
|
||
readColor: SystemStatusHUDPalette.diskRead,
|
||
writeColor: SystemStatusHUDPalette.diskWrite
|
||
)
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(
|
||
localization.format(
|
||
"accessibility.diskFormat",
|
||
defaultValue: "磁盘 %@%@,%@",
|
||
rate.value,
|
||
rate.unit,
|
||
diskFootnote
|
||
)
|
||
)
|
||
}
|
||
|
||
private func networkTile(history: [SystemStatusHistoryPoint]) -> some View {
|
||
let rate = rateParts(totalNetworkBytesPerSecond)
|
||
return SystemStatusHUDMetricTile(
|
||
title: SystemStatusMetricKind.network.title(localization: localization),
|
||
glyph: "network",
|
||
accent: SystemStatusHUDPalette.network,
|
||
value: rate.value,
|
||
unit: rate.unit,
|
||
chip: networkChip,
|
||
footnote: nil,
|
||
footer: {
|
||
SystemStatusHUDRateFooter(
|
||
firstLabel: "↓",
|
||
firstText: SystemStatusFormatter.speed(snapshot.network.downloadBytesPerSecond),
|
||
firstColor: SystemStatusHUDPalette.networkDownload,
|
||
secondLabel: "↑",
|
||
secondText: SystemStatusFormatter.speed(snapshot.network.uploadBytesPerSecond),
|
||
secondColor: SystemStatusHUDPalette.networkUpload
|
||
)
|
||
}
|
||
) {
|
||
SystemStatusHUDRateChart(
|
||
firstValues: networkDownloadHistory(history),
|
||
secondValues: networkUploadHistory(history),
|
||
firstColor: SystemStatusHUDPalette.networkDownload,
|
||
secondColor: SystemStatusHUDPalette.networkUpload
|
||
)
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(
|
||
localization.format(
|
||
"accessibility.networkFormat",
|
||
defaultValue: "网络 %@%@,%@",
|
||
rate.value,
|
||
rate.unit,
|
||
networkFootnote
|
||
)
|
||
)
|
||
}
|
||
|
||
private func batteryTile(history: [SystemStatusHistoryPoint]) -> some View {
|
||
let color = batteryColor
|
||
let value = snapshot.battery.isAvailable
|
||
? percentParts(snapshot.battery.level)
|
||
: (value: "—", unit: "")
|
||
|
||
return SystemStatusHUDValueTile(
|
||
eyebrow: SystemStatusMetricKind.battery.title(localization: localization),
|
||
glyph: "battery.100",
|
||
accent: color,
|
||
chartColor: color,
|
||
value: value.value,
|
||
unit: value.unit,
|
||
chip: temperatureChip(snapshot.battery.temperatureCelsius),
|
||
values: percentHistory(\.batteryLevel, fallback: snapshot.battery.level, history: history),
|
||
chartStyle: .area,
|
||
footnote: batteryFootnote
|
||
)
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(
|
||
localization.format(
|
||
"accessibility.batteryFormat",
|
||
defaultValue: "电量 %@,%@",
|
||
SystemStatusFormatter.percent(snapshot.battery.level),
|
||
batteryFootnote
|
||
)
|
||
)
|
||
}
|
||
|
||
private var topProcessesSection: some View {
|
||
VStack(alignment: .leading, spacing: SystemStatusHUDLayout.metricInternalSpacing) {
|
||
SystemStatusHUDEyebrow(
|
||
text: SystemStatusMetricKind.topProcesses.title(localization: localization),
|
||
glyph: "list.bullet",
|
||
color: SystemStatusHUDPalette.textSecondary
|
||
)
|
||
.frame(height: SystemStatusHUDLayout.metricTitleHeight, alignment: .leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if snapshot.topProcesses.isEmpty {
|
||
Text(localization.string("topProcesses.collecting", defaultValue: "采集中…"))
|
||
.font(SystemStatusHUDFont.mono(10))
|
||
.foregroundStyle(SystemStatusHUDPalette.textSecondary)
|
||
.frame(
|
||
maxWidth: .infinity,
|
||
minHeight: SystemStatusHUDLayout.processListHeight,
|
||
alignment: .center
|
||
)
|
||
} else {
|
||
VStack(spacing: SystemStatusHUDLayout.processListSpacing) {
|
||
ForEach(Array(snapshot.topProcesses.prefix(SystemStatusHUDLayout.processLimit))) { process in
|
||
SystemStatusHUDProcessRow(process: process, localization: localization)
|
||
}
|
||
}
|
||
.frame(
|
||
height: SystemStatusHUDLayout.processListHeight,
|
||
alignment: .topLeading
|
||
)
|
||
}
|
||
}
|
||
.padding(SystemStatusComponentLayout.cardContentPadding)
|
||
.frame(height: SystemStatusHUDLayout.lowerTileHeight, alignment: .topLeading)
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.background(SystemStatusHUDCardBackground())
|
||
}
|
||
|
||
private var diskFreeBytes: UInt64? {
|
||
guard let totalBytes = snapshot.disk.totalBytes, let usedBytes = snapshot.disk.usedBytes else {
|
||
return nil
|
||
}
|
||
|
||
return totalBytes >= usedBytes ? totalBytes - usedBytes : 0
|
||
}
|
||
|
||
private var totalNetworkBytesPerSecond: UInt64? {
|
||
guard snapshot.network.downloadBytesPerSecond != nil || snapshot.network.uploadBytesPerSecond != nil else {
|
||
return nil
|
||
}
|
||
|
||
return (snapshot.network.downloadBytesPerSecond ?? 0) + (snapshot.network.uploadBytesPerSecond ?? 0)
|
||
}
|
||
|
||
private var totalDiskBytesPerSecond: UInt64? {
|
||
guard snapshot.disk.readBytesPerSecond != nil || snapshot.disk.writeBytesPerSecond != nil else {
|
||
return nil
|
||
}
|
||
|
||
return (snapshot.disk.readBytesPerSecond ?? 0) + (snapshot.disk.writeBytesPerSecond ?? 0)
|
||
}
|
||
|
||
private var memoryChip: (text: String, color: Color)? {
|
||
(SystemStatusFormatter.bytes(snapshot.memory.totalBytes), SystemStatusHUDPalette.badgeText)
|
||
}
|
||
|
||
private var networkChip: (text: String, color: Color)? {
|
||
guard let name = snapshot.network.interfaceName, !name.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
return (name, SystemStatusHUDPalette.badgeText)
|
||
}
|
||
|
||
private var cpuFootnote: String {
|
||
let powerText = localization.format(
|
||
"metric.powerFormat",
|
||
defaultValue: "功率 %@",
|
||
SystemStatusFormatter.power(snapshot.cpu.systemPowerWatts)
|
||
)
|
||
guard let load = snapshot.cpu.loadAverage1Minute else {
|
||
return localization.format(
|
||
"cpu.footnote.loadUnavailableFormat",
|
||
defaultValue: "负载 — · %@",
|
||
powerText
|
||
)
|
||
}
|
||
|
||
return localization.format(
|
||
"cpu.footnote.loadFormat",
|
||
defaultValue: "负载 %.2f · %@",
|
||
load,
|
||
powerText
|
||
)
|
||
}
|
||
|
||
private var gpuFootnote: String {
|
||
if let name = snapshot.gpu.name, !name.isEmpty {
|
||
return name
|
||
}
|
||
|
||
return snapshot.gpu.isAvailable
|
||
? localization.string("gpu.footnote.graphicsLoad", defaultValue: "图形负载")
|
||
: localization.string("metric.unavailable", defaultValue: "不可用")
|
||
}
|
||
|
||
private var memoryFootnote: String {
|
||
localization.format(
|
||
"memory.footnote.usedSwapFormat",
|
||
defaultValue: "已用 %@ · 交换 %@",
|
||
SystemStatusFormatter.bytes(snapshot.memory.usedBytes),
|
||
SystemStatusFormatter.bytes(snapshot.memory.swapUsedBytes)
|
||
)
|
||
}
|
||
|
||
private var networkFootnote: String {
|
||
"↓ \(SystemStatusFormatter.speed(snapshot.network.downloadBytesPerSecond)) ↑ \(SystemStatusFormatter.speed(snapshot.network.uploadBytesPerSecond))"
|
||
}
|
||
|
||
private var diskFootnote: String {
|
||
"R \(SystemStatusFormatter.speed(snapshot.disk.readBytesPerSecond)) W \(SystemStatusFormatter.speed(snapshot.disk.writeBytesPerSecond))"
|
||
}
|
||
|
||
private var batteryFootnote: String {
|
||
guard snapshot.battery.isAvailable else {
|
||
return snapshot.battery.state.title(localization: localization)
|
||
}
|
||
|
||
var parts: [String] = []
|
||
|
||
if let batteryPowerText = batteryPowerText {
|
||
parts.append(batteryPowerText)
|
||
}
|
||
|
||
if let batteryHealthText {
|
||
parts.append(batteryHealthText)
|
||
}
|
||
|
||
if let cycleCount = snapshot.battery.cycleCount {
|
||
parts.append(
|
||
localization.format(
|
||
"battery.cyclesFormat",
|
||
defaultValue: "%d 次循环",
|
||
cycleCount
|
||
)
|
||
)
|
||
}
|
||
|
||
return parts.isEmpty ? snapshot.battery.state.title(localization: localization) : parts.joined(separator: " · ")
|
||
}
|
||
|
||
private var batteryPowerText: String? {
|
||
guard let batteryPowerWatts = snapshot.battery.batteryPowerWatts else {
|
||
return nil
|
||
}
|
||
|
||
let absoluteWatts = abs(batteryPowerWatts)
|
||
guard absoluteWatts >= 0.1 else {
|
||
return nil
|
||
}
|
||
|
||
if batteryPowerWatts < 0 {
|
||
return localization.format(
|
||
"battery.chargePowerFormat",
|
||
defaultValue: "充电 %@",
|
||
SystemStatusFormatter.power(absoluteWatts)
|
||
)
|
||
}
|
||
|
||
return localization.format(
|
||
"battery.dischargePowerFormat",
|
||
defaultValue: "放电 %@",
|
||
SystemStatusFormatter.power(absoluteWatts)
|
||
)
|
||
}
|
||
|
||
private var batteryHealthText: String? {
|
||
guard let healthPercent = snapshot.battery.healthPercent else {
|
||
return nil
|
||
}
|
||
|
||
return localization.format("battery.healthFormat", defaultValue: "健康度 %d%%", healthPercent)
|
||
}
|
||
|
||
private func diskAvailableUnit(_ unit: String) -> String {
|
||
guard !unit.isEmpty else {
|
||
return localization.string("disk.availableSuffix", defaultValue: "可用")
|
||
}
|
||
|
||
return localization.format("disk.availableUnitFormat", defaultValue: "%@可用", unit)
|
||
}
|
||
|
||
private var batteryColor: Color {
|
||
guard snapshot.battery.isAvailable else {
|
||
return SystemStatusHUDPalette.textTertiary
|
||
}
|
||
|
||
if snapshot.battery.state == .charging || snapshot.battery.state == .charged || snapshot.battery.state == .acPower {
|
||
return SystemStatusHUDPalette.green
|
||
}
|
||
|
||
let level = snapshot.battery.level ?? 0
|
||
if level < 0.15 {
|
||
return SystemStatusHUDPalette.red
|
||
}
|
||
|
||
if level < 0.35 {
|
||
return SystemStatusHUDPalette.gold
|
||
}
|
||
|
||
return SystemStatusHUDPalette.amber
|
||
}
|
||
|
||
private func temperatureChip(_ temperature: Double?) -> (text: String, color: Color)? {
|
||
guard let temperature, temperature > 0 else {
|
||
return nil
|
||
}
|
||
|
||
return (SystemStatusFormatter.temperature(temperature), SystemStatusHUDPalette.badgeText)
|
||
}
|
||
|
||
private func percentParts(_ value: Double?) -> (value: String, unit: String) {
|
||
guard let value else {
|
||
return ("—", "")
|
||
}
|
||
|
||
return (format(value * 100, fractionDigits: 0), "%")
|
||
}
|
||
|
||
private func rateParts(_ bytesPerSecond: UInt64?) -> (value: String, unit: String) {
|
||
guard let bytesPerSecond else {
|
||
return ("—", "")
|
||
}
|
||
|
||
let units = ["B/s", "KB/s", "MB/s", "GB/s"]
|
||
var value = Double(bytesPerSecond)
|
||
var unitIndex = 0
|
||
|
||
while value >= 1024, unitIndex < units.count - 1 {
|
||
value /= 1024
|
||
unitIndex += 1
|
||
}
|
||
|
||
let fractionDigits = unitIndex == 0 || value >= 100 ? 0 : 1
|
||
return (format(value, fractionDigits: fractionDigits), units[unitIndex])
|
||
}
|
||
|
||
private func bytesParts(_ bytes: UInt64?) -> (value: String, unit: String) {
|
||
guard let bytes else {
|
||
return ("—", "")
|
||
}
|
||
|
||
let units = ["B", "KB", "MB", "GB", "TB"]
|
||
var value = Double(bytes)
|
||
var unitIndex = 0
|
||
|
||
while value >= 1024, unitIndex < units.count - 1 {
|
||
value /= 1024
|
||
unitIndex += 1
|
||
}
|
||
|
||
let fractionDigits = unitIndex == 0 || value >= 100 ? 0 : 1
|
||
return (format(value, fractionDigits: fractionDigits), units[unitIndex])
|
||
}
|
||
|
||
private func percentHistory(
|
||
_ keyPath: KeyPath<SystemStatusHistoryPoint, Double?>,
|
||
fallback: Double?,
|
||
history: [SystemStatusHistoryPoint]
|
||
) -> [Double] {
|
||
let values = downsample(history.compactMap { point in
|
||
point[keyPath: keyPath].map { min(max($0 * 100, 0), 100) }
|
||
})
|
||
|
||
guard values.count <= 1, let fallback else {
|
||
return values
|
||
}
|
||
|
||
let value = min(max(fallback * 100, 0), 100)
|
||
return [value, value]
|
||
}
|
||
|
||
private func networkDownloadHistory(_ history: [SystemStatusHistoryPoint]) -> [Double] {
|
||
SystemStatusHUDDualLineChart.downsamplePeaks(
|
||
history.map { Double($0.networkDownloadBytesPerSecond ?? 0) },
|
||
limit: SystemStatusHUDLayout.chartSampleLimit
|
||
)
|
||
}
|
||
|
||
private func networkUploadHistory(_ history: [SystemStatusHistoryPoint]) -> [Double] {
|
||
SystemStatusHUDDualLineChart.downsamplePeaks(
|
||
history.map { Double($0.networkUploadBytesPerSecond ?? 0) },
|
||
limit: SystemStatusHUDLayout.chartSampleLimit
|
||
)
|
||
}
|
||
|
||
private func diskReadHistory(_ history: [SystemStatusHistoryPoint]) -> [Double] {
|
||
SystemStatusHUDDualLineChart.downsamplePeaks(
|
||
history.map { Double($0.diskReadBytesPerSecond ?? 0) },
|
||
limit: SystemStatusHUDLayout.chartSampleLimit
|
||
)
|
||
}
|
||
|
||
private func diskWriteHistory(_ history: [SystemStatusHistoryPoint]) -> [Double] {
|
||
SystemStatusHUDDualLineChart.downsamplePeaks(
|
||
history.map { Double($0.diskWriteBytesPerSecond ?? 0) },
|
||
limit: SystemStatusHUDLayout.chartSampleLimit
|
||
)
|
||
}
|
||
|
||
private var chartHistory: [SystemStatusHistoryPoint] {
|
||
guard let latestTimestamp = snapshot.history.last?.timestamp else {
|
||
return []
|
||
}
|
||
|
||
let cutoff = latestTimestamp - SystemStatusHUDLayout.chartDisplayInterval
|
||
return snapshot.history.filter { point in
|
||
point.timestamp >= cutoff && point.timestamp <= latestTimestamp
|
||
}
|
||
}
|
||
|
||
private func downsample(_ values: [Double], limit: Int = SystemStatusHUDLayout.chartSampleLimit) -> [Double] {
|
||
guard values.count > limit else {
|
||
return values
|
||
}
|
||
|
||
let stride = Double(values.count - 1) / Double(limit - 1)
|
||
return (0..<limit).map { index in
|
||
values[Int((Double(index) * stride).rounded())]
|
||
}
|
||
}
|
||
|
||
private func format(_ value: Double, fractionDigits: Int) -> String {
|
||
if fractionDigits == 0 {
|
||
return String(Int(value.rounded()))
|
||
}
|
||
|
||
return String(format: "%.\(fractionDigits)f", value)
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDEyebrow: View {
|
||
let text: String
|
||
let glyph: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: glyph)
|
||
.font(.system(size: 9.5, weight: .semibold))
|
||
.foregroundStyle(color)
|
||
.frame(width: 11, height: 11)
|
||
|
||
Text(text)
|
||
.font(SystemStatusHUDFont.sans(10, .semibold))
|
||
.foregroundStyle(SystemStatusHUDPalette.textSecondary)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
.layoutPriority(0)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDChip: View {
|
||
let text: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
.font(SystemStatusHUDFont.mono(9, .medium))
|
||
.foregroundStyle(color)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.minimumScaleFactor(0.8)
|
||
.padding(.horizontal, 7)
|
||
.frame(height: 18, alignment: .center)
|
||
.background(
|
||
Capsule(style: .continuous)
|
||
.fill(SystemStatusHUDPalette.chipFill)
|
||
)
|
||
.fixedSize(horizontal: true, vertical: true)
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDValueTile: View {
|
||
let eyebrow: String
|
||
let glyph: String
|
||
let accent: Color
|
||
let chartColor: Color
|
||
let value: String
|
||
var unit: String = ""
|
||
var chip: (text: String, color: Color)? = nil
|
||
let values: [Double]
|
||
var chartStyle: SystemStatusHUDMiniChart.Style = .area
|
||
var footnote: String? = nil
|
||
|
||
var body: some View {
|
||
SystemStatusHUDMetricTile(
|
||
title: eyebrow,
|
||
glyph: glyph,
|
||
accent: accent,
|
||
value: value,
|
||
unit: unit,
|
||
chip: chip,
|
||
footnote: footnote
|
||
) {
|
||
SystemStatusHUDMiniChart(values: values, color: chartColor, style: chartStyle)
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel("\(eyebrow) \(value)\(unit)")
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDMetricTile<Visual: View, Footer: View>: View {
|
||
let title: String
|
||
let glyph: String
|
||
let accent: Color
|
||
let value: String
|
||
let unit: String
|
||
let chip: (text: String, color: Color)?
|
||
let footnote: String?
|
||
@ViewBuilder let footer: Footer
|
||
@ViewBuilder let visual: Visual
|
||
|
||
init(
|
||
title: String,
|
||
glyph: String,
|
||
accent: Color,
|
||
value: String,
|
||
unit: String,
|
||
chip: (text: String, color: Color)?,
|
||
footnote: String?,
|
||
@ViewBuilder footer: () -> Footer,
|
||
@ViewBuilder visual: () -> Visual
|
||
) {
|
||
self.title = title
|
||
self.glyph = glyph
|
||
self.accent = accent
|
||
self.value = value
|
||
self.unit = unit
|
||
self.chip = chip
|
||
self.footnote = footnote
|
||
self.footer = footer()
|
||
self.visual = visual()
|
||
}
|
||
}
|
||
|
||
private extension SystemStatusHUDMetricTile where Footer == EmptyView {
|
||
init(
|
||
title: String,
|
||
glyph: String,
|
||
accent: Color,
|
||
value: String,
|
||
unit: String,
|
||
chip: (text: String, color: Color)?,
|
||
footnote: String?,
|
||
@ViewBuilder visual: () -> Visual
|
||
) {
|
||
self.init(
|
||
title: title,
|
||
glyph: glyph,
|
||
accent: accent,
|
||
value: value,
|
||
unit: unit,
|
||
chip: chip,
|
||
footnote: footnote,
|
||
footer: { EmptyView() },
|
||
visual: visual
|
||
)
|
||
}
|
||
}
|
||
|
||
private extension SystemStatusHUDMetricTile {
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: SystemStatusHUDLayout.metricInternalSpacing) {
|
||
HStack(spacing: 4) {
|
||
SystemStatusHUDEyebrow(text: title, glyph: glyph, color: accent)
|
||
Spacer(minLength: 2)
|
||
if let chip {
|
||
SystemStatusHUDChip(text: chip.text, color: chip.color)
|
||
.layoutPriority(1)
|
||
}
|
||
}
|
||
.frame(height: SystemStatusHUDLayout.metricTitleHeight, alignment: .leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||
Text(value)
|
||
.font(SystemStatusHUDFont.mono(15, .semibold))
|
||
.foregroundStyle(SystemStatusHUDPalette.textPrimary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.58)
|
||
|
||
if !unit.isEmpty {
|
||
Text(unit)
|
||
.font(SystemStatusHUDFont.mono(9))
|
||
.foregroundStyle(SystemStatusHUDPalette.textSecondary)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(height: SystemStatusHUDLayout.metricValueHeight, alignment: .leading)
|
||
|
||
visual
|
||
.frame(height: SystemStatusHUDLayout.metricVisualHeight)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
if Footer.self != EmptyView.self {
|
||
footer
|
||
.frame(height: SystemStatusHUDLayout.metricFootnoteHeight, alignment: .leading)
|
||
} else {
|
||
Text(footnote ?? "")
|
||
.font(SystemStatusHUDFont.mono(8.5))
|
||
.foregroundStyle(SystemStatusHUDPalette.textTertiary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.minimumScaleFactor(0.6)
|
||
.frame(height: SystemStatusHUDLayout.metricFootnoteHeight, alignment: .leading)
|
||
}
|
||
}
|
||
.padding(SystemStatusComponentLayout.cardContentPadding)
|
||
.frame(height: SystemStatusHUDLayout.metricTileHeight, alignment: .topLeading)
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.background(SystemStatusHUDCardBackground())
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDDiskTile: View {
|
||
let title: String
|
||
let freeValue: String
|
||
let freeUnit: String
|
||
let totalText: String
|
||
let readText: String
|
||
let writeText: String
|
||
let readValues: [Double]
|
||
let writeValues: [Double]
|
||
let readColor: Color
|
||
let writeColor: Color
|
||
|
||
var body: some View {
|
||
SystemStatusHUDMetricTile(
|
||
title: title,
|
||
glyph: "internaldrive",
|
||
accent: readColor,
|
||
value: freeValue,
|
||
unit: freeUnit,
|
||
chip: (totalText, SystemStatusHUDPalette.badgeText),
|
||
footnote: nil,
|
||
footer: {
|
||
SystemStatusHUDRateFooter(
|
||
firstLabel: "R",
|
||
firstText: readText,
|
||
firstColor: readColor,
|
||
secondLabel: "W",
|
||
secondText: writeText,
|
||
secondColor: writeColor
|
||
)
|
||
}
|
||
) {
|
||
SystemStatusHUDRateChart(
|
||
firstValues: readValues,
|
||
secondValues: writeValues,
|
||
firstColor: readColor,
|
||
secondColor: writeColor
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDProcessRow: View {
|
||
let process: SystemStatusTopProcess
|
||
let localization: PluginLocalization
|
||
@State private var cachedIcon: NSImage?
|
||
@State private var cachedIconKey: String?
|
||
|
||
private var iconKey: String {
|
||
"\(process.pid)|\(process.displayName)|\(process.command)"
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
if let icon = cachedIcon {
|
||
Image(nsImage: icon)
|
||
.resizable()
|
||
.interpolation(.high)
|
||
.frame(width: 15, height: 15)
|
||
.clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous))
|
||
} else {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||
.fill(SystemStatusHUDPalette.chipFill)
|
||
Image(systemName: "app.dashed")
|
||
.font(.system(size: 9, weight: .medium))
|
||
.foregroundStyle(SystemStatusHUDPalette.textTertiary)
|
||
}
|
||
.frame(width: 15, height: 15)
|
||
}
|
||
|
||
Text(process.displayName)
|
||
.font(SystemStatusHUDFont.sans(11))
|
||
.foregroundStyle(SystemStatusHUDPalette.textPrimary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
|
||
Spacer(minLength: 6)
|
||
|
||
HStack(spacing: 10) {
|
||
processMetricText(SystemStatusFormatter.wholePercent(process.cpuPercent, fractionDigits: 1))
|
||
.frame(width: 46, alignment: .trailing)
|
||
|
||
processMetricText(processMemoryText)
|
||
.frame(width: 52, alignment: .trailing)
|
||
}
|
||
.frame(alignment: .trailing)
|
||
}
|
||
.frame(height: SystemStatusHUDLayout.processRowHeight)
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(
|
||
localization.format(
|
||
"accessibility.processFormat",
|
||
defaultValue: "%@ CPU %@,内存 %@",
|
||
process.displayName,
|
||
SystemStatusFormatter.wholePercent(process.cpuPercent, fractionDigits: 1),
|
||
processMemoryText
|
||
)
|
||
)
|
||
.onAppear(perform: resolveIconIfNeeded)
|
||
.onChange(of: iconKey) {
|
||
resolveIconIfNeeded()
|
||
}
|
||
}
|
||
|
||
private func processMetricText(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(SystemStatusHUDFont.mono(10))
|
||
.foregroundStyle(SystemStatusHUDPalette.textSecondary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.75)
|
||
}
|
||
|
||
private func resolveIconIfNeeded() {
|
||
guard cachedIconKey != iconKey else {
|
||
return
|
||
}
|
||
|
||
cachedIconKey = iconKey
|
||
cachedIcon = Self.processIcon(for: process)
|
||
}
|
||
|
||
private static func processIcon(for process: SystemStatusTopProcess) -> NSImage? {
|
||
if let icon = NSRunningApplication(processIdentifier: pid_t(process.pid))?.icon {
|
||
return icon
|
||
}
|
||
|
||
if let appPath = appBundlePath(in: process.command) {
|
||
return NSWorkspace.shared.icon(forFile: appPath)
|
||
}
|
||
|
||
let commandPath = executablePath(from: process.command)
|
||
if let icon = runningApplicationIcon(matching: commandPath, process: process) {
|
||
return icon
|
||
}
|
||
|
||
guard
|
||
commandPath.hasPrefix("/"),
|
||
FileManager.default.fileExists(atPath: commandPath)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return NSWorkspace.shared.icon(forFile: commandPath)
|
||
}
|
||
|
||
private static func runningApplicationIcon(matching commandPath: String, process: SystemStatusTopProcess) -> NSImage? {
|
||
let candidates = [
|
||
process.displayName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
URL(fileURLWithPath: commandPath).lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
].filter { !$0.isEmpty }
|
||
|
||
for application in NSWorkspace.shared.runningApplications {
|
||
guard let icon = application.icon else {
|
||
continue
|
||
}
|
||
|
||
let applicationNames = [
|
||
application.localizedName,
|
||
application.executableURL?.lastPathComponent
|
||
].compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
|
||
if applicationNames.contains(where: { applicationName in
|
||
candidates.contains(where: { candidate in
|
||
candidate == applicationName || candidate.hasPrefix("\(applicationName) ")
|
||
})
|
||
}) {
|
||
return icon
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private static func executablePath(from command: String) -> String {
|
||
let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard trimmedCommand.hasPrefix("/") else {
|
||
return trimmedCommand
|
||
}
|
||
|
||
if let appRange = trimmedCommand.range(of: ".app/") {
|
||
return String(trimmedCommand[..<trimmedCommand.index(before: appRange.upperBound)])
|
||
}
|
||
|
||
if let whitespaceIndex = trimmedCommand.firstIndex(where: { $0 == " " || $0 == "\t" }) {
|
||
return String(trimmedCommand[..<whitespaceIndex])
|
||
}
|
||
|
||
return trimmedCommand
|
||
}
|
||
|
||
private static func appBundlePath(in command: String) -> String? {
|
||
let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard
|
||
trimmedCommand.hasPrefix("/"),
|
||
let appRange = trimmedCommand.range(of: ".app", options: [.caseInsensitive])
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return String(trimmedCommand[..<appRange.upperBound])
|
||
}
|
||
|
||
private var processMemoryText: String {
|
||
if let memoryBytes = process.memoryBytes, memoryBytes > 0 {
|
||
return SystemStatusFormatter.bytes(memoryBytes)
|
||
}
|
||
|
||
return SystemStatusFormatter.wholePercent(process.memoryPercent, fractionDigits: 1)
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDRateFooter: View {
|
||
private enum Layout {
|
||
static let itemSpacing: CGFloat = 6
|
||
static let itemWidth: CGFloat = 61
|
||
static let labelWidth: CGFloat = 9
|
||
static let valueWidth: CGFloat = 50
|
||
}
|
||
|
||
let firstLabel: String
|
||
let firstText: String
|
||
let firstColor: Color
|
||
let secondLabel: String
|
||
let secondText: String
|
||
let secondColor: Color
|
||
|
||
var body: some View {
|
||
HStack(spacing: Layout.itemSpacing) {
|
||
rateItem(label: firstLabel, text: firstText, color: firstColor)
|
||
rateItem(label: secondLabel, text: secondText, color: secondColor)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func rateItem(label: String, text: String, color: Color) -> some View {
|
||
HStack(spacing: 2) {
|
||
Text(label)
|
||
.font(SystemStatusHUDFont.mono(8.5, .semibold))
|
||
.foregroundStyle(color)
|
||
.lineLimit(1)
|
||
.frame(width: Layout.labelWidth, alignment: .leading)
|
||
|
||
Text(text)
|
||
.font(SystemStatusHUDFont.mono(8.5))
|
||
.foregroundStyle(SystemStatusHUDPalette.textTertiary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.65)
|
||
.frame(width: Layout.valueWidth, alignment: .leading)
|
||
}
|
||
.frame(width: Layout.itemWidth, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDMiniChart: View {
|
||
enum Style {
|
||
case area
|
||
case bars
|
||
}
|
||
|
||
let values: [Double]
|
||
let color: Color
|
||
var style: Style = .area
|
||
|
||
private var samples: [Double] {
|
||
values.count > 120 ? Array(values.suffix(120)) : values
|
||
}
|
||
|
||
var body: some View {
|
||
GeometryReader { proxy in
|
||
let width = proxy.size.width
|
||
let height = proxy.size.height
|
||
let bounds = bounds()
|
||
let denominator = max(bounds.high - bounds.low, 0.0001)
|
||
|
||
if samples.count < 2 {
|
||
baseline(width: width, height: height)
|
||
} else {
|
||
switch style {
|
||
case .area:
|
||
area(width: width, height: height, low: bounds.low, denominator: denominator)
|
||
case .bars:
|
||
bars(width: width, height: height, low: bounds.low, denominator: denominator)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func y(_ value: Double, height: CGFloat, low: Double, denominator: Double) -> CGFloat {
|
||
(1.0 - CGFloat((value - low) / denominator)) * height
|
||
}
|
||
|
||
private func baseline(width: CGFloat, height: CGFloat) -> some View {
|
||
Path { path in
|
||
path.move(to: CGPoint(x: 0, y: height - 1))
|
||
path.addLine(to: CGPoint(x: width, y: height - 1))
|
||
}
|
||
.stroke(color.opacity(0.25), lineWidth: 1)
|
||
}
|
||
|
||
private func area(width: CGFloat, height: CGFloat, low: Double, denominator: Double) -> some View {
|
||
let points = samples.enumerated().map { index, value in
|
||
CGPoint(
|
||
x: width * CGFloat(index) / CGFloat(samples.count - 1),
|
||
y: y(value, height: height, low: low, denominator: denominator)
|
||
)
|
||
}
|
||
|
||
return ZStack {
|
||
Path { path in
|
||
guard let first = points.first, let last = points.last else {
|
||
return
|
||
}
|
||
|
||
path.move(to: CGPoint(x: first.x, y: height))
|
||
path.addLine(to: first)
|
||
for point in points.dropFirst() {
|
||
path.addLine(to: point)
|
||
}
|
||
path.addLine(to: CGPoint(x: last.x, y: height))
|
||
path.closeSubpath()
|
||
}
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [color.opacity(0.30), color.opacity(0.02)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
)
|
||
|
||
Path { path in
|
||
guard let first = points.first else {
|
||
return
|
||
}
|
||
|
||
path.move(to: first)
|
||
for point in points.dropFirst() {
|
||
path.addLine(to: point)
|
||
}
|
||
}
|
||
.stroke(color, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
||
}
|
||
}
|
||
|
||
private func bars(width: CGFloat, height: CGFloat, low: Double, denominator: Double) -> some View {
|
||
let count = max(samples.count, 1)
|
||
let slot = width / CGFloat(count)
|
||
let barWidth = max(1.5, slot * 0.62)
|
||
|
||
return Path { path in
|
||
for (index, value) in samples.enumerated() {
|
||
let barHeight = max(1.5, CGFloat((value - low) / denominator) * height)
|
||
let x = CGFloat(index) * slot + (slot - barWidth) / 2
|
||
path.addRoundedRect(
|
||
in: CGRect(x: x, y: height - barHeight, width: barWidth, height: barHeight),
|
||
cornerSize: CGSize(width: 1, height: 1),
|
||
style: .continuous
|
||
)
|
||
}
|
||
}
|
||
.fill(color.opacity(0.85))
|
||
}
|
||
|
||
private func bounds() -> (low: Double, high: Double) {
|
||
let low = min(samples.min() ?? 0, 0)
|
||
let high = samples.max() ?? 1
|
||
if high - low < 0.001 {
|
||
return (low, high + 1)
|
||
}
|
||
|
||
return (low, high)
|
||
}
|
||
}
|
||
|
||
enum SystemStatusHUDDualLineChart {
|
||
static func downsamplePeaks(
|
||
_ values: [Double],
|
||
limit: Int
|
||
) -> [Double] {
|
||
guard limit > 0 else {
|
||
return []
|
||
}
|
||
|
||
guard values.count > limit else {
|
||
return values.map { max($0, 0) }
|
||
}
|
||
|
||
guard limit > 1 else {
|
||
return [max(values.max() ?? 0, 0)]
|
||
}
|
||
|
||
let bucketSize = Double(values.count) / Double(limit)
|
||
return (0..<limit).map { index in
|
||
let start = Int((Double(index) * bucketSize).rounded(.down))
|
||
let proposedEnd = Int((Double(index + 1) * bucketSize).rounded(.down))
|
||
let end = min(values.count, max(start + 1, proposedEnd))
|
||
return max(values[start..<end].max() ?? 0, 0)
|
||
}
|
||
}
|
||
|
||
static func points(
|
||
values: [Double],
|
||
width: CGFloat,
|
||
height: CGFloat,
|
||
maximumValue: Double? = nil
|
||
) -> [CGPoint] {
|
||
let samples = values.map { max($0, 0) }
|
||
guard !samples.isEmpty else {
|
||
return []
|
||
}
|
||
|
||
let maximumValue = max(maximumValue ?? (samples.max() ?? 0), 0.0001)
|
||
return samples.enumerated().map { index, sample in
|
||
let x = width * CGFloat(index) / CGFloat(max(samples.count - 1, 1))
|
||
let ratio = scaledRatio(value: sample, maximumValue: maximumValue)
|
||
let y = (1 - ratio) * height
|
||
return CGPoint(x: x, y: y)
|
||
}
|
||
}
|
||
|
||
static func scaledRatio(value: Double, maximumValue: Double) -> CGFloat {
|
||
let normalized = min(max(value / max(maximumValue, 0.0001), 0), 1)
|
||
return CGFloat(sqrt(normalized))
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDRateChart: View {
|
||
let firstValues: [Double]
|
||
let secondValues: [Double]
|
||
let firstColor: Color
|
||
let secondColor: Color
|
||
|
||
var body: some View {
|
||
GeometryReader { proxy in
|
||
let width = proxy.size.width
|
||
let height = proxy.size.height
|
||
let firstSeries = firstValues.map { max($0, 0) }
|
||
let secondSeries = secondValues.map { max($0, 0) }
|
||
let firstMaximum = firstSeries.max() ?? 0
|
||
let secondMaximum = secondSeries.max() ?? 0
|
||
|
||
if max(firstMaximum, secondMaximum) <= 0 || (firstSeries.count < 2 && secondSeries.count < 2) {
|
||
Color.clear
|
||
} else {
|
||
ZStack {
|
||
series(
|
||
values: firstSeries,
|
||
width: width,
|
||
height: height,
|
||
maximumValue: firstMaximum,
|
||
color: firstColor
|
||
)
|
||
series(
|
||
values: secondSeries,
|
||
width: width,
|
||
height: height,
|
||
maximumValue: secondMaximum,
|
||
color: secondColor
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func series(
|
||
values: [Double],
|
||
width: CGFloat,
|
||
height: CGFloat,
|
||
maximumValue: Double,
|
||
color: Color
|
||
) -> some View {
|
||
if values.count >= 2 {
|
||
let points = SystemStatusHUDDualLineChart.points(
|
||
values: values,
|
||
width: width,
|
||
height: height,
|
||
maximumValue: maximumValue
|
||
)
|
||
|
||
ZStack {
|
||
Path { path in
|
||
guard let first = points.first, let last = points.last else {
|
||
return
|
||
}
|
||
|
||
path.move(to: CGPoint(x: first.x, y: height))
|
||
path.addLine(to: first)
|
||
for point in points.dropFirst() {
|
||
path.addLine(to: point)
|
||
}
|
||
path.addLine(to: CGPoint(x: last.x, y: height))
|
||
path.closeSubpath()
|
||
}
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [color.opacity(0.30), color.opacity(0.02)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
)
|
||
|
||
Path { path in
|
||
guard let first = points.first else {
|
||
return
|
||
}
|
||
|
||
path.move(to: first)
|
||
for point in points.dropFirst() {
|
||
path.addLine(to: point)
|
||
}
|
||
}
|
||
.stroke(color, style: StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SystemStatusHUDCardBackground: View {
|
||
var body: some View {
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.fill(SystemStatusHUDPalette.cardFill)
|
||
}
|
||
}
|
||
|
||
private enum SystemStatusHUDPalette {
|
||
static let textPrimary = Color.primary
|
||
static let textSecondary = Color.secondary
|
||
static let textTertiary = Color.secondary.opacity(0.72)
|
||
|
||
static let cardFill = Color.primary.opacity(0.045)
|
||
static let chipFill = Color.primary.opacity(0.06)
|
||
static let badgeText = textSecondary
|
||
|
||
static let green = Color(nsColor: .systemGreen)
|
||
static let gold = Color(nsColor: .systemYellow)
|
||
static let amber = Color(nsColor: .systemOrange)
|
||
static let orange = Color(nsColor: .systemOrange)
|
||
static let blue = Color(nsColor: .systemBlue)
|
||
static let red = Color(nsColor: .systemRed)
|
||
|
||
static let gpu = Color(red: 0.949, green: 0.537, blue: 0.306)
|
||
static let network = Color(red: 0.180, green: 0.690, blue: 0.659)
|
||
static let networkDownload = Color(red: 0.204, green: 0.596, blue: 0.859)
|
||
static let networkUpload = Color(red: 0.941, green: 0.467, blue: 0.294)
|
||
static let diskRead = Color(red: 0.275, green: 0.588, blue: 0.941)
|
||
static let diskWrite = Color(red: 0.608, green: 0.431, blue: 0.902)
|
||
}
|
||
|
||
private enum SystemStatusHUDFont {
|
||
static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
|
||
.system(size: size, weight: weight, design: .monospaced)
|
||
}
|
||
|
||
static func sans(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
|
||
.system(size: size, weight: weight)
|
||
}
|
||
}
|