ggbond268--mactools
422 行
14 KiB
Swift
422 行
14 KiB
Swift
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)
|
|
}
|
|
}
|