Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Plugins/PowerStatus/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,70 @@
"ar" : { "stringUnit" : { "state" : "translated", "value" : "متوسط استهلاك الطاقة خلال آخر فترة قياس." } }
}
},
"energyApps.expandHint" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Show the apps using the most energy" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "展开查看高耗电应用" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "展開查看高耗電應用" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "電力消費の多いアプリを表示" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "에너지 사용이 많은 앱 보기" } },
"de" : { "stringUnit" : { "state" : "translated", "value" : "Apps mit dem höchsten Energieverbrauch anzeigen" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les apps les plus énergivores" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar las apps que más energía consumen" } },
"pt" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar os apps que mais consomem energia" } },
"ru" : { "stringUnit" : { "state" : "translated", "value" : "Показать приложения с наибольшим энергопотреблением" } },
"tr" : { "stringUnit" : { "state" : "translated", "value" : "En çok enerji tüketen uygulamaları göster" } },
"ar" : { "stringUnit" : { "state" : "translated", "value" : "عرض التطبيقات الأكثر استهلاكًا للطاقة" } }
}
},
"energyApps.collapseHint" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Collapse the high energy apps list" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "收起高耗电应用" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "收起高耗電應用" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "電力消費の多いアプリを折りたたむ" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "에너지 사용이 많은 앱 접기" } },
"de" : { "stringUnit" : { "state" : "translated", "value" : "Liste der Apps mit hohem Energieverbrauch einklappen" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Réduire la liste des apps les plus énergivores" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Contraer la lista de apps con mayor consumo" } },
"pt" : { "stringUnit" : { "state" : "translated", "value" : "Recolher a lista de apps com maior consumo" } },
"ru" : { "stringUnit" : { "state" : "translated", "value" : "Свернуть список приложений с высоким энергопотреблением" } },
"tr" : { "stringUnit" : { "state" : "translated", "value" : "Yüksek enerji tüketen uygulama listesini daralt" } },
"ar" : { "stringUnit" : { "state" : "translated", "value" : "طي قائمة التطبيقات عالية الاستهلاك" } }
}
},
"energyApps.loading" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Measuring app energy…" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在采集应用功耗…" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在收集應用功耗…" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "アプリの消費電力を計測中…" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "앱 전력 사용량 측정 중…" } },
"de" : { "stringUnit" : { "state" : "translated", "value" : "App-Energieverbrauch wird gemessen…" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Mesure de la consommation des apps…" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Midiendo el consumo de las apps…" } },
"pt" : { "stringUnit" : { "state" : "translated", "value" : "Medindo o consumo dos apps…" } },
"ru" : { "stringUnit" : { "state" : "translated", "value" : "Измерение энергопотребления приложений…" } },
"tr" : { "stringUnit" : { "state" : "translated", "value" : "Uygulama enerji tüketimi ölçülüyor…" } },
"ar" : { "stringUnit" : { "state" : "translated", "value" : "جارٍ قياس استهلاك التطبيقات للطاقة…" } }
}
},
"energyApps.empty" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "No high energy apps found" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未发现高耗电应用" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "未發現高耗電應用" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "電力消費の多いアプリはありません" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "에너지 사용이 많은 앱이 없습니다" } },
"de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Apps mit hohem Energieverbrauch gefunden" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucune app très énergivore trouvée" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "No se encontraron apps con alto consumo" } },
"pt" : { "stringUnit" : { "state" : "translated", "value" : "Nenhum app com alto consumo encontrado" } },
"ru" : { "stringUnit" : { "state" : "translated", "value" : "Приложения с высоким энергопотреблением не найдены" } },
"tr" : { "stringUnit" : { "state" : "translated", "value" : "Yüksek enerji tüketen uygulama bulunamadı" } },
"ar" : { "stringUnit" : { "state" : "translated", "value" : "لم يتم العثور على تطبيقات عالية الاستهلاك" } }
}
},
"sensors.title" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Temperature & Power" } },
Expand Down
40 changes: 31 additions & 9 deletions Plugins/PowerStatus/Sources/PowerStatusAppEnergy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ struct PowerStatusAppEnergyReading: Sendable, Equatable {
let watts: Double
}

/// The result of one background sampling pass. `hasMeasurements` is false for
/// the baseline pass so the panel can distinguish loading from a real empty
/// result.
struct PowerStatusAppEnergySample: Sendable, Equatable {
let readings: [PowerStatusAppEnergyReading]
let hasMeasurements: Bool
}

/// A point-in-time snapshot of a running application. Passing value types to
/// the sampler keeps `NSRunningApplication` access and icon loading on the main
/// actor.
Expand Down Expand Up @@ -116,6 +124,11 @@ actor PowerStatusAppEnergyMonitor {
private var smoothedWatts: [String: Double] = [:]
private var sessionID: Int?

private static let emptySample = PowerStatusAppEnergySample(
readings: [],
hasMeasurements: false
)

/// Samples cumulative energy and returns the five heaviest applications
/// since the previous call. The first call in a session only records a
/// baseline. A newer session drops the previous counters so reopening the
Expand All @@ -124,10 +137,10 @@ actor PowerStatusAppEnergyMonitor {
identities: [PowerStatusAppIdentity],
now: Date = Date(),
sessionID: Int
) -> [PowerStatusAppEnergyReading] {
guard !Task.isCancelled else { return [] }
) -> PowerStatusAppEnergySample {
guard !Task.isCancelled else { return Self.emptySample }
if let currentSession = self.sessionID, sessionID < currentSession {
return []
return Self.emptySample
}
if self.sessionID != sessionID {
self.sessionID = sessionID
Expand All @@ -138,26 +151,30 @@ actor PowerStatusAppEnergyMonitor {
let energies = Self.readCumulativeEnergy()
defer { baseline = Baseline(timestamp: now, energyByPID: energies) }

guard let baseline else { return [] }
guard let baseline else { return Self.emptySample }
let elapsed = now.timeIntervalSince(baseline.timestamp)
guard elapsed > 0.2, elapsed <= Self.maximumSampleInterval else {
smoothedWatts.removeAll()
return []
return Self.emptySample
}

let wattsByPID = PowerStatusAppEnergyMath.watts(
previous: baseline.energyByPID,
current: energies,
elapsed: elapsed
)
guard !wattsByPID.isEmpty else { return [] }
guard !wattsByPID.isEmpty else {
return PowerStatusAppEnergySample(readings: [], hasMeasurements: true)
}

var appsByPID: [Int32: PowerStatusAppIdentity] = [:]
appsByPID.reserveCapacity(identities.count)
for app in identities where app.pid > 0 {
appsByPID[app.pid] = app
}
guard !appsByPID.isEmpty else { return [] }
guard !appsByPID.isEmpty else {
return PowerStatusAppEnergySample(readings: [], hasMeasurements: true)
}

// Attribute helpers and child processes to the app that owns them, so
// a browser's renderer and GPU helpers count toward the browser.
Expand All @@ -169,7 +186,9 @@ actor PowerStatusAppEnergyMonitor {
wattsByApp[owner.bundleID, default: 0] += watts
appByID[owner.bundleID] = owner
}
guard !wattsByApp.isEmpty else { return [] }
guard !wattsByApp.isEmpty else {
return PowerStatusAppEnergySample(readings: [], hasMeasurements: true)
}

var smoothed: [String: Double] = [:]
for (id, watts) in wattsByApp {
Expand All @@ -186,7 +205,10 @@ actor PowerStatusAppEnergyMonitor {
watts: watts
)
}
return PowerStatusAppEnergyMath.rankedReadings(readings)
return PowerStatusAppEnergySample(
readings: PowerStatusAppEnergyMath.rankedReadings(readings),
hasMeasurements: true
)
}

// MARK: - Process inspection
Expand Down
177 changes: 135 additions & 42 deletions Plugins/PowerStatus/Sources/PowerStatusComponentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,25 +119,6 @@ struct PowerStatusComponentView: View {
.lineLimit(1)

Spacer(minLength: 4)

if let headerPowerW = viewModel.snapshot.headerPowerW {
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(String(format: "%.0f", headerPowerW))
.font(PluginTypography.sectionTitle.font.weight(.bold))
.foregroundStyle(theme.text.primary)
.monospacedDigit()
Text("W")
.font(PluginTypography.caption.font)
.foregroundStyle(theme.text.secondary)
}
} else {
// Nothing establishes the current total draw; say so instead of
// claiming 0 W.
Text(PowerStatusValueText.unavailable)
.font(PluginTypography.sectionTitle.font.weight(.bold))
.foregroundStyle(theme.text.tertiary)
.monospacedDigit()
}
}
.padding(.horizontal, PowerStatusLayout.horizontalPadding)
.padding(.bottom, PowerStatusLayout.sectionHeaderContent)
Expand Down Expand Up @@ -241,23 +222,20 @@ struct PowerStatusComponentView: View {

// MARK: - Energy-consuming apps

/// The five heaviest applications in the current sampling window. Empty
/// until the second sample establishes a baseline.
@ViewBuilder
/// The five heaviest applications in the current sampling window. The
/// section stays collapsed by default and only samples while expanded.
private var energyAppsSection: some View {
let apps = viewModel.snapshot.topEnergyApps
if !apps.isEmpty {
detailGroup(title: localization.string("energyApps.title", defaultValue: "高耗电应用")) {
VStack(spacing: PowerStatusLayout.rowVertical) {
ForEach(apps) { app in
PowerStatusAppEnergyRow(app: app)
}
}
PowerStatusEnergyAppsSection(
isExpanded: viewModel.isEnergyAppsExpanded,
apps: viewModel.snapshot.topEnergyApps,
isSampled: viewModel.isEnergyAppsSampled,
localization: localization,
onToggle: {
viewModel.setEnergyAppsExpanded(!viewModel.isEnergyAppsExpanded)
}
.help(localization.string("energyApps.help", defaultValue: "最近一次采样间隔内的平均功耗"))
.padding(.horizontal, PowerStatusLayout.horizontalPadding)
.padding(.top, PowerStatusLayout.section)
}
)
.padding(.horizontal, PowerStatusLayout.horizontalPadding)
.padding(.top, PowerStatusLayout.section)
}

// MARK: - Telemetry
Expand Down Expand Up @@ -521,6 +499,7 @@ enum PowerStatusLayout {
static let batteryBarHeight: CGFloat = 26
static let miniMetricRowHeight: CGFloat = 31
static let appEnergyRowHeight: CGFloat = 20
static let appEnergyHeaderHeight: CGFloat = 22
static let sensorHeaderHeight: CGFloat = 22
static let sensorTableHeaderHeight: CGFloat = 14
static let sensorRowHeight: CGFloat = 18
Expand All @@ -545,6 +524,7 @@ enum PowerStatusLayout {
capacityTileCount: Int,
voltageCurrentTileCount: Int,
energyAppCount: Int = 0,
isEnergyAppsExpanded: Bool = false,
sensorRowCount: Int = 0,
isSensorsExpanded: Bool = false,
isDataAvailable: Bool
Expand All @@ -559,7 +539,10 @@ enum PowerStatusLayout {
let powerFlow = CGFloat(rows) * metricRowHeight
+ CGFloat(max(rows - 1, 0)) * rowVertical
+ section
let energyApps = energyAppsHeight(rowCount: energyAppCount)
let energyApps = energyAppsHeight(
rowCount: energyAppCount,
isExpanded: isEnergyAppsExpanded
)
let sensors = sensorsHeight(rowCount: sensorRowCount, isExpanded: isSensorsExpanded)
// The capacity group always renders; it carries the battery level bar.
let capacity = groupHeight(
Expand All @@ -585,13 +568,16 @@ enum PowerStatusLayout {
return header + sectionHeaderContent + table
}

/// One row per application, under the same group header as the telemetry
/// groups but in a single column.
private static func energyAppsHeight(rowCount: Int) -> CGFloat {
guard rowCount > 0 else { return 0 }
return section + detailGroupHeaderHeight + sectionHeaderContent
+ CGFloat(rowCount) * appEnergyRowHeight
+ CGFloat(max(rowCount - 1, 0)) * rowVertical
/// The collapsible header always renders; the list adds one row per app,
/// or one placeholder row before the first sample completes.
private static func energyAppsHeight(rowCount: Int, isExpanded: Bool) -> CGFloat {
let header = section + appEnergyHeaderHeight
guard isExpanded else { return header }

let rows = max(rowCount, 1)
return header + sectionHeaderContent
+ CGFloat(rows) * appEnergyRowHeight
+ CGFloat(max(rows - 1, 0)) * rowVertical
}

/// One telemetry group: its own leading spacing, header, an optional
Expand Down Expand Up @@ -1291,6 +1277,113 @@ private struct PowerStatusSensorSection: View {
}
}

// MARK: - High energy apps

private struct PowerStatusEnergyAppsSection: View {
let isExpanded: Bool
let apps: [PowerStatusAppEnergy]
let isSampled: Bool
let localization: PluginLocalization
let onToggle: () -> Void

@Environment(\.pluginComponentTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
VStack(alignment: .leading, spacing: 0) {
header

if isExpanded {
content
.padding(.top, PowerStatusLayout.sectionHeaderContent)
}
}
}

private var header: some View {
Button(action: toggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(theme.text.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 10)

Image(systemName: "bolt.fill")
.font(.system(size: 11, weight: .medium))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(theme.status.warning)
.frame(width: 16)

Text(localization.string("energyApps.title", defaultValue: "高耗电应用"))
.font(PluginTypography.caption.font.weight(.semibold))
.foregroundStyle(theme.text.secondary)
.lineLimit(1)

Spacer(minLength: 4)
}
.frame(height: PowerStatusLayout.appEnergyHeaderHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(localization.string("energyApps.help", defaultValue: "最近一次采样间隔内的平均功耗"))
.accessibilityLabel(localization.string("energyApps.title", defaultValue: "高耗电应用"))
.accessibilityHint(isExpanded ? collapseHint : expandHint)
}

@ViewBuilder
private var content: some View {
if apps.isEmpty {
placeholderRow
} else {
VStack(spacing: PowerStatusLayout.rowVertical) {
ForEach(apps) { app in
PowerStatusAppEnergyRow(app: app)
}
}
}
}

private var placeholderRow: some View {
Text(
localization.string(
isSampled ? "energyApps.empty" : "energyApps.loading",
defaultValue: isSampled ? "未发现高耗电应用" : "正在采集应用功耗…"
)
)
.font(PluginTypography.detail.font)
.foregroundStyle(theme.text.tertiary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 22)
.frame(height: PowerStatusLayout.appEnergyRowHeight)
}

private func toggle() {
if reduceMotion {
onToggle()
} else {
withAnimation(.easeInOut(duration: 0.18)) {
onToggle()
}
}
}

private var expandHint: String {
localization.string(
"energyApps.expandHint",
defaultValue: "展开查看高耗电应用"
)
}

private var collapseHint: String {
localization.string(
"energyApps.collapseHint",
defaultValue: "收起高耗电应用"
)
}
}

private struct PowerStatusAppEnergyRow: View {
let app: PowerStatusAppEnergy

Expand Down
Loading
Loading