diff --git a/Plugins/PowerStatus/Resources/Localizable.xcstrings b/Plugins/PowerStatus/Resources/Localizable.xcstrings index 7b7e87ef3..5cec23d1f 100644 --- a/Plugins/PowerStatus/Resources/Localizable.xcstrings +++ b/Plugins/PowerStatus/Resources/Localizable.xcstrings @@ -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" } }, diff --git a/Plugins/PowerStatus/Sources/PowerStatusAppEnergy.swift b/Plugins/PowerStatus/Sources/PowerStatusAppEnergy.swift index abc50091d..3822614a7 100644 --- a/Plugins/PowerStatus/Sources/PowerStatusAppEnergy.swift +++ b/Plugins/PowerStatus/Sources/PowerStatusAppEnergy.swift @@ -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. @@ -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 @@ -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 @@ -138,11 +151,11 @@ 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( @@ -150,14 +163,18 @@ actor PowerStatusAppEnergyMonitor { 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. @@ -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 { @@ -186,7 +205,10 @@ actor PowerStatusAppEnergyMonitor { watts: watts ) } - return PowerStatusAppEnergyMath.rankedReadings(readings) + return PowerStatusAppEnergySample( + readings: PowerStatusAppEnergyMath.rankedReadings(readings), + hasMeasurements: true + ) } // MARK: - Process inspection diff --git a/Plugins/PowerStatus/Sources/PowerStatusComponentView.swift b/Plugins/PowerStatus/Sources/PowerStatusComponentView.swift index 0ce25ac41..2793feee2 100644 --- a/Plugins/PowerStatus/Sources/PowerStatusComponentView.swift +++ b/Plugins/PowerStatus/Sources/PowerStatusComponentView.swift @@ -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) @@ -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 @@ -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 @@ -545,6 +524,7 @@ enum PowerStatusLayout { capacityTileCount: Int, voltageCurrentTileCount: Int, energyAppCount: Int = 0, + isEnergyAppsExpanded: Bool = false, sensorRowCount: Int = 0, isSensorsExpanded: Bool = false, isDataAvailable: Bool @@ -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( @@ -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 @@ -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 diff --git a/Plugins/PowerStatus/Sources/PowerStatusPlugin.swift b/Plugins/PowerStatus/Sources/PowerStatusPlugin.swift index 68b550290..be9cd154b 100644 --- a/Plugins/PowerStatus/Sources/PowerStatusPlugin.swift +++ b/Plugins/PowerStatus/Sources/PowerStatusPlugin.swift @@ -58,6 +58,7 @@ final class PowerStatusPlugin: MacToolsPlugin { capacityTileCount: PowerStatusLayout.capacityTileCount(for: snapshot), voltageCurrentTileCount: PowerStatusLayout.voltageCurrentTileCount(for: snapshot), energyAppCount: snapshot.topEnergyApps.count, + isEnergyAppsExpanded: viewModel.isEnergyAppsExpanded, sensorRowCount: viewModel.sensorSnapshot.components.count, isSensorsExpanded: viewModel.isSensorsExpanded, isDataAvailable: snapshot.isDataAvailable diff --git a/Plugins/PowerStatus/Sources/PowerStatusViewModel.swift b/Plugins/PowerStatus/Sources/PowerStatusViewModel.swift index dd4a4a4d8..904dc517b 100644 --- a/Plugins/PowerStatus/Sources/PowerStatusViewModel.swift +++ b/Plugins/PowerStatus/Sources/PowerStatusViewModel.swift @@ -11,6 +11,8 @@ final class PowerStatusViewModel: ObservableObject { @Published private(set) var isPanelVisible: Bool = false @Published private(set) var sensorSnapshot: PowerStatusSensorSnapshot = .empty @Published private(set) var isSensorsExpanded: Bool = false + @Published private(set) var isEnergyAppsExpanded: Bool = false + @Published private(set) var isEnergyAppsSampled: Bool = false var onSnapshotChange: (() -> Void)? @@ -86,6 +88,26 @@ final class PowerStatusViewModel: ObservableObject { return nil } + // MARK: - App energy section + + /// Expands or collapses the high energy apps list. Sampling only runs while + /// the panel and the section are both visible. + func setEnergyAppsExpanded(_ expanded: Bool) { + guard expanded != isEnergyAppsExpanded else { return } + isEnergyAppsExpanded = expanded + energySession += 1 + energyTask?.cancel() + energyTask = nil + isEnergyAppsSampled = false + + if expanded { + sampleEnergyAppsIfNeeded(timestamp: Date()) + } else { + snapshot.topEnergyApps = [] + } + onSnapshotChange?() + } + // MARK: - Panel visibility func setPanelVisible(_ visible: Bool) { @@ -111,6 +133,7 @@ final class PowerStatusViewModel: ObservableObject { energySession += 1 energyTask?.cancel() energyTask = nil + isEnergyAppsSampled = false if !snapshot.topEnergyApps.isEmpty { snapshot.topEnergyApps = [] onSnapshotChange?() @@ -126,6 +149,9 @@ final class PowerStatusViewModel: ObservableObject { energySession += 1 energyTask?.cancel() energyTask = nil + isEnergyAppsExpanded = false + isEnergyAppsSampled = false + snapshot.topEnergyApps = [] sensorGeneration += 1 sensorTask?.cancel() sensorTask = nil @@ -183,34 +209,41 @@ final class PowerStatusViewModel: ObservableObject { newSnapshot.smoothedDischargePowerW = updateSmoothedDischargePower(from: newSnapshot) // Keep the last list visible while a new background sample is in // flight instead of clearing it every two seconds. - newSnapshot.topEnergyApps = newSnapshot.isDataAvailable ? snapshot.topEnergyApps : [] + newSnapshot.topEnergyApps = newSnapshot.isDataAvailable && isEnergyAppsExpanded + ? snapshot.topEnergyApps + : [] if newSnapshot != snapshot { snapshot = newSnapshot onSnapshotChange?() } - updateEnergyAppsIfNeeded(timestamp: newSnapshot.timestamp) + sampleEnergyAppsIfNeeded(timestamp: newSnapshot.timestamp) updateSensorsIfNeeded() } // MARK: - App energy - private func updateEnergyAppsIfNeeded(timestamp: Date) { - guard isPanelVisible, snapshot.isDataAvailable, energyTask == nil else { return } + private func sampleEnergyAppsIfNeeded(timestamp: Date) { + guard isPanelVisible, + isEnergyAppsExpanded, + snapshot.isDataAvailable, + energyTask == nil else { return } let session = energySession let identities = Self.runningAppIdentities() energyTask = Task { [weak self] in guard let self else { return } - let readings = await self.energyMonitor.sample( + let sample = await self.energyMonitor.sample( identities: identities, now: timestamp, sessionID: session ) guard self.energySession == session else { return } self.energyTask = nil - guard self.isPanelVisible, self.snapshot.isDataAvailable else { return } + guard self.isPanelVisible, + self.isEnergyAppsExpanded, + self.snapshot.isDataAvailable else { return } - self.snapshot.topEnergyApps = readings.map { reading in + self.snapshot.topEnergyApps = sample.readings.map { reading in PowerStatusAppEnergy( id: reading.id, name: reading.name, @@ -218,6 +251,9 @@ final class PowerStatusViewModel: ObservableObject { watts: reading.watts ) } + if sample.hasMeasurements { + self.isEnergyAppsSampled = true + } self.onSnapshotChange?() } } diff --git a/Plugins/PowerStatus/Tests/PowerStatusPluginTests.swift b/Plugins/PowerStatus/Tests/PowerStatusPluginTests.swift index e77417f87..8bca550b6 100644 --- a/Plugins/PowerStatus/Tests/PowerStatusPluginTests.swift +++ b/Plugins/PowerStatus/Tests/PowerStatusPluginTests.swift @@ -822,6 +822,60 @@ final class PowerStatusPluginTests: XCTestCase { ) } + func testEnergyAppsLayoutHeightGrowsOnlyWhenExpanded() { + let collapsed = PowerStatusLayout.contentHeight( + flowRowCount: 3, + diagramRowCount: 1, + capacityTileCount: 7, + voltageCurrentTileCount: 4, + energyAppCount: 5, + isEnergyAppsExpanded: false, + isDataAvailable: true + ) + let collapsedWithoutRows = PowerStatusLayout.contentHeight( + flowRowCount: 3, + diagramRowCount: 1, + capacityTileCount: 7, + voltageCurrentTileCount: 4, + energyAppCount: 0, + isEnergyAppsExpanded: false, + isDataAvailable: true + ) + let expanded = PowerStatusLayout.contentHeight( + flowRowCount: 3, + diagramRowCount: 1, + capacityTileCount: 7, + voltageCurrentTileCount: 4, + energyAppCount: 5, + isEnergyAppsExpanded: true, + isDataAvailable: true + ) + let expandedWithoutRows = PowerStatusLayout.contentHeight( + flowRowCount: 3, + diagramRowCount: 1, + capacityTileCount: 7, + voltageCurrentTileCount: 4, + energyAppCount: 0, + isEnergyAppsExpanded: true, + isDataAvailable: true + ) + + XCTAssertEqual(collapsed, collapsedWithoutRows, accuracy: 0.5) + XCTAssertGreaterThan(expanded, collapsed) + XCTAssertEqual( + expanded - collapsed, + PowerStatusLayout.sectionHeaderContent + + 5 * PowerStatusLayout.appEnergyRowHeight + + 4 * PowerStatusLayout.rowVertical, + accuracy: 0.5 + ) + XCTAssertEqual( + expandedWithoutRows - collapsed, + PowerStatusLayout.sectionHeaderContent + PowerStatusLayout.appEnergyRowHeight, + accuracy: 0.5 + ) + } + // MARK: - Helpers private func makeSnapshot( diff --git a/changes/unreleased/power-status-collapsible-energy-apps.md b/changes/unreleased/power-status-collapsible-energy-apps.md new file mode 100644 index 000000000..3c5498fef --- /dev/null +++ b/changes/unreleased/power-status-collapsible-energy-apps.md @@ -0,0 +1,6 @@ +--- +release: plugin +type: changed +--- + +Power Status now keeps the high energy apps list collapsed by default and samples it only while expanded, reducing background work when the panel is open. diff --git a/changes/unreleased/power-status-remove-header-watt.md b/changes/unreleased/power-status-remove-header-watt.md new file mode 100644 index 000000000..5d419ede8 --- /dev/null +++ b/changes/unreleased/power-status-remove-header-watt.md @@ -0,0 +1,6 @@ +--- +release: plugin +type: changed +--- + +Power Status no longer repeats the total wattage in the panel header; the power flow rows already show the current draw.