From b575758ac3b0b0f932c9b01effad0ea3274b25b3 Mon Sep 17 00:00:00 2001 From: 3manu31 <166739282+3manu31@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:33:16 +0200 Subject: [PATCH] Add configurable metrics bar display styles with independent battery option Lets users choose how CPU/Memory/Storage values are shown in the menu bar (Percentage, Pie, or Bar), addressing issue #33 by allowing a more compact footprint. Battery gets its own independent style choice (Compact icon-with-inline-percentage, or classic Percentage) since it doesn't have a meaningful pie/bar variant of its own. A thunderbolt indicator appears next to the percentage whenever charging, in every mode. --- .../Metrics/MetricsBarBatteryStyle.swift | 26 ++++++ .../Metrics/MetricsBarConfiguration.swift | 14 ++- .../Metrics/MetricsBarValueStyle.swift | 27 ++++++ .../Model/Stores/MetricsBarSettings.swift | 12 +++ .../GraphicsContext+Extension.swift | 92 +++++++++++++++++++ .../Resources/Localizable.xcstrings | 84 +++++++++++++++++ .../Views/MetricsBar/IndicatorKind.swift | 9 ++ .../MetricsBar/MetricsBarSettingsView.swift | 31 +++++++ .../Views/MetricsBar/MetricsBarView.swift | 55 +++++++++-- .../MetricsBarConfigurationTests.swift | 71 ++++++++++++++ .../StoreTests/MetricsBarSettingsTests.swift | 28 ++++++ RunCatNeo.xcodeproj/project.pbxproj | 4 +- 12 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarBatteryStyle.swift create mode 100644 LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarValueStyle.swift create mode 100644 LocalPackage/Tests/DataSourceTests/EntityTests/MetricsBarConfigurationTests.swift diff --git a/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarBatteryStyle.swift b/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarBatteryStyle.swift new file mode 100644 index 0000000..16a2798 --- /dev/null +++ b/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarBatteryStyle.swift @@ -0,0 +1,26 @@ +/* + MetricsBarBatteryStyle.swift + DataSource + + Created by Takuto Nakamura on 2026/05/24. + Copyright 2026 Kyome22 (Takuto Nakamura) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +public enum MetricsBarBatteryStyle: String, Codable, Sendable, CaseIterable, Identifiable { + case compact + case percentage + + public var id: String { rawValue } +} diff --git a/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarConfiguration.swift b/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarConfiguration.swift index 5f84112..04b93cf 100644 --- a/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarConfiguration.swift +++ b/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarConfiguration.swift @@ -27,6 +27,8 @@ public struct MetricsBarConfiguration: Codable, Sendable, Equatable { public var showsBattery: Bool public var showsNetwork: Bool public var visibleCustomMetricsSourceIDs: Set + public var valueStyle: MetricsBarValueStyle? + public var batteryStyle: MetricsBarBatteryStyle? public var isEmpty: Bool { !showsCPU && !showsMemory && !showsStorage && !showsBattery && !showsNetwork @@ -37,12 +39,22 @@ public struct MetricsBarConfiguration: Codable, Sendable, Equatable { visibleCustomMetricsSourceIDs.contains(id) } + public var resolvedValueStyle: MetricsBarValueStyle { + valueStyle ?? .percentage + } + + public var resolvedBatteryStyle: MetricsBarBatteryStyle { + batteryStyle ?? .percentage + } + public static let `default` = Self( showsCPU: true, showsMemory: false, showsStorage: false, showsBattery: false, showsNetwork: false, - visibleCustomMetricsSourceIDs: [] + visibleCustomMetricsSourceIDs: [], + valueStyle: .percentage, + batteryStyle: .percentage ) } diff --git a/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarValueStyle.swift b/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarValueStyle.swift new file mode 100644 index 0000000..41a52f4 --- /dev/null +++ b/LocalPackage/Sources/DataSource/Entities/Metrics/MetricsBarValueStyle.swift @@ -0,0 +1,27 @@ +/* + MetricsBarValueStyle.swift + DataSource + + Created by Takuto Nakamura on 2026/05/24. + Copyright 2026 Kyome22 (Takuto Nakamura) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +public enum MetricsBarValueStyle: String, Codable, Sendable, CaseIterable, Identifiable { + case percentage + case pie + case bar + + public var id: String { rawValue } +} diff --git a/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift b/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift index c7d55ac..2dcb7bb 100644 --- a/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift +++ b/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift @@ -111,6 +111,16 @@ public final class MetricsBarSettings: Composable { } userDefaultsRepository.metricsBarConfiguration = metricsBarConfiguration systemMetricsService.emitConfigurationChange() + + case let .valueStyleChanged(style): + metricsBarConfiguration.valueStyle = style + userDefaultsRepository.metricsBarConfiguration = metricsBarConfiguration + systemMetricsService.emitConfigurationChange() + + case let .batteryStyleChanged(style): + metricsBarConfiguration.batteryStyle = style + userDefaultsRepository.metricsBarConfiguration = metricsBarConfiguration + systemMetricsService.emitConfigurationChange() } } @@ -124,5 +134,7 @@ public final class MetricsBarSettings: Composable { case onDisappear case showsSystemMetricsToggleSwitched(SystemInfoType, Bool) case showsCustomMetricsToggleSwitched(UUID, Bool) + case valueStyleChanged(MetricsBarValueStyle) + case batteryStyleChanged(MetricsBarBatteryStyle) } } diff --git a/LocalPackage/Sources/UserInterface/Extensions/GraphicsContext+Extension.swift b/LocalPackage/Sources/UserInterface/Extensions/GraphicsContext+Extension.swift index 9f30575..6de61f1 100644 --- a/LocalPackage/Sources/UserInterface/Extensions/GraphicsContext+Extension.swift +++ b/LocalPackage/Sources/UserInterface/Extensions/GraphicsContext+Extension.swift @@ -18,6 +18,7 @@ limitations under the License. */ +import AppKit import SwiftUI extension GraphicsContext { @@ -42,4 +43,95 @@ extension GraphicsContext { ) draw(image, at: point, anchor: .center) } + + mutating func drawBoltIcon(point: CGPoint, size: CGSize) { + var resolvedText = resolve(Text(Image(systemName: "bolt.fill")).font(.system(size: size.height * 0.7))) + resolvedText.shading = .color(.black) + draw(resolvedText, in: CGRect(origin: point, size: size)) + } + + mutating func drawUsageBar(origin: CGPoint, size: CGSize, percentage: Double) { + let outlineRect = CGRect(origin: origin, size: size) + stroke(Path(roundedRect: outlineRect, cornerRadius: 1), with: .color(.black), lineWidth: 1) + let fillHeight = (size.height - 2) * (min(max(percentage, 0), 100) / 100) + let fillRect = CGRect( + x: origin.x + 1, + y: origin.y + size.height - 1 - fillHeight, + width: size.width - 2, + height: fillHeight + ) + fill(Path(fillRect), with: .color(.black)) + } + + mutating func drawUsagePieChart(origin: CGPoint, size: CGSize, percentage: Double) { + let clamped = min(max(percentage, 0), 100) + let center = CGPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2) + let radius = min(size.width, size.height) / 2 - 1 + let angle = (clamped / 100) * 360 + + stroke(Path(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)), with: .color(.black), lineWidth: 1) + + if clamped >= 99.5 { + fill(Path(ellipseIn: CGRect(x: center.x - (radius - 1), y: center.y - (radius - 1), width: (radius - 1) * 2, height: (radius - 1) * 2)), with: .color(.black)) + } else if clamped > 0 { + var path = Path() + path.move(to: center) + let endAngle = Angle(degrees: -90 + angle) + let startAngle = Angle(degrees: -90) + path.addArc(center: center, radius: radius - 1, startAngle: startAngle, endAngle: endAngle, clockwise: false) + path.closeSubpath() + fill(path, with: .color(.black)) + } + } + + mutating func drawBatteryIndicator(origin: CGPoint, size: CGSize, percentage: Double, isCharging: Bool) { + let clamped = min(max(percentage, 0), 100) + let bodyWidth = size.width * 0.75 + let bodyHeight = size.height * 0.6 + let bodyRect = CGRect( + x: origin.x + (size.width - bodyWidth) / 2, + y: origin.y + (size.height - bodyHeight) / 2, + width: bodyWidth, + height: bodyHeight + ) + let terminalRect = CGRect( + x: bodyRect.maxX, + y: bodyRect.midY - size.height * 0.15, + width: size.width * 0.15, + height: size.height * 0.3 + ) + + fill(Path(roundedRect: bodyRect, cornerRadius: 1), with: .color(.black)) + fill(Path(terminalRect), with: .color(.black)) + + let fontSize = size.height * 0.45 + let font = NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold) + let numberString = "\(Int(clamped))" + let textSize = NSAttributedString(string: numberString, attributes: [.font: font]).size() + var resolvedText = resolve( + Text(verbatim: numberString).font(.system(size: fontSize, weight: .semibold)).monospacedDigit() + ) + resolvedText.shading = .color(.black) + + let boltSize = CGSize(width: fontSize * 0.7, height: fontSize * 0.9) + let boltWidth = isCharging ? boltSize.width : 0 + let boltGap: CGFloat = isCharging ? 1 : 0 + let contentWidth = boltWidth + boltGap + textSize.width + let contentOriginX = bodyRect.midX - contentWidth / 2 + let textOrigin = CGPoint(x: contentOriginX + boltWidth + boltGap, y: bodyRect.midY - textSize.height / 2) + + // The final image is displayed with .renderingMode(.template), which discards color and + // keeps only alpha — drawing the number (and bolt) in a different color on top of the solid + // fill would stay fully opaque and disappear. Punching them out with .destinationOut instead + // leaves transparent gaps in the fill, which is what actually reads as visible. + blendMode = .destinationOut + draw(resolvedText, in: CGRect(origin: textOrigin, size: textSize)) + if isCharging { + var resolvedBolt = resolve(Text(Image(systemName: "bolt.fill")).font(.system(size: fontSize * 0.85))) + resolvedBolt.shading = .color(.black) + let boltOrigin = CGPoint(x: contentOriginX, y: bodyRect.midY - boltSize.height / 2) + draw(resolvedBolt, in: CGRect(origin: boltOrigin, size: boltSize)) + } + blendMode = .normal + } } diff --git a/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings b/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings index fd4aee3..86e4441 100644 --- a/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings +++ b/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings @@ -2839,6 +2839,90 @@ } } }, + "metricsBarValueStyle" : { + "comment" : "A picker label to choose how metrics bar values are visualized.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style" + } + } + } + }, + "metricsBarValueStylePercentage" : { + "comment" : "A picker option that shows metrics bar values as percentage text.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Percentage" + } + } + } + }, + "metricsBarValueStylePie" : { + "comment" : "A picker option that shows metrics bar values as pie charts.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pie" + } + } + } + }, + "metricsBarValueStyleBar" : { + "comment" : "A picker option that shows metrics bar values as vertical fill bars.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bar" + } + } + } + }, + "metricsBarBatteryStyle" : { + "comment" : "A picker label to choose how the battery indicator is displayed, independent of the general value style.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Style" + } + } + } + }, + "metricsBarBatteryStyleCompact" : { + "comment" : "A picker option that shows the battery percentage inside a compact battery icon.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compact" + } + } + } + }, + "metricsBarBatteryStylePercentage" : { + "comment" : "A picker option that shows the battery icon with a separate percentage text label.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Percentage" + } + } + } + }, "metricsBarNotes" : { "comment" : "A description of the functionality of the metrics bar.", "isCommentAutoGenerated" : true, diff --git a/LocalPackage/Sources/UserInterface/Views/MetricsBar/IndicatorKind.swift b/LocalPackage/Sources/UserInterface/Views/MetricsBar/IndicatorKind.swift index 51890a2..b090d2f 100644 --- a/LocalPackage/Sources/UserInterface/Views/MetricsBar/IndicatorKind.swift +++ b/LocalPackage/Sources/UserInterface/Views/MetricsBar/IndicatorKind.swift @@ -26,6 +26,9 @@ enum IndicatorKind { case spacer case usageFullLabel case usageHalfLabel + case usageBar + case usagePieChart + case boltIcon var size: CGSize { switch self { @@ -39,6 +42,12 @@ enum IndicatorKind { CGSize(width: 40.0, height: 16.0) case .usageHalfLabel: CGSize(width: 46.0, height: 8.0) + case .usageBar: + CGSize(width: 8.0, height: 16.0) + case .usagePieChart: + CGSize(width: 14.0, height: 16.0) + case .boltIcon: + CGSize(width: 10.0, height: 16.0) } } diff --git a/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarSettingsView.swift b/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarSettingsView.swift index d562c85..01af372 100644 --- a/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarSettingsView.swift +++ b/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarSettingsView.swift @@ -28,6 +28,21 @@ struct MetricsBarSettingsView: View { var body: some View { Form { Section { + Picker(selection: Binding( + get: { store.metricsBarConfiguration.resolvedValueStyle }, + asyncSet: { await store.send(.valueStyleChanged($0)) } + )) { + ForEach(MetricsBarValueStyle.allCases) { style in + let text = switch style { + case .percentage: Text("metricsBarValueStylePercentage", bundle: .module) + case .pie: Text("metricsBarValueStylePie", bundle: .module) + case .bar: Text("metricsBarValueStyleBar", bundle: .module) + } + text.tag(style) + } + } label: { + Text("metricsBarValueStyle", bundle: .module) + } Toggle(isOn: Binding( get: { store.metricsBarConfiguration.showsCPU }, asyncSet: { await store.send(.showsSystemMetricsToggleSwitched(.cpu, $0)) } @@ -52,6 +67,22 @@ struct MetricsBarSettingsView: View { )) { Text("showBatteryStatus", bundle: .module) } + if store.metricsBarConfiguration.showsBattery { + Picker(selection: Binding( + get: { store.metricsBarConfiguration.resolvedBatteryStyle }, + asyncSet: { await store.send(.batteryStyleChanged($0)) } + )) { + ForEach(MetricsBarBatteryStyle.allCases) { style in + let text = switch style { + case .compact: Text("metricsBarBatteryStyleCompact", bundle: .module) + case .percentage: Text("metricsBarBatteryStylePercentage", bundle: .module) + } + text.tag(style) + } + } label: { + Text("metricsBarBatteryStyle", bundle: .module) + } + } Toggle(isOn: Binding( get: { store.metricsBarConfiguration.showsNetwork }, asyncSet: { await store.send(.showsSystemMetricsToggleSwitched(.network, $0)) } diff --git a/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarView.swift b/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarView.swift index eef1171..6ababff 100644 --- a/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarView.swift +++ b/LocalPackage/Sources/UserInterface/Views/MetricsBar/MetricsBarView.swift @@ -26,22 +26,31 @@ import SystemInfoKit struct MetricsBarView: View { @StateObject var store: MetricsBar + private var usageIndicatorKind: IndicatorKind { + switch store.metricsBarConfiguration.resolvedValueStyle { + case .percentage: .usageFullLabel + case .bar: .usageBar + case .pie: .usagePieChart + } + } + private var size: CGSize { var widthArray = [CGFloat]() let iconWidth = IndicatorKind.categoryIcon.size.width if store.metricsBarConfiguration.showsCPU, store.systemInfoBundle.cpuInfo != nil { - widthArray.append(iconWidth + IndicatorKind.usageFullLabel.size.width) + widthArray.append(iconWidth + usageIndicatorKind.size.width) } if store.metricsBarConfiguration.showsMemory, store.systemInfoBundle.memoryInfo != nil { - widthArray.append(iconWidth + IndicatorKind.usageFullLabel.size.width) + widthArray.append(iconWidth + usageIndicatorKind.size.width) } if store.metricsBarConfiguration.showsStorage, store.systemInfoBundle.storageInfo != nil { - widthArray.append(iconWidth + IndicatorKind.usageFullLabel.size.width) + widthArray.append(iconWidth + usageIndicatorKind.size.width) } if store.metricsBarConfiguration.showsBattery, let batteryInfo = store.systemInfoBundle.batteryInfo?.simulated(store.isPreview) { - if batteryInfo.isInstalled { - widthArray.append(iconWidth + IndicatorKind.usageFullLabel.size.width) + if batteryInfo.isInstalled && store.metricsBarConfiguration.resolvedBatteryStyle == .percentage { + let boltWidth = batteryInfo.isCharging ? IndicatorKind.boltIcon.size.width : 0 + widthArray.append(iconWidth + boltWidth + IndicatorKind.usageFullLabel.size.width) } else { widthArray.append(iconWidth) } @@ -100,18 +109,29 @@ struct MetricsBarView: View { systemInfo: any SystemInfo ) { let iconSize = IndicatorKind.categoryIcon.size + + if let batteryInfo = systemInfo as? BatteryInfo, + batteryInfo.isInstalled, + store.metricsBarConfiguration.resolvedBatteryStyle == .compact { + context.drawBatteryIndicator(origin: point, size: iconSize, percentage: batteryInfo.percentage.value, isCharging: batteryInfo.isCharging) + point.x += iconSize.width + IndicatorKind.spacer.size.width + return + } + context.drawIcon(systemName: systemInfo.icon, point: point, size: iconSize) point.x += iconSize.width switch systemInfo { case is CPUInfo, is MemoryInfo, is StorageInfo: - context.drawBlackText(origin: point, size: IndicatorKind.usageFullLabel.size) { - Text(verbatim: systemInfo.percentage.menuBarDescription) - } - point.x += IndicatorKind.usageFullLabel.size.width + IndicatorKind.spacer.size.width + drawUsage(context: &context, point: point, percentage: systemInfo.percentage) + point.x += usageIndicatorKind.size.width + IndicatorKind.spacer.size.width case let batteryInfo as BatteryInfo: if batteryInfo.isInstalled { + if batteryInfo.isCharging { + context.drawBoltIcon(point: point, size: IndicatorKind.boltIcon.size) + point.x += IndicatorKind.boltIcon.size.width + } context.drawBlackText(origin: point, size: IndicatorKind.usageFullLabel.size) { Text(verbatim: batteryInfo.percentage.menuBarDescription) } @@ -138,6 +158,23 @@ struct MetricsBarView: View { } } + private func drawUsage( + context: inout GraphicsContext, + point: CGPoint, + percentage: Percentage + ) { + switch store.metricsBarConfiguration.resolvedValueStyle { + case .percentage: + context.drawBlackText(origin: point, size: usageIndicatorKind.size) { + Text(verbatim: percentage.menuBarDescription) + } + case .bar: + context.drawUsageBar(origin: point, size: usageIndicatorKind.size, percentage: percentage.value) + case .pie: + context.drawUsagePieChart(origin: point, size: usageIndicatorKind.size, percentage: percentage.value) + } + } + private func drawCustomMetrics( context: inout GraphicsContext, point: inout CGPoint, diff --git a/LocalPackage/Tests/DataSourceTests/EntityTests/MetricsBarConfigurationTests.swift b/LocalPackage/Tests/DataSourceTests/EntityTests/MetricsBarConfigurationTests.swift new file mode 100644 index 0000000..bf6a788 --- /dev/null +++ b/LocalPackage/Tests/DataSourceTests/EntityTests/MetricsBarConfigurationTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing + +@testable import DataSource + +struct MetricsBarConfigurationTests { + @Test + func decode_defaults_to_percentage_valueStyle_when_key_missing() throws { + let json = """ + { + "showsCPU" : true, + "showsMemory" : false, + "showsStorage" : false, + "showsBattery" : false, + "showsNetwork" : false, + "visibleCustomMetricsSourceIDs" : [] + } + """.data(using: .utf8)! + let configuration = try JSONDecoder().decode(MetricsBarConfiguration.self, from: json) + #expect(configuration.resolvedValueStyle == .percentage) + } + + @Test + func resolvedValueStyle_returns_pie_when_set() { + var configuration = MetricsBarConfiguration.default + configuration.valueStyle = .pie + #expect(configuration.resolvedValueStyle == .pie) + } + + @Test + func roundtrip_preserves_valueStyle() throws { + var original = MetricsBarConfiguration.default + original.valueStyle = .bar + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(MetricsBarConfiguration.self, from: data) + #expect(decoded == original) + } + + @Test + func decode_defaults_to_percentage_batteryStyle_when_key_missing() throws { + let json = """ + { + "showsCPU" : true, + "showsMemory" : false, + "showsStorage" : false, + "showsBattery" : false, + "showsNetwork" : false, + "visibleCustomMetricsSourceIDs" : [], + "valueStyle" : "bar" + } + """.data(using: .utf8)! + let configuration = try JSONDecoder().decode(MetricsBarConfiguration.self, from: json) + #expect(configuration.resolvedBatteryStyle == .percentage) + } + + @Test + func resolvedBatteryStyle_returns_compact_when_set() { + var configuration = MetricsBarConfiguration.default + configuration.batteryStyle = .compact + #expect(configuration.resolvedBatteryStyle == .compact) + } + + @Test + func roundtrip_preserves_batteryStyle() throws { + var original = MetricsBarConfiguration.default + original.batteryStyle = .compact + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(MetricsBarConfiguration.self, from: data) + #expect(decoded == original) + } +} diff --git a/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift index 9743f31..c7ef26e 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift @@ -111,6 +111,34 @@ struct MetricsBarSettingsTests { #expect(appState.withLock(\.systemMetricsConfigurationChanges.latestValue) != nil) } + @MainActor @Test + func send_valueStyleChanged_persists_pie_style_and_emits_change() async { + let appState = AllocatedUnfairLock(initialState: .init()) + let storage = UserDefaultsClient.storage() + let sut = MetricsBarSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: storage.client + )) + await sut.send(.valueStyleChanged(.pie)) + #expect(sut.metricsBarConfiguration.resolvedValueStyle == .pie) + #expect(storage.currentMetricsBarConfiguration()?.resolvedValueStyle == .pie) + #expect(appState.withLock(\.systemMetricsConfigurationChanges.latestValue) != nil) + } + + @MainActor @Test + func send_batteryStyleChanged_persists_compact_style_and_emits_change() async { + let appState = AllocatedUnfairLock(initialState: .init()) + let storage = UserDefaultsClient.storage() + let sut = MetricsBarSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: storage.client + )) + await sut.send(.batteryStyleChanged(.compact)) + #expect(sut.metricsBarConfiguration.resolvedBatteryStyle == .compact) + #expect(storage.currentMetricsBarConfiguration()?.resolvedBatteryStyle == .compact) + #expect(appState.withLock(\.systemMetricsConfigurationChanges.latestValue) != nil) + } + @MainActor @Test func send_showsSystemMetricsToggleSwitched_cpu_does_not_toggle_activation() async { let activationCount = AllocatedUnfairLock(initialState: 0) diff --git a/RunCatNeo.xcodeproj/project.pbxproj b/RunCatNeo.xcodeproj/project.pbxproj index 6063c48..6554ad6 100644 --- a/RunCatNeo.xcodeproj/project.pbxproj +++ b/RunCatNeo.xcodeproj/project.pbxproj @@ -300,7 +300,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.0.3; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = VJ5N2X84K8; + DEVELOPMENT_TEAM = 266TX52NJD; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; @@ -349,7 +349,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.0.3; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = VJ5N2X84K8; + DEVELOPMENT_TEAM = 266TX52NJD; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;