Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public struct MetricsBarConfiguration: Codable, Sendable, Equatable {
public var showsBattery: Bool
public var showsNetwork: Bool
public var visibleCustomMetricsSourceIDs: Set<UUID>
public var valueStyle: MetricsBarValueStyle?
public var batteryStyle: MetricsBarBatteryStyle?

public var isEmpty: Bool {
!showsCPU && !showsMemory && !showsStorage && !showsBattery && !showsNetwork
Expand All @@ -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
)
}
Original file line number Diff line number Diff line change
@@ -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 }
}
12 changes: 12 additions & 0 deletions LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
limitations under the License.
*/

import AppKit
import SwiftUI

extension GraphicsContext {
Expand All @@ -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
}
}
84 changes: 84 additions & 0 deletions LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ enum IndicatorKind {
case spacer
case usageFullLabel
case usageHalfLabel
case usageBar
case usagePieChart
case boltIcon

var size: CGSize {
switch self {
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ struct MetricsBarSettingsView: View {
var body: some View {
Form {
Section {
Picker(selection: Binding<MetricsBarValueStyle>(
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<Bool>(
get: { store.metricsBarConfiguration.showsCPU },
asyncSet: { await store.send(.showsSystemMetricsToggleSwitched(.cpu, $0)) }
Expand All @@ -52,6 +67,22 @@ struct MetricsBarSettingsView: View {
)) {
Text("showBatteryStatus", bundle: .module)
}
if store.metricsBarConfiguration.showsBattery {
Picker(selection: Binding<MetricsBarBatteryStyle>(
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<Bool>(
get: { store.metricsBarConfiguration.showsNetwork },
asyncSet: { await store.send(.showsSystemMetricsToggleSwitched(.network, $0)) }
Expand Down
Loading