1101 lines
42 KiB
Swift
1101 lines
42 KiB
Swift
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
enum GeneralSettingsCardLayout {
|
|
static let horizontalPadding: CGFloat = 8
|
|
static let verticalPadding: CGFloat = 4
|
|
static let iconSize: CGFloat = 30
|
|
static let iconCornerRadius: CGFloat = 8
|
|
static let headerSpacing: CGFloat = 16
|
|
static let minRowHeight: CGFloat = 38
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
@ObservedObject private var runtimeLocale = PluginRuntimeLocalization.source
|
|
@ObservedObject var appUpdater: AppUpdater
|
|
@ObservedObject var menuBarIconSettings: MenuBarIconSettings
|
|
@ObservedObject var menuBarIconGallery: MenuBarIconGalleryLibrary
|
|
@ObservedObject var launchAtLoginController: LaunchAtLoginController
|
|
|
|
var body: some View {
|
|
// Recreate native AppKit-backed controls when the shared locale changes.
|
|
let _ = runtimeLocale.revision
|
|
|
|
return TabView(selection: $pluginHost.selectedSettingsDestination) {
|
|
GeneralSettingsView(
|
|
menuBarIconSettings: menuBarIconSettings,
|
|
menuBarIconGallery: menuBarIconGallery,
|
|
launchAtLoginController: launchAtLoginController
|
|
)
|
|
.tag(SettingsDestination.general)
|
|
.tabItem {
|
|
Label(AppL10n.settings("tab.general", defaultValue: "通用"), systemImage: "gearshape")
|
|
}
|
|
|
|
FeatureSettingsView(pluginHost: pluginHost)
|
|
.tag(SettingsDestination.pluginConfiguration)
|
|
.tabItem {
|
|
Label(AppL10n.settings("tab.plugins", defaultValue: "插件"), systemImage: "slider.horizontal.3")
|
|
}
|
|
|
|
AboutSettingsView(appUpdater: appUpdater)
|
|
.tag(SettingsDestination.about)
|
|
.tabItem {
|
|
Label(AppL10n.settings("tab.about", defaultValue: "关于"), systemImage: "info.circle")
|
|
}
|
|
}
|
|
.id(runtimeLocale.revision)
|
|
.frame(minWidth: 720, maxWidth: .infinity, minHeight: 480, maxHeight: .infinity)
|
|
.environment(\.locale, PluginRuntimeLocalization.locale)
|
|
.environment(\.layoutDirection, layoutDirection)
|
|
}
|
|
|
|
private var layoutDirection: LayoutDirection {
|
|
PluginRuntimeLocalization.locale.language.characterDirection == .rightToLeft
|
|
? .rightToLeft
|
|
: .leftToRight
|
|
}
|
|
}
|
|
|
|
private struct PermissionSettingsRow: View {
|
|
let card: PluginPermissionCard
|
|
let statusColor: Color
|
|
let onAction: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
Image(systemName: card.iconSystemImage)
|
|
.pluginSettingsRowIconStyle(visualScale: card.iconVisualScale)
|
|
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
|
|
HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) {
|
|
Text(card.title)
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
Label {
|
|
Text(card.statusText)
|
|
.lineLimit(1)
|
|
} icon: {
|
|
Image(systemName: card.statusSystemImage)
|
|
}
|
|
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
|
.foregroundStyle(statusColor)
|
|
}
|
|
|
|
Text(card.description)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if let footnote = card.footnote {
|
|
Text(footnote)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Button(card.buttonTitle, action: onAction)
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct GeneralSettingsView: View {
|
|
@ObservedObject var menuBarIconSettings: MenuBarIconSettings
|
|
@ObservedObject var menuBarIconGallery: MenuBarIconGalleryLibrary
|
|
@ObservedObject var launchAtLoginController: LaunchAtLoginController
|
|
@AppStorage(AppAppearancePreference.userDefaultsKey) private var appearancePreferenceRawValue = AppAppearancePreference.system.rawValue
|
|
@AppStorage(AppLanguagePreference.userDefaultsKey) private var languagePreferenceRawValue = AppLanguagePreference.system.rawValue
|
|
@AppStorage(MenuBarClickBehaviorPreference.userDefaultsKey) private var clickBehaviorRawValue = MenuBarClickBehaviorPreference.standard.rawValue
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
LaunchAtLoginSettingsRow(controller: launchAtLoginController)
|
|
} header: {
|
|
Text(AppL10n.settings("general.section.startup", defaultValue: "启动"))
|
|
}
|
|
|
|
Section {
|
|
AppearanceSettingsRow(selection: appearancePreferenceBinding)
|
|
LanguageSettingsRow(selection: languagePreferenceBinding)
|
|
} header: {
|
|
Text(AppL10n.settings("general.section.appearance", defaultValue: "外观"))
|
|
}
|
|
|
|
Section {
|
|
MenuBarIconSettingsView(
|
|
iconSettings: menuBarIconSettings,
|
|
gallery: menuBarIconGallery
|
|
)
|
|
MenuBarClickBehaviorSettingsRow(selection: clickBehaviorBinding)
|
|
} header: {
|
|
Text(AppL10n.settings("general.section.menuBarIcon", defaultValue: "状态栏图标"))
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
|
|
private var appearancePreferenceBinding: Binding<AppAppearancePreference> {
|
|
Binding {
|
|
AppAppearancePreference(rawValue: appearancePreferenceRawValue) ?? .system
|
|
} set: { preference in
|
|
appearancePreferenceRawValue = preference.rawValue
|
|
preference.apply()
|
|
}
|
|
}
|
|
|
|
private var languagePreferenceBinding: Binding<AppLanguagePreference> {
|
|
Binding {
|
|
AppLanguagePreference(rawValue: languagePreferenceRawValue) ?? .system
|
|
} set: { preference in
|
|
let oldPreference = AppLanguagePreference(rawValue: languagePreferenceRawValue) ?? .system
|
|
guard oldPreference != preference else {
|
|
return
|
|
}
|
|
|
|
languagePreferenceRawValue = preference.rawValue
|
|
preference.store()
|
|
}
|
|
}
|
|
|
|
private var clickBehaviorBinding: Binding<MenuBarClickBehaviorPreference> {
|
|
Binding {
|
|
MenuBarClickBehaviorPreference(rawValue: clickBehaviorRawValue) ?? .standard
|
|
} set: { preference in
|
|
clickBehaviorRawValue = preference.rawValue
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MenuBarClickBehaviorSettingsRow: View {
|
|
@Binding var selection: MenuBarClickBehaviorPreference
|
|
@State private var toggleID = UUID()
|
|
|
|
private var isSwapped: Binding<Bool> {
|
|
Binding {
|
|
selection.isSwapped
|
|
} set: { enabled in
|
|
selection = enabled ? .swapped : .standard
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: GeneralSettingsCardLayout.headerSpacing) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: GeneralSettingsCardLayout.iconCornerRadius, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
|
|
Image(systemName: "cursorarrow.click.2")
|
|
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.frame(width: GeneralSettingsCardLayout.iconSize, height: GeneralSettingsCardLayout.iconSize)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(AppL10n.settings("menuBarClick.title", defaultValue: "交换左键与右键功能"))
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
Text(AppL10n.settings("menuBarClick.description", defaultValue: "关闭时左键打开仪表盘、右键功能打开功能面板;开启后互换。"))
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(AppL10n.settings(
|
|
"menuBarClick.rightClickShortcutNotice",
|
|
defaultValue: "可以使用 Option + 左键触发右键功能。"
|
|
))
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Toggle(AppL10n.settings("menuBarClick.toggle", defaultValue: "交换左键与右键功能"), isOn: isSwapped)
|
|
.toggleStyle(.switch)
|
|
.labelsHidden()
|
|
.id(toggleID)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: GeneralSettingsCardLayout.minRowHeight, alignment: .leading)
|
|
.padding(.horizontal, GeneralSettingsCardLayout.horizontalPadding)
|
|
.padding(.vertical, GeneralSettingsCardLayout.verticalPadding)
|
|
.help(AppL10n.settings("menuBarClick.help", defaultValue: "开启后左键打开功能面板,右键功能打开仪表盘"))
|
|
.onAppear {
|
|
DispatchQueue.main.async {
|
|
toggleID = UUID()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AppearanceSettingsRow: View {
|
|
@Binding var selection: AppAppearancePreference
|
|
|
|
var body: some View {
|
|
HStack(spacing: GeneralSettingsCardLayout.headerSpacing) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: GeneralSettingsCardLayout.iconCornerRadius, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
|
|
Image(systemName: "circle.lefthalf.filled")
|
|
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.frame(width: GeneralSettingsCardLayout.iconSize, height: GeneralSettingsCardLayout.iconSize)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(AppL10n.settings("appearance.title", defaultValue: "应用外观"))
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
Text(AppL10n.settings("appearance.description", defaultValue: "自动跟随系统,也可以固定为深色或浅色。"))
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Picker(AppL10n.settings("appearance.picker", defaultValue: "外观"), selection: $selection) {
|
|
ForEach(AppAppearancePreference.allCases) { preference in
|
|
Text(preference.title)
|
|
.tag(preference)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: GeneralSettingsCardLayout.minRowHeight, alignment: .leading)
|
|
.padding(.horizontal, GeneralSettingsCardLayout.horizontalPadding)
|
|
.padding(.vertical, GeneralSettingsCardLayout.verticalPadding)
|
|
.help(AppL10n.settings("appearance.help", defaultValue: "设置应用外观"))
|
|
}
|
|
}
|
|
|
|
private struct LanguageSettingsRow: View {
|
|
@Binding var selection: AppLanguagePreference
|
|
|
|
var body: some View {
|
|
HStack(spacing: GeneralSettingsCardLayout.headerSpacing) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: GeneralSettingsCardLayout.iconCornerRadius, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
|
|
Image(systemName: "globe")
|
|
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.frame(width: GeneralSettingsCardLayout.iconSize, height: GeneralSettingsCardLayout.iconSize)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(AppL10n.settings("language.title", defaultValue: "语言"))
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
Text(AppL10n.settings("language.description", defaultValue: "默认跟随系统语言,也可以固定为指定语言。"))
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Picker(AppL10n.settings("language.picker", defaultValue: "语言"), selection: $selection) {
|
|
ForEach(AppLanguagePreference.allCases) { preference in
|
|
Text(preference.pickerTitle)
|
|
.tag(preference)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.labelsHidden()
|
|
.frame(width: 150, alignment: .trailing)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: GeneralSettingsCardLayout.minRowHeight, alignment: .leading)
|
|
.padding(.horizontal, GeneralSettingsCardLayout.horizontalPadding)
|
|
.padding(.vertical, GeneralSettingsCardLayout.verticalPadding)
|
|
.help(AppL10n.settings("language.help", defaultValue: "设置应用语言"))
|
|
}
|
|
}
|
|
|
|
private struct LaunchAtLoginSettingsRow: View {
|
|
@ObservedObject var controller: LaunchAtLoginController
|
|
@State private var toggleID = UUID()
|
|
|
|
var body: some View {
|
|
HStack(spacing: GeneralSettingsCardLayout.headerSpacing) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: GeneralSettingsCardLayout.iconCornerRadius, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
|
|
Image(systemName: "power")
|
|
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.frame(width: GeneralSettingsCardLayout.iconSize, height: GeneralSettingsCardLayout.iconSize)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(AppL10n.settings("launchAtLogin.title", defaultValue: "开机时启动"))
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
Text(subtitle)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(controller.lastErrorMessage == nil ? .secondary : Color.orange)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Toggle(AppL10n.settings("launchAtLogin.toggle", defaultValue: "开机时启动 MacTools"), isOn: enabledBinding)
|
|
.toggleStyle(.switch)
|
|
.labelsHidden()
|
|
.id(toggleID)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: GeneralSettingsCardLayout.minRowHeight, alignment: .leading)
|
|
.padding(.horizontal, GeneralSettingsCardLayout.horizontalPadding)
|
|
.padding(.vertical, GeneralSettingsCardLayout.verticalPadding)
|
|
.help(AppL10n.settings("launchAtLogin.help", defaultValue: "登录系统时自动启动 MacTools 并显示在菜单栏。"))
|
|
.onAppear {
|
|
DispatchQueue.main.async {
|
|
toggleID = UUID()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var enabledBinding: Binding<Bool> {
|
|
Binding {
|
|
controller.isEnabled
|
|
} set: { newValue in
|
|
controller.setEnabled(newValue)
|
|
}
|
|
}
|
|
|
|
private var subtitle: String {
|
|
controller.lastErrorMessage ?? AppL10n.settings("launchAtLogin.description", defaultValue: "登录系统时自动启动 MacTools 并显示在菜单栏。")
|
|
}
|
|
}
|
|
|
|
private struct FeatureSettingsView: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
FeatureSettingsSidebar(
|
|
configurationItems: pluginHost.pluginConfigurationItems,
|
|
selection: selectionBinding
|
|
)
|
|
.frame(width: 220)
|
|
|
|
FeatureSettingsDetailPane(pluginHost: pluginHost)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.background(SettingsStyle.windowBackground)
|
|
}
|
|
|
|
private var selectionBinding: Binding<FeatureSettingsPane> {
|
|
Binding {
|
|
pluginHost.selectedFeatureSettingsPane
|
|
} set: { selection in
|
|
pluginHost.selectFeatureSettingsPane(selection)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FeatureSettingsSidebar: View {
|
|
let configurationItems: [PluginConfigurationItem]
|
|
@Binding var selection: FeatureSettingsPane
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 4) {
|
|
FeatureSettingsSidebarSectionTitle(AppL10n.settings("plugins.sidebar.marketplaceSection", defaultValue: "插件市场"))
|
|
.padding(.top, 14)
|
|
|
|
FeatureSettingsSidebarRow(
|
|
title: AppL10n.settings("plugins.sidebar.installed", defaultValue: "已安装"),
|
|
systemImage: "checkmark.circle",
|
|
iconTint: .green,
|
|
isSelected: selection == .installed
|
|
) {
|
|
selection = .installed
|
|
}
|
|
|
|
FeatureSettingsSidebarRow(
|
|
title: AppL10n.settings("plugins.sidebar.marketplace", defaultValue: "市场"),
|
|
systemImage: "shippingbox",
|
|
iconTint: .blue,
|
|
isSelected: selection == .marketplace
|
|
) {
|
|
selection = .marketplace
|
|
}
|
|
|
|
FeatureSettingsSidebarSectionTitle(AppL10n.settings("plugins.sidebar.configurationSection", defaultValue: "插件设置"))
|
|
.padding(.top, 16)
|
|
|
|
if configurationItems.isEmpty {
|
|
Text(AppL10n.settings("plugins.sidebar.emptyConfigurations", defaultValue: "暂无可设置插件"))
|
|
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
} else {
|
|
ForEach(configurationItems) { item in
|
|
FeatureSettingsSidebarRow(
|
|
title: item.title,
|
|
systemImage: item.iconName,
|
|
iconTint: item.iconTint,
|
|
isSelected: selection == .configuration(item.id)
|
|
) {
|
|
selection = .configuration(item.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.bottom, 14)
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.background(SettingsStyle.sidebarBackground)
|
|
}
|
|
}
|
|
|
|
private struct FeatureSettingsSidebarSectionTitle: View {
|
|
let title: String
|
|
|
|
init(_ title: String) {
|
|
self.title = title
|
|
}
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(PluginSettingsTheme.Typography.statusBadge)
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
.padding(.horizontal, 10)
|
|
.padding(.bottom, 4)
|
|
}
|
|
}
|
|
|
|
private struct FeatureSettingsSidebarRow: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let iconTint: Color
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
@State private var isHovered = false
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: systemImage)
|
|
.font(PluginSettingsTheme.Typography.sectionTitle)
|
|
.foregroundStyle(isSelected ? Color.accentColor : iconTint)
|
|
.frame(width: 18, height: 18)
|
|
|
|
Text(title)
|
|
.font(PluginSettingsTheme.Typography.sectionTitle)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, minHeight: 34, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(rowBackground)
|
|
)
|
|
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onHover { isHovered = $0 }
|
|
.help(title)
|
|
}
|
|
|
|
private var rowBackground: Color {
|
|
if isSelected {
|
|
return SettingsStyle.sidebarSelectionBackground
|
|
}
|
|
|
|
return isHovered ? SettingsStyle.sidebarHoverBackground : .clear
|
|
}
|
|
}
|
|
|
|
private struct FeatureSettingsDetailPane: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
|
|
var body: some View {
|
|
switch pluginHost.selectedFeatureSettingsPane {
|
|
case .installed:
|
|
InstalledFeaturesSettingsView(pluginHost: pluginHost)
|
|
case .marketplace:
|
|
PluginManagementSettingsView(pluginHost: pluginHost)
|
|
case let .configuration(pluginID):
|
|
PluginConfigurationDetailPane(
|
|
pluginHost: pluginHost,
|
|
item: configurationItem(for: pluginID)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func configurationItem(for pluginID: String) -> PluginConfigurationItem? {
|
|
pluginHost.pluginConfigurationItems.first { $0.id == pluginID }
|
|
}
|
|
}
|
|
|
|
private struct InstalledFeaturesSettingsView: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
@State private var searchText: String = ""
|
|
@State private var selectedFilter: PluginCategoryFilter = .all
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 18) {
|
|
SettingsPageHeader(
|
|
title: AppL10n.settings("plugins.installed.title", defaultValue: "已安装"),
|
|
description: AppL10n.settings(
|
|
"plugins.installed.description",
|
|
defaultValue: "启用、隐藏并拖拽调整插件在菜单栏里的显示顺序。"
|
|
),
|
|
systemImage: "checkmark.circle",
|
|
iconTint: .green
|
|
)
|
|
|
|
if !pluginHost.featureManagementItems.isEmpty {
|
|
PluginFilterBarView(
|
|
searchText: $searchText,
|
|
selectedFilter: $selectedFilter,
|
|
countsByFilter: countsByFilter,
|
|
searchPrompt: AppL10n.settings("plugins.installed.searchPrompt", defaultValue: "搜索已安装插件")
|
|
)
|
|
}
|
|
|
|
SettingsCardContainer {
|
|
if pluginHost.featureManagementItems.isEmpty {
|
|
ContentUnavailableView(
|
|
AppL10n.settings("plugins.installed.empty.title", defaultValue: "暂无已安装插件"),
|
|
systemImage: "checkmark.circle",
|
|
description: Text(AppL10n.settings("plugins.installed.empty.description", defaultValue: "安装插件后,会显示在这里。"))
|
|
)
|
|
.frame(maxWidth: .infinity, minHeight: 180)
|
|
} else if filteredItems.isEmpty {
|
|
ContentUnavailableView(
|
|
AppL10n.settings("plugins.filter.empty.title", defaultValue: "未找到匹配的插件"),
|
|
systemImage: "magnifyingglass",
|
|
description: Text(AppL10n.settings("plugins.filter.empty.description", defaultValue: "尝试调整关键字或切换分类。"))
|
|
)
|
|
.frame(maxWidth: .infinity, minHeight: 180)
|
|
} else {
|
|
FeatureManagementTableView(
|
|
items: filteredItems,
|
|
isReorderEnabled: !isFiltering,
|
|
onVisibilityChange: { pluginID, isVisible in
|
|
pluginHost.setFeatureVisibility(isVisible, for: pluginID)
|
|
},
|
|
onMove: { pluginID, targetOffset in
|
|
pluginHost.moveFeatureManagementItem(id: pluginID, toOffset: targetOffset)
|
|
}
|
|
)
|
|
.frame(height: featureManagementListHeight)
|
|
}
|
|
}
|
|
|
|
if isFiltering && !filteredItems.isEmpty {
|
|
Text(AppL10n.settings(
|
|
"plugins.installed.filteringReorderHint",
|
|
defaultValue: "筛选中暂时不能拖拽排序,清除关键字或选择「全部」即可重新排序。"
|
|
))
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 4)
|
|
}
|
|
}
|
|
.padding(PluginSettingsTheme.Spacing.pagePadding)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.background(SettingsStyle.contentBackground)
|
|
}
|
|
|
|
private var filteredItems: [PluginFeatureManagementItem] {
|
|
pluginHost.featureManagementItems.filter {
|
|
PluginListFilter.matches(featureItem: $0, query: searchText, filter: selectedFilter)
|
|
}
|
|
}
|
|
|
|
private var countsByFilter: [PluginCategoryFilter: Int] {
|
|
PluginListFilter.countsByFilter(
|
|
featureItems: pluginHost.featureManagementItems,
|
|
query: searchText
|
|
)
|
|
}
|
|
|
|
private var isFiltering: Bool {
|
|
!PluginListFilter.normalized(searchText).isEmpty || selectedFilter != .all
|
|
}
|
|
|
|
private var featureManagementListHeight: CGFloat {
|
|
FeatureManagementTableView.preferredHeight(for: filteredItems.count)
|
|
}
|
|
}
|
|
|
|
private struct SettingsCardContainer<Content: View>: View {
|
|
let content: Content
|
|
|
|
init(@ViewBuilder content: () -> Content) {
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.pluginSettingsCardBackground(.host)
|
|
}
|
|
}
|
|
|
|
private struct PluginConfigurationDetailPane: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
let item: PluginConfigurationItem?
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let item {
|
|
if item.prefersFullHeight {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
PluginConfigurationHeader(item: item)
|
|
.padding(PluginSettingsTheme.Spacing.pagePadding)
|
|
|
|
if item.hasCustomConfiguration {
|
|
pluginHost.pluginConfigurationViewItem(for: item.pluginID).content
|
|
.padding(.horizontal, PluginSettingsTheme.Spacing.pagePadding)
|
|
.padding(.bottom, PluginSettingsTheme.Spacing.pagePadding)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 18) {
|
|
PluginConfigurationHeader(item: item)
|
|
|
|
if !item.settingsCards.isEmpty {
|
|
PluginSettingsCardSection(
|
|
pluginHost: pluginHost,
|
|
cards: item.settingsCards
|
|
)
|
|
}
|
|
|
|
if !item.permissionCards.isEmpty {
|
|
PluginPermissionCardSection(
|
|
pluginHost: pluginHost,
|
|
cards: item.permissionCards
|
|
)
|
|
}
|
|
|
|
if item.hasCustomConfiguration {
|
|
pluginHost.pluginConfigurationViewItem(for: item.pluginID).content
|
|
}
|
|
|
|
if !item.shortcutItems.isEmpty {
|
|
PluginShortcutSection(pluginHost: pluginHost, items: item.shortcutItems)
|
|
}
|
|
}
|
|
.padding(PluginSettingsTheme.Spacing.pagePadding)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.background(SettingsStyle.contentBackground)
|
|
}
|
|
} else {
|
|
ContentUnavailableView(
|
|
AppL10n.settings("plugins.configuration.empty.title", defaultValue: "暂无可配置插件"),
|
|
systemImage: "slider.horizontal.3",
|
|
description: Text(AppL10n.settings(
|
|
"plugins.configuration.empty.description",
|
|
defaultValue: "当插件提供权限、快捷键或自定义设置后,会显示在这里。"
|
|
))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.background(SettingsStyle.contentBackground)
|
|
}
|
|
}
|
|
|
|
private struct PluginConfigurationHeader: View {
|
|
let item: PluginConfigurationItem
|
|
|
|
var body: some View {
|
|
SettingsPageHeader(
|
|
title: item.title,
|
|
description: item.description,
|
|
systemImage: item.iconName,
|
|
iconTint: item.iconTint
|
|
)
|
|
.padding(.bottom, 2)
|
|
}
|
|
}
|
|
|
|
private struct SettingsPageHeader: View {
|
|
let title: String
|
|
let description: String
|
|
let systemImage: String
|
|
let iconTint: Color
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(iconTint.opacity(0.14))
|
|
|
|
Image(systemName: systemImage)
|
|
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
|
.foregroundStyle(iconTint)
|
|
}
|
|
.frame(width: PluginSettingsTheme.Size.pageIcon, height: PluginSettingsTheme.Size.pageIcon)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(PluginSettingsTheme.Typography.pageTitle)
|
|
|
|
Text(description)
|
|
.font(PluginSettingsTheme.Typography.pageDescription)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PluginSettingsCardSection: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
let cards: [PluginSettingsCard]
|
|
|
|
var body: some View {
|
|
PluginConfigurationSection(title: AppL10n.settings("plugins.configuration.section.settings", defaultValue: "设置"), systemImage: "switch.2") {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
|
|
PluginSettingsCardRow(
|
|
card: card,
|
|
statusColor: statusColor(for: card.statusTone),
|
|
onAction: {
|
|
if let actionID = card.actionID {
|
|
pluginHost.performSettingsAction(pluginID: card.pluginID, actionID: actionID)
|
|
}
|
|
}
|
|
)
|
|
|
|
if index < cards.count - 1 {
|
|
PluginSettingsListDivider()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PluginSettingsCardRow: View {
|
|
let card: PluginSettingsCard
|
|
let statusColor: Color
|
|
let onAction: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
|
|
HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
|
|
Text(card.title)
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
Text(card.description)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Label {
|
|
Text(card.statusText)
|
|
.lineLimit(1)
|
|
} icon: {
|
|
Image(systemName: card.statusSystemImage)
|
|
}
|
|
.font(PluginSettingsTheme.Typography.secondaryLabel.weight(.semibold))
|
|
.foregroundStyle(statusColor)
|
|
}
|
|
|
|
if let footnote = card.footnote {
|
|
Text(footnote)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if let buttonTitle = card.buttonTitle, card.actionID != nil {
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button(buttonTitle, action: onAction)
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
}
|
|
.padding(PluginSettingsTheme.Spacing.cardContent)
|
|
}
|
|
}
|
|
|
|
private struct PluginPermissionCardSection: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
let cards: [PluginPermissionCard]
|
|
|
|
var body: some View {
|
|
PluginConfigurationSection(title: AppL10n.settings("plugins.configuration.section.permissions", defaultValue: "权限"), systemImage: "lock.shield") {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
|
|
PermissionSettingsRow(
|
|
card: card,
|
|
statusColor: statusColor(for: card.statusTone),
|
|
onAction: {
|
|
pluginHost.performPermissionAction(
|
|
pluginID: card.pluginID,
|
|
permissionID: card.permissionID
|
|
)
|
|
}
|
|
)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
|
|
if index < cards.count - 1 {
|
|
PluginSettingsListDivider()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PluginShortcutSection: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
let items: [ShortcutSettingsItem]
|
|
|
|
var body: some View {
|
|
PluginConfigurationSection(title: AppL10n.settings("plugins.configuration.section.shortcuts", defaultValue: "快捷键"), systemImage: "command") {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if groupedItems.isEmpty {
|
|
ShortcutSettingsRowsView(pluginHost: pluginHost, items: items)
|
|
} else {
|
|
GroupedShortcutSettingsRowsView(pluginHost: pluginHost, groups: groupedItems)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var groupedItems: [ShortcutSettingsGroup] {
|
|
guard items.allSatisfy({ $0.settingsGroupID != nil }) else {
|
|
return []
|
|
}
|
|
|
|
var groupOrder: [String] = []
|
|
var groups: [String: [ShortcutSettingsItem]] = [:]
|
|
|
|
for item in items {
|
|
guard let groupID = item.settingsGroupID else {
|
|
continue
|
|
}
|
|
|
|
if groups[groupID] == nil {
|
|
groupOrder.append(groupID)
|
|
}
|
|
groups[groupID, default: []].append(item)
|
|
}
|
|
|
|
return groupOrder.compactMap { groupID in
|
|
guard let groupItems = groups[groupID], let firstItem = groupItems.first else { return nil }
|
|
|
|
return ShortcutSettingsGroup(
|
|
id: groupID,
|
|
title: firstItem.settingsGroupTitle ?? firstItem.title,
|
|
description: firstItem.settingsGroupDescription ?? firstItem.description,
|
|
items: groupItems
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PluginConfigurationSection<Content: View>: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let content: Content
|
|
|
|
init(
|
|
title: String,
|
|
systemImage: String,
|
|
@ViewBuilder content: () -> Content
|
|
) {
|
|
self.title = title
|
|
self.systemImage = systemImage
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
|
|
Label(title, systemImage: systemImage)
|
|
.font(PluginSettingsTheme.Typography.sectionTitle)
|
|
.foregroundStyle(.secondary)
|
|
|
|
SettingsCardContainer {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func statusColor(for tone: PluginStatusTone) -> Color {
|
|
switch tone {
|
|
case .neutral:
|
|
return .secondary
|
|
case .positive:
|
|
return .green
|
|
case .caution:
|
|
return .orange
|
|
}
|
|
}
|
|
|
|
struct AboutSettingsView: View {
|
|
@StateObject private var updateViewModel: AboutUpdateViewModel
|
|
|
|
init(appUpdater: AppUpdater) {
|
|
_updateViewModel = StateObject(
|
|
wrappedValue: AboutUpdateViewModel(updater: appUpdater)
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 28)
|
|
|
|
AppIconPreview()
|
|
|
|
Text(AppMetadata.appName)
|
|
.font(.system(size: 22, weight: .bold))
|
|
.padding(.top, 8)
|
|
|
|
Text(AppL10n.settingsFormat("about.versionFormat", defaultValue: "版本 %@", AppMetadata.versionDescription))
|
|
.font(.title3)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 8)
|
|
|
|
AboutUpdateCard(viewModel: updateViewModel)
|
|
.padding(.top, 28)
|
|
.frame(maxWidth: 420)
|
|
|
|
Text(AppMetadata.aboutDescription)
|
|
.font(.title3)
|
|
.lineLimit(nil)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: 320)
|
|
.padding(.top, 28)
|
|
|
|
VStack(spacing: 0) {
|
|
Link(AppMetadata.repositoryDisplayName, destination: AppMetadata.repositoryURL)
|
|
.font(.title3)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 28)
|
|
|
|
Spacer(minLength: 36)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.padding(.horizontal, 40)
|
|
.padding(.vertical, 28)
|
|
}
|
|
}
|
|
|
|
private struct AboutUpdateCard: View {
|
|
private enum Layout {
|
|
static let verticalSpacing: CGFloat = 12
|
|
static let statusMinHeight: CGFloat = 16
|
|
}
|
|
|
|
@ObservedObject var viewModel: AboutUpdateViewModel
|
|
|
|
var body: some View {
|
|
VStack(spacing: Layout.verticalSpacing) {
|
|
Button(viewModel.primaryButtonTitle) {
|
|
Task {
|
|
await viewModel.performPrimaryAction()
|
|
}
|
|
}
|
|
.buttonStyle(AboutUpdatePrimaryButtonStyle())
|
|
.disabled(viewModel.isPrimaryButtonDisabled)
|
|
|
|
Text(statusText ?? " ")
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(viewModel.statusColor)
|
|
.lineLimit(nil)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, minHeight: Layout.statusMinHeight, alignment: .top)
|
|
.opacity(statusText == nil ? 0 : 1)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var statusText: String? {
|
|
switch viewModel.state {
|
|
case .idle:
|
|
return nil
|
|
default:
|
|
return viewModel.statusDetail ?? viewModel.statusHeadline
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AboutUpdatePrimaryButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(PluginSettingsTheme.Typography.rowTitle)
|
|
.foregroundStyle(.white.opacity(isEnabled ? 1 : 0.82))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.frame(minWidth: 92)
|
|
.background(
|
|
Capsule(style: .continuous)
|
|
.fill(buttonBackgroundColor(isPressed: configuration.isPressed))
|
|
)
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1)
|
|
}
|
|
|
|
private func buttonBackgroundColor(isPressed: Bool) -> Color {
|
|
let baseOpacity: CGFloat = isEnabled ? 1 : 0.45
|
|
let pressedOpacity: CGFloat = isEnabled ? 0.82 : baseOpacity
|
|
return Color.accentColor.opacity(isPressed ? pressedOpacity : baseOpacity)
|
|
}
|
|
}
|
|
|
|
private struct AppIconPreview: View {
|
|
private static let iconSize: CGFloat = 82
|
|
|
|
var body: some View {
|
|
if let appIcon = AppMetadata.appIcon {
|
|
Image(nsImage: appIcon)
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: Self.iconSize, height: Self.iconSize)
|
|
} else {
|
|
Image(systemName: "wrench.and.screwdriver.fill")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.padding(12)
|
|
.foregroundStyle(.secondary)
|
|
.background(PluginSettingsTheme.Palette.nativeCardBackground)
|
|
.frame(width: Self.iconSize, height: Self.iconSize)
|
|
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
}
|
|
}
|
|
}
|