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

647 lines
26 KiB
Swift

import AppKit
import SwiftUI
import UniformTypeIdentifiers
import MacToolsPluginKit
private enum MenuBarIconSettingsMetrics {
static let recentPreviewHeight: CGFloat = 44
static let recentTileSize = CGSize(width: 58, height: 58)
static let recentBadgeSize: CGFloat = 18
static let galleryColumnWidth: CGFloat = 128
static let galleryColumnSpacing: CGFloat = 10
static let galleryColumnCount = 4
static let galleryContentWidth: CGFloat = 554
static let galleryContentHeight: CGFloat = 340
static let galleryPopoverWidth: CGFloat = 582
static let galleryPreviewHeight: CGFloat = 34
static let galleryPreviewMaxWidth: CGFloat = 122
static let galleryTileSize = CGSize(width: 128, height: 52)
static let galleryCellSize = CGSize(width: 128, height: 82)
static let galleryTitleWidth: CGFloat = 120
}
struct MenuBarIconSettingsView: View {
@ObservedObject var iconSettings: MenuBarIconSettings
@ObservedObject var gallery: MenuBarIconGalleryLibrary
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
MenuBarIconEditorControls(iconSettings: iconSettings, gallery: gallery)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, GeneralSettingsCardLayout.horizontalPadding)
.padding(.vertical, GeneralSettingsCardLayout.verticalPadding)
}
private var header: some View {
HStack(spacing: GeneralSettingsCardLayout.headerSpacing) {
ZStack {
RoundedRectangle(cornerRadius: GeneralSettingsCardLayout.iconCornerRadius, style: .continuous)
.fill(Color.accentColor.opacity(0.12))
Image(systemName: "menubar.rectangle")
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
.frame(width: GeneralSettingsCardLayout.iconSize, height: GeneralSettingsCardLayout.iconSize)
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(AppL10n.settings("menuBarIcon.title", defaultValue: "菜单栏图标"))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Text(AppL10n.settings(
"menuBarIcon.description",
defaultValue: "统一设置浅色和深色菜单栏图标,导入时会自动扣除纯色背景。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button {
iconSettings.resetToDefault()
} label: {
Label(AppL10n.settings("menuBarIcon.restoreDefault", defaultValue: "恢复默认"), systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!iconSettings.hasCustomIcon)
}
.frame(maxWidth: .infinity, minHeight: GeneralSettingsCardLayout.minRowHeight, alignment: .leading)
.help(AppL10n.settings("menuBarIcon.help", defaultValue: "设置菜单栏图标"))
}
}
private struct MenuBarIconEditorControls: View {
@ObservedObject var iconSettings: MenuBarIconSettings
@ObservedObject var gallery: MenuBarIconGalleryLibrary
@State private var sliderID = UUID()
private let rowLabelWidth: CGFloat = 76
private let contentMaxWidth: CGFloat = 520
private let animationModePickerWidth: CGFloat = 240
private let manualSpeedSliderWidth: CGFloat = 180
var body: some View {
VStack(alignment: .leading, spacing: 14) {
controlRow(AppL10n.settings("menuBarIcon.source", defaultValue: "图标来源")) {
actionButtons
}
contentOnlyRow {
Text(AppL10n.settings(
"menuBarIcon.sourceDescription",
defaultValue: "支持图片、轻量 GIF/MP4 和在线动态图标;导入时会自动扣除纯色背景。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
}
animationSpeedControls
controlRow(AppL10n.settings("menuBarIcon.recent", defaultValue: "最近使用"), alignment: .top) {
MenuBarIconRecentGrid(iconSettings: iconSettings, gallery: gallery)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
}
if let warningText = iconSettings.contrastReport(for: .light).warningText
?? iconSettings.contrastReport(for: .dark).warningText {
contentOnlyRow {
Label(warningText, systemImage: "exclamationmark.triangle")
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.orange)
}
}
if let errorMessage = iconSettings.lastErrorMessage {
contentOnlyRow {
Label(errorMessage, systemImage: "xmark.circle")
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.red)
}
}
}
}
private func controlRow<Content: View>(
_ title: String,
alignment: VerticalAlignment = .center,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: alignment, spacing: 12) {
Text(title)
.font(PluginSettingsTheme.Typography.secondaryLabel)
.foregroundStyle(.secondary)
.frame(width: rowLabelWidth, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func contentOnlyRow<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .center, spacing: 12) {
Color.clear
.frame(width: rowLabelWidth, height: 1)
content()
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var actionButtons: some View {
HStack(spacing: 8) {
Button {
selectMedia()
} label: {
Label(AppL10n.settings("menuBarIcon.upload", defaultValue: "上传图片或动画"), systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
MenuBarIconGalleryPicker(iconSettings: iconSettings, gallery: gallery)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: contentMaxWidth, alignment: .leading)
}
private var animationSpeedControls: some View {
VStack(alignment: .leading, spacing: 8) {
controlRow(AppL10n.settings("menuBarIcon.playbackSpeed", defaultValue: "播放速度")) {
Picker(AppL10n.settings("menuBarIcon.playbackSpeed", defaultValue: "播放速度"), selection: Binding(
get: { iconSettings.animationSpeedMode },
set: { iconSettings.animationSpeedMode = $0 }
)) {
ForEach(MenuBarIconAnimationSpeedMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: animationModePickerWidth, alignment: .leading)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
}
controlRow(AppL10n.settings("menuBarIcon.multiplier", defaultValue: "倍率")) {
animationMultiplierControls
}
}
}
private var speedDescription: String {
switch iconSettings.animationSpeedMode {
case .manual:
return AppL10n.settings("menuBarIcon.speedDescription.manual", defaultValue: "固定倍率循环播放。")
case .adaptiveSystemLoad:
return AppL10n.settings("menuBarIcon.speedDescription.adaptiveSystemLoad", defaultValue: "CPU、GPU、内存越高越快。")
}
}
private var animationMultiplierControls: some View {
HStack(spacing: 12) {
Text(speedDescription)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.fixedSize(horizontal: true, vertical: false)
manualSpeedSlider
.opacity(isManualAnimationSpeed ? 1 : 0)
.disabled(!isManualAnimationSpeed)
Text(String(format: "%.1fx", iconSettings.manualAnimationSpeedMultiplier))
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.secondary)
.frame(width: 38, alignment: .trailing)
.opacity(isManualAnimationSpeed ? 1 : 0)
Spacer(minLength: 0)
}
.frame(maxWidth: contentMaxWidth, minHeight: PluginSettingsTheme.Size.controlHeight, alignment: .leading)
.onAppear {
DispatchQueue.main.async {
sliderID = UUID()
}
}
}
private var isManualAnimationSpeed: Bool {
iconSettings.animationSpeedMode == .manual
}
private var manualSpeedSlider: some View {
Slider(
value: Binding(
get: { iconSettings.manualAnimationSpeedMultiplier },
set: { iconSettings.manualAnimationSpeedMultiplier = $0 }
),
in: MenuBarIconAnimationSpeedPolicy.minimumMultiplier...MenuBarIconAnimationSpeedPolicy.maximumMultiplier
)
.labelsHidden()
.frame(width: manualSpeedSliderWidth)
.id(sliderID)
}
private func selectMedia() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = MenuBarIconProcessing.supportedImageContentTypes
+ MenuBarIconProcessing.supportedAnimationContentTypes
panel.message = AppL10n.settings(
"menuBarIcon.openPanel.message",
defaultValue: "选择图片、GIF 或 MP4 作为 MacTools 状态栏图标"
)
guard panel.runModal() == .OK, let url = panel.url else {
return
}
let contentType = UTType(filenameExtension: url.pathExtension)
if let contentType,
MenuBarIconProcessing.supportedAnimationContentTypes.contains(where: { contentType.conforms(to: $0) }) {
Task {
await iconSettings.importAnimation(from: url)
}
} else {
iconSettings.importIcon(from: url)
}
}
}
private struct MenuBarIconRecentGrid: View {
@ObservedObject var iconSettings: MenuBarIconSettings
@ObservedObject var gallery: MenuBarIconGalleryLibrary
@State private var activeRecentID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if iconSettings.recentItems.isEmpty {
Text(AppL10n.settings("menuBarIcon.recent.empty", defaultValue: "上传或选择图标后会显示在这里。"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 46, alignment: .leading)
} else {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(iconSettings.recentItems.prefix(6)) { item in
recentItemButton(for: item)
}
}
.frame(minHeight: MenuBarIconSettingsMetrics.recentTileSize.height, alignment: .leading)
}
.scrollIndicators(.never)
.frame(maxWidth: .infinity, minHeight: MenuBarIconSettingsMetrics.recentTileSize.height, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func recentItemButton(for item: MenuBarIconRecentItem) -> some View {
let previewImage = iconSettings.previewImage(for: item)
return Button {
Task {
activeRecentID = item.id
_ = await gallery.selectRecentItem(item, iconSettings: iconSettings)
activeRecentID = nil
}
} label: {
ZStack(alignment: .bottomTrailing) {
MenuBarIconThumbnail(
image: previewImage,
height: MenuBarIconSettingsMetrics.recentPreviewHeight,
maxWidth: nil
)
.frame(
minWidth: MenuBarIconSettingsMetrics.recentTileSize.width,
minHeight: MenuBarIconSettingsMetrics.recentTileSize.height
)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Color.primary.opacity(0.1), lineWidth: 1)
)
badge(for: item)
}
}
.buttonStyle(.plain)
.disabled(activeRecentID != nil)
.help(item.displayName)
}
@ViewBuilder
private func badge(for item: MenuBarIconRecentItem) -> some View {
if activeRecentID == item.id {
ProgressView()
.controlSize(.small)
.frame(width: MenuBarIconSettingsMetrics.recentBadgeSize, height: MenuBarIconSettingsMetrics.recentBadgeSize)
.background(.thinMaterial, in: Circle())
} else if iconSettings.remoteAssetSelection(forRecentItem: item) != nil,
!iconSettings.isRemoteAssetCached(for: item) {
Image(systemName: "icloud.and.arrow.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: MenuBarIconSettingsMetrics.recentBadgeSize, height: MenuBarIconSettingsMetrics.recentBadgeSize)
.background(Color(nsColor: .windowBackgroundColor))
.clipShape(Circle())
} else if item.mediaKind == .animation {
Image(systemName: "play.fill")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.frame(width: MenuBarIconSettingsMetrics.recentBadgeSize, height: MenuBarIconSettingsMetrics.recentBadgeSize)
.background(Color.accentColor)
.clipShape(Circle())
}
}
}
private struct MenuBarIconGalleryPicker: View {
@ObservedObject var iconSettings: MenuBarIconSettings
@ObservedObject var gallery: MenuBarIconGalleryLibrary
@State private var selectedCategoryID: String?
@State private var isPickerPresented = false
@State private var activeAssetID: String?
private var selectedCategory: String? {
selectedCategoryID ?? gallery.categories.first?.id
}
private var filteredAssets: [MenuBarIconGalleryAsset] {
guard let selectedCategory else {
return []
}
return gallery.assets.filter { asset in
asset.categoryID == selectedCategory
}
}
var body: some View {
Button {
isPickerPresented.toggle()
} label: {
Label(AppL10n.settings("menuBarIcon.gallery.title", defaultValue: "在线图库"), systemImage: "sparkles")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.popover(isPresented: $isPickerPresented, arrowEdge: .bottom) {
pickerContent
.task {
await gallery.loadCatalogIfNeeded()
if selectedCategoryID == nil {
selectedCategoryID = gallery.categories.first?.id
}
}
}
}
private var pickerContent: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Text(AppL10n.settings("menuBarIcon.gallery.title", defaultValue: "在线图库"))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Spacer()
if gallery.status.isLoading {
ProgressView()
.controlSize(.small)
.frame(width: 18, height: 18)
}
Button {
Task {
await gallery.refreshCatalog()
selectedCategoryID = selectedCategoryID ?? gallery.categories.first?.id
}
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help(AppL10n.settings("menuBarIcon.gallery.refresh", defaultValue: "刷新图库"))
.disabled(gallery.status.isLoading)
Picker(AppL10n.settings("menuBarIcon.gallery.category", defaultValue: "分组"), selection: Binding(
get: { selectedCategoryID ?? gallery.categories.first?.id ?? "" },
set: { selectedCategoryID = $0 }
)) {
ForEach(gallery.categories) { category in
Text(category.title).tag(category.id)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 110)
.disabled(gallery.categories.isEmpty)
}
content
}
.padding(14)
.frame(width: MenuBarIconSettingsMetrics.galleryPopoverWidth)
}
@ViewBuilder
private var content: some View {
if gallery.status.isLoading && gallery.assets.isEmpty {
ProgressView()
.frame(
width: MenuBarIconSettingsMetrics.galleryContentWidth,
height: MenuBarIconSettingsMetrics.galleryContentHeight
)
} else if gallery.assets.isEmpty {
ContentUnavailableView(
AppL10n.settings("menuBarIcon.gallery.unavailable", defaultValue: "图库不可用"),
systemImage: "wifi.exclamationmark",
description: Text(gallery.lastErrorMessage ?? AppL10n.settings("menuBarIcon.gallery.tryLater", defaultValue: "稍后再试。"))
)
.frame(
width: MenuBarIconSettingsMetrics.galleryContentWidth,
height: MenuBarIconSettingsMetrics.galleryContentHeight
)
} else {
ScrollView {
LazyVGrid(
columns: Array(
repeating: GridItem(
.fixed(MenuBarIconSettingsMetrics.galleryColumnWidth),
spacing: MenuBarIconSettingsMetrics.galleryColumnSpacing
),
count: MenuBarIconSettingsMetrics.galleryColumnCount
),
alignment: .leading,
spacing: 10
) {
ForEach(filteredAssets) { asset in
Button {
Task {
activeAssetID = asset.id
let didSelect = await gallery.selectAsset(asset, iconSettings: iconSettings)
activeAssetID = nil
if didSelect {
isPickerPresented = false
}
}
} label: {
MenuBarIconGalleryAssetCell(
asset: asset,
state: gallery.state(for: asset),
previewImage: gallery.previewImage(for: asset),
isBusy: activeAssetID == asset.id,
isSelected: iconSettings.selectedRemoteAsset?.id == asset.id
&& iconSettings.selectedRemoteAsset?.version == asset.version
)
}
.buttonStyle(.plain)
.disabled(activeAssetID != nil)
.help(AppL10n.settingsFormat("menuBarIcon.gallery.useAssetFormat", defaultValue: "使用 %@", asset.title))
.task {
await gallery.loadPreviewIfNeeded(for: asset)
}
}
}
.padding(.vertical, 2)
}
.frame(
width: MenuBarIconSettingsMetrics.galleryContentWidth,
height: MenuBarIconSettingsMetrics.galleryContentHeight
)
}
}
}
private struct MenuBarIconGalleryAssetCell: View {
let asset: MenuBarIconGalleryAsset
let state: MenuBarIconGalleryAssetState
let previewImage: NSImage?
let isBusy: Bool
let isSelected: Bool
var body: some View {
VStack(spacing: 6) {
ZStack(alignment: .bottomTrailing) {
preview
.frame(
width: MenuBarIconSettingsMetrics.galleryTileSize.width,
height: MenuBarIconSettingsMetrics.galleryTileSize.height
)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1)
)
badge
}
Text(asset.title)
.font(PluginSettingsTheme.Typography.statusBadge)
.lineLimit(1)
.truncationMode(.tail)
.frame(width: MenuBarIconSettingsMetrics.galleryTitleWidth)
}
.frame(
width: MenuBarIconSettingsMetrics.galleryCellSize.width,
height: MenuBarIconSettingsMetrics.galleryCellSize.height
)
}
@ViewBuilder
private var preview: some View {
if let previewImage {
MenuBarIconThumbnail(
image: previewImage,
height: MenuBarIconSettingsMetrics.galleryPreviewHeight,
maxWidth: MenuBarIconSettingsMetrics.galleryPreviewMaxWidth
)
} else {
MenuBarIconThumbnail(
image: nil,
height: MenuBarIconSettingsMetrics.galleryPreviewHeight,
maxWidth: MenuBarIconSettingsMetrics.galleryPreviewMaxWidth
)
}
}
@ViewBuilder
private var badge: some View {
if isBusy {
ProgressView()
.controlSize(.small)
.frame(width: 14, height: 14)
.background(Color(nsColor: .windowBackgroundColor))
.clipShape(Circle())
} else {
switch state {
case .cached:
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(isSelected ? Color.accentColor : .secondary)
.background(Color(nsColor: .windowBackgroundColor))
.clipShape(Circle())
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.orange)
.background(Color(nsColor: .windowBackgroundColor))
.clipShape(Circle())
case .downloading:
ProgressView()
.controlSize(.small)
.frame(width: 14, height: 14)
.background(Color(nsColor: .windowBackgroundColor))
.clipShape(Circle())
case .available:
Image(systemName: "icloud.and.arrow.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 16, height: 16)
.background(Color(nsColor: .windowBackgroundColor))
.clipShape(Circle())
}
}
}
private var borderColor: Color {
isSelected ? .accentColor : Color.primary.opacity(0.1)
}
}
private struct MenuBarIconThumbnail: View {
let image: NSImage?
let height: CGFloat
let maxWidth: CGFloat?
var body: some View {
Group {
if let image {
Image(nsImage: image)
.resizable()
.renderingMode(.original)
.scaledToFit()
.frame(height: height)
} else {
Image(systemName: "photo")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: height, height: height)
}
}
.frame(maxWidth: maxWidth, maxHeight: height)
}
}