911 lines
31 KiB
Swift
911 lines
31 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
import MacToolsPluginKit
|
||
|
||
struct DeviceBatteryComponentView: View {
|
||
@ObservedObject var viewModel: DeviceBatteryViewModel
|
||
@ObservedObject var store: DeviceBatteryStore
|
||
let localization: PluginLocalization
|
||
let openSettings: () -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
if visibleItems.isEmpty {
|
||
emptyState
|
||
} else {
|
||
switch store.layoutMode {
|
||
case .grid:
|
||
DeviceBatteryListCard(
|
||
items: Array(visibleItems.prefix(DeviceBatteryLayout.maximumListItems)),
|
||
totalCount: visibleItems.count,
|
||
localization: localization
|
||
)
|
||
case .list:
|
||
DeviceBatteryGaugeGrid(
|
||
items: Array(visibleItems.prefix(DeviceBatteryLayout.maximumGaugeItems)),
|
||
totalCount: visibleItems.count,
|
||
localization: localization
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
private var visibleItems: [DeviceBatteryItem] {
|
||
viewModel.snapshot.visibleItems
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: viewModel.snapshot.accessState.isError ? "exclamationmark.triangle" : "battery.0percent")
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(viewModel.snapshot.accessState.isError ? Color(nsColor: .systemOrange) : Color.secondary)
|
||
|
||
Text(emptyTitle)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
|
||
Text(emptySubtitle)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.lineLimit(2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
if viewModel.snapshot.accessState == .permissionDenied {
|
||
Button(action: openSettings) {
|
||
Label(
|
||
localization.string("empty.openSettings", defaultValue: "打开系统设置"),
|
||
systemImage: "gearshape"
|
||
)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius))
|
||
}
|
||
|
||
private var emptyTitle: String {
|
||
switch viewModel.snapshot.accessState {
|
||
case .permissionDenied:
|
||
return localization.string("empty.title.permissionDenied", defaultValue: "需要输入监控权限")
|
||
case .scanning:
|
||
return localization.string("empty.title.scanning", defaultValue: "正在读取电量")
|
||
case .failed:
|
||
return localization.string("empty.title.failed", defaultValue: "读取失败")
|
||
case .idle, .ready, .noDevices:
|
||
return localization.string("empty.title.noDevices", defaultValue: "暂无设备电量")
|
||
}
|
||
}
|
||
|
||
private var emptySubtitle: String {
|
||
switch viewModel.snapshot.accessState {
|
||
case .permissionDenied:
|
||
return localization.string("empty.subtitle.permissionDenied", defaultValue: "授权后可读取厂商 HID 鼠标")
|
||
case .failed(let message):
|
||
return message
|
||
case .scanning:
|
||
return localization.string("empty.subtitle.scanning", defaultValue: "正在查询系统电源与蓝牙设备")
|
||
case .idle, .ready, .noDevices:
|
||
return localization.string("empty.subtitle.noDevices", defaultValue: "连接蓝牙设备或厂商 HID 鼠标")
|
||
}
|
||
}
|
||
}
|
||
|
||
enum DeviceBatteryComponentLayout {
|
||
static let width = 4
|
||
static let cornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius
|
||
static let horizontalPadding: CGFloat = 12
|
||
static let rowHeight: CGFloat = 34
|
||
static let overflowHeight: CGFloat = 20
|
||
static let rowIconWidth: CGFloat = 26
|
||
static let percentWidth: CGFloat = 38
|
||
static let batteryWidth: CGFloat = 23
|
||
static let cardVerticalPadding: CGFloat = 6
|
||
static let gaugeHorizontalPadding: CGFloat = 12
|
||
static let gaugeVerticalPadding: CGFloat = 10
|
||
static let maximumListItems = 6
|
||
static let maximumGaugeItems = 8
|
||
static let ringSpan: CGFloat = 0.78
|
||
static let ringRotation: Double = 129.6
|
||
static let ringTrackLineWidthScale: CGFloat = 1.15
|
||
static let poweredRingLineWidthScale: CGFloat = 1.38
|
||
|
||
static func contentHeight(mode: DeviceBatteryLayoutMode, visibleItemCount: Int) -> CGFloat {
|
||
guard visibleItemCount > 0 else {
|
||
return 116
|
||
}
|
||
|
||
switch mode {
|
||
case .grid:
|
||
return listCardHeight(totalCount: visibleItemCount)
|
||
case .list:
|
||
return gaugeCardHeight(totalCount: visibleItemCount)
|
||
}
|
||
}
|
||
|
||
static func spanHeight(
|
||
mode: DeviceBatteryLayoutMode,
|
||
visibleItemCount: Int,
|
||
metrics: PluginComponentPanelLayoutMetrics = .default
|
||
) -> Int {
|
||
metrics.heightSpan(
|
||
fittingContentHeight: contentHeight(
|
||
mode: mode,
|
||
visibleItemCount: visibleItemCount
|
||
)
|
||
)
|
||
}
|
||
|
||
static func listCardHeight(totalCount: Int) -> CGFloat {
|
||
let displayCount = min(max(totalCount, 1), maximumListItems)
|
||
return cardVerticalPadding * 2
|
||
+ CGFloat(displayCount) * rowHeight
|
||
+ CGFloat(max(displayCount - 1, 0))
|
||
+ overflowHeightIfNeeded(totalCount: totalCount, displayedCount: displayCount)
|
||
}
|
||
|
||
static func gaugeCardHeight(totalCount: Int) -> CGFloat {
|
||
let displayCount = min(max(totalCount, 1), maximumGaugeItems)
|
||
let tileSize = gaugeTileSize(for: displayCount)
|
||
let rows = gaugeRowCount(totalCount: totalCount)
|
||
let rowSpacing = gaugeRowSpacing(for: displayCount)
|
||
return gaugeVerticalPadding * 2
|
||
+ CGFloat(rows) * (tileSize + gaugePercentHeight)
|
||
+ CGFloat(max(rows - 1, 0)) * rowSpacing
|
||
}
|
||
|
||
static func gaugeColumnCount(for itemCount: Int) -> Int {
|
||
switch itemCount {
|
||
case 0...1:
|
||
return 1
|
||
case 2:
|
||
return 2
|
||
case 3:
|
||
return 3
|
||
default:
|
||
return 4
|
||
}
|
||
}
|
||
|
||
static func gaugeRowCount(totalCount: Int) -> Int {
|
||
let displayCount = min(max(totalCount, 1), maximumGaugeItems)
|
||
let cellCount = displayCount + (totalCount > displayCount ? 1 : 0)
|
||
let columns = gaugeColumnCount(for: displayCount)
|
||
return Int(ceil(Double(cellCount) / Double(columns)))
|
||
}
|
||
|
||
static func gaugeTileSize(for itemCount: Int) -> CGFloat {
|
||
switch itemCount {
|
||
case 0...2:
|
||
return 66
|
||
case 3...4:
|
||
return 58
|
||
default:
|
||
return 50
|
||
}
|
||
}
|
||
|
||
static func gaugeLineWidth(for tileSize: CGFloat) -> CGFloat {
|
||
max(4, tileSize * 0.078)
|
||
}
|
||
|
||
static let gaugePercentHeight: CGFloat = 20
|
||
|
||
static func gaugeRowSpacing(for itemCount: Int) -> CGFloat {
|
||
itemCount > 4 ? 8 : 6
|
||
}
|
||
|
||
static func overflowHeightIfNeeded(totalCount: Int, displayedCount: Int) -> CGFloat {
|
||
totalCount > displayedCount ? overflowHeight + 1 : 0
|
||
}
|
||
}
|
||
|
||
private typealias DeviceBatteryLayout = DeviceBatteryComponentLayout
|
||
|
||
private struct DeviceBatteryListCard: View {
|
||
let items: [DeviceBatteryItem]
|
||
let totalCount: Int
|
||
let localization: PluginLocalization
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
|
||
DeviceBatteryNativeRow(
|
||
item: item,
|
||
rowHeight: DeviceBatteryLayout.rowHeight,
|
||
showsDetail: false,
|
||
localization: localization
|
||
)
|
||
|
||
if index < items.count - 1 {
|
||
DeviceBatteryDivider()
|
||
}
|
||
}
|
||
|
||
if totalCount > items.count {
|
||
DeviceBatteryDivider()
|
||
DeviceBatteryOverflowRow(count: totalCount - items.count, localization: localization)
|
||
}
|
||
}
|
||
.padding(.vertical, DeviceBatteryLayout.cardVerticalPadding)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius))
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryGaugeGrid: View {
|
||
let items: [DeviceBatteryItem]
|
||
let totalCount: Int
|
||
let localization: PluginLocalization
|
||
|
||
var body: some View {
|
||
Group {
|
||
LazyVGrid(columns: columns, spacing: rowSpacing) {
|
||
ForEach(items) { item in
|
||
DeviceBatteryGaugeTile(
|
||
item: item,
|
||
tileSize: tileSize,
|
||
lineWidth: DeviceBatteryLayout.gaugeLineWidth(for: tileSize),
|
||
localization: localization
|
||
)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
if totalCount > items.count {
|
||
Text("+\(totalCount - items.count)")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: tileSize, height: tileSize + DeviceBatteryLayout.gaugePercentHeight)
|
||
}
|
||
}
|
||
.padding(.horizontal, DeviceBatteryLayout.gaugeHorizontalPadding)
|
||
.padding(.vertical, DeviceBatteryLayout.gaugeVerticalPadding)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius))
|
||
}
|
||
|
||
private var tileSize: CGFloat {
|
||
DeviceBatteryLayout.gaugeTileSize(for: items.count)
|
||
}
|
||
|
||
private var rowSpacing: CGFloat {
|
||
DeviceBatteryLayout.gaugeRowSpacing(for: items.count)
|
||
}
|
||
|
||
private var columns: [GridItem] {
|
||
Array(
|
||
repeating: GridItem(.flexible(minimum: tileSize), spacing: items.count > 4 ? 6 : 8),
|
||
count: DeviceBatteryLayout.gaugeColumnCount(for: items.count)
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryGaugeTile: View {
|
||
let item: DeviceBatteryItem
|
||
let tileSize: CGFloat
|
||
let lineWidth: CGFloat
|
||
let localization: PluginLocalization
|
||
|
||
var body: some View {
|
||
VStack(spacing: 2) {
|
||
ZStack {
|
||
DeviceBatteryRing(
|
||
item: item,
|
||
size: tileSize,
|
||
lineWidth: lineWidth,
|
||
localization: localization
|
||
)
|
||
|
||
Image(systemName: deviceSymbolName(for: item))
|
||
.font(.system(size: tileSize * 0.32, weight: .regular))
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(iconTint)
|
||
.frame(width: tileSize * 0.54, height: tileSize * 0.48)
|
||
}
|
||
.frame(width: tileSize, height: tileSize)
|
||
.overlay {
|
||
if chargingSymbolName(for: item) != nil {
|
||
let badgeSize = max(14, tileSize * 0.24)
|
||
DeviceBatteryChargingBadge(
|
||
systemName: "bolt.fill",
|
||
color: batteryTint(for: item),
|
||
size: badgeSize
|
||
)
|
||
.position(x: tileSize / 2, y: chargingBadgeCenterY)
|
||
}
|
||
}
|
||
|
||
Text(DeviceBatteryFormatter.percent(item.clampedLevel))
|
||
.font(.system(size: max(10, tileSize * 0.18), weight: .regular, design: .rounded))
|
||
.foregroundStyle(.primary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
.frame(width: tileSize, height: tileSize + DeviceBatteryLayout.gaugePercentHeight)
|
||
.compositingGroup()
|
||
.help(helpText(for: item, localization: localization))
|
||
}
|
||
|
||
private var iconTint: Color {
|
||
chargingSymbolName(for: item) == nil
|
||
? Color.primary.opacity(0.58)
|
||
: batteryTint(for: item).opacity(0.84)
|
||
}
|
||
|
||
private var chargingBadgeCenterY: CGFloat {
|
||
ringPathInset
|
||
}
|
||
|
||
private var ringPathInset: CGFloat {
|
||
lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale / 2
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryChargingBadge: View {
|
||
let systemName: String
|
||
let color: Color
|
||
let size: CGFloat
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.white)
|
||
|
||
Image(systemName: systemName)
|
||
.font(.system(size: max(7.2, size * 0.54), weight: .black))
|
||
.foregroundStyle(color)
|
||
}
|
||
.frame(width: size, height: size)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryRing: View {
|
||
let item: DeviceBatteryItem
|
||
let size: CGFloat
|
||
let lineWidth: CGFloat
|
||
var localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Circle()
|
||
.inset(by: ringPathInset)
|
||
.trim(from: 0, to: DeviceBatteryLayout.ringSpan)
|
||
.stroke(
|
||
Color.primary.opacity(0.12),
|
||
style: StrokeStyle(
|
||
lineWidth: lineWidth * DeviceBatteryLayout.ringTrackLineWidthScale,
|
||
lineCap: .round,
|
||
lineJoin: .round
|
||
)
|
||
)
|
||
|
||
if showsPoweredOverlay {
|
||
Circle()
|
||
.inset(by: ringPathInset)
|
||
.trim(from: 0, to: DeviceBatteryLayout.ringSpan)
|
||
.stroke(
|
||
batteryTint(for: item).opacity(0.12),
|
||
style: StrokeStyle(
|
||
lineWidth: lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale,
|
||
lineCap: .round,
|
||
lineJoin: .round
|
||
)
|
||
)
|
||
|
||
if progressSpan > 0.002, progress < 0.995 {
|
||
Circle()
|
||
.inset(by: ringPathInset)
|
||
.trim(from: max(progressSpan - 0.002, 0), to: max(progressSpan - 0.0004, 0))
|
||
.stroke(
|
||
batteryTint(for: item),
|
||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
|
||
)
|
||
.shadow(color: batteryTint(for: item).opacity(0.32), radius: lineWidth * 0.7)
|
||
.clipShape(
|
||
Circle()
|
||
.inset(by: ringPathInset)
|
||
.trim(from: 0, to: DeviceBatteryLayout.ringSpan)
|
||
.stroke(style: StrokeStyle(lineWidth: lineWidth * 1.4, lineCap: .round, lineJoin: .round))
|
||
)
|
||
}
|
||
}
|
||
|
||
Circle()
|
||
.inset(by: ringPathInset)
|
||
.trim(from: 0, to: progressSpan)
|
||
.stroke(
|
||
batteryTint(for: item),
|
||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
|
||
)
|
||
}
|
||
.rotationEffect(.degrees(DeviceBatteryLayout.ringRotation))
|
||
.frame(width: size, height: size)
|
||
.accessibilityLabel(
|
||
localization.format(
|
||
"accessibility.ring",
|
||
defaultValue: "%@,%@",
|
||
item.name,
|
||
DeviceBatteryFormatter.percent(item.clampedLevel)
|
||
)
|
||
)
|
||
}
|
||
|
||
private var progress: CGFloat {
|
||
CGFloat(item.clampedLevel ?? 0) / 100
|
||
}
|
||
|
||
private var progressSpan: CGFloat {
|
||
DeviceBatteryLayout.ringSpan * progress
|
||
}
|
||
|
||
private var showsPoweredOverlay: Bool {
|
||
chargingSymbolName(for: item) != nil
|
||
}
|
||
|
||
private var ringPathInset: CGFloat {
|
||
lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale / 2
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryDivider: View {
|
||
var body: some View {
|
||
Divider()
|
||
.padding(.leading, DeviceBatteryLayout.horizontalPadding + DeviceBatteryLayout.rowIconWidth + 10)
|
||
.padding(.trailing, DeviceBatteryLayout.horizontalPadding)
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryOverflowRow: View {
|
||
let count: Int
|
||
let localization: PluginLocalization
|
||
|
||
var body: some View {
|
||
Text(localization.format("overflow.moreDevices", defaultValue: "还有 %d 台设备", count))
|
||
.font(.caption2.weight(.medium))
|
||
.foregroundStyle(.tertiary)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: DeviceBatteryLayout.overflowHeight)
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryNativeRow: View {
|
||
let item: DeviceBatteryItem
|
||
let rowHeight: CGFloat
|
||
var showsDetail = false
|
||
var compact = false
|
||
var prominent = false
|
||
var localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
|
||
var body: some View {
|
||
HStack(spacing: compact ? 8 : 10) {
|
||
Image(systemName: deviceSymbolName(for: item))
|
||
.font(.system(size: iconSize, weight: .regular))
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(Color.primary.opacity(iconOpacity))
|
||
.frame(width: DeviceBatteryLayout.rowIconWidth, height: rowHeight)
|
||
|
||
VStack(alignment: .leading, spacing: showsDetail ? 2 : 0) {
|
||
Text(item.name)
|
||
.font(titleFont)
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.minimumScaleFactor(0.72)
|
||
|
||
if showsDetail {
|
||
Text(deviceDetailText(for: item, localization: localization))
|
||
.font(.caption2.weight(.medium))
|
||
.foregroundStyle(.tertiary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.layoutPriority(1)
|
||
|
||
Text(DeviceBatteryFormatter.percent(item.clampedLevel))
|
||
.font(percentFont)
|
||
.foregroundStyle(item.clampedLevel ?? 100 <= 10 ? Color(nsColor: .systemRed) : .primary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.75)
|
||
.frame(width: DeviceBatteryLayout.percentWidth, alignment: .trailing)
|
||
|
||
DeviceBatterySystemBattery(item: item)
|
||
.frame(width: DeviceBatteryLayout.batteryWidth, height: 14)
|
||
}
|
||
.padding(.horizontal, DeviceBatteryLayout.horizontalPadding)
|
||
.frame(height: rowHeight)
|
||
.help(helpText(for: item, localization: localization))
|
||
}
|
||
|
||
private var titleFont: Font {
|
||
if prominent {
|
||
return .body.weight(.semibold)
|
||
}
|
||
return compact ? .caption.weight(.medium) : .subheadline.weight(.medium)
|
||
}
|
||
|
||
private var percentFont: Font {
|
||
if prominent {
|
||
return .subheadline
|
||
}
|
||
return compact
|
||
? .caption2
|
||
: .caption
|
||
}
|
||
|
||
private var iconSize: CGFloat {
|
||
if prominent {
|
||
return 21
|
||
}
|
||
return compact ? 15 : 17
|
||
}
|
||
|
||
private var iconOpacity: Double {
|
||
prominent ? 0.64 : 0.56
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatterySystemBattery: View {
|
||
let item: DeviceBatteryItem
|
||
|
||
var body: some View {
|
||
HStack(spacing: 1) {
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 1.9, style: .continuous)
|
||
.stroke(Color.primary.opacity(0.30), lineWidth: 0.85)
|
||
.frame(width: 18, height: 8.2)
|
||
|
||
RoundedRectangle(cornerRadius: 1.2, style: .continuous)
|
||
.fill(batteryTint(for: item))
|
||
.frame(width: fillWidth, height: 4.8)
|
||
.padding(.leading, 1.9)
|
||
.opacity(item.clampedLevel == nil ? 0.18 : 0.82)
|
||
|
||
if chargingSymbolName(for: item) != nil {
|
||
Image(systemName: "bolt.fill")
|
||
.font(.system(size: 5.1, weight: .black))
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(.white)
|
||
.frame(width: 18, height: 8.2)
|
||
}
|
||
}
|
||
|
||
RoundedRectangle(cornerRadius: 0.8, style: .continuous)
|
||
.fill(Color.primary.opacity(0.30))
|
||
.frame(width: 1.3, height: 3.6)
|
||
}
|
||
.accessibilityLabel(DeviceBatteryFormatter.percent(item.clampedLevel))
|
||
}
|
||
|
||
private var fillWidth: CGFloat {
|
||
guard let level = item.clampedLevel else {
|
||
return 3
|
||
}
|
||
|
||
return max(2.8, 14.2 * CGFloat(level) / 100)
|
||
}
|
||
}
|
||
|
||
private struct DeviceBatteryCardBackground: View {
|
||
let cornerRadius: CGFloat
|
||
|
||
var body: some View {
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(Color.primary.opacity(0.045))
|
||
}
|
||
}
|
||
|
||
private func batteryTint(for item: DeviceBatteryItem?) -> Color {
|
||
guard let item else {
|
||
return Color(nsColor: .systemGreen)
|
||
}
|
||
|
||
if item.chargeState == .charging || item.chargeState == .charged {
|
||
return Color(nsColor: .systemGreen)
|
||
}
|
||
|
||
guard let level = item.clampedLevel else {
|
||
return Color(nsColor: .systemBlue)
|
||
}
|
||
|
||
if level <= 20 {
|
||
return Color(nsColor: .systemRed)
|
||
}
|
||
if level <= 35 {
|
||
return Color(nsColor: .systemOrange)
|
||
}
|
||
return Color(nsColor: .systemGreen)
|
||
}
|
||
|
||
private func chargingSymbolName(for item: DeviceBatteryItem) -> String? {
|
||
switch item.chargeState {
|
||
case .charging, .charged:
|
||
return "bolt.fill"
|
||
case .plugged:
|
||
return "powerplug.fill"
|
||
case .unknown, .normal, .invalid:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func batterySymbolName(for item: DeviceBatteryItem) -> String {
|
||
if item.chargeState == .charging || item.chargeState == .charged || item.chargeState == .plugged {
|
||
return "battery.100percent.bolt"
|
||
}
|
||
|
||
guard let level = item.clampedLevel else {
|
||
return "battery.0percent"
|
||
}
|
||
|
||
switch level {
|
||
case 76...100:
|
||
return "battery.100percent"
|
||
case 51...75:
|
||
return "battery.75percent"
|
||
case 26...50:
|
||
return "battery.50percent"
|
||
case 11...25:
|
||
return "battery.25percent"
|
||
default:
|
||
return "battery.0percent"
|
||
}
|
||
}
|
||
|
||
private func deviceDetailText(
|
||
for item: DeviceBatteryItem,
|
||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
) -> String {
|
||
if let detail = item.detail, !detail.isEmpty {
|
||
return detail
|
||
}
|
||
if let model = item.model, !model.isEmpty, model != item.name {
|
||
return model
|
||
}
|
||
return item.chargeState == .normal
|
||
? localization.string("deviceDetail.connected", defaultValue: "已连接")
|
||
: item.chargeState.title(localization: localization)
|
||
}
|
||
|
||
func deviceSymbolName(for item: DeviceBatteryItem) -> String {
|
||
let haystack = [
|
||
item.name,
|
||
item.model,
|
||
item.detail,
|
||
item.parentName,
|
||
item.kind.title()
|
||
]
|
||
.compactMap { $0?.lowercased() }
|
||
.joined(separator: " ")
|
||
|
||
switch item.kind {
|
||
case .internalBattery:
|
||
if containsAny(haystack, ["macbook", "mac book", "book"]) {
|
||
return "macbook"
|
||
}
|
||
if containsAny(haystack, ["mac mini", "macmini", "mini"]) {
|
||
return "macmini"
|
||
}
|
||
if containsAny(haystack, ["imac"]) {
|
||
return "desktopcomputer"
|
||
}
|
||
return "desktopcomputer.and.macbook"
|
||
case .phone:
|
||
return "iphone.gen2"
|
||
case .tablet:
|
||
return "ipad"
|
||
case .mediaPlayer:
|
||
return "ipodtouch"
|
||
case .watch:
|
||
return "applewatch"
|
||
case .spatialComputer:
|
||
return "visionpro"
|
||
case .rapooMouse:
|
||
return "computermouse.fill"
|
||
case .airPodsPart:
|
||
let ownName = item.name.lowercased()
|
||
if ownName.contains("case") || ownName.contains("充电盒") {
|
||
return airPodsSymbolName(in: haystack, part: .case)
|
||
}
|
||
if ownName.contains("左耳") || ownName.contains("left") || ownName.contains("🄻") {
|
||
return airPodsSymbolName(in: haystack, part: .left)
|
||
}
|
||
if ownName.contains("右耳") || ownName.contains("right") || ownName.contains("🅁") {
|
||
return airPodsSymbolName(in: haystack, part: .right)
|
||
}
|
||
return airPodsSymbolName(in: haystack, part: .all)
|
||
case .bluetooth, .magicAccessory, .other:
|
||
if containsAny(haystack, ["iphone", "phone", "mobile phone", "手机"]) {
|
||
return "iphone.gen2"
|
||
}
|
||
if containsAny(haystack, ["ipad", "tablet", "平板"]) {
|
||
return "ipad"
|
||
}
|
||
if containsAny(haystack, ["watch", "手表"]) {
|
||
return "applewatch"
|
||
}
|
||
if haystack.contains("vision") {
|
||
return "visionpro"
|
||
}
|
||
if containsAny(haystack, ["macbook", "mac book", "notebook", "laptop"]) {
|
||
return "macbook"
|
||
}
|
||
if containsAny(haystack, ["mac mini", "macmini"]) {
|
||
return "macmini"
|
||
}
|
||
if containsAny(haystack, ["imac", "desktop computer"]) {
|
||
return "desktopcomputer"
|
||
}
|
||
if containsAny(haystack, ["magic mouse", "magicmouse"]) {
|
||
return "magicmouse.fill"
|
||
}
|
||
if containsAny(haystack, ["mouse", "pointing", "鼠标", "mx anywhere", "mx master", "razer"]) {
|
||
return "computermouse.fill"
|
||
}
|
||
if containsAny(haystack, ["keyboard", "键盘", "hhkb", "niz"]) {
|
||
return "keyboard.fill"
|
||
}
|
||
if containsAny(haystack, ["trackpad", "touchpad", "触控板"]) {
|
||
return "rectangle.and.hand.point.up.left.fill"
|
||
}
|
||
if containsAny(haystack, ["airpods"]) {
|
||
return airPodsSymbolName(in: haystack, part: .all)
|
||
}
|
||
if containsAny(haystack, ["beats", "headphone", "headphones", "headset", "earbud", "earbuds", "耳机"]) {
|
||
return "headphones"
|
||
}
|
||
if containsAny(haystack, ["gamepad", "controller", "joystick", "手柄"]) {
|
||
return "gamecontroller.fill"
|
||
}
|
||
if containsAny(haystack, ["speaker", "音箱", "扬声器"]) {
|
||
return "hifispeaker.fill"
|
||
}
|
||
if containsAny(haystack, ["printer", "打印机"]) {
|
||
return "printer.fill"
|
||
}
|
||
if containsAny(haystack, ["camera", "摄像头", "相机"]) {
|
||
return "camera.fill"
|
||
}
|
||
return item.kind.iconName
|
||
}
|
||
}
|
||
|
||
private func containsAny(_ text: String, _ needles: [String]) -> Bool {
|
||
needles.contains { text.contains($0) }
|
||
}
|
||
|
||
private enum DeviceBatteryAirPodsPart {
|
||
case all
|
||
case left
|
||
case right
|
||
case `case`
|
||
}
|
||
|
||
private func airPodsSymbolName(
|
||
in haystack: String,
|
||
part: DeviceBatteryAirPodsPart
|
||
) -> String {
|
||
if haystack.contains("airpods max") {
|
||
return availableSystemSymbolName(["airpods.max", "airpodsmax"])
|
||
}
|
||
|
||
if isAirPodsPro(in: haystack) {
|
||
switch part {
|
||
case .all:
|
||
return "airpodspro"
|
||
case .left:
|
||
return "airpodpro.left"
|
||
case .right:
|
||
return "airpodpro.right"
|
||
case .case:
|
||
return "airpodspro.chargingcase.wireless"
|
||
}
|
||
}
|
||
|
||
if isAirPodsGeneration(4, in: haystack) {
|
||
switch part {
|
||
case .all:
|
||
return availableSystemSymbolName(["airpods.gen4", "airpods"])
|
||
case .left:
|
||
return availableSystemSymbolName(["airpods.gen4.left", "airpod.left"])
|
||
case .right:
|
||
return availableSystemSymbolName(["airpods.gen4.right", "airpod.right"])
|
||
case .case:
|
||
return availableSystemSymbolName(["airpods.gen4.chargingcase.wireless", "airpods.chargingcase"])
|
||
}
|
||
}
|
||
|
||
if isAirPodsGeneration(3, in: haystack) {
|
||
switch part {
|
||
case .all:
|
||
return availableSystemSymbolName(["airpods.gen3", "airpods"])
|
||
case .left:
|
||
return availableSystemSymbolName(["airpod.gen3.left", "airpod.left"])
|
||
case .right:
|
||
return availableSystemSymbolName(["airpod.gen3.right", "airpod.right"])
|
||
case .case:
|
||
return availableSystemSymbolName(["airpods.gen3.chargingcase.wireless", "airpods.chargingcase"])
|
||
}
|
||
}
|
||
|
||
switch part {
|
||
case .all:
|
||
return "airpods"
|
||
case .left:
|
||
return "airpod.left"
|
||
case .right:
|
||
return "airpod.right"
|
||
case .case:
|
||
return "airpods.chargingcase"
|
||
}
|
||
}
|
||
|
||
private func isAirPodsPro(in haystack: String) -> Bool {
|
||
haystack.contains("airpods pro") || haystack.contains("airpodspro")
|
||
}
|
||
|
||
private func isAirPodsGeneration(_ generation: Int, in haystack: String) -> Bool {
|
||
let compact = haystack.replacingOccurrences(of: " ", with: "")
|
||
return haystack.contains("airpods \(generation)")
|
||
|| haystack.contains("airpods gen\(generation)")
|
||
|| haystack.contains("airpods gen \(generation)")
|
||
|| haystack.contains("airpods.gen\(generation)")
|
||
|| compact.contains("airpods\(generation)")
|
||
|| compact.contains("第\(generation)代")
|
||
}
|
||
|
||
private func availableSystemSymbolName(_ candidates: [String]) -> String {
|
||
for candidate in candidates {
|
||
if NSImage(systemSymbolName: candidate, accessibilityDescription: nil) != nil {
|
||
return candidate
|
||
}
|
||
}
|
||
|
||
return candidates.last ?? "airpods"
|
||
}
|
||
|
||
private func helpText(
|
||
for item: DeviceBatteryItem,
|
||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
) -> String {
|
||
var lines = [
|
||
item.name,
|
||
localization.format(
|
||
"help.levelAndState",
|
||
defaultValue: "%@ · %@",
|
||
DeviceBatteryFormatter.percent(item.clampedLevel),
|
||
item.chargeState.title(localization: localization)
|
||
),
|
||
localization.format("help.source", defaultValue: "来源:%@", item.source)
|
||
]
|
||
if let parentName = item.parentName {
|
||
lines.append(localization.format("help.parent", defaultValue: "关联:%@", parentName))
|
||
}
|
||
if let lastUpdated = item.lastUpdated {
|
||
lines.append(
|
||
localization.format(
|
||
"help.updated",
|
||
defaultValue: "更新:%@",
|
||
DeviceBatteryFormatter.time(lastUpdated, localization: localization)
|
||
)
|
||
)
|
||
}
|
||
return lines.joined(separator: "\n")
|
||
}
|