Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

564 lines
22 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
struct SystemStatusSettingsView: View {
@ObservedObject var controller: SystemStatusSettingsController
let localization: PluginLocalization
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
panelSection
menuBarSection
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private var panelSection: some View {
metricSection(
systemName: "square.grid.2x2",
title: localization.string("settings.panel.title", defaultValue: "组件面板"),
description: localization.string(
"settings.panel.description",
defaultValue: "选择组件面板显示的内容,并拖拽调整顺序。"
),
items: panelItems,
listID: "panel",
onVisibilityChange: controller.setPanelMetric(_:visible:),
onMove: controller.movePanelMetric(_:toOffset:)
)
}
private var menuBarSection: some View {
metricSection(
systemName: "menubar.rectangle",
title: localization.string("settings.menuBar.title", defaultValue: "菜单栏指标"),
description: localization.string(
"settings.menuBar.description",
defaultValue: "选择要显示在菜单栏里的指标。"
),
items: menuBarItems,
listID: "menu-bar",
onVisibilityChange: controller.setMenuBarMetric(_:visible:),
onMove: controller.moveMenuBarMetric(_:toOffset:)
)
}
private func metricSection(
systemName: String,
title: String,
description: String,
items: [SystemStatusMetricPreferenceTableItem],
listID: String,
onVisibilityChange: @escaping (SystemStatusMetricKind, Bool) -> Void,
onMove: @escaping (SystemStatusMetricKind, Int) -> Void
) -> some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
VStack(alignment: .leading, spacing: 4) {
Label(title, systemImage: systemName)
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
Text(description)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.lineLimit(2)
}
SystemStatusMetricPreferenceTableView(
items: items,
listID: listID,
onVisibilityChange: onVisibilityChange,
onMove: onMove
)
.frame(height: SystemStatusMetricPreferenceTableView.preferredHeight(for: items.count))
.pluginSettingsCardBackground(.plugin)
}
}
private var panelItems: [SystemStatusMetricPreferenceTableItem] {
controller.configuration.panelItems.map {
tableItem(
preference: $0,
description: panelDescription(for: $0.kind)
)
}
}
private var menuBarItems: [SystemStatusMetricPreferenceTableItem] {
controller.configuration.menuBarItems.map {
tableItem(
preference: $0,
description: menuBarDescription(for: $0.kind)
)
}
}
private func tableItem(
preference: SystemStatusMetricPreference,
description: String
) -> SystemStatusMetricPreferenceTableItem {
SystemStatusMetricPreferenceTableItem(
kind: preference.kind,
title: preference.kind.title(localization: localization),
description: description,
iconName: preference.kind.symbolName,
iconTint: tint(for: preference.kind),
isVisible: preference.isVisible
)
}
private func panelDescription(for kind: SystemStatusMetricKind) -> String {
switch kind {
case .cpu:
return localization.string("settings.metric.cpu.panelDescription", defaultValue: "使用率、温度和功率")
case .gpu:
return localization.string("settings.metric.gpu.panelDescription", defaultValue: "使用率、温度和型号")
case .network:
return localization.string("settings.metric.network.panelDescription", defaultValue: "上传、下载和地址")
case .disk:
return localization.string("settings.metric.disk.panelDescription", defaultValue: "容量和读写速率")
case .memory:
return localization.string("settings.metric.memory.panelDescription", defaultValue: "内存和交换空间")
case .battery:
return localization.string("settings.metric.battery.panelDescription", defaultValue: "电量、健康度和温度")
case .topProcesses:
return localization.string("settings.metric.topProcesses.panelDescription", defaultValue: "CPU 占用最高的进程")
}
}
private func menuBarDescription(for kind: SystemStatusMetricKind) -> String {
switch kind {
case .cpu:
return localization.string("settings.metric.cpu.menuBarDescription", defaultValue: "显示 CPU 使用率")
case .gpu:
return localization.string("settings.metric.gpu.menuBarDescription", defaultValue: "显示 GPU 使用率")
case .network:
return localization.string("settings.metric.network.menuBarDescription", defaultValue: "显示上下行速率")
case .disk:
return localization.string("settings.metric.disk.menuBarDescription", defaultValue: "显示磁盘使用率")
case .memory:
return localization.string("settings.metric.memory.menuBarDescription", defaultValue: "显示内存使用率")
case .battery:
return localization.string("settings.metric.battery.menuBarDescription", defaultValue: "显示电量")
case .topProcesses:
return localization.string("settings.metric.topProcesses.menuBarDescription", defaultValue: "不支持菜单栏显示")
}
}
private func tint(for kind: SystemStatusMetricKind) -> Color {
switch kind {
case .cpu:
return Color(nsColor: .systemGreen)
case .gpu:
return Color(nsColor: .systemPurple)
case .network:
return Color(nsColor: .systemCyan)
case .disk:
return Color(nsColor: .systemBlue)
case .memory:
return Color(nsColor: .systemOrange)
case .battery:
return Color(nsColor: .systemMint)
case .topProcesses:
return Color(nsColor: .systemGray)
}
}
}
struct SystemStatusMetricPreferenceTableItem: Equatable, Identifiable {
let kind: SystemStatusMetricKind
let title: String
let description: String
let iconName: String
let iconTint: Color
let isVisible: Bool
var id: String { kind.rawValue }
static func == (lhs: SystemStatusMetricPreferenceTableItem, rhs: SystemStatusMetricPreferenceTableItem) -> Bool {
lhs.kind == rhs.kind
&& lhs.title == rhs.title
&& lhs.description == rhs.description
&& lhs.iconName == rhs.iconName
&& lhs.isVisible == rhs.isVisible
}
}
struct SystemStatusMetricPreferenceTableView: NSViewRepresentable {
static let rowHeight: CGFloat = 58
static let rowSpacing: CGFloat = 6
static let verticalContentInset: CGFloat = 6
private static let dragType = NSPasteboard.PasteboardType("com.ggbond.mactools.system-status.metric-preference")
let items: [SystemStatusMetricPreferenceTableItem]
let listID: String
let onVisibilityChange: (SystemStatusMetricKind, Bool) -> Void
let onMove: (SystemStatusMetricKind, Int) -> Void
static func preferredHeight(for itemCount: Int) -> CGFloat {
let visibleItemCount = max(itemCount, 1)
let spacing = CGFloat(max(itemCount - 1, 0)) * rowSpacing
return CGFloat(visibleItemCount) * rowHeight + spacing + verticalContentInset * 2
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = SystemStatusMetricPreferenceScrollView()
scrollView.contentView = SystemStatusMetricPreferenceClipView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = false
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.verticalScrollElasticity = .none
scrollView.horizontalScrollElasticity = .none
scrollView.contentInsets = NSEdgeInsets(
top: Self.verticalContentInset,
left: 0,
bottom: Self.verticalContentInset,
right: 0
)
let tableView = NSTableView()
tableView.headerView = nil
tableView.rowHeight = Self.rowHeight
tableView.intercellSpacing = NSSize(width: 0, height: Self.rowSpacing)
tableView.backgroundColor = .clear
tableView.selectionHighlightStyle = .none
tableView.focusRingType = .none
tableView.allowsColumnReordering = false
tableView.allowsColumnResizing = false
tableView.allowsEmptySelection = true
tableView.allowsTypeSelect = false
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
tableView.draggingDestinationFeedbackStyle = .gap
tableView.verticalMotionCanBeginDrag = true
tableView.setDraggingSourceOperationMask(.move, forLocal: true)
tableView.registerForDraggedTypes([Self.dragType])
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("metric"))
column.isEditable = false
column.resizingMask = .autoresizingMask
tableView.addTableColumn(column)
tableView.delegate = context.coordinator
tableView.dataSource = context.coordinator
scrollView.documentView = tableView
context.coordinator.tableView = tableView
syncLayout(in: scrollView, coordinator: context.coordinator)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
syncLayout(in: scrollView, coordinator: context.coordinator)
}
private func syncLayout(in scrollView: NSScrollView, coordinator: Coordinator) {
guard let tableView = coordinator.tableView, !coordinator.isDragging else {
return
}
let contentHeight = Self.preferredHeight(for: items.count)
let contentWidth = max(scrollView.contentSize.width, 1)
let signature = SystemStatusMetricPreferenceTableSignature(
items: items,
listID: listID,
contentWidth: contentWidth
)
guard coordinator.lastSignature != signature else {
return
}
coordinator.lastSignature = signature
tableView.reloadData()
tableView.noteNumberOfRowsChanged()
tableView.frame = NSRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
scrollView.contentView.scroll(to: .zero)
scrollView.reflectScrolledClipView(scrollView.contentView)
}
@MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var parent: SystemStatusMetricPreferenceTableView
weak var tableView: NSTableView?
fileprivate var lastSignature: SystemStatusMetricPreferenceTableSignature?
fileprivate var isDragging = false
init(parent: SystemStatusMetricPreferenceTableView) {
self.parent = parent
}
func numberOfRows(in tableView: NSTableView) -> Int {
parent.items.count
}
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
SystemStatusMetricPreferenceTableView.rowHeight
}
func tableView(
_ tableView: NSTableView,
viewFor tableColumn: NSTableColumn?,
row: Int
) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("SystemStatusMetricPreferenceCell")
let view = (tableView.makeView(withIdentifier: identifier, owner: nil) as? SystemStatusMetricPreferenceCellView)
?? SystemStatusMetricPreferenceCellView(frame: .zero)
view.identifier = identifier
let item = parent.items[row]
view.configure(
item: item,
onVisibilityChange: { [weak self] isVisible in
self?.parent.onVisibilityChange(item.kind, isVisible)
}
)
return view
}
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(
"\(parent.listID):\(parent.items[row].kind.rawValue)",
forType: SystemStatusMetricPreferenceTableView.dragType
)
return pasteboardItem
}
func tableView(
_ tableView: NSTableView,
draggingSession session: NSDraggingSession,
willBeginAt screenPoint: NSPoint,
forRowIndexes rowIndexes: IndexSet
) {
isDragging = true
session.animatesToStartingPositionsOnCancelOrFail = true
session.draggingFormation = .none
}
func tableView(
_ tableView: NSTableView,
draggingSession session: NSDraggingSession,
endedAt screenPoint: NSPoint,
operation: NSDragOperation
) {
isDragging = false
lastSignature = nil
tableView.reloadData()
}
func tableView(
_ tableView: NSTableView,
validateDrop info: NSDraggingInfo,
proposedRow row: Int,
proposedDropOperation dropOperation: NSTableView.DropOperation
) -> NSDragOperation {
guard draggedKind(from: info.draggingPasteboard) != nil else {
return []
}
tableView.setDropRow(min(max(row, 0), parent.items.count), dropOperation: .above)
return .move
}
func tableView(
_ tableView: NSTableView,
acceptDrop info: NSDraggingInfo,
row: Int,
dropOperation: NSTableView.DropOperation
) -> Bool {
guard let kind = draggedKind(from: info.draggingPasteboard) else {
return false
}
parent.onMove(kind, min(max(row, 0), parent.items.count))
return true
}
private func draggedKind(from pasteboard: NSPasteboard) -> SystemStatusMetricKind? {
guard
let payload = pasteboard.string(forType: SystemStatusMetricPreferenceTableView.dragType),
payload.hasPrefix("\(parent.listID):")
else {
return nil
}
let rawValue = String(payload.dropFirst(parent.listID.count + 1))
let kind = SystemStatusMetricKind(rawValue: rawValue)
guard let kind, parent.items.contains(where: { $0.kind == kind }) else {
return nil
}
return kind
}
}
}
private struct SystemStatusMetricPreferenceTableSignature: Equatable {
let rows: [SystemStatusMetricPreferenceTableItem]
let listID: String
let contentWidth: CGFloat
init(
items: [SystemStatusMetricPreferenceTableItem],
listID: String,
contentWidth: CGFloat
) {
self.rows = items
self.listID = listID
self.contentWidth = contentWidth.rounded(.toNearestOrAwayFromZero)
}
}
private final class SystemStatusMetricPreferenceScrollView: NSScrollView {
override func scrollWheel(with event: NSEvent) {
nextResponder?.scrollWheel(with: event)
}
}
private final class SystemStatusMetricPreferenceClipView: NSClipView {
override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
var bounds = super.constrainBoundsRect(proposedBounds)
bounds.origin = .zero
return bounds
}
}
private final class SystemStatusMetricPreferenceCellView: NSTableCellView {
private let containerView = NSView()
private let iconBackgroundView = NSView()
private let iconImageView = NSImageView()
private let titleLabel = NSTextField(labelWithString: "")
private let descriptionLabel = NSTextField(labelWithString: "")
private let visibilityButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
private let handleImageView = NSImageView()
private var visibilityHandler: ((Bool) -> Void)?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
buildViewHierarchy()
configureStyles()
configureLayout()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
item: SystemStatusMetricPreferenceTableItem,
onVisibilityChange: @escaping (Bool) -> Void
) {
visibilityHandler = onVisibilityChange
titleLabel.stringValue = item.title
descriptionLabel.stringValue = item.description
iconImageView.image = NSImage(
systemSymbolName: item.iconName,
accessibilityDescription: item.title
)
iconImageView.contentTintColor = NSColor(item.iconTint)
iconBackgroundView.layer?.backgroundColor = NSColor(item.iconTint.opacity(0.14)).cgColor
visibilityButton.state = item.isVisible ? .on : .off
toolTip = item.title
visibilityButton.toolTip = item.title
}
private func buildViewHierarchy() {
wantsLayer = true
containerView.wantsLayer = true
iconBackgroundView.wantsLayer = true
addSubview(containerView)
containerView.addSubview(iconBackgroundView)
iconBackgroundView.addSubview(iconImageView)
containerView.addSubview(titleLabel)
containerView.addSubview(descriptionLabel)
containerView.addSubview(visibilityButton)
containerView.addSubview(handleImageView)
}
private func configureStyles() {
containerView.layer?.cornerRadius = 12
containerView.layer?.backgroundColor = NSColor.clear.cgColor
iconBackgroundView.layer?.cornerRadius = 10
titleLabel.font = .systemFont(ofSize: 13, weight: .semibold)
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.maximumNumberOfLines = 1
descriptionLabel.font = .systemFont(ofSize: 11, weight: .medium)
descriptionLabel.textColor = .secondaryLabelColor
descriptionLabel.lineBreakMode = .byTruncatingTail
descriptionLabel.maximumNumberOfLines = 1
visibilityButton.setButtonType(.switch)
visibilityButton.title = ""
visibilityButton.target = self
visibilityButton.action = #selector(handleVisibilityToggle(_:))
handleImageView.image = NSImage(
systemSymbolName: "line.3.horizontal",
accessibilityDescription: "拖拽调整顺序"
)
handleImageView.contentTintColor = .secondaryLabelColor
handleImageView.symbolConfiguration = .init(pointSize: 13, weight: .semibold)
}
private func configureLayout() {
[
containerView,
iconBackgroundView,
iconImageView,
titleLabel,
descriptionLabel,
visibilityButton,
handleImageView
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
NSLayoutConstraint.activate([
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
containerView.topAnchor.constraint(equalTo: topAnchor),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
iconBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
iconBackgroundView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
iconBackgroundView.widthAnchor.constraint(equalToConstant: 30),
iconBackgroundView.heightAnchor.constraint(equalToConstant: 30),
iconImageView.centerXAnchor.constraint(equalTo: iconBackgroundView.centerXAnchor),
iconImageView.centerYAnchor.constraint(equalTo: iconBackgroundView.centerYAnchor),
titleLabel.leadingAnchor.constraint(equalTo: iconBackgroundView.trailingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: visibilityButton.leadingAnchor, constant: -12),
titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10),
descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
descriptionLabel.trailingAnchor.constraint(lessThanOrEqualTo: visibilityButton.leadingAnchor, constant: -12),
descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
visibilityButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
visibilityButton.trailingAnchor.constraint(equalTo: handleImageView.leadingAnchor, constant: -12),
handleImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
handleImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
handleImageView.widthAnchor.constraint(equalToConstant: 16),
handleImageView.heightAnchor.constraint(equalToConstant: 16)
])
}
@objc
private func handleVisibilityToggle(_ sender: NSButton) {
visibilityHandler?(sender.state == .on)
}
}