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) } }