import SwiftUI import MacToolsPluginKit private enum ShortcutSettingsLayout { static let standardRecorderWidth: CGFloat = 126 static let groupedRecorderWidth: CGFloat = 126 static let groupedControlWidth: CGFloat = 192 static let groupedIconWidth: CGFloat = 22 static let actionButtonSize: CGFloat = 22 static let actionButtonsWidth: CGFloat = 50 } struct ShortcutSettingsView: View { @ObservedObject var pluginHost: PluginHost var body: some View { ScrollView { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { VStack(alignment: .leading, spacing: 8) { Label(AppL10n.settings("shortcuts.title", defaultValue: "键盘快捷键"), systemImage: "command") .font(PluginSettingsTheme.Typography.pageTitle) Text(AppL10n.settings( "shortcuts.description", defaultValue: "为常用动作配置全局快捷键。编辑后立即生效,必要项不可删除。" )) .font(PluginSettingsTheme.Typography.pageDescription) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(PluginSettingsTheme.Spacing.cardContent) .frame(maxWidth: .infinity, alignment: .leading) .pluginSettingsCardBackground(.host) ShortcutSettingsRowsView(pluginHost: pluginHost, items: pluginHost.shortcutItems) .pluginSettingsCardBackground(.host) } .padding(PluginSettingsTheme.Spacing.pagePadding) } .background(SettingsStyle.contentBackground) } } struct ShortcutSettingsRowsView: View { @ObservedObject var pluginHost: PluginHost let items: [ShortcutSettingsItem] var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in ShortcutSettingsStandardRow( item: item, recordShortcut: { binding in configure(item, binding: binding) }, onConfigure: { pluginHost.clearShortcutError(for: item.id) }, onClear: { clear(item) }, onReset: { reset(item) } ) if index < items.count - 1 { PluginSettingsListDivider() } } } } private func configure(_ item: ShortcutSettingsItem, binding: ShortcutBinding) -> String? { pluginHost.clearShortcutError(for: item.id) return pluginHost.setShortcutBindingAndReturnError(binding, for: item.id) } private func clear(_ item: ShortcutSettingsItem) { pluginHost.clearShortcutError(for: item.id) pluginHost.clearShortcut(for: item.id) } private func reset(_ item: ShortcutSettingsItem) { pluginHost.clearShortcutError(for: item.id) pluginHost.resetShortcut(for: item.id) } } struct GroupedShortcutSettingsRowsView: View { @ObservedObject var pluginHost: PluginHost let groups: [ShortcutSettingsGroup] var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(groups.enumerated()), id: \.element.id) { index, group in GroupedShortcutSettingsRow( group: group, recordShortcut: configure, onBeginRecording: { item in pluginHost.clearShortcutError(for: item.id) }, onClear: clear, onReset: reset ) if index < groups.count - 1 { PluginSettingsListDivider() } } } } private func configure(_ item: ShortcutSettingsItem, binding: ShortcutBinding) -> String? { pluginHost.clearShortcutError(for: item.id) return pluginHost.setShortcutBindingAndReturnError(binding, for: item.id) } private func clear(_ item: ShortcutSettingsItem) { pluginHost.clearShortcutError(for: item.id) pluginHost.clearShortcut(for: item.id) } private func reset(_ item: ShortcutSettingsItem) { pluginHost.clearShortcutError(for: item.id) pluginHost.resetShortcut(for: item.id) } } struct ShortcutSettingsGroup: Identifiable { let id: String let title: String let description: String? let items: [ShortcutSettingsItem] } private struct ShortcutSettingsStandardRow: View { let item: ShortcutSettingsItem let recordShortcut: (ShortcutBinding) -> String? let onConfigure: () -> Void let onClear: () -> Void let onReset: () -> Void private var supportingText: String { item.errorMessage ?? item.description } private var supportingColor: Color { item.errorMessage == nil ? .secondary : .red } private var rowHelpText: String { [item.title, supportingText] .filter { !$0.isEmpty } .joined(separator: "\n") } var body: some View { HStack(alignment: .center, spacing: 16) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(item.title) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) .lineLimit(1) .truncationMode(.tail) .help(item.title) if item.isRequired { ShortcutStatusBadge(text: AppL10n.settings("shortcuts.required", defaultValue: "必填")) } } if !supportingText.isEmpty { Text(supportingText) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(supportingColor) .lineLimit(1) .truncationMode(.tail) .help(supportingText) } } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .help(rowHelpText) HStack(alignment: .center, spacing: 10) { ShortcutBindingControl( item: item, onRecord: { binding in PluginShortcutRecordingResult.from( errorMessage: recordShortcut(binding) ) }, onBeginRecording: onConfigure, onConfigure: onConfigure, onReset: onReset, onClear: onClear ) } } .pluginSettingsListRowPadding(interactive: true) .frame(maxWidth: .infinity, alignment: .leading) } } private struct GroupedShortcutSettingsRow: View { private enum Layout { static let controlWidth = ShortcutSettingsLayout.groupedControlWidth } let group: ShortcutSettingsGroup let recordShortcut: (ShortcutSettingsItem, ShortcutBinding) -> String? let onBeginRecording: (ShortcutSettingsItem) -> Void let onClear: (ShortcutSettingsItem) -> Void let onReset: (ShortcutSettingsItem) -> Void var body: some View { HStack(alignment: .center, spacing: 16) { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(group.title) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) .lineLimit(1) .truncationMode(.tail) .help(group.title) if !supportingText.isEmpty { Text(supportingText) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(supportingColor) .lineLimit(1) .truncationMode(.tail) .help(supportingText) } } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) HStack(alignment: .center, spacing: 10) { ForEach(group.items) { item in ShortcutBindingControl( item: item, onRecord: { binding in PluginShortcutRecordingResult.from( errorMessage: recordShortcut(item, binding) ) }, onBeginRecording: { onBeginRecording(item) }, onConfigure: { onBeginRecording(item) }, onReset: { onReset(item) }, onClear: { onClear(item) }, title: item.settingsControlTitle ?? item.title, systemImage: item.settingsControlSystemImage, layout: .stacked ) .frame(width: Layout.controlWidth, alignment: .leading) } } } .pluginSettingsListRowPadding(interactive: true) .frame(maxWidth: .infinity, alignment: .leading) } private var supportingText: String { let messages = group.items.compactMap(\.errorMessage) if !messages.isEmpty { return messages.joined(separator: ";") } return group.description ?? "" } private var supportingColor: Color { group.items.contains(where: { $0.errorMessage != nil }) ? .red : .secondary } } private struct ShortcutBindingControl: View { enum LayoutStyle: Equatable { case horizontal case stacked } let item: ShortcutSettingsItem let onRecord: (ShortcutBinding) -> PluginShortcutRecordingResult let onBeginRecording: () -> Void let onConfigure: () -> Void let onReset: () -> Void let onClear: () -> Void var title: String? = nil var systemImage: String? = nil var layout: LayoutStyle = .horizontal var body: some View { switch layout { case .horizontal: HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.controlCluster) { recorderButton actionButtons } case .stacked: HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.controlCluster) { controlLabel recorderButton actionButtons } } } @ViewBuilder private var controlLabel: some View { if let systemImage { Image(systemName: systemImage) .font(PluginSettingsTheme.Typography.sectionTitle) .symbolRenderingMode(.monochrome) .foregroundStyle(.secondary) .frame(width: ShortcutSettingsLayout.groupedIconWidth, alignment: .center) .accessibilityLabel(Text(title ?? item.title)) .help(title ?? item.title) } else if let title { Text(title) .font(PluginSettingsTheme.Typography.secondaryLabel) .foregroundStyle(.secondary) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .help(title) } } private var recorderWidth: CGFloat { switch layout { case .horizontal: return ShortcutSettingsLayout.standardRecorderWidth case .stacked: return ShortcutSettingsLayout.groupedRecorderWidth } } private var recorderButton: some View { PluginShortcutRecorder( title: title ?? item.title, displayText: item.bindingText, minWidth: recorderWidth, onRecord: onRecord, onBeginRecording: onBeginRecording ) .frame(width: recorderWidth) } @ViewBuilder private var actionButtons: some View { if shouldShowReset || item.canClear { HStack(spacing: 6) { if shouldShowReset { ShortcutInlineActionButton( systemName: "arrow.counterclockwise", helpText: AppL10n.settings("shortcuts.resetHelp", defaultValue: "重置为默认快捷键"), action: onReset ) } if item.canClear { ShortcutInlineActionButton( systemName: "xmark.circle.fill", helpText: AppL10n.settings("shortcuts.clearHelp", defaultValue: "清除快捷键"), action: onClear ) } } .frame(width: actionButtonsWidth, alignment: .leading) } } private var shouldShowReset: Bool { guard layout == .horizontal || item.isRequired else { return false } return !item.usesDefaultValue } private var actionButtonsWidth: CGFloat { shouldShowReset && item.canClear ? ShortcutSettingsLayout.actionButtonsWidth : ShortcutSettingsLayout.actionButtonSize } } private struct ShortcutStatusBadge: View { let text: String var body: some View { Text(text) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(Color.accentColor) .padding(.horizontal, 8) .padding(.vertical, 4) .background( Capsule(style: .continuous) .fill(SettingsStyle.activeControlBackground) ) } } private struct ShortcutInlineActionButton: View { let systemName: String let helpText: String let action: () -> Void var body: some View { Button(action: action) { Image(systemName: systemName) .font(PluginSettingsTheme.Typography.rowIcon) .symbolRenderingMode(.monochrome) .frame( width: ShortcutSettingsLayout.actionButtonSize, height: ShortcutSettingsLayout.actionButtonSize ) } .buttonStyle(.plain) .foregroundStyle(Color.secondary) .help(helpText) } }