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

422 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}