From 50fb3773e5172b11d1d427e7c315f9484c0fa120 Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Sun, 25 Oct 2026 14:53:38 +0800 Subject: [PATCH] feat(power-status): make high energy apps collapsible Keep the high energy apps list collapsed by default, sample it only while expanded, and show loading or empty placeholders. Remove the duplicate header wattage since the power flow rows already report the current draw. --- .../Resources/Localizable.xcstrings | 64 +++++++ .../Sources/PowerStatusAppEnergy.swift | 40 +++- .../Sources/PowerStatusComponentView.swift | 177 +++++++++++++----- .../Sources/PowerStatusPlugin.swift | 1 + .../Sources/PowerStatusViewModel.swift | 50 ++++- .../Tests/PowerStatusPluginTests.swift | 54 ++++++ .../power-status-collapsible-energy-apps.md | 6 + .../power-status-remove-header-watt.md | 6 + 8 files changed, 340 insertions(+), 58 deletions(-) create mode 100644 changes/unreleased/power-status-collapsible-energy-apps.md create mode 100644 changes/unreleased/power-status-remove-header-watt.md 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.