Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift
T
gentpan 8f7691d290 进程 CPU 在概览里改为占整机的比例,悬停显示单核口径
用户反馈 CPU 占用高:CPU 弹窗“按应用汇总”与仪表盘“高占用进程”按单核满载为 100% 计(活动监视器的算法),多核 Mac 上一个应用常显示 99%、75%,又放在整机总占用下面,容易误读。

- Format.machineShare:单核口径 ÷ 逻辑核心数,全部核心跑满为 100%;小于 0.1% 且不为 0 写 “<0.1%”。Format.coreShare 保留单核口径写法
- CPU 弹窗 / CPU 页的按应用汇总、仪表盘高占用进程改用整机占比,数值悬停显示单核口径;仪表盘着色阈值改为整机占比 25% / 50%
- 进程页保持单核口径,与活动监视器一致
- 单元测试覆盖两种写法;译文与更新日志同步
2026-09-16 20:53:52 +05:00

814 lines
39 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Localization
import Metrics
import SMC
import SwiftUI
private let heroGauge = DS.Space.s12 + DS.Space.s2
extension View {
/// 详情图表高度:弹窗里紧凑,主窗口里放大
@MainActor
func detailChartHeight(_ isDetailPage: Bool) -> CGFloat {
isDetailPage ? DS.Size.chartHeight * 3 : DS.Size.chartHeight
}
}
private func loadTone(_ value: Double) -> Tone {
value >= 0.85 ? .error : value >= 0.6 ? .warning : .primary
}
// MARK: - CPU
struct CPUPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
CPUHero()
if isDetailPage {
// 主窗口更宽:先是最直观的各核心圆环,再是热力图;核心分工与排队程度并排,按应用汇总占满一行
card(.cpuCores)
card(.cpuHeatmap)
HStack(alignment: .top, spacing: DS.Space.s3) {
card(.cpuClusters)
card(.cpuLoadAverage)
}
card(.cpuApps)
} else {
ForEach(MenuBarItem.cpu.popoverSections.filter { model.settings.isVisible($0) }) { section in
card(section)
}
}
}
@ViewBuilder
private func card(_ section: PopoverSection) -> some View {
let store = model.store
let cpu = store.cpu
let cores = Double(max(1, store.topology.logicalCores))
switch section {
// 各区块的解释都放在悬停提示里(标题旁的 ⓘ 或卡片内容),界面上只留数据
case .cpuHeatmap:
SectionCard(title: section.title,
hint: tr("每行一个核心(共 \(store.topology.logicalCores) 个),每列一次采样,颜色越深越忙;右侧粗条是此刻的占用"),
trailing: { HeatLegend() }) {
CoreHeatmap(topology: store.topology, history: store.coreHistory.elements,
columns: isDetailPage ? 60 : 36,
rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke)
}
case .cpuCores:
// 主窗口:每个核心一个圆环,中间写百分比,最直观;弹窗窄,用每核一根柱子的紧凑版(默认关闭,在设置里打开)
if isDetailPage {
SectionCard(title: section.title, hint: tr("此刻各核心的占用;超过 60% 变橙色、85% 变红色")) {
CoreRingGrid(topology: store.topology, perCore: cpu?.perCore ?? [])
}
} else {
SectionCard(title: section.title, hint: tr("每根柱子一个核心,按核心类型着色")) {
CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [], byCluster: true)
}
}
case .cpuClusters:
SectionCard(title: section.title, hint: clusterExplanation(store.topology),
trailing: { Text(verbatim: store.topology.brand) }) {
ForEach(store.topology.clusters) { cluster in
let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0
ShareRow(label: tr("\(cluster.name) · \(cluster.coreIndices.count) 核"), value: Format.percent(average),
detail: store.power?.clusterFrequency[cluster.id].map { Format.frequency(megahertz: $0) },
fraction: average, color: DS.Palette.cluster(cluster.id))
}
if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) {
InfoRow(label: tr("最忙的核心"), text: busiest)
}
}
case .cpuLoadAverage:
SectionCard(title: section.title,
hint: tr("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU"),
trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) {
let averages = cpu?.loadAverage ?? []
ForEach(Array([tr("1 分钟"), tr("5 分钟"), tr("15 分钟")].enumerated()), id: \.offset) { index, label in
let value = averages[safe: index] ?? 0
ShareRow(label: label,
value: tr("每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))"),
fraction: value / cores, color: loadTone(value / cores).color)
}
}
case .cpuApps:
SectionCard(title: section.title,
hint: tr("每个应用占整机 CPU 的比例:\(Format.logicalCores) 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法)")) {
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu },
rowCount: isDetailPage ? 10 : 6, metric: .cpu)
}
default:
EmptyView()
}
}
private func loadTrend(_ averages: [Double]) -> String {
guard averages.count >= 3, averages[2] > 0 else { return "" }
let change = averages[0] / averages[2]
return change > 1.15 ? tr("负载在上升") : change < 0.85 ? tr("负载在下降") : tr("负载平稳")
}
/// 核心按 1…N 统一编号,与“各核心占用”里的圆环标签一致,括号里注明它属于哪一类
private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? {
guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }),
let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil }
return tr("核心 \(index + 1)(\(cluster.name))· \(Format.percent(value))")
}
/// 用人话说明每类核心是干什么的,只有一类核心(Intel)时不显示
private func clusterExplanation(_ topology: CPUTopology) -> String? {
let roles = topology.clusters.sorted { $0.id < $1.id }.compactMap { cluster in
cluster.role(in: topology).map { tr("\(cluster.name):\($0)") }
}
guard !roles.isEmpty else { return nil }
return tr("\(roles.joined(separator: " · "))。系统会自动分配任务,不用你操心")
}
}
extension CPUCluster {
/// 这类核心的分工:最高档最快、最低档最省电、中间档介于两者之间;只有一类核心时没有分工可言
func role(in topology: CPUTopology) -> String? {
let levels = topology.clusters.count
guard levels > 1 else { return nil }
if id == 0 { return tr("最快,重活优先交给它们") }
if id == levels - 1 { return tr("更省电,负责后台和轻量任务") }
return tr("速度与省电介于两者之间")
}
}
/// 主窗口里的各核心占用:每个核心一个小圆环,中间写百分比,按核心类型分组,
/// 组名后面用一句话说明这类核心是干什么的;圆环颜色随占用变化,忙的核心一眼能看到
private struct CoreRingGrid: View {
let topology: CPUTopology
let perCore: [Double]
private static let ring = DS.Space.s12 + DS.Space.s2
private static let columns = [GridItem(.adaptive(minimum: DS.Space.s16 + DS.Space.s2), spacing: DS.Space.s2)]
var body: some View {
// 最快的一类在前
let clusters = topology.clusters.sorted { $0.id < $1.id }
VStack(alignment: .leading, spacing: DS.Space.s3) {
ForEach(clusters) { cluster in
VStack(alignment: .leading, spacing: DS.Space.s2) {
if clusters.count > 1 {
// 这类核心是干什么的,悬停组名时再说
HStack(spacing: DS.Space.s1) {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(DS.Palette.cluster(cluster.id))
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(verbatim: tr("\(cluster.name) · \(cluster.coreIndices.count) 个"))
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
}
.help(optional: cluster.role(in: topology))
}
LazyVGrid(columns: Self.columns, alignment: .leading, spacing: DS.Space.s2) {
ForEach(cluster.coreIndices, id: \.self) { index in
let value = perCore[safe: index] ?? 0
VStack(spacing: DS.Space.s1) {
RingGauge(fraction: value, color: loadTone(value).color, size: Self.ring) {
Text(verbatim: Format.percent(value))
.dsFont(.xs, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
Text(verbatim: tr("核心 \(index + 1)"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
.frame(maxWidth: .infinity)
}
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(tr("各核心占用"))
}
}
/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、CPU 温度(附离 100°C 的余量),
/// 下面是最近 1 / 3 / 5 分钟的走势线(主窗口里更高,带百分比刻度、参考线与时间轴)、峰值和用户 / 系统 / 空闲构成
private struct CPUHero: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let store = model.store
let cpu = store.cpu
let total = cpu?.total ?? 0
let history = store.cpuTotal.elements
let temperature = store.sensors?.temperature(.cpu)
let fahrenheit = model.settings.useFahrenheit
// 走势按真实时间定位:最新一次采样在右边缘,往左铺满所选时长
let duration = TimeInterval(model.settings.cpuChartSeconds)
let end = store.cpuTimeline.elements.last?.date ?? Date()
let timeline = store.cpuTimeline.elements.filter { $0.date >= end.addingTimeInterval(-duration) }
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
HeroValue(value: cpu.map { "\(Int(($0.total * 100).rounded()))" } ?? "—", unit: "%", size: .xxl)
VStack(alignment: .leading, spacing: DS.Space.s1) {
StatusBadge(text: status(total), tone: total >= 0.85 ? .error : total >= 0.6 ? .warning : .success)
Text(verbatim: trend(history))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
Spacer(minLength: DS.Space.s2)
if let temperature {
let hottest = temperature.maximum
VStack(alignment: .trailing, spacing: 0) {
Text(tr("CPU 温度")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(verbatim: Format.temperature(hottest, fahrenheit: fahrenheit))
.dsFont(.base, weight: .semibold)
.monospacedDigit()
.foregroundStyle(Tone.forTemperature(hottest).color)
Text(verbatim: tr("余量 \(headroom(hottest, fahrenheit: fahrenheit))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
.help(tr("核心最高温度;余量是离 100°C 还差多少度,越接近 0 越可能因过热降频"))
}
}
if isDetailPage {
HStack(spacing: DS.Space.s2) {
PercentAxis(height: DS.Size.chartHeight * 2)
TimedLineChart(points: timeline, duration: duration, end: end, grid: true, height: DS.Size.chartHeight * 2)
}
HStack {
Text(verbatim: Self.clock(end.addingTimeInterval(-duration)))
Spacer()
Text(verbatim: Self.clock(end.addingTimeInterval(-duration / 2)))
Spacer()
Text(tr("现在"))
}
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.padding(.leading, PercentAxis.width + DS.Space.s2)
} else {
TimedLineChart(points: timeline, duration: duration, end: end, height: DS.Space.s8)
}
HStack {
ChartDurationPicker()
Spacer()
Text(verbatim: tr("峰值 \(Format.percent(timeline.map(\.value).max() ?? 0))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
}
if let cpu {
WaterlineBar(segments: [(cpu.user, DS.Palette.primary), (cpu.system, DS.Palette.secondary)],
total: 1, height: DS.Space.s2)
HStack(spacing: DS.Space.s3) {
LegendItem(color: DS.Palette.primary, label: tr("用户"), value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: tr("系统"), value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: tr("空闲"), value: Format.percent(max(0, 1 - cpu.total)))
}
}
}
}
private func status(_ total: Double) -> String {
total >= 0.85 ? tr("满载") : total >= 0.6 ? tr("繁忙") : total >= 0.25 ? tr("适中") : tr("空闲")
}
/// 离 100°C 还差多少度;华氏制下按温差换算(差 45°C 即差 81°F)
private func headroom(_ celsius: Double, fahrenheit: Bool) -> String {
let degrees = max(0, 100 - celsius)
return fahrenheit ? "\(Int((degrees * 9 / 5).rounded()))°F" : "\(Int(degrees.rounded()))°C"
}
/// 与大约 30 秒前(第 15 个采样之前)相比的变化
private func trend(_ history: [Double]) -> String {
guard history.count > 15, let now = history.last else { return tr("正在收集走势…") }
let before = history[history.count - 16]
let delta = Int(((now - before) * 100).rounded())
if abs(delta) < 3 { return tr("与 30 秒前持平") }
return delta > 0 ? tr("比 30 秒前高 \(delta)%") : tr("比 30 秒前低 \(-delta)%")
}
/// 时间轴刻度:走势只有几分钟,带秒才分得开;小时固定两位,零点前后宽度不变
private static func clock(_ date: Date) -> String {
date.formatted(Date.FormatStyle(locale: L10n.locale).hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits))
}
}
/// 走势时长 1 / 3 / 5 分钟:一行小字标签,选中的用品牌蓝加粗,不另占高度
private struct ChartDurationPicker: View {
@Environment(AppModel.self) private var model
var body: some View {
let settings = model.settings
HStack(spacing: DS.Space.s3) {
ForEach(AppSettings.cpuChartOptions, id: \.self) { seconds in
let selected = settings.cpuChartSeconds == seconds
Button {
settings.cpuChartSeconds = seconds
} label: {
Text(verbatim: tr("\(seconds / 60) 分钟"))
.dsFont(.xs, weight: selected ? .semibold : .regular)
.foregroundStyle(selected ? DS.Palette.primary : DS.Palette.textTertiary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.help(tr("走势图显示最近多长时间"))
.accessibilityLabel(tr("走势时长"))
}
}
/// 走势图左侧的 0–100% 刻度,位置与参考线对齐;最上、最下两个标签收进图表范围内,不压到相邻的行
private struct PercentAxis: View {
static let width = DS.Space.s8 + DS.Space.s2
let height: CGFloat
var body: some View {
GeometryReader { proxy in
let inset = DS.Size.chartLine
let usable = proxy.size.height - inset * 2
let half = DS.TextSize.xs.rawValue / 2 + DS.Size.stroke
ForEach([1.0, 0.75, 0.5, 0.25, 0.0], id: \.self) { fraction in
Text(verbatim: "\(Int(fraction * 100))%")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.lineLimit(1)
.fixedSize()
.frame(width: Self.width, alignment: .trailing)
.position(x: Self.width / 2, y: min(max(inset + usable * CGFloat(1 - fraction), half), proxy.size.height - half))
}
}
.frame(width: Self.width, height: height)
.accessibilityHidden(true)
}
}
// MARK: - 内存
struct MemoryPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let memory = store.memory
MemoryHero()
ForEach(MenuBarItem.memory.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .memoryWaterline:
SectionCard(title: section.title, trailing: { Text(verbatim: memory.map { Format.bytes($0.total) } ?? "") }) {
if let memory {
let parts: [(String, UInt64, Color)] = [
("App", memory.app, DS.Palette.primary),
(tr("联动"), memory.wired, DS.Palette.secondary),
(tr("压缩"), memory.compressed, DS.Palette.warning),
(tr("缓存"), memory.cached, DS.Palette.primarySoft),
(tr("空闲"), memory.free, DS.Palette.track),
]
WaterlineBar(segments: parts.map { (Double($0.1), $0.2) }, total: Double(memory.total))
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible())], spacing: DS.Space.s2) {
ForEach(parts, id: \.0) { part in
HStack(spacing: DS.Space.s1) {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(part.2)
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(part.0).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: DS.Space.s1)
Text(verbatim: Format.bytes(part.1)).dsFont(.xs, weight: .medium).monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
}
}
}
}
case .memoryCompression:
SectionCard(title: section.title) {
if let memory {
InfoRow(label: tr("压缩为你省下")) {
Text(verbatim: Format.bytes(memory.compressionSavings)).foregroundStyle(DS.Palette.success)
}
InfoRow(label: tr("压缩比"), text: memory.compressionRatio.map { "\($0.formatted(.number.precision(.fractionLength(1))))×" } ?? "—")
InfoRow(label: tr("交换区"), text: memory.swapTotal > 0
? "\(Format.bytes(memory.swapUsed)) / \(Format.bytes(memory.swapTotal))" : tr("未使用"))
InfoRow(label: tr("换入 / 换出"), text: store.swapRate.map { "\(Format.menuBarRate($0.swapIn)) / \(Format.menuBarRate($0.swapOut))" } ?? "—")
if let rate = store.swapRate, rate.swapOut > 1024 * 1024 {
InfoBanner(icon: "exclamationmark.triangle.fill", text: tr("系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。"), tone: .warning)
}
}
}
case .memoryApps:
SectionCard(title: section.title, trailing: { Text(tr("内存")) }) {
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.memory > $1.memory },
rowCount: isDetailPage ? 10 : 6, metric: .memory, total: memory?.used)
}
default:
EmptyView()
}
}
}
}
/// 内存顶部:可用多少、压力状态、最近一分钟的压力走势
private struct MemoryHero: View {
@Environment(AppModel.self) private var model
var body: some View {
let store = model.store
let memory = store.memory
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: 0) {
Text(tr("还可用")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
HeroValue(value: memory.map { availableNumber($0.available) } ?? "—", unit: "GB", size: .xxl)
}
Spacer(minLength: DS.Space.s2)
VStack(alignment: .trailing, spacing: DS.Space.s1) {
StatusBadge(text: tr("压力\(memory?.pressure.title ?? "—")"), tone: tone(memory?.pressure))
Text(verbatim: memory.map { tr("已用 \(Format.bytes($0.used)) · \(Format.percent($0.usedFraction))") } ?? "")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
}
PressureStrip(history: store.pressureHistory.elements)
HStack {
Text(tr("压力走势")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Spacer()
Text(tr("最近 60 秒")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
private func availableNumber(_ bytes: UInt64) -> String {
(Double(bytes) / 1_073_741_824).formatted(.number.precision(.fractionLength(1)))
}
private func tone(_ pressure: MemoryPressure?) -> Tone {
switch pressure {
case .normal: .success
case .warning: .warning
case .critical: .error
case nil: .neutral
}
}
}
// MARK: - 按应用汇总
/// 同一个应用的主进程与辅助进程合并计算
struct AppUsage: Identifiable {
let id: String
let name: String
let bundlePath: String?
var cpu: Double
var memory: UInt64
var processCount: Int
/// 占用最高的那个进程,用于图标与 Apple 智能解释
var representative: ProcessUsage
@MainActor
static func group(_ processes: [ProcessUsage]) -> [AppUsage] {
var groups: [String: AppUsage] = [:]
for process in processes {
let key = process.appBundlePath ?? "pid-\(process.pid)"
if var group = groups[key] {
group.cpu += process.cpu
group.memory += process.memory
group.processCount += 1
if process.memory > group.representative.memory { group.representative = process }
groups[key] = group
} else {
let name = process.appBundlePath.map { AppNameCache.shared.name(forBundle: $0) } ?? process.displayName
groups[key] = AppUsage(id: key, name: name, bundlePath: process.appBundlePath,
cpu: process.cpu, memory: process.memory, processCount: 1, representative: process)
}
}
return Array(groups.values)
}
}
private struct AppUsageList: View {
enum Metric { case cpu, memory }
let apps: [AppUsage]
let rowCount: Int
let metric: Metric
var total: UInt64?
var body: some View {
let visible = Array(apps.prefix(rowCount))
// 横条以列表里最大的一项为满格,比较谁占得多
let peak = visible.map { metric == .cpu ? $0.cpu : Double($0.memory) }.max() ?? 1
if visible.isEmpty {
Text(tr("正在统计…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(visible) { app in
let value = metric == .cpu ? app.cpu : Double(app.memory)
VStack(spacing: DS.Space.s1) {
HStack(spacing: DS.Space.s2) {
AppIconCache.shared.image(bundlePath: app.bundlePath)
.resizable()
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
Text(verbatim: app.name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if app.processCount > 1 {
Text(verbatim: tr("\(app.processCount) 个进程")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.fixedSize()
}
Spacer(minLength: DS.Space.s2)
Text(verbatim: valueText(app))
.dsFont(.xs, weight: .medium)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
.help(optional: metric == .cpu ? tr("按单核满载为 100% 计:\(Format.coreShare(app.cpu))") : nil)
}
ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1, showsTrack: false)
.padding(.leading, DS.Size.iconInline + DS.Space.s2)
}
.explainable(.init(app.representative))
}
}
private func valueText(_ app: AppUsage) -> String {
switch metric {
case .cpu:
// 占整机的比例;单核口径的数值放在悬停提示里
return Format.machineShare(app.cpu)
case .memory:
guard let total, total > 0 else { return Format.bytes(app.memory) }
return "\(Format.bytes(app.memory)) · \(Format.percent(Double(app.memory) / Double(total)))"
}
}
}
/// 标签 + 数值 + 横条
private struct ShareRow: View {
let label: String
let value: String
/// 跟在数值后面的补充读数(如频率),用次要颜色,不抢主数值
var detail: String?
let fraction: Double
let color: Color
var body: some View {
VStack(spacing: DS.Space.s1) {
InfoRow(label: label) {
if let detail {
Text(verbatim: value)
+ Text(verbatim: " · \(detail)").foregroundStyle(DS.Palette.textSecondary).fontWeight(.regular)
} else {
Text(verbatim: value)
}
}
ProgressTrack(fraction: fraction, color: color, height: DS.Space.s1 + DS.Space.s1 / 2)
}
}
}
extension View {
/// 进程行右键菜单:用 Apple 智能解释
@ViewBuilder
func explainable(_ subject: ProcessExplainer.Subject) -> some View {
if ProcessExplainer.isSupported {
ExplainableRow(subject: subject) { self }
} else {
self
}
}
}
private struct ExplainableRow<Content: View>: View {
let subject: ProcessExplainer.Subject
@ViewBuilder var content: Content
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
var body: some View {
content
.contentShape(Rectangle())
.contextMenu(isSnapshot ? nil : ContextMenu {
Button(tr("用 Apple 智能解释")) { model.explainProcess(subject) }
})
}
}
/// 标题栏里的“释放内存”:执行中显示进度,完成后短暂显示结果再恢复
struct PurgeMemoryButton: View {
@Environment(AppModel.self) private var model
@State private var result: MaintenanceController.Outcome?
var body: some View {
let maintenance = model.maintenance
let running = maintenance.running == .purgeMemory
Button {
Task {
await maintenance.run(.purgeMemory)
result = maintenance.outcomes[.purgeMemory]
try? await Task.sleep(for: .seconds(3))
result = nil
}
} label: {
HStack(spacing: DS.Space.s1) {
if running {
ProgressView().controlSize(.mini)
} else {
Image(systemName: result == nil ? "wind" : result!.isError ? "exclamationmark.triangle" : "checkmark")
}
Text(running ? tr("正在释放…") : result?.text ?? tr("释放内存")).lineLimit(1).fixedSize()
}
.foregroundStyle(result?.isError == true ? DS.Palette.error : result != nil ? DS.Palette.success : DS.Palette.textPrimary)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(maintenance.running != nil)
.help(tr("清理可回收的缓存内存(需要管理员授权或辅助工具)"))
}
}
// MARK: - GPU
struct GPUPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let gpu = store.gpu
let temperature = store.sensors?.temperature(.gpu)
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
RingGauge(fraction: gpu?.utilization ?? 0, color: loadTone(gpu?.utilization ?? 0).color, size: heroGauge) {
Text(verbatim: gpu.map { Format.percent($0.utilization) } ?? "—")
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(verbatim: gpu?.name ?? "GPU")
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(verbatim: gpu?.coreCount.map { tr("\($0) 核图形处理器") } ?? tr("图形处理器"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
if let temperature {
Chip(text: Format.temperature(temperature.maximum, fahrenheit: settings.useFahrenheit),
icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum))
}
}
}
ForEach(MenuBarItem.gpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .gpuHistory:
SectionCard(title: section.title, trailing: { Text(tr("最近 60 秒")) }) {
LineHistoryChart(values: store.gpuHistory.elements, color: DS.Palette.secondary, height: detailChartHeight(isDetailPage))
}
case .gpuDetails:
SectionCard(title: section.title) {
InfoRow(label: tr("型号"), text: gpu?.name ?? "—")
InfoRow(label: tr("核心数"), text: gpu?.coreCount.map(String.init) ?? "—")
InfoRow(label: tr("平均温度"), text: temperature.map { Format.temperature($0.average, fahrenheit: settings.useFahrenheit) } ?? "—")
InfoRow(label: tr("最高温度"), text: temperature.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
}
default:
EmptyView()
}
}
}
}
// MARK: - 温度与风扇
struct ThermalPopover: View {
let item: MenuBarItem
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let cpu = store.sensors?.temperature(.cpu)
let fans = store.sensors?.fans ?? []
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(tr("CPU 最高")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: cpu.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(tr("风扇")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: store.fastestFan.map { Int($0.current).formatted() } ?? "—", unit: fans.isEmpty ? nil : "RPM")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
ForEach(item.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .thermalSensors:
SectionCard(title: section.title, trailing: { Text(tr("最高 / 平均")) }) {
let temperatures = store.sensors?.temperatures ?? []
if temperatures.isEmpty {
Text(tr("正在读取传感器…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(temperatures) { summary in
VStack(spacing: DS.Space.s1) {
InfoRow(label: summary.group.title) {
Text(verbatim: "\(Format.temperature(summary.maximum, fahrenheit: settings.useFahrenheit)) / \(Format.temperature(summary.average, fahrenheit: settings.useFahrenheit))")
}
ProgressTrack(fraction: summary.maximum / DS.Thermal.scaleMax,
color: Tone.forTemperature(summary.maximum).color,
height: DS.Space.s1)
}
}
}
case .thermalFans:
SectionCard(title: section.title, trailing: { Text(fanStatusText(fans: fans, mode: model.fans.mode)) }) {
if fans.isEmpty {
Text(tr("未检测到风扇")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else {
ForEach(fans) { fan in
VStack(spacing: DS.Space.s1) {
InfoRow(label: tr("风扇 \(fan.id + 1)"), text: Format.rpm(fan.current))
ProgressTrack(fraction: fan.maximum > 0 ? fan.current / fan.maximum : 0, height: DS.Space.s1)
}
}
HStack(spacing: DS.Space.s1) {
ForEach([FanController.Mode.automatic, .cooling, .maximum]) { mode in
ChipButton(title: mode.title, isSelected: model.fans.mode == mode) {
model.requestFanMode(mode)
}
}
}
}
}
case .thermalPower:
SectionCard(title: section.title, trailing: { Text(store.power?.system.map(Format.watts) ?? "—") }) {
PowerRows(power: store.power, history: store.powerHistory.elements, compact: true)
}
default:
EmptyView()
}
}
}
}
/// 固件只记录“手动 / 自动”,无法区分是谁设置的:OpenStats 未下发时即为其他程序(如 Stats、Mole)
@MainActor
func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String {
if fans.isEmpty { return tr("未检测到风扇") }
guard fans.contains(where: \.isManual) else { return tr("由 macOS 调节") }
return mode == .automatic ? tr("其他程序手动控制") : tr("OpenStats 控制中")
}
/// 功耗明细:整机、电源输入、电池、GPU,附整机功耗走势
struct PowerRows: View {
let power: PowerReading?
let history: [Double]
var compact = false
var body: some View {
if let power, power.system != nil || power.gpu != nil {
if history.count > 1 {
LineHistoryChart(values: history, maxValue: max(history.max() ?? 0, 10) * 1.2,
color: DS.Palette.warning, height: compact ? DS.Size.tileChart : DS.Size.chartHeight)
}
if let system = power.system { InfoRow(label: tr("整机"), text: Format.watts(system)) }
if let adapter = power.adapter { InfoRow(label: tr("电源输入"), text: Format.watts(adapter)) }
if let battery = power.battery {
// PPBR 为正表示电池在放电供电,接通电源时是充电功率
InfoRow(label: power.adapter == nil ? tr("电池放电") : tr("电池充电"), text: Format.watts(abs(battery)))
}
if let gpu = power.gpu { InfoRow(label: "GPU", text: Format.watts(gpu)) }
Text(tr("由 SMC 与系统能耗统计读取;新款芯片不提供可靠的 CPU 单独功耗"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(tr("这台 Mac 不提供功耗读数")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}