Files
ggbond268--mactools/Sources/App/FeatureManagementTableView.swift
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

688 lines
25 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
struct FeatureManagementTableView: NSViewRepresentable {
static let rowHeight: CGFloat = 62
static let rowSpacing: CGFloat = 6
static let verticalContentInset: CGFloat = 6
private static let dragType = NSPasteboard.PasteboardType("com.ggbond.mactools.feature-management-item")
let items: [PluginFeatureManagementItem]
var isReorderEnabled: Bool = true
let onVisibilityChange: (String, Bool) -> Void
let onMove: (String, 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 = NonScrollingTableScrollView()
scrollView.contentView = LockedClipView()
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.usesAlternatingRowBackgroundColors = false
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("feature"))
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 else {
return
}
guard !coordinator.isDragging else {
return
}
let contentHeight = Self.preferredHeight(for: items.count)
let contentWidth = max(scrollView.contentSize.width, 1)
let signature = FeatureManagementTableSignature(
items: items,
isReorderEnabled: isReorderEnabled,
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: FeatureManagementTableView
weak var tableView: NSTableView?
fileprivate var lastSignature: FeatureManagementTableSignature?
init(parent: FeatureManagementTableView) {
self.parent = parent
}
func numberOfRows(in tableView: NSTableView) -> Int {
parent.items.count
}
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
FeatureManagementTableView.rowHeight
}
func tableView(
_ tableView: NSTableView,
viewFor tableColumn: NSTableColumn?,
row: Int
) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("FeatureManagementCell")
let view = (tableView.makeView(withIdentifier: identifier, owner: nil) as? FeatureManagementTableCellView)
?? FeatureManagementTableCellView(frame: .zero)
view.identifier = identifier
let item = parent.items[row]
view.configure(
item: item,
showsHandle: parent.isReorderEnabled,
onVisibilityChange: { [weak self] isVisible in
self?.parent.onVisibilityChange(item.id, isVisible)
}
)
return view
}
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
guard parent.isReorderEnabled else {
return nil
}
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(parent.items[row].id, forType: FeatureManagementTableView.dragType)
return pasteboardItem
}
private(set) var isDragging = false
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, updateDraggingItemsForDrag draggingInfo: NSDraggingInfo) {
draggingInfo.enumerateDraggingItems(
options: [],
for: tableView,
classes: [NSPasteboardItem.self],
searchOptions: [:]
) { [weak self] draggingItem, _, _ in
guard
let self,
let pasteboardItem = draggingItem.item as? NSPasteboardItem,
let pluginID = pasteboardItem.string(forType: FeatureManagementTableView.dragType),
let item = parent.items.first(where: { $0.id == pluginID })
else {
return
}
let image = FeatureManagementDragPreview.image(
for: item,
width: tableView.bounds.width
)
let frame = NSRect(
origin: draggingItem.draggingFrame.origin,
size: image.size
)
draggingItem.setDraggingFrame(frame, contents: image)
}
}
func tableView(
_ tableView: NSTableView,
validateDrop info: NSDraggingInfo,
proposedRow row: Int,
proposedDropOperation dropOperation: NSTableView.DropOperation
) -> NSDragOperation {
guard parent.isReorderEnabled else {
return []
}
guard info.draggingPasteboard.availableType(from: [FeatureManagementTableView.dragType]) != nil else {
return []
}
let targetRow = min(max(row, 0), parent.items.count)
tableView.setDropRow(targetRow, dropOperation: .above)
return .move
}
func tableView(
_ tableView: NSTableView,
acceptDrop info: NSDraggingInfo,
row: Int,
dropOperation: NSTableView.DropOperation
) -> Bool {
guard parent.isReorderEnabled else {
return false
}
guard
let draggedID = info.draggingPasteboard.string(forType: FeatureManagementTableView.dragType)
else {
return false
}
let targetRow = min(max(row, 0), parent.items.count)
parent.onMove(draggedID, targetRow)
return true
}
}
}
fileprivate struct FeatureManagementTableSignature: Equatable {
struct Row: Equatable {
let id: String
let title: String
let description: String
let iconName: String
let isVisible: Bool
let isActive: Bool
let presentation: PluginFeaturePresentation
let category: String?
let releaseChannel: String?
}
let rows: [Row]
let isReorderEnabled: Bool
let contentWidth: CGFloat
init(
items: [PluginFeatureManagementItem],
isReorderEnabled: Bool,
contentWidth: CGFloat
) {
self.rows = items.map {
Row(
id: $0.id,
title: $0.title,
description: $0.description,
iconName: $0.iconName,
isVisible: $0.isVisible,
isActive: $0.isActive,
presentation: $0.presentation,
category: $0.category,
releaseChannel: $0.releaseChannel
)
}
self.isReorderEnabled = isReorderEnabled
self.contentWidth = contentWidth.rounded(.toNearestOrAwayFromZero)
}
}
#if DEBUG
enum FeatureManagementTableUpdatePolicy {
static func needsUpdate(
previousItems: [PluginFeatureManagementItem],
currentItems: [PluginFeatureManagementItem],
previousIsReorderEnabled: Bool,
currentIsReorderEnabled: Bool,
previousContentWidth: CGFloat,
currentContentWidth: CGFloat
) -> Bool {
FeatureManagementTableSignature(
items: previousItems,
isReorderEnabled: previousIsReorderEnabled,
contentWidth: previousContentWidth
) != FeatureManagementTableSignature(
items: currentItems,
isReorderEnabled: currentIsReorderEnabled,
contentWidth: currentContentWidth
)
}
}
#endif
private final class NonScrollingTableScrollView: NSScrollView {
override func scrollWheel(with event: NSEvent) {
nextResponder?.scrollWheel(with: event)
}
}
private final class LockedClipView: NSClipView {
override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
var bounds = super.constrainBoundsRect(proposedBounds)
bounds.origin = .zero
return bounds
}
}
@MainActor
private enum FeatureManagementDragPreview {
static func image(for item: PluginFeatureManagementItem, width: CGFloat) -> NSImage {
let imageSize = NSSize(
width: min(max(width, 320), 620),
height: FeatureManagementTableView.rowHeight
)
let image = NSImage(size: imageSize)
image.lockFocus()
defer { image.unlockFocus() }
NSGraphicsContext.current?.imageInterpolation = .high
let bounds = NSRect(origin: .zero, size: imageSize)
let contentBounds = bounds.insetBy(dx: 1, dy: 1)
let backgroundPath = NSBezierPath(
roundedRect: contentBounds,
xRadius: 12,
yRadius: 12
)
NSColor.windowBackgroundColor.withAlphaComponent(0.96).setFill()
backgroundPath.fill()
NSColor.separatorColor.withAlphaComponent(0.28).setStroke()
backgroundPath.lineWidth = 1
backgroundPath.stroke()
let tintColor = NSColor(item.iconTint)
let iconBackgroundRect = NSRect(x: 9, y: 16, width: 30, height: 30)
NSColor(item.iconTint.opacity(0.14)).setFill()
NSBezierPath(roundedRect: iconBackgroundRect, xRadius: 10, yRadius: 10).fill()
drawSymbol(
item.iconName,
in: NSRect(x: 16, y: 23, width: 16, height: 16),
color: tintColor,
pointSize: 16
)
let trailingWidth: CGFloat = 74
let textX = iconBackgroundRect.maxX + 12
let textWidth = max(imageSize.width - textX - trailingWidth - 12, 80)
let titleRect = NSRect(x: textX, y: 34, width: textWidth, height: 18)
let descriptionRect = NSRect(x: textX, y: 13, width: textWidth, height: 17)
drawText(
item.title,
in: titleRect,
font: .systemFont(ofSize: 13, weight: .semibold),
color: .labelColor
)
drawText(
"\(item.description) · \(featureManagementPresentationText(for: item.presentation))",
in: descriptionRect,
font: .systemFont(ofSize: 11, weight: .medium),
color: .secondaryLabelColor
)
if item.isActive {
NSColor.systemGreen.setFill()
NSBezierPath(
ovalIn: NSRect(
x: imageSize.width - 72,
y: (imageSize.height - 8) / 2,
width: 8,
height: 8
)
)
.fill()
}
drawVisibilityCheckbox(
isOn: item.isVisible,
in: NSRect(x: imageSize.width - 50, y: 24, width: 14, height: 14)
)
drawSymbol(
"line.3.horizontal",
in: NSRect(x: imageSize.width - 20, y: 23, width: 13, height: 13),
color: .secondaryLabelColor,
pointSize: 13
)
return image
}
private static func drawText(
_ text: String,
in rect: NSRect,
font: NSFont,
color: NSColor
) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byTruncatingTail
NSString(string: text).draw(
in: rect,
withAttributes: [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle
]
)
}
private static func drawSymbol(
_ name: String,
in rect: NSRect,
color: NSColor,
pointSize: CGFloat
) {
guard
let symbol = NSImage(
systemSymbolName: name,
accessibilityDescription: nil
)?.withSymbolConfiguration(.init(pointSize: pointSize, weight: .semibold))
else {
return
}
let tintedSymbol = symbol.tinted(with: color)
tintedSymbol.draw(in: rect)
}
private static func drawVisibilityCheckbox(isOn: Bool, in rect: NSRect) {
let path = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3)
if isOn {
NSColor.controlAccentColor.setFill()
path.fill()
let checkPath = NSBezierPath()
checkPath.lineWidth = 1.5
checkPath.lineCapStyle = .round
checkPath.lineJoinStyle = .round
checkPath.move(to: NSPoint(
x: rect.minX + rect.width * 0.2,
y: rect.midY
))
checkPath.line(to: NSPoint(
x: rect.minX + rect.width * 0.42,
y: rect.minY + rect.height * 0.28
))
checkPath.line(to: NSPoint(
x: rect.maxX - rect.width * 0.18,
y: rect.maxY - rect.height * 0.22
))
NSColor.white.setStroke()
checkPath.stroke()
} else {
NSColor.windowBackgroundColor.setFill()
path.fill()
NSColor.separatorColor.withAlphaComponent(0.6).setStroke()
path.lineWidth = 1
path.stroke()
}
}
}
private final class FeatureManagementTableCellView: NSTableCellView {
private let containerView = NSView()
private let iconBackgroundView = NSView()
private let iconImageView = NSImageView()
private let titleRowStackView = NSStackView()
private let titleLabel = NSTextField(labelWithString: "")
private let releaseChannelBadgeView = NSHostingView(rootView: PluginReleaseChannelBadge(releaseChannel: nil))
private let descriptionLabel = NSTextField(labelWithString: "")
private let activeDotView = NSView()
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: PluginFeatureManagementItem,
showsHandle: Bool,
onVisibilityChange: @escaping (Bool) -> Void
) {
visibilityHandler = onVisibilityChange
titleLabel.stringValue = item.title
configureReleaseChannelBadge(item.releaseChannel)
descriptionLabel.stringValue = "\(item.description) · \(featureManagementPresentationText(for: item.presentation))"
iconImageView.image = NSImage(
systemSymbolName: item.iconName,
accessibilityDescription: item.title
)
iconImageView.contentTintColor = NSColor(item.iconTint)
iconBackgroundView.layer?.backgroundColor = NSColor(item.iconTint.opacity(0.14)).cgColor
activeDotView.isHidden = !item.isActive
visibilityButton.state = item.isVisible ? .on : .off
handleImageView.isHidden = !showsHandle
toolTip = item.title
visibilityButton.toolTip = item.title
}
private func buildViewHierarchy() {
wantsLayer = true
containerView.wantsLayer = true
iconBackgroundView.wantsLayer = true
activeDotView.wantsLayer = true
addSubview(containerView)
containerView.addSubview(iconBackgroundView)
iconBackgroundView.addSubview(iconImageView)
containerView.addSubview(titleRowStackView)
titleRowStackView.addArrangedSubview(titleLabel)
titleRowStackView.addArrangedSubview(releaseChannelBadgeView)
containerView.addSubview(descriptionLabel)
containerView.addSubview(activeDotView)
containerView.addSubview(visibilityButton)
containerView.addSubview(handleImageView)
}
private func configureStyles() {
containerView.layer?.cornerRadius = 12
containerView.layer?.backgroundColor = NSColor.clear.cgColor
iconBackgroundView.layer?.cornerRadius = 10
titleRowStackView.orientation = .horizontal
titleRowStackView.alignment = .centerY
titleRowStackView.spacing = 6
titleRowStackView.distribution = .fill
titleLabel.font = .systemFont(ofSize: 13, weight: .semibold)
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.maximumNumberOfLines = 1
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
releaseChannelBadgeView.setContentHuggingPriority(.required, for: .horizontal)
releaseChannelBadgeView.setContentCompressionResistancePriority(.required, for: .horizontal)
descriptionLabel.font = .systemFont(ofSize: 11, weight: .medium)
descriptionLabel.textColor = .secondaryLabelColor
descriptionLabel.lineBreakMode = .byWordWrapping
descriptionLabel.maximumNumberOfLines = 2
descriptionLabel.usesSingleLineMode = false
activeDotView.layer?.cornerRadius = 4
activeDotView.layer?.backgroundColor = NSColor.systemGreen.cgColor
visibilityButton.setButtonType(.switch)
visibilityButton.title = ""
visibilityButton.target = self
visibilityButton.action = #selector(handleVisibilityToggle(_:))
handleImageView.image = NSImage(
systemSymbolName: "line.3.horizontal",
accessibilityDescription: AppL10n.plugins(
"plugin.management.reorderAccessibility",
defaultValue: "拖拽调整顺序"
)
)
handleImageView.contentTintColor = .secondaryLabelColor
handleImageView.symbolConfiguration = .init(pointSize: 13, weight: .semibold)
}
private func configureLayout() {
[
containerView,
iconBackgroundView,
iconImageView,
titleRowStackView,
descriptionLabel,
activeDotView,
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),
titleRowStackView.leadingAnchor.constraint(equalTo: iconBackgroundView.trailingAnchor, constant: 12),
titleRowStackView.trailingAnchor.constraint(lessThanOrEqualTo: activeDotView.leadingAnchor, constant: -10),
titleRowStackView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10),
descriptionLabel.leadingAnchor.constraint(equalTo: titleRowStackView.leadingAnchor),
descriptionLabel.trailingAnchor.constraint(lessThanOrEqualTo: visibilityButton.leadingAnchor, constant: -12),
descriptionLabel.topAnchor.constraint(equalTo: titleRowStackView.bottomAnchor, constant: 4),
activeDotView.widthAnchor.constraint(equalToConstant: 8),
activeDotView.heightAnchor.constraint(equalToConstant: 8),
activeDotView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
activeDotView.trailingAnchor.constraint(equalTo: visibilityButton.leadingAnchor, constant: -14),
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)
}
private func configureReleaseChannelBadge(_ rawValue: String?) {
releaseChannelBadgeView.rootView = PluginReleaseChannelBadge(releaseChannel: rawValue)
releaseChannelBadgeView.isHidden = PluginReleaseChannel(rawString: rawValue) == nil
}
}
private func featureManagementPresentationText(for presentation: PluginFeaturePresentation) -> String {
switch presentation {
case .featurePanel:
return AppL10n.plugins("plugin.presentation.featurePanel", defaultValue: "操作面板")
case .componentPanel:
return AppL10n.plugins("plugin.presentation.componentPanel", defaultValue: "组件")
case .featureAndComponentPanel:
return AppL10n.plugins("plugin.presentation.featureAndComponentPanel", defaultValue: "操作面板与组件")
}
}
private extension NSImage {
func tinted(with color: NSColor) -> NSImage {
let image = NSImage(size: size)
image.lockFocus()
defer { image.unlockFocus() }
let rect = NSRect(origin: .zero, size: size)
color.setFill()
rect.fill()
draw(in: rect, from: rect, operation: .destinationIn, fraction: 1)
image.isTemplate = false
return image
}
}