import SwiftUI
|
import AppKit
|
import Carbon
|
|
// MARK: - Preferences View
|
|
private let themePreviewCountdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
|
private enum SettingsTab: String, CaseIterable, Identifiable {
|
case language
|
case general
|
case theme
|
case hotkeys
|
case tags
|
case data
|
case pro
|
case about
|
|
var id: String { rawValue }
|
|
var titleKey: String {
|
switch self {
|
case .language: return "settings.language"
|
case .general: return "settings.general"
|
case .theme: return "settings.theme"
|
case .hotkeys: return "quickSearch.hotkeys"
|
case .tags: return "settings.tags"
|
case .data: return "settings.data"
|
case .pro: return "pro.card.badge"
|
case .about: return "settings.about"
|
}
|
}
|
|
var systemImage: String {
|
switch self {
|
case .language: return "globe"
|
case .general: return "gearshape"
|
case .theme: return "paintpalette"
|
case .hotkeys: return "keyboard"
|
case .tags: return "tag"
|
case .data: return "externaldrive"
|
case .pro: return "crown"
|
case .about: return "info.circle"
|
}
|
}
|
}
|
|
private enum InitialLayoutMode: String {
|
case uncategorized
|
case smart
|
}
|
|
struct PreferencesView: View {
|
private let settingsWindowWidth: CGFloat = 1000
|
private let settingsWindowHeight: CGFloat = 640
|
private let settingsContentWidth: CGFloat = 940
|
private let languageContentWidth: CGFloat = 820
|
private let generalContentWidth: CGFloat = 720
|
private let generalLabelWidth: CGFloat = 190
|
private let generalControlWidth: CGFloat = 500
|
private let compactPickerWidth: CGFloat = 320
|
private let dataPanelWidth: CGFloat = 820
|
private let dataLabelWidth: CGFloat = 190
|
private let dataActionWidth: CGFloat = 128
|
|
@AppStorage("tagFontSize") private var tagFontSize: Double = AppDefaults.tagFontSize
|
@AppStorage("iconSize") private var iconSize: Double = AppDefaults.iconSize
|
@AppStorage("tagPosition") private var tagPosition = AppDefaults.tagPosition
|
@AppStorage("defaultGroupName") private var defaultGroupName = "Other"
|
@AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
|
@AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
|
@AppStorage("showDockIcon") private var showDockIcon = AppDefaults.showDockIcon
|
@AppStorage("launchAtLogin") private var launchAtLogin = AppDefaults.launchAtLogin
|
@AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
|
@AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips
|
@AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID
|
@AppStorage(AppGridTheme.deepBlueTuningStorageKey) private var deepBlueThemeTuning = AppGridTheme.deepBlue.defaultColorTuning
|
@AppStorage(AppGridTheme.colorfulTuningStorageKey) private var colorfulThemeTuning = AppGridTheme.colorful.defaultColorTuning
|
@AppStorage("mainHotkeyRegistrationState") private var mainHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue
|
@AppStorage("quickSearchHotkeyRegistrationState") private var quickSearchHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue
|
@ObservedObject private var proEntitlement = ProEntitlementCenter.shared
|
@State private var selectedLanguage = L10n.selectedLanguageCode
|
@State private var isRefreshingLanguage = false
|
@State private var allApps: [AppInfo] = []
|
@State private var tagColors: [String: Int] = [:]
|
@State private var tagCustomColors: [String: TagCustomColor] = [:]
|
@State private var categoryScheme = TagDatabase.CategorySchemeState()
|
@State private var isApplyingSystemScheme = false
|
@State private var isResettingToUncategorized = false
|
@State private var showApplySystemSchemeConfirmation = false
|
@State private var showResetToUncategorizedConfirmation = false
|
@State private var isDataFilePanelPresented = false
|
@State private var hotkeyStatusToast: String? = nil
|
@State private var hotkeyStatusToastToken: UUID? = nil
|
@State private var recordingHotkeyKind: LauncherHotkeyKind? = nil
|
@State private var hotkeyRecorderMonitor: Any? = nil
|
@State private var settingsEscapeMonitor: Any? = nil
|
@State private var hotkeySettingsRefreshToken = UUID()
|
@State private var selectedTab: SettingsTab = .general
|
@State private var selectedInitialLayoutMode: InitialLayoutMode = .smart
|
@State private var settingsProPromptFeature: ProFeature? = nil
|
@State private var settingsCustomContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
|
@State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus()
|
@State private var themePreviewNow = Date()
|
|
init(initialTabRawValue: String? = nil) {
|
let initialTab = initialTabRawValue.flatMap(SettingsTab.init(rawValue:)) ?? .general
|
_selectedTab = State(initialValue: initialTab)
|
}
|
|
private func selectTab(rawValue: String?) {
|
guard let rawValue, let tab = SettingsTab(rawValue: rawValue) else { return }
|
selectedTab = tab
|
}
|
|
private func scanApps() {
|
DispatchQueue.global(qos: .userInitiated).async {
|
var apps = AppIndexer.scan()
|
let store = TagDatabase.loadWithEnsuredCategoryScheme()
|
apps = TagEditor.annotate(apps: apps)
|
let colors = store.tags.mapValues { $0.color }
|
let customColors = store.tags.compactMapValues { $0.customColor }
|
let quotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
|
DispatchQueue.main.async {
|
allApps = apps
|
tagColors = colors
|
tagCustomColors = customColors
|
categoryScheme = store.categoryScheme
|
settingsCustomContainerQuotaOverviewStatus = quotaStatus
|
}
|
}
|
}
|
|
private func refreshDataState() {
|
categoryScheme = TagDatabase.loadWithEnsuredCategoryScheme().categoryScheme
|
}
|
|
private func exportTags() {
|
guard proEntitlement.isUnlocked else {
|
presentSettingsProPrompt(for: .layoutExport)
|
return
|
}
|
guard prepareDataFilePanelPresentation() else { return }
|
DispatchQueue.main.async {
|
TagDatabase.flushPendingCategorySchemeBackupBatch()
|
let currentScheme = TagDatabase.loadWithEnsuredCategoryScheme().categoryScheme
|
categoryScheme = currentScheme
|
|
let panel = NSSavePanel()
|
panel.title = tr("settings.export")
|
panel.nameFieldStringValue = TagDatabase.exportFileName(for: currentScheme)
|
panel.allowedContentTypes = [.json]
|
|
beginFilePanel(panel) { response in
|
finishDataFilePanelPresentation()
|
guard response == .OK, let url = panel.url else { return }
|
do {
|
try TagDatabase.exportTo(url)
|
} catch let error as ProEntitlementError {
|
if let feature = error.feature {
|
presentSettingsProPrompt(for: feature)
|
}
|
} catch {
|
fputs("[TagLauncher] Export failed: \(error)\n", stderr)
|
showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription)
|
}
|
}
|
}
|
}
|
|
private func importTags() {
|
guard proEntitlement.isUnlocked else {
|
presentSettingsProPrompt(for: .layoutImport)
|
return
|
}
|
guard prepareDataFilePanelPresentation() else { return }
|
DispatchQueue.main.async {
|
let panel = NSOpenPanel()
|
panel.title = tr("settings.import")
|
panel.allowedContentTypes = [.json]
|
panel.allowsMultipleSelection = false
|
|
beginFilePanel(panel) { response in
|
finishDataFilePanelPresentation()
|
guard response == .OK, let url = panel.url else { return }
|
do {
|
TagDatabase.flushPendingCategorySchemeBackupBatch()
|
_ = try TagDatabase.importFrom(url)
|
scanApps()
|
refreshDataState()
|
notifyDataChanged()
|
} catch let error as ProEntitlementError {
|
if let feature = error.feature {
|
presentSettingsProPrompt(for: feature)
|
}
|
} catch {
|
fputs("[TagLauncher] Import failed: \(error)\n", stderr)
|
showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription)
|
}
|
}
|
}
|
}
|
|
private func prepareDataFilePanelPresentation() -> Bool {
|
guard !isDataFilePanelPresented else { return false }
|
isDataFilePanelPresented = true
|
NotificationCenter.default.post(
|
name: .tagLauncherModalInteractionChanged,
|
object: nil,
|
userInfo: ["active": true]
|
)
|
NSApp.activate(ignoringOtherApps: true)
|
preferencesWindow?.makeKeyAndOrderFront(nil)
|
return true
|
}
|
|
private func finishDataFilePanelPresentation() {
|
isDataFilePanelPresented = false
|
NotificationCenter.default.post(
|
name: .tagLauncherModalInteractionChanged,
|
object: nil,
|
userInfo: ["active": false]
|
)
|
}
|
|
private func beginFilePanel(
|
_ panel: NSSavePanel,
|
completion: @escaping (NSApplication.ModalResponse) -> Void
|
) {
|
if let window = preferencesWindow {
|
panel.beginSheetModal(for: window, completionHandler: completion)
|
} else {
|
panel.level = .modalPanel
|
panel.begin(completionHandler: completion)
|
panel.orderFrontRegardless()
|
}
|
}
|
|
private func installSettingsEscapeMonitor() {
|
guard settingsEscapeMonitor == nil else { return }
|
settingsEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
guard Int(event.keyCode) == kVK_Escape,
|
isSettingsOverlayEscapeEvent(event)
|
else { return event }
|
return handleSettingsEscapeKey() ? nil : event
|
}
|
}
|
|
private func removeSettingsEscapeMonitor() {
|
if let settingsEscapeMonitor {
|
NSEvent.removeMonitor(settingsEscapeMonitor)
|
}
|
settingsEscapeMonitor = nil
|
}
|
|
private func isSettingsOverlayEscapeEvent(_ event: NSEvent) -> Bool {
|
guard let window = preferencesWindow,
|
window.isVisible,
|
let parent = window.parent as? OverlayPanel,
|
parent.isVisible
|
else { return false }
|
|
if let eventWindow = event.window {
|
return eventWindow == window || eventWindow == parent || eventWindow.parent == window
|
}
|
return NSApp.keyWindow == window || NSApp.keyWindow == parent || NSApp.mainWindow == window
|
}
|
|
private func handleSettingsEscapeKey() -> Bool {
|
guard let window = preferencesWindow else { return false }
|
guard !isDataFilePanelPresented, window.attachedSheet == nil else { return false }
|
|
if settingsProPromptFeature != nil {
|
dismissSettingsProPrompt()
|
return true
|
}
|
|
if showApplySystemSchemeConfirmation {
|
withAnimation(.easeOut(duration: 0.14)) {
|
showApplySystemSchemeConfirmation = false
|
}
|
return true
|
}
|
|
if showResetToUncategorizedConfirmation {
|
withAnimation(.easeOut(duration: 0.14)) {
|
showResetToUncategorizedConfirmation = false
|
}
|
return true
|
}
|
|
if recordingHotkeyKind != nil {
|
stopHotkeyRecording(showCancelledToast: true)
|
return true
|
}
|
|
window.performClose(nil)
|
return true
|
}
|
|
private var preferencesWindow: NSWindow? {
|
if let taggedWindow = NSApp.windows.first(where: {
|
$0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible
|
}) {
|
return taggedWindow
|
}
|
|
return [NSApp.keyWindow, NSApp.mainWindow]
|
.compactMap { $0 }
|
.first(where: isPreferencesWindowFallback(_:))
|
}
|
|
private var localizedPreferencesWindowTitle: String {
|
tr("menu.preferences").replacingOccurrences(of: "…", with: "")
|
}
|
|
private func refreshPreferencesWindowTitle() {
|
preferencesWindow?.title = localizedPreferencesWindowTitle
|
}
|
|
private func isPreferencesWindowFallback(_ window: NSWindow) -> Bool {
|
guard window.isVisible,
|
!(window is NSPanel),
|
!(window is OverlayPanel)
|
else { return false }
|
|
return window.title == localizedPreferencesWindowTitle
|
}
|
|
private func showDataAlert(title: String, message: String) {
|
let alert = NSAlert()
|
alert.messageText = title
|
alert.informativeText = message
|
alert.alertStyle = .warning
|
if let window = preferencesWindow {
|
alert.beginSheetModal(for: window)
|
} else {
|
alert.runModal()
|
}
|
}
|
|
private var mainHotkeyState: LauncherHotkeyRegistrationState {
|
LauncherHotkeyRegistrationState(rawValue: mainHotkeyRegistrationState) ?? .active
|
}
|
|
private var quickSearchHotkeyState: LauncherHotkeyRegistrationState {
|
LauncherHotkeyRegistrationState(rawValue: quickSearchHotkeyRegistrationState) ?? .active
|
}
|
|
private func hotkeyStatusText(for kind: LauncherHotkeyKind) -> String {
|
switch kind {
|
case .main:
|
return mainHotkeyState == .failed
|
? tr("quickSearch.status.registrationFailed")
|
: tr("quickSearch.status.Active")
|
case .quickSearch:
|
return quickSearchHotkeyState == .failed
|
? tr("quickSearch.status.unavailable")
|
: tr("quickSearch.status.Active")
|
}
|
}
|
|
private func hotkeyStatusTone(for kind: LauncherHotkeyKind) -> HotkeyStatusTone {
|
switch kind {
|
case .main:
|
return mainHotkeyState == .failed ? .warning : .active
|
case .quickSearch:
|
return quickSearchHotkeyState == .failed ? .warning : .active
|
}
|
}
|
|
private func showPendingHotkeyWarningIfNeeded() {
|
var messages: [String] = []
|
if LauncherHotkeyRegistrationStore.consumeNeedsAttention(for: .main),
|
LauncherHotkeyRegistrationStore.state(for: .main) == .failed {
|
messages.append(hotkeyFailureMessage(for: .main))
|
}
|
if LauncherHotkeyRegistrationStore.consumeNeedsAttention(for: .quickSearch),
|
LauncherHotkeyRegistrationStore.state(for: .quickSearch) == .failed {
|
messages.append(hotkeyFailureMessage(for: .quickSearch))
|
}
|
guard !messages.isEmpty else { return }
|
showHotkeyStatusToast(messages.joined(separator: "\n\n"))
|
}
|
|
private func hotkeyFailureMessage(for kind: LauncherHotkeyKind) -> String {
|
let key = kind == .main
|
? "quickSearch.mainHotkeyUnavailableMessage"
|
: "quickSearch.globalHotkeyUnavailableMessage"
|
let status = LauncherHotkeyRegistrationStore.failureCode(for: kind)
|
.map(String.init) ?? "-"
|
return tr(key)
|
.replacingOccurrences(of: "%shortcut%", with: LauncherHotkeySettings.effectiveHotkey(for: kind).displayString)
|
.replacingOccurrences(of: "%status%", with: status)
|
}
|
|
private func startHotkeyRecording(for kind: LauncherHotkeyKind) {
|
guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else {
|
presentSettingsProPrompt(for: .customHotkeys)
|
return
|
}
|
guard let appDelegate = AppDelegate.shared else {
|
handleHotkeyCustomizationResult(.registrationFailed(-1))
|
return
|
}
|
stopHotkeyRecording(showCancelledToast: false)
|
appDelegate.suspendConfiguredHotkeysForRecording()
|
recordingHotkeyKind = kind
|
showHotkeyStatusToast(tr("hotkeys.recording"))
|
hotkeyRecorderMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
DispatchQueue.main.async {
|
self.captureHotkeyEvent(event)
|
}
|
return nil
|
}
|
}
|
|
private func captureHotkeyEvent(_ event: NSEvent) {
|
guard let kind = recordingHotkeyKind else { return }
|
if Int(event.keyCode) == kVK_Escape {
|
stopHotkeyRecording(showCancelledToast: true)
|
return
|
}
|
let candidate = LauncherHotkeySettings.candidate(from: event)
|
let result = AppDelegate.shared?
|
.applyCustomHotkey(candidate, for: kind)
|
?? .registrationFailed(-1)
|
stopHotkeyRecording(showCancelledToast: false)
|
handleHotkeyCustomizationResult(result)
|
}
|
|
private func restoreDefaultHotkey(for kind: LauncherHotkeyKind) {
|
guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else {
|
presentSettingsProPrompt(for: .customHotkeys)
|
return
|
}
|
let result = AppDelegate.shared?
|
.restoreDefaultHotkey(for: kind)
|
?? .registrationFailed(-1)
|
handleHotkeyCustomizationResult(result)
|
}
|
|
private func stopHotkeyRecording(showCancelledToast: Bool = false) {
|
let wasRecording = recordingHotkeyKind != nil || hotkeyRecorderMonitor != nil
|
if let hotkeyRecorderMonitor {
|
NSEvent.removeMonitor(hotkeyRecorderMonitor)
|
}
|
hotkeyRecorderMonitor = nil
|
recordingHotkeyKind = nil
|
if wasRecording {
|
AppDelegate.shared?.resumeConfiguredHotkeysAfterRecording()
|
}
|
if showCancelledToast {
|
showHotkeyStatusToast(tr("hotkeys.recordingCancelled"))
|
}
|
}
|
|
private func handleHotkeyCustomizationResult(_ result: LauncherHotkeyCustomizationResult) {
|
if case .locked = result {
|
presentSettingsProPrompt(for: .customHotkeys)
|
return
|
}
|
hotkeySettingsRefreshToken = UUID()
|
showHotkeyStatusToast(hotkeyCustomizationMessage(for: result))
|
}
|
|
private func hotkeyCustomizationMessage(for result: LauncherHotkeyCustomizationResult) -> String {
|
switch result {
|
case .registrationFailed(let status):
|
return tr(result.messageKey).replacingOccurrences(of: "%status%", with: String(status))
|
default:
|
return tr(result.messageKey)
|
}
|
}
|
|
private func showHotkeyStatusToast(_ message: String) {
|
let token = UUID()
|
hotkeyStatusToastToken = token
|
withAnimation(.easeOut(duration: 0.16)) {
|
hotkeyStatusToast = message
|
}
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
guard hotkeyStatusToastToken == token else { return }
|
withAnimation(.easeOut(duration: 0.18)) {
|
hotkeyStatusToast = nil
|
}
|
}
|
}
|
|
private func restorePreviousCategoryScheme() {
|
TagDatabase.flushPendingCategorySchemeBackupBatch()
|
guard let backupPath = categoryScheme.previousBackupPath else { return }
|
let restoredName = categoryScheme.previousName ?? tr("settings.noPreviousScheme")
|
let restored = SmartStartService.restoreBackup(at: backupPath)
|
if restored {
|
scanApps()
|
refreshDataState()
|
notifyDataChanged()
|
showDataAlert(title: tr("settings.schemeRestored"), message: restoredName)
|
} else {
|
showDataAlert(title: tr("settings.restoreFailed"), message: backupPath)
|
}
|
}
|
|
private func applySystemInitialScheme() {
|
guard !isApplyingSystemScheme else { return }
|
withAnimation(.easeOut(duration: 0.16)) {
|
showApplySystemSchemeConfirmation = true
|
}
|
}
|
|
private func applySelectedInitialLayout() {
|
switch selectedInitialLayoutMode {
|
case .uncategorized:
|
guard !isResettingToUncategorized else { return }
|
withAnimation(.easeOut(duration: 0.16)) {
|
showResetToUncategorizedConfirmation = true
|
}
|
case .smart:
|
applySystemInitialScheme()
|
}
|
}
|
|
private func performApplySystemInitialScheme() {
|
guard !isApplyingSystemScheme else { return }
|
showApplySystemSchemeConfirmation = false
|
TagDatabase.flushPendingCategorySchemeBackupBatch()
|
isApplyingSystemScheme = true
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
let result = AppLibraryController.applySystemInitialScheme()
|
|
DispatchQueue.main.async {
|
isApplyingSystemScheme = false
|
allApps = result.snapshot.apps
|
tagColors = result.snapshot.tagColors
|
refreshDataState()
|
notifyDataChanged()
|
|
if let summary = result.summary {
|
showDataAlert(
|
title: tr("settings.systemSchemeApplied"),
|
message: formattedSystemSchemeAppliedMessage(summary)
|
)
|
} else {
|
showDataAlert(
|
title: tr("settings.restoreFailed"),
|
message: tr("settings.systemSchemeApplyFailed")
|
)
|
}
|
}
|
}
|
}
|
|
private func performResetToUncategorized() {
|
guard !isResettingToUncategorized else { return }
|
showResetToUncategorizedConfirmation = false
|
TagDatabase.flushPendingCategorySchemeBackupBatch()
|
isResettingToUncategorized = true
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
let result = AppLibraryController.resetToUncategorized()
|
|
DispatchQueue.main.async {
|
isResettingToUncategorized = false
|
allApps = result.snapshot.apps
|
tagColors = result.snapshot.tagColors
|
refreshDataState()
|
notifyDataChanged()
|
showDataAlert(
|
title: tr("settings.resetToUncategorizedApplied"),
|
message: tr("settings.resetToUncategorizedAppliedMessage")
|
)
|
}
|
}
|
}
|
|
private func formattedSystemSchemeAppliedMessage(_ summary: SmartStartSummary) -> String {
|
tr("settings.systemSchemeAppliedMessage")
|
.replacingOccurrences(of: "%appCount%", with: "\(summary.matchedAppCount)")
|
.replacingOccurrences(of: "%tagCount%", with: "\(summary.assignedTagCount)")
|
}
|
|
private func notifyDataChanged() {
|
NotificationCenter.default.post(name: .tagLauncherDataDidChange, object: nil)
|
}
|
|
private var currentCategorySchemeName: String {
|
if let createdAt = categoryScheme.currentCreatedAt ?? categoryScheme.lastChangedAt {
|
return TagDatabase.normalizedSchemeName(
|
storedName: categoryScheme.currentName,
|
createdAt: createdAt,
|
fallbackPrefixKey: "scheme.local"
|
)
|
}
|
return categoryScheme.currentName ?? tr("settings.schemeNotNamed")
|
}
|
|
private var previousCategorySchemeName: String {
|
guard let storedName = categoryScheme.previousName ?? (categoryScheme.previousBackupPath != nil ? "" : nil) else {
|
return tr("settings.noPreviousScheme")
|
}
|
let createdAt = categoryScheme.previousCreatedAt
|
?? categoryScheme.currentCreatedAt
|
?? categoryScheme.lastChangedAt
|
?? Date()
|
return TagDatabase.normalizedSchemeName(
|
storedName: storedName,
|
createdAt: createdAt,
|
fallbackPrefixKey: "scheme.beforeSmartStart"
|
)
|
}
|
|
private var canRestorePreviousScheme: Bool {
|
guard let path = categoryScheme.previousBackupPath else { return false }
|
return FileManager.default.fileExists(atPath: path)
|
}
|
|
private func bubbleScopeOption(
|
_ title: String,
|
isSelected: Bool,
|
action: @escaping () -> Void
|
) -> some View {
|
Button(action: action) {
|
HStack(spacing: 6) {
|
Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
|
.font(.system(size: 13, weight: .medium))
|
.frame(width: 16, height: 16)
|
Text(title)
|
.font(.system(size: 13, weight: .medium))
|
}
|
.foregroundStyle(isSelected ? Color.accentColor : Color.primary)
|
.contentShape(Rectangle())
|
}
|
.buttonStyle(.plain)
|
}
|
|
private func initialLayoutOption(
|
_ title: String,
|
mode: InitialLayoutMode
|
) -> some View {
|
let isSelected = selectedInitialLayoutMode == mode
|
return Button {
|
selectedInitialLayoutMode = mode
|
} label: {
|
HStack(spacing: 6) {
|
Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
|
.font(.system(size: 13, weight: .medium))
|
.frame(width: 16, height: 16)
|
Text(title)
|
.font(.system(size: 13, weight: .medium))
|
.lineLimit(1)
|
.minimumScaleFactor(0.85)
|
}
|
.foregroundStyle(isSelected ? Color.accentColor : Color.primary)
|
.contentShape(Rectangle())
|
}
|
.buttonStyle(.plain)
|
}
|
|
private func dataSectionTitle(_ title: String) -> some View {
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.primary)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
.multilineTextAlignment(.trailing)
|
.frame(width: dataLabelWidth, alignment: .trailing)
|
}
|
|
private func dataSection<Content: View>(
|
minHeight: CGFloat,
|
@ViewBuilder content: () -> Content
|
) -> some View {
|
content()
|
.padding(.horizontal, 20)
|
.padding(.vertical, 18)
|
.frame(width: dataPanelWidth, alignment: .leading)
|
.frame(minHeight: minHeight, alignment: .leading)
|
.background(
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
|
)
|
}
|
|
private func syncSelectedLanguage() {
|
let code = L10n.selectedLanguageCode
|
guard selectedLanguage != code else { return }
|
selectedLanguage = code
|
}
|
|
private var appVersion: String {
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
}
|
private var buildVersion: String {
|
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
}
|
private var helpPDFURL: URL {
|
HelpDocument.currentURL
|
}
|
private var languageColumns: [GridItem] {
|
[
|
GridItem(.flexible(minimum: 180), spacing: 14, alignment: .top),
|
GridItem(.flexible(minimum: 180), spacing: 14, alignment: .top),
|
GridItem(.flexible(minimum: 180), spacing: 14, alignment: .top),
|
]
|
}
|
private var displayModeColumns: [GridItem] {
|
[
|
GridItem(.flexible(minimum: 220), spacing: 10, alignment: .top),
|
GridItem(.flexible(minimum: 220), spacing: 10, alignment: .top),
|
]
|
}
|
private var themeColumns: [GridItem] {
|
[
|
GridItem(.flexible(minimum: 220), spacing: 12, alignment: .top),
|
GridItem(.flexible(minimum: 220), spacing: 12, alignment: .top),
|
]
|
}
|
private var displayModeOptions: [(id: String, title: String)] {
|
[
|
("flat", tr("settings.flat")),
|
("container", tr("settings.container")),
|
("coloredContainer", tr("settings.coloredContainer")),
|
("gridContainer", tr("settings.gridContainer")),
|
("coloredGridContainer", tr("settings.coloredGridContainer")),
|
]
|
}
|
private var containerDisplayModeOptions: [(id: String, title: String)] {
|
Array(displayModeOptions.dropFirst())
|
}
|
private var selectedAppGridTheme: AppGridTheme {
|
AppGridTheme(storedID: appGridThemeID)
|
}
|
|
private var effectiveAppGridTheme: AppGridTheme {
|
ProEntitlementPolicy.effectiveTheme(for: selectedAppGridTheme)
|
}
|
|
private var effectiveDisplayMode: String {
|
ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
|
}
|
|
private var proStatusPillStyle: ProStatusPillStyle {
|
if proEntitlement.isPreviewingProAppearance {
|
return .preview
|
}
|
switch proEntitlement.accessState {
|
case .free:
|
return .neutral
|
case .purchased:
|
return .unlocked
|
case .legacy:
|
return .legacy
|
}
|
}
|
|
private var proAccentColor: Color {
|
Color(red: 0.56, green: 0.35, blue: 0.03)
|
}
|
|
@ViewBuilder
|
private func proHeaderStatusLabel(compact: Bool = false) -> some View {
|
if proEntitlement.accessState == .free && !proEntitlement.isPreviewingProAppearance {
|
Text(tr("settings.proStatus.freeUser"))
|
.font(.system(size: compact ? 12 : 13, weight: .semibold))
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
} else {
|
ProStatusPill(
|
text: tr(proEntitlement.compactStatusTextKey),
|
style: proStatusPillStyle,
|
compact: compact
|
)
|
}
|
}
|
|
private func centeredSettingsScrollContent<Content: View>(
|
width: CGFloat,
|
@ViewBuilder content: @escaping () -> Content
|
) -> some View {
|
GeometryReader { proxy in
|
ScrollView(.vertical, showsIndicators: true) {
|
VStack(alignment: .center, spacing: 18) {
|
content()
|
}
|
.frame(width: width, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(minHeight: proxy.size.height, alignment: .center)
|
}
|
}
|
}
|
|
@ViewBuilder
|
private func proUnlockedBenefitLine(_ textKey: String, bottomPadding: CGFloat = 14) -> some View {
|
if proEntitlement.isUnlocked {
|
HStack(spacing: 12) {
|
ProStatusPill(
|
text: tr(proEntitlement.compactStatusTextKey),
|
style: proStatusPillStyle
|
)
|
|
Text(tr(textKey))
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.secondary)
|
.lineLimit(2)
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer(minLength: 0)
|
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
.padding(.bottom, bottomPadding)
|
}
|
}
|
|
private func proPurchaseGuidanceLine(
|
_ textKey: String,
|
feature: ProFeature,
|
bottomPadding: CGFloat = 14,
|
compact: Bool = false
|
) -> some View {
|
HStack(spacing: compact ? 8 : 12) {
|
proHeaderStatusLabel(compact: compact)
|
.layoutPriority(1)
|
|
Text(tr(textKey))
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.secondary)
|
.lineLimit(2)
|
.fixedSize(horizontal: false, vertical: true)
|
.layoutPriority(2)
|
|
Spacer(minLength: 8)
|
|
if !proEntitlement.isUnlocked {
|
Button(unlockButtonTitle()) {
|
presentSettingsProPrompt(for: feature)
|
}
|
.buttonStyle(.borderedProminent)
|
.controlSize(compact ? .small : .regular)
|
.layoutPriority(3)
|
|
Button(tr("pro.card.restore")) {
|
proEntitlement.restorePurchases()
|
}
|
.buttonStyle(.bordered)
|
.controlSize(compact ? .small : .regular)
|
.layoutPriority(3)
|
}
|
}
|
.padding(.horizontal, compact ? 10 : 16)
|
.padding(.vertical, compact ? 8 : 14)
|
.background(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.stroke(Color.secondary.opacity(0.16), lineWidth: 1)
|
)
|
.padding(.bottom, bottomPadding)
|
}
|
|
@ViewBuilder
|
private func proAccessGuidanceLine(
|
freeTextKey: String,
|
unlockedTextKey: String,
|
feature: ProFeature,
|
bottomPadding: CGFloat = 14,
|
compact: Bool = false
|
) -> some View {
|
if proEntitlement.isUnlocked {
|
proUnlockedBenefitLine(unlockedTextKey, bottomPadding: bottomPadding)
|
} else {
|
proPurchaseGuidanceLine(freeTextKey, feature: feature, bottomPadding: bottomPadding, compact: compact)
|
}
|
}
|
|
private var settingsProHeaderFeature: ProFeature {
|
switch selectedTab {
|
case .hotkeys:
|
return .customHotkeys
|
case .tags:
|
return .customTagColors
|
case .data:
|
return .layoutExport
|
case .theme, .general, .language, .pro, .about:
|
return .premiumThemes
|
}
|
}
|
|
private var settingsProHeaderText: String {
|
if selectedTab == .pro {
|
return tr(proEntitlement.isUnlocked ? "settings.proStatus.proUser" : "settings.proStatus.freeUser")
|
}
|
|
switch selectedTab {
|
case .language, .about:
|
return ""
|
case .general:
|
return tr(proEntitlement.isUnlocked ? "settings.proBenefit.general" : "settings.proGuide.general")
|
case .theme:
|
return themeStatusSummary
|
case .hotkeys:
|
return tr(proEntitlement.isUnlocked ? "settings.proBenefit.hotkeys" : "settings.proGuide.hotkeys")
|
case .tags:
|
return tr(proEntitlement.isUnlocked ? "settings.proBenefit.tags" : "settings.proGuide.tags")
|
case .data:
|
return tr(proEntitlement.isUnlocked ? "settings.proBenefit.data" : "settings.proGuide.data")
|
case .pro:
|
return tr(proEntitlement.isUnlocked ? "settings.proStatus.proUser" : "settings.proStatus.freeUser")
|
}
|
}
|
|
private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
|
if proEntitlement.isUnlocked {
|
return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
|
}
|
return settingsCustomContainerQuotaOverviewStatus
|
}
|
|
private var customContainerQuotaAccessory: some View {
|
let status = effectiveCustomContainerQuotaStatus
|
let text = status.isUnlimited
|
? tr("pro.customContainers.quotaUnlimitedShort")
|
: tr(
|
"pro.customContainers.quotaShort",
|
replacements: [
|
"%used%": "\(status.used)",
|
"%limit%": "\(status.limit)"
|
]
|
)
|
return Text(text)
|
.font(.system(size: 12, weight: .medium))
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
}
|
|
private func refreshSettingsCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
|
settingsCustomContainerQuotaOverviewStatus = status
|
}
|
|
private func refreshSettingsCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
|
settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
|
}
|
|
private var shouldShowSettingsFixedProHeader: Bool {
|
true
|
}
|
|
private var shouldCenterUnlockedIdentityHeader: Bool {
|
guard proEntitlement.isUnlocked else { return false }
|
switch selectedTab {
|
case .language, .pro, .about:
|
return true
|
case .general, .theme, .hotkeys, .tags, .data:
|
return false
|
}
|
}
|
|
private var settingsFixedProHeader: some View {
|
HStack {
|
HStack(spacing: 12) {
|
if shouldCenterUnlockedIdentityHeader {
|
Spacer(minLength: 0)
|
|
ProStatusPill(
|
text: tr("pro.status.unlocked"),
|
style: .unlocked,
|
compact: true
|
)
|
.layoutPriority(1)
|
|
Text(tr("settings.proStatus.proUser"))
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
.fixedSize(horizontal: false, vertical: true)
|
.layoutPriority(2)
|
|
Spacer(minLength: 0)
|
} else {
|
if selectedTab != .pro {
|
proHeaderStatusLabel(compact: true)
|
.layoutPriority(1)
|
}
|
|
if !settingsProHeaderText.isEmpty {
|
Text(settingsProHeaderText)
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.secondary)
|
.lineLimit(2)
|
.fixedSize(horizontal: false, vertical: true)
|
.layoutPriority(2)
|
}
|
|
Spacer(minLength: 8)
|
|
if !proEntitlement.isUnlocked {
|
Button(unlockButtonTitle()) {
|
presentSettingsProPrompt(for: settingsProHeaderFeature)
|
}
|
.buttonStyle(.borderedProminent)
|
.controlSize(.small)
|
.layoutPriority(3)
|
|
Button(tr("pro.card.restore")) {
|
proEntitlement.restorePurchases()
|
}
|
.buttonStyle(.bordered)
|
.controlSize(.small)
|
.layoutPriority(3)
|
}
|
}
|
}
|
.padding(.horizontal, 16)
|
.padding(.vertical, 10)
|
.frame(width: dataPanelWidth, alignment: .leading)
|
.frame(minHeight: 44)
|
.background(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.stroke(Color.secondary.opacity(0.16), lineWidth: 1)
|
)
|
}
|
.frame(maxWidth: .infinity, alignment: .center)
|
.padding(.top, 12)
|
.padding(.bottom, 10)
|
.background(Color(nsColor: .windowBackgroundColor))
|
}
|
|
private var proTabComparisonFeatures: [ProTabComparisonFeature] {
|
[
|
ProTabComparisonFeature(
|
title: tr("pro.feature.maintainableTagCount"),
|
freeStatusKey: "settings.proStatus.maxSixTags",
|
proLockedStatusKey: "settings.proStatus.unlimitedTags",
|
proUnlockedStatusKey: "settings.proStatus.unlimitedTags",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: tr("pro.feature.containerAppOrderPersistence"),
|
freeStatusKey: "settings.proStatus.previewOnly",
|
proLockedStatusKey: "settings.proStatus.supported",
|
proUnlockedStatusKey: "settings.proStatus.supported",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: tr("pro.feature.customTagColors"),
|
freeStatusKey: "settings.proStatus.unavailableFeature",
|
proLockedStatusKey: "settings.proStatus.lockedFeature",
|
proUnlockedStatusKey: "settings.proStatus.unlockedFeature",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: "\(tr("settings.coloredContainer")) / \(tr("settings.coloredGridContainer"))",
|
freeStatusKey: "settings.proStatus.previewFiveMinutes",
|
proLockedStatusKey: "settings.proStatus.lockedFeature",
|
proUnlockedStatusKey: "settings.proStatus.unlockedFeature",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: tr("pro.feature.themes"),
|
freeStatusKey: "settings.proStatus.previewFiveMinutes",
|
proLockedStatusKey: "settings.proStatus.lockedFeature",
|
proUnlockedStatusKey: "settings.proStatus.unlockedFeature",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: tr("pro.feature.customHotkeys"),
|
freeStatusKey: "settings.proStatus.unavailableFeature",
|
proLockedStatusKey: "settings.proStatus.lockedFeature",
|
proUnlockedStatusKey: "settings.proStatus.unlockedFeature",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: tr("settings.proFeature.appNotes"),
|
freeStatusKey: "settings.proStatus.freeNotes",
|
proLockedStatusKey: "settings.proStatus.unlimitedNotes",
|
proUnlockedStatusKey: "settings.proStatus.unlimitedNotes",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
),
|
ProTabComparisonFeature(
|
title: tr("settings.proFeature.dataBackup"),
|
freeStatusKey: "settings.proStatus.unavailableFeature",
|
proLockedStatusKey: "settings.proStatus.lockedFeature",
|
proUnlockedStatusKey: "settings.proStatus.unlockedFeature",
|
freeStyle: .neutral,
|
proLockedStyle: .neutral,
|
proUnlockedStyle: .neutral
|
)
|
]
|
}
|
|
private func proTabComparisonTable(width: CGFloat) -> some View {
|
VStack(alignment: .leading, spacing: 8) {
|
VStack(spacing: 8) {
|
HStack(spacing: 10) {
|
Text("")
|
.frame(maxWidth: .infinity, alignment: .leading)
|
Text(tr("settings.proCompare.free"))
|
.font(.system(size: 12, weight: .semibold))
|
.foregroundStyle(.secondary)
|
.frame(width: 118, alignment: .leading)
|
HStack(spacing: 4) {
|
Image(systemName: "crown")
|
.font(.system(size: 12, weight: .semibold))
|
Text(tr("settings.proCompare.pro"))
|
.font(.system(size: 12, weight: .semibold))
|
}
|
.foregroundStyle(proAccentColor)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
.frame(width: 118, alignment: .leading)
|
}
|
|
ForEach(proTabComparisonFeatures) { feature in
|
let proStatusKey = proEntitlement.isUnlocked ? feature.proUnlockedStatusKey : feature.proLockedStatusKey
|
let proStyle = proEntitlement.isUnlocked ? feature.proUnlockedStyle : feature.proLockedStyle
|
|
HStack(alignment: .top, spacing: 10) {
|
Text(feature.title)
|
.font(.system(size: 12, weight: .medium))
|
.foregroundStyle(proAccentColor)
|
.lineLimit(2)
|
.fixedSize(horizontal: false, vertical: true)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
ProStatusPill(
|
text: tr(feature.freeStatusKey),
|
style: feature.freeStyle,
|
compact: true
|
)
|
.frame(width: 118, alignment: .leading)
|
|
ProStatusPill(
|
text: tr(proStatusKey),
|
style: proStyle,
|
compact: true
|
)
|
.frame(width: 118, alignment: .leading)
|
}
|
.padding(.horizontal, 10)
|
.padding(.vertical, 8)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.72))
|
)
|
}
|
}
|
}
|
.padding(.horizontal, 16)
|
.padding(.vertical, 14)
|
.frame(width: width, alignment: .leading)
|
.background(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.stroke(Color.secondary.opacity(0.16), lineWidth: 1)
|
)
|
}
|
|
private var themeStatusSummary: String {
|
if proEntitlement.isPreviewingProAppearance {
|
if let countdown = themePreviewCountdownText {
|
return tr("pro.theme.status.previewCountdown")
|
.replacingOccurrences(of: "%time%", with: countdown)
|
}
|
return tr("pro.theme.status.preview")
|
}
|
switch proEntitlement.accessState {
|
case .free:
|
return tr("pro.theme.status.free")
|
case .purchased, .legacy:
|
return tr("pro.theme.status.unlocked")
|
}
|
}
|
|
private func unlockButtonTitle() -> String {
|
tr("pro.card.unlock")
|
}
|
|
private func handleThemeSelection(_ theme: AppGridTheme) {
|
if theme.isFreeTheme || proEntitlement.isUnlocked {
|
proEntitlement.stopThemePreview()
|
appGridThemeID = theme.rawValue
|
return
|
}
|
proEntitlement.startThemePreview(for: theme, tuning: currentThemeTuning(for: theme))
|
}
|
|
private func handleDisplayModeSelection(_ mode: String) {
|
if ProEntitlementPolicy.canUseDisplayMode(mode) {
|
proEntitlement.stopDisplayModePreview()
|
displayMode = mode
|
return
|
}
|
proEntitlement.startDisplayModePreview(for: mode)
|
}
|
|
private var activeColorTuningTheme: AppGridTheme? {
|
let theme = effectiveAppGridTheme
|
return theme.supportsColorTuning ? theme : nil
|
}
|
|
private func currentThemeTuning(for theme: AppGridTheme) -> Double {
|
if let preview = proEntitlement.themePreviewState,
|
preview.theme == theme {
|
return theme.normalizedTuning(preview.themeTuning)
|
}
|
switch theme {
|
case .deepBlue:
|
return theme.normalizedTuning(deepBlueThemeTuning)
|
case .colorful:
|
return theme.normalizedTuning(colorfulThemeTuning)
|
case .defaultLight, .black, .pink, .purple, .green, .blue:
|
return theme.defaultColorTuning
|
}
|
}
|
|
private func persistThemeTuning(_ tuning: Double?, for theme: AppGridTheme) {
|
let normalized = theme.normalizedTuning(tuning)
|
switch theme {
|
case .deepBlue:
|
deepBlueThemeTuning = normalized
|
case .colorful:
|
colorfulThemeTuning = normalized
|
case .defaultLight, .black, .pink, .purple, .green, .blue:
|
break
|
}
|
}
|
|
private func updateThemeTuning(_ value: Double, for theme: AppGridTheme) {
|
let normalized = theme.normalizedTuning(value)
|
if proEntitlement.isUnlocked || theme.isFreeTheme {
|
persistThemeTuning(normalized, for: theme)
|
return
|
}
|
if proEntitlement.themePreviewState?.theme == theme {
|
proEntitlement.updateThemePreviewTuning(normalized, for: theme)
|
} else {
|
proEntitlement.startThemePreview(for: theme, tuning: normalized)
|
}
|
}
|
|
private func themeTuningBinding(for theme: AppGridTheme) -> Binding<Double> {
|
Binding(
|
get: { currentThemeTuning(for: theme) },
|
set: { updateThemeTuning($0, for: theme) }
|
)
|
}
|
|
private func themeOptionAccessory(for theme: AppGridTheme) -> ThemeOptionAccessory {
|
if let preview = proEntitlement.themePreviewState?.theme, preview == theme {
|
return .countdown(text: themePreviewCountdownText ?? "00:00")
|
}
|
if theme.requiresPro {
|
return .pill(text: tr("pro.card.badge"), style: .locked)
|
}
|
return effectiveAppGridTheme == theme ? .checkmark : .circle
|
}
|
|
private func displayModeOptionAccessory(for mode: String) -> ThemeOptionAccessory {
|
if proEntitlement.displayModePreviewState?.displayMode == mode {
|
return .countdown(text: themePreviewCountdownText ?? "00:00")
|
}
|
if ProEntitlementPolicy.isPremiumDisplayMode(mode) {
|
return .pill(text: tr("pro.card.badge"), style: .locked)
|
}
|
return effectiveDisplayMode == mode ? .checkmark : .circle
|
}
|
|
private func presentSettingsProPrompt(
|
for feature: ProFeature,
|
customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
|
) {
|
proEntitlement.clearTransientOperationState()
|
settingsCustomContainerQuotaStatus = customContainerQuotaStatus
|
withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
|
settingsProPromptFeature = feature
|
}
|
}
|
|
private func presentSettingsCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
|
presentSettingsProPrompt(
|
for: .customContainerQuota,
|
customContainerQuotaStatus: status
|
)
|
}
|
|
private var themePreviewCountdownText: String? {
|
if let preview = proEntitlement.themePreviewState {
|
return Self.formatThemePreviewCountdown(seconds: preview.remainingSeconds(at: themePreviewNow))
|
}
|
if let preview = proEntitlement.displayModePreviewState {
|
return Self.formatThemePreviewCountdown(seconds: preview.remainingSeconds(at: themePreviewNow))
|
}
|
return nil
|
}
|
|
private static func formatThemePreviewCountdown(seconds: Int) -> String {
|
let clamped = max(0, seconds)
|
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
|
}
|
|
private func refreshThemePreviewCountdown(now: Date = Date()) {
|
themePreviewNow = now
|
if let preview = proEntitlement.themePreviewState,
|
preview.remainingSeconds(at: now) <= 0 {
|
proEntitlement.stopThemePreview()
|
}
|
if let preview = proEntitlement.displayModePreviewState,
|
preview.remainingSeconds(at: now) <= 0 {
|
proEntitlement.stopDisplayModePreview()
|
}
|
}
|
|
private func dismissSettingsProPrompt() {
|
proEntitlement.clearTransientOperationState()
|
withAnimation(.easeOut(duration: 0.18)) {
|
settingsProPromptFeature = nil
|
}
|
settingsCustomContainerQuotaStatus = nil
|
}
|
|
private func settingsProPromptMessage(for feature: ProFeature) -> String {
|
if feature == .customContainerQuota,
|
let status = settingsCustomContainerQuotaStatus,
|
!status.isUnlimited {
|
return tr(
|
"pro.customContainers.quotaPrompt",
|
replacements: [
|
"%used%": "\(status.used)",
|
"%limit%": "\(status.limit)",
|
"%remaining%": "\(status.remaining)"
|
]
|
)
|
}
|
return tr(feature.promptMessageKey)
|
}
|
|
private func settingsProPromptSecondaryTitle(for feature: ProFeature) -> String {
|
feature == .customContainerQuota
|
? tr("pro.customContainers.manageExisting")
|
: tr("pro.card.restore")
|
}
|
|
private func handleSettingsProPromptSecondaryAction(for feature: ProFeature) {
|
if feature == .customContainerQuota {
|
dismissSettingsProPrompt()
|
selectedTab = .tags
|
return
|
}
|
proEntitlement.restorePurchases()
|
}
|
|
private func handleProAccessStateChange(_ newState: ProAccessState) {
|
guard newState.isUnlocked else {
|
stopHotkeyRecording(showCancelledToast: false)
|
return
|
}
|
if let preview = proEntitlement.themePreviewState {
|
appGridThemeID = preview.theme.rawValue
|
persistThemeTuning(preview.themeTuning, for: preview.theme)
|
proEntitlement.stopThemePreview()
|
}
|
if let preview = proEntitlement.displayModePreviewState {
|
displayMode = preview.displayMode
|
proEntitlement.stopDisplayModePreview()
|
}
|
if settingsProPromptFeature != nil {
|
dismissSettingsProPrompt()
|
}
|
}
|
|
@ViewBuilder
|
private var themeColorTuningSection: some View {
|
if let theme = activeColorTuningTheme {
|
VStack(alignment: .leading, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
Label(
|
tr(theme == .deepBlue ? "theme.tuning.dark" : "theme.tuning.light"),
|
systemImage: "slider.horizontal.3"
|
)
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.primary)
|
|
Spacer(minLength: 12)
|
|
Text("\(Int(round(currentThemeTuning(for: theme) * 100)))")
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.foregroundStyle(.secondary)
|
}
|
|
Slider(value: themeTuningBinding(for: theme), in: 0...1)
|
.controlSize(.regular)
|
.accessibilityLabel(tr(theme == .deepBlue ? "theme.tuning.dark" : "theme.tuning.light"))
|
|
Text(tr("theme.tuning.desc"))
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.fixedSize(horizontal: false, vertical: true)
|
}
|
.padding(14)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
.background(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor).opacity(0.82))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
|
)
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
}
|
}
|
|
private func generalSettingRow<Control: View>(
|
_ label: String,
|
description: String,
|
@ViewBuilder control: () -> Control
|
) -> some View {
|
HStack(alignment: .top, spacing: 16) {
|
Text(label)
|
.font(.system(size: 14, weight: .semibold))
|
.multilineTextAlignment(.trailing)
|
.lineLimit(2)
|
.minimumScaleFactor(0.86)
|
.frame(width: generalLabelWidth, alignment: .trailing)
|
.padding(.top, 5)
|
|
VStack(alignment: .leading, spacing: 6) {
|
control()
|
.frame(width: generalControlWidth, alignment: .leading)
|
Text(description)
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.multilineTextAlignment(.leading)
|
.fixedSize(horizontal: false, vertical: true)
|
.frame(width: generalControlWidth, alignment: .leading)
|
}
|
}
|
.frame(width: generalContentWidth, alignment: .leading)
|
}
|
|
private func dataActionButton(
|
_ title: String,
|
prominent: Bool,
|
enabled: Bool,
|
action: @escaping () -> Void
|
) -> some View {
|
Button(action: action) {
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(prominent ? Color.white.opacity(enabled ? 1.0 : 0.92) : Color.primary.opacity(enabled ? 1.0 : 0.42))
|
.lineLimit(1)
|
.minimumScaleFactor(0.85)
|
.frame(width: dataActionWidth, height: 32, alignment: .center)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(
|
prominent
|
? Color.accentColor.opacity(enabled ? 1.0 : 0.45)
|
: Color(nsColor: .controlBackgroundColor).opacity(enabled ? 1.0 : 0.72)
|
)
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(
|
prominent
|
? Color.accentColor.opacity(enabled ? 0.16 : 0.08)
|
: Color.secondary.opacity(enabled ? 0.22 : 0.12),
|
lineWidth: 1
|
)
|
)
|
}
|
.buttonStyle(.plain)
|
.disabled(!enabled)
|
.opacity(enabled ? 1.0 : 0.98)
|
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
}
|
|
private func hotkeySettingsPanel() -> some View {
|
VStack(alignment: .leading, spacing: 12) {
|
Text(tr("quickSearch.hotkeys"))
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.secondary)
|
|
CustomizableHotkeyInfoRow(
|
title: tr("quickSearch.mainHotkey"),
|
description: tr("quickSearch.mainHotkeyDesc"),
|
displayText: LauncherHotkeySettings.effectiveHotkey(for: .main).displayString,
|
statusText: hotkeyStatusText(for: .main),
|
statusTone: hotkeyStatusTone(for: .main),
|
customizeTitle: tr("hotkeys.customize"),
|
proBadgeTitle: tr("pro.card.badge"),
|
restoreTitle: tr("hotkeys.restoreDefault"),
|
isRecording: recordingHotkeyKind == .main,
|
isProLocked: !proEntitlement.isUnlocked,
|
canRestore: proEntitlement.isUnlocked && LauncherHotkeySettings.hasCustomHotkey(for: .main),
|
onCustomize: { startHotkeyRecording(for: .main) },
|
onRestore: { restoreDefaultHotkey(for: .main) }
|
)
|
|
StaticHotkeyInfoRow(
|
title: tr("quickSearch.internalHotkey"),
|
description: tr("quickSearch.internalHotkeyDesc"),
|
displayText: tr("quickSearch.spaceDisplay"),
|
statusText: tr("quickSearch.internalHotkeyStatus"),
|
statusTone: .neutral
|
)
|
|
CustomizableHotkeyInfoRow(
|
title: tr("quickSearch.globalHotkey"),
|
description: tr("quickSearch.globalHotkeyDesc"),
|
displayText: LauncherHotkeySettings.effectiveHotkey(for: .quickSearch).displayString,
|
statusText: hotkeyStatusText(for: .quickSearch),
|
statusTone: hotkeyStatusTone(for: .quickSearch),
|
customizeTitle: tr("hotkeys.customize"),
|
proBadgeTitle: tr("pro.card.badge"),
|
restoreTitle: tr("hotkeys.restoreDefault"),
|
isRecording: recordingHotkeyKind == .quickSearch,
|
isProLocked: !proEntitlement.isUnlocked,
|
canRestore: proEntitlement.isUnlocked && LauncherHotkeySettings.hasCustomHotkey(for: .quickSearch),
|
onCustomize: { startHotkeyRecording(for: .quickSearch) },
|
onRestore: { restoreDefaultHotkey(for: .quickSearch) }
|
)
|
|
StaticHotkeyInfoRow(
|
title: tr("trackpadGesture.title"),
|
description: tr("trackpadGesture.description"),
|
displayText: "taglauncher://show",
|
statusText: tr("trackpadGesture.status"),
|
statusTone: .neutral
|
)
|
}
|
.id(hotkeySettingsRefreshToken)
|
}
|
|
@ViewBuilder
|
private var themeProStatusRow: some View {
|
if proEntitlement.isUnlocked {
|
proUnlockedBenefitLine("pro.theme.status.unlocked", bottomPadding: 0)
|
} else {
|
HStack(spacing: 12) {
|
proHeaderStatusLabel()
|
|
Text(themeStatusSummary)
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.secondary)
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer(minLength: 0)
|
|
Button(unlockButtonTitle()) {
|
presentSettingsProPrompt(for: .premiumThemes)
|
}
|
.buttonStyle(.borderedProminent)
|
|
Button(tr("pro.card.restore")) {
|
proEntitlement.restorePurchases()
|
}
|
.buttonStyle(.bordered)
|
}
|
.padding(.horizontal, 16)
|
.padding(.vertical, 14)
|
.background(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.stroke(Color.secondary.opacity(0.16), lineWidth: 1)
|
)
|
}
|
}
|
|
private var settingsTabBar: some View {
|
HStack(spacing: 6) {
|
ForEach(SettingsTab.allCases) { tab in
|
settingsTabButton(tab)
|
}
|
}
|
.frame(height: 96, alignment: .center)
|
.frame(maxWidth: .infinity)
|
.background(Color(nsColor: .windowBackgroundColor))
|
}
|
|
private func settingsTabButton(_ tab: SettingsTab) -> some View {
|
let isSelected = selectedTab == tab
|
return Button {
|
selectedTab = tab
|
} label: {
|
VStack(spacing: 5) {
|
Image(systemName: tab.systemImage)
|
.font(.system(size: 24, weight: .regular))
|
.symbolRenderingMode(.hierarchical)
|
.frame(width: 30, height: 30)
|
Text(tr(tab.titleKey))
|
.font(.system(size: 12, weight: .semibold))
|
.lineLimit(2)
|
.multilineTextAlignment(.center)
|
.minimumScaleFactor(0.8)
|
}
|
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
.frame(width: 94, height: 72)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(isSelected ? Color.accentColor.opacity(0.10) : Color.clear)
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(isSelected ? Color.accentColor.opacity(0.20) : Color.clear, lineWidth: 1)
|
)
|
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
}
|
.buttonStyle(.plain)
|
.accessibilityLabel(tr(tab.titleKey))
|
.accessibilityIdentifier("settings-tab-\(tab.rawValue)")
|
}
|
|
var body: some View {
|
ZStack {
|
VStack(spacing: 0) {
|
settingsTabBar
|
Divider()
|
if shouldShowSettingsFixedProHeader {
|
settingsFixedProHeader
|
}
|
Group {
|
switch selectedTab {
|
case .language:
|
// Tab 1: Language
|
centeredSettingsScrollContent(width: languageContentWidth) {
|
LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) {
|
Button {
|
selectedLanguage = L10n.automaticCode
|
} label: {
|
HStack(spacing: 8) {
|
Image(systemName: selectedLanguage == L10n.automaticCode ? "checkmark" : "circle")
|
.font(.system(size: 12, weight: .semibold))
|
.foregroundStyle(selectedLanguage == L10n.automaticCode ? Color.accentColor : Color.secondary.opacity(0.28))
|
.frame(width: 16)
|
Text(tr("settings.language.auto"))
|
.foregroundStyle(.primary)
|
.lineLimit(1)
|
Text("(\(L10n.supported.first(where: { $0.code == L10n.currentCode })?.name ?? "English"))")
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
Spacer(minLength: 0)
|
}
|
.padding(.horizontal, 8)
|
.padding(.vertical, 5)
|
.contentShape(Rectangle())
|
}
|
.buttonStyle(.plain)
|
.accessibilityLabel("\(tr("settings.languagePicker")) \(tr("settings.language.auto"))")
|
|
ForEach(L10n.supported, id: \.code) { language in
|
Button {
|
selectedLanguage = language.code
|
} label: {
|
HStack(spacing: 8) {
|
Image(systemName: selectedLanguage == language.code ? "checkmark" : "circle")
|
.font(.system(size: 12, weight: .semibold))
|
.foregroundStyle(selectedLanguage == language.code ? Color.accentColor : Color.secondary.opacity(0.28))
|
.frame(width: 16)
|
Text(language.name)
|
.foregroundStyle(.primary)
|
.lineLimit(1)
|
.truncationMode(.tail)
|
Spacer(minLength: 0)
|
}
|
.padding(.horizontal, 8)
|
.padding(.vertical, 5)
|
.contentShape(Rectangle())
|
}
|
.buttonStyle(.plain)
|
.accessibilityLabel("\(tr("settings.languagePicker")) \(language.name)")
|
}
|
}
|
.padding(.vertical, 4)
|
}
|
|
case .general:
|
// Tab 2: General
|
ScrollView(.vertical, showsIndicators: true) {
|
VStack(alignment: .leading, spacing: 0) {
|
// Toggle row — centered as a rectangular block
|
HStack(spacing: 20) {
|
if AppDelegate.supportsLaunchAtLogin {
|
Toggle(tr("settings.launchAtLogin"), isOn: $launchAtLogin)
|
.onChange(of: launchAtLogin) { _, enabled in
|
if enabled {
|
AppDelegate.enableLaunchAtLogin()
|
} else {
|
AppDelegate.disableLaunchAtLogin()
|
}
|
}
|
}
|
Toggle(tr("settings.showInDock"), isOn: $showDockIcon)
|
.onChange(of: showDockIcon) { _, _ in
|
AppDelegate.refreshChromeSettings()
|
}
|
Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames)
|
Toggle(tr("settings.hideUsageTips"), isOn: $hideUsageTips)
|
.help(tr("settings.hideUsageTipsDesc"))
|
}
|
.frame(maxWidth: .infinity, alignment: .center)
|
.padding(.top, 12)
|
.padding(.bottom, 18)
|
|
Divider()
|
.padding(.bottom, 16)
|
|
VStack(spacing: 18) {
|
generalSettingRow(tr("settings.appListStyle"), description: tr("settings.flatDesc")) {
|
VStack(spacing: 8) {
|
DisplayModeOptionButton(
|
mode: "flat",
|
title: tr("settings.flat"),
|
isSelected: effectiveDisplayMode == "flat",
|
accessory: displayModeOptionAccessory(for: "flat")
|
) {
|
handleDisplayModeSelection("flat")
|
}
|
|
LazyVGrid(columns: displayModeColumns, alignment: .leading, spacing: 8) {
|
ForEach(containerDisplayModeOptions, id: \.id) { option in
|
DisplayModeOptionButton(
|
mode: option.id,
|
title: option.title,
|
isSelected: effectiveDisplayMode == option.id,
|
accessory: displayModeOptionAccessory(for: option.id)
|
) {
|
handleDisplayModeSelection(option.id)
|
}
|
}
|
}
|
}
|
.frame(width: generalControlWidth, alignment: .leading)
|
}
|
|
generalSettingRow(tr("settings.tagPosition"), description: tr("settings.tagPosDesc")) {
|
Picker("", selection: $tagPosition) {
|
Text(tr("settings.left")).tag("left")
|
Text(tr("settings.right")).tag("right")
|
Text(tr("settings.top")).tag("top")
|
}
|
.pickerStyle(.segmented)
|
.frame(width: compactPickerWidth, alignment: .leading)
|
}
|
|
generalSettingRow(tr("settings.tagFontSize"), description: tr("settings.tagFontDesc")) {
|
Picker("", selection: $tagFontSize) {
|
ForEach([16.0, 18.0, 20.0, 22.0, 24.0, 26.0], id: \.self) { size in
|
Text("\(Int(size))").tag(size)
|
}
|
}
|
.pickerStyle(.segmented)
|
.frame(width: compactPickerWidth, alignment: .leading)
|
}
|
|
generalSettingRow(tr("settings.iconSize"), description: tr("settings.iconSizeDesc")) {
|
Picker("", selection: $iconSize) {
|
ForEach([40.0, 48.0, 56.0, 64.0, 72.0, 80.0], id: \.self) { size in
|
Text("\(Int(size))").tag(size)
|
}
|
}
|
.pickerStyle(.segmented)
|
.frame(width: compactPickerWidth, alignment: .leading)
|
}
|
}
|
}
|
.frame(maxWidth: generalContentWidth, alignment: .center)
|
.padding(.horizontal)
|
.padding(.top, 28)
|
.padding(.bottom)
|
}
|
|
case .theme:
|
ScrollView(.vertical, showsIndicators: true) {
|
VStack(alignment: .leading, spacing: 18) {
|
LazyVGrid(columns: themeColumns, alignment: .leading, spacing: 12) {
|
ForEach(AppGridTheme.settingsDisplayOrder) { theme in
|
ThemeOptionButton(
|
theme: theme,
|
title: tr(theme.titleKey),
|
isSelected: effectiveAppGridTheme == theme,
|
previewTuning: currentThemeTuning(for: theme),
|
accessory: themeOptionAccessory(for: theme)
|
) {
|
handleThemeSelection(theme)
|
}
|
}
|
}
|
|
themeColorTuningSection
|
}
|
.frame(maxWidth: generalContentWidth, alignment: .leading)
|
.padding(.horizontal)
|
.padding(.top, 28)
|
.padding(.bottom)
|
}
|
|
case .hotkeys:
|
// Tab 3: Hotkeys
|
ScrollView(.vertical, showsIndicators: true) {
|
VStack(alignment: .leading, spacing: 16) {
|
hotkeySettingsPanel()
|
.frame(width: generalContentWidth, alignment: .leading)
|
}
|
.frame(maxWidth: generalContentWidth, alignment: .center)
|
.padding()
|
}
|
|
case .tags:
|
// Tab 4: Tags
|
VStack(spacing: 0) {
|
TagEditorView(
|
tagColors: $tagColors,
|
tagCustomColors: $tagCustomColors,
|
excludedTagNames: ["Mac自带", defaultGroupName],
|
isCustomColorUnlocked: proEntitlement.isUnlocked,
|
onLockedCustomColor: { presentSettingsProPrompt(for: .customTagColors) },
|
onCustomContainerQuotaExceeded: presentSettingsCustomContainerQuotaPrompt,
|
onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus,
|
topLeadingAccessory: AnyView(customContainerQuotaAccessory),
|
onRefresh: {
|
refreshSettingsCustomContainerQuotaStatus()
|
scanApps()
|
}
|
)
|
}
|
.onAppear {
|
refreshSettingsCustomContainerQuotaStatus()
|
scanApps()
|
}
|
|
case .data:
|
// Tab 5: Data
|
ScrollView(.vertical, showsIndicators: true) {
|
VStack(spacing: 18) {
|
VStack(alignment: .center, spacing: 24) {
|
dataSection(minHeight: 124) {
|
HStack(alignment: .center, spacing: 12) {
|
dataSectionTitle(tr("settings.initialLayout"))
|
|
HStack(spacing: 22) {
|
initialLayoutOption(
|
tr("settings.initialLayoutUncategorized"),
|
mode: .uncategorized
|
)
|
initialLayoutOption(
|
tr("settings.initialLayoutSmart"),
|
mode: .smart
|
)
|
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
.layoutPriority(1)
|
|
dataActionButton(
|
tr("settings.applyScheme"),
|
prominent: true,
|
enabled: !isApplyingSystemScheme && !isResettingToUncategorized,
|
action: applySelectedInitialLayout
|
)
|
.frame(width: dataActionWidth, alignment: .trailing)
|
.layoutPriority(2)
|
}
|
}
|
|
dataSection(minHeight: 190) {
|
HStack(alignment: .top, spacing: 12) {
|
dataSectionTitle(tr("settings.customLayout"))
|
.padding(.top, 4)
|
|
VStack(alignment: .leading, spacing: 10) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
Text(tr("settings.previousScheme"))
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
Text(previousCategorySchemeName)
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(canRestorePreviousScheme ? .primary : .tertiary)
|
.lineLimit(1)
|
.truncationMode(.middle)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
}
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
Text(tr("settings.currentScheme"))
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
Text(currentCategorySchemeName)
|
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.primary)
|
.lineLimit(1)
|
.truncationMode(.middle)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
}
|
|
Divider().opacity(0.35)
|
.padding(.top, 2)
|
|
VStack(alignment: .center, spacing: 8) {
|
HStack(spacing: 12) {
|
Button {
|
exportTags()
|
} label: {
|
HStack(spacing: 8) {
|
Text(tr("settings.export"))
|
ProStatusPill(text: tr("pro.card.badge"), style: .locked, compact: true)
|
}
|
}
|
.buttonStyle(.bordered)
|
.disabled(isDataFilePanelPresented)
|
Button {
|
importTags()
|
} label: {
|
HStack(spacing: 8) {
|
Text(tr("settings.import"))
|
ProStatusPill(text: tr("pro.card.badge"), style: .locked, compact: true)
|
}
|
}
|
.buttonStyle(.bordered)
|
.disabled(isDataFilePanelPresented)
|
}
|
Text(tr("settings.backupDesc"))
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.multilineTextAlignment(.center)
|
.fixedSize(horizontal: false, vertical: true)
|
.frame(maxWidth: dataPanelWidth - dataLabelWidth - dataActionWidth - 92, alignment: .center)
|
}
|
.frame(maxWidth: .infinity, alignment: .center)
|
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
.layoutPriority(1)
|
|
dataActionButton(
|
tr("settings.restorePreviousScheme"),
|
prominent: false,
|
enabled: canRestorePreviousScheme,
|
action: restorePreviousCategoryScheme
|
)
|
.frame(width: dataActionWidth, alignment: .trailing)
|
.layoutPriority(2)
|
}
|
}
|
}
|
.frame(width: dataPanelWidth, alignment: .center)
|
|
HStack(alignment: .center, spacing: 14) {
|
Text(tr("settings.bubbleDisplayScope"))
|
.font(.system(size: 13, weight: .medium))
|
.frame(width: 190, alignment: .trailing)
|
|
HStack(spacing: 20) {
|
bubbleScopeOption(
|
tr("settings.bubbleAllApps"),
|
isSelected: !showUncommonAppBubbles
|
) {
|
showUncommonAppBubbles = false
|
}
|
bubbleScopeOption(
|
tr("settings.bubbleUncommonOnly"),
|
isSelected: showUncommonAppBubbles
|
) {
|
showUncommonAppBubbles = true
|
}
|
}
|
.frame(width: 300, alignment: .leading)
|
}
|
.frame(width: 520, alignment: .center)
|
}
|
.frame(maxWidth: dataPanelWidth, alignment: .center)
|
.padding()
|
}
|
.onAppear { refreshDataState() }
|
|
case .pro:
|
// Tab 6: Pro
|
centeredSettingsScrollContent(width: dataPanelWidth) {
|
proTabComparisonTable(width: dataPanelWidth)
|
}
|
|
case .about:
|
// Tab 7: About
|
centeredSettingsScrollContent(width: dataPanelWidth) {
|
HStack(alignment: .center, spacing: 24) {
|
if let icon = NSImage(named: NSImage.applicationIconName) {
|
Image(nsImage: icon)
|
.resizable()
|
.frame(width: 112, height: 112)
|
}
|
VStack(alignment: .leading, spacing: 4) {
|
Text("TagLauncher")
|
.font(.title2)
|
.fontWeight(.semibold)
|
Text(tr("app.description"))
|
.font(.body)
|
.foregroundStyle(.secondary)
|
Text("\(tr("app.version")) \(appVersion) (\(tr("app.build")) \(buildVersion))")
|
.font(.callout)
|
.foregroundStyle(.tertiary)
|
|
Divider()
|
.padding(.vertical, 4)
|
|
HStack(spacing: 8) {
|
Button {
|
NSWorkspace.shared.open(helpPDFURL)
|
} label: {
|
Label(tr("help.openPDF"), systemImage: "questionmark.circle")
|
}
|
.buttonStyle(.borderedProminent)
|
.controlSize(.regular)
|
|
Button {
|
NSPasteboard.general.clearContents()
|
NSPasteboard.general.setString(helpPDFURL.absoluteString, forType: .string)
|
} label: {
|
Image(systemName: "link")
|
}
|
.buttonStyle(.bordered)
|
.controlSize(.regular)
|
.help(tr("help.copyLink"))
|
}
|
|
Text(tr("help.currentLanguage"))
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.padding(.bottom, 2)
|
|
Text("万物之ä¸ï¼Œå¸Œæœ›æœ€ç¾Ž")
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
Text("永桔@shanghai3168@gmail.com")
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
}
|
.frame(maxWidth: 520, alignment: .leading)
|
}
|
}
|
}
|
}
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}
|
.onChange(of: selectedLanguage) { _, code in
|
L10n.switchSelection(to: code)
|
}
|
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { notification in
|
if let code = notification.userInfo?["code"] as? String {
|
selectedLanguage = code
|
} else {
|
syncSelectedLanguage()
|
}
|
refreshPreferencesWindowTitle()
|
scanApps()
|
refreshDataState()
|
showLanguageRefresh()
|
}
|
.onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
|
refreshDataState()
|
}
|
.onChange(of: proEntitlement.accessState) { _, newState in
|
handleProAccessStateChange(newState)
|
}
|
.onChange(of: selectedTab) { _, newTab in
|
if newTab != .hotkeys {
|
stopHotkeyRecording(showCancelledToast: false)
|
}
|
}
|
|
if isRefreshingLanguage {
|
ZStack {
|
Color(nsColor: .windowBackgroundColor)
|
.opacity(0.84)
|
Image(systemName: "arrow.clockwise")
|
.font(.system(size: 30, weight: .regular))
|
.symbolRenderingMode(.hierarchical)
|
.foregroundStyle(.secondary)
|
}
|
.transition(.opacity)
|
}
|
|
if showApplySystemSchemeConfirmation {
|
SettingsConfirmationView(
|
title: tr("settings.applySystemSchemeWarningTitle"),
|
message: tr("settings.applySystemSchemeWarningMessage"),
|
confirmTitle: tr("settings.confirmApplyScheme"),
|
cancelTitle: tr("settings.cancel"),
|
onConfirm: performApplySystemInitialScheme,
|
onCancel: {
|
withAnimation(.easeOut(duration: 0.14)) {
|
showApplySystemSchemeConfirmation = false
|
}
|
}
|
)
|
.zIndex(3)
|
.transition(.opacity)
|
}
|
|
if showResetToUncategorizedConfirmation {
|
SettingsConfirmationView(
|
title: tr("settings.resetToUncategorizedWarningTitle"),
|
message: tr("settings.resetToUncategorizedWarningMessage"),
|
confirmTitle: tr("settings.confirmResetToUncategorized"),
|
cancelTitle: tr("settings.cancel"),
|
onConfirm: performResetToUncategorized,
|
onCancel: {
|
withAnimation(.easeOut(duration: 0.14)) {
|
showResetToUncategorizedConfirmation = false
|
}
|
}
|
)
|
.zIndex(3)
|
.transition(.opacity)
|
}
|
|
if let feature = settingsProPromptFeature {
|
ZStack {
|
Color.black.opacity(0.16)
|
.ignoresSafeArea()
|
|
ProUpgradePromptView(
|
title: tr(feature.titleKey),
|
message: settingsProPromptMessage(for: feature),
|
benefitText: tr(feature.benefitKey),
|
operationText: proEntitlement.operationState.statusMessageKey.map(tr),
|
unlockTitle: unlockButtonTitle(),
|
restoreTitle: settingsProPromptSecondaryTitle(for: feature),
|
isBusy: proEntitlement.operationState.isBusy,
|
onClose: dismissSettingsProPrompt,
|
onUnlock: { proEntitlement.purchasePro() },
|
onRestore: { handleSettingsProPromptSecondaryAction(for: feature) }
|
)
|
}
|
.zIndex(4)
|
.transition(.opacity)
|
}
|
|
if let hotkeyStatusToast {
|
VStack {
|
Spacer()
|
Text(hotkeyStatusToast)
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.white)
|
.multilineTextAlignment(.center)
|
.lineSpacing(2)
|
.padding(.horizontal, 18)
|
.padding(.vertical, 12)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(Color.black.opacity(0.92))
|
)
|
.shadow(color: .black.opacity(0.28), radius: 18, y: 10)
|
.frame(maxWidth: 620)
|
.padding(.bottom, 22)
|
}
|
.zIndex(5)
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
}
|
}
|
.frame(width: settingsWindowWidth, height: settingsWindowHeight)
|
.onAppear {
|
syncSelectedLanguage()
|
refreshPreferencesWindowTitle()
|
installSettingsEscapeMonitor()
|
showPendingHotkeyWarningIfNeeded()
|
refreshThemePreviewCountdown()
|
}
|
.onDisappear {
|
removeSettingsEscapeMonitor()
|
stopHotkeyRecording()
|
}
|
.onReceive(themePreviewCountdownTimer) { now in
|
refreshThemePreviewCountdown(now: now)
|
}
|
.onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in
|
hotkeySettingsRefreshToken = UUID()
|
showPendingHotkeyWarningIfNeeded()
|
}
|
.onReceive(NotificationCenter.default.publisher(for: .tagLauncherPreferencesTabRequested)) { notification in
|
selectTab(rawValue: notification.userInfo?[SettingsTabTarget.userInfoKey] as? String)
|
}
|
}
|
|
private func showLanguageRefresh() {
|
isRefreshingLanguage = true
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
withAnimation(.easeOut(duration: 0.18)) {
|
isRefreshingLanguage = false
|
}
|
}
|
}
|
}
|
|
private struct ProTabComparisonFeature: Identifiable {
|
var id: String { title }
|
let title: String
|
let freeStatusKey: String
|
let proLockedStatusKey: String
|
let proUnlockedStatusKey: String
|
let freeStyle: ProStatusPillStyle
|
let proLockedStyle: ProStatusPillStyle
|
let proUnlockedStyle: ProStatusPillStyle
|
}
|
|
private enum HotkeyStatusTone {
|
case active
|
case warning
|
case neutral
|
|
var foreground: Color {
|
switch self {
|
case .active: return Color.green
|
case .warning: return Color.orange
|
case .neutral: return Color.secondary
|
}
|
}
|
|
var background: Color {
|
foreground.opacity(0.13)
|
}
|
}
|
|
private struct StaticHotkeyInfoRow: View {
|
let title: String
|
let description: String
|
let displayText: String
|
let statusText: String
|
let statusTone: HotkeyStatusTone
|
|
var body: some View {
|
HStack(alignment: .center, spacing: 18) {
|
VStack(alignment: .leading, spacing: 3) {
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
Text(description)
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.fixedSize(horizontal: false, vertical: true)
|
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
HStack(alignment: .center, spacing: 12) {
|
Text(displayText)
|
.font(.system(size: 15, weight: .semibold))
|
.lineLimit(1)
|
.minimumScaleFactor(0.85)
|
.frame(width: 156, alignment: .leading)
|
|
HotkeyStatusPill(text: statusText, tone: statusTone)
|
}
|
.frame(width: 310, alignment: .leading)
|
}
|
.padding(12)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(Color.secondary.opacity(0.14), lineWidth: 1)
|
)
|
}
|
}
|
|
private struct CustomizableHotkeyInfoRow: View {
|
let title: String
|
let description: String
|
let displayText: String
|
let statusText: String
|
let statusTone: HotkeyStatusTone
|
let customizeTitle: String
|
let proBadgeTitle: String
|
let restoreTitle: String
|
let isRecording: Bool
|
let isProLocked: Bool
|
let canRestore: Bool
|
let onCustomize: () -> Void
|
let onRestore: () -> Void
|
|
var body: some View {
|
HStack(alignment: .center, spacing: 18) {
|
VStack(alignment: .leading, spacing: 3) {
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
Text(description)
|
.font(.caption)
|
.foregroundStyle(.secondary)
|
.fixedSize(horizontal: false, vertical: true)
|
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
HStack(alignment: .center, spacing: 12) {
|
VStack(alignment: .leading, spacing: 6) {
|
Text(isRecording ? "…" : displayText)
|
.font(.system(size: 15, weight: .semibold))
|
.lineLimit(1)
|
.minimumScaleFactor(0.85)
|
|
HStack(spacing: 6) {
|
HotkeyStatusPill(text: statusText, tone: statusTone)
|
|
if isRecording {
|
Text(tr("hotkeys.recordingInline"))
|
.font(.system(size: 11, weight: .semibold))
|
.foregroundStyle(.blue)
|
}
|
|
if !isProLocked {
|
ProStatusPill(text: proBadgeTitle, style: .locked, compact: true)
|
}
|
}
|
}
|
.frame(width: 156, alignment: .leading)
|
|
HotkeyPrimaryActionButton(
|
title: customizeTitle,
|
proBadgeTitle: proBadgeTitle,
|
isProLocked: isProLocked,
|
action: onCustomize
|
)
|
|
if canRestore {
|
HotkeySecondaryActionButton(
|
title: restoreTitle,
|
action: onRestore
|
)
|
}
|
}
|
.frame(width: 310, alignment: .leading)
|
}
|
.padding(12)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(Color.secondary.opacity(0.14), lineWidth: 1)
|
)
|
}
|
}
|
|
private struct HotkeyStatusPill: View {
|
let text: String
|
let tone: HotkeyStatusTone
|
|
var body: some View {
|
Text(text)
|
.font(.system(size: 11, weight: .semibold))
|
.foregroundStyle(tone.foreground)
|
.lineLimit(1)
|
.padding(.horizontal, 8)
|
.padding(.vertical, 3)
|
.background(Capsule().fill(tone.background))
|
}
|
}
|
|
private struct HotkeyPrimaryActionButton: View {
|
let title: String
|
let proBadgeTitle: String
|
let isProLocked: Bool
|
let action: () -> Void
|
|
var body: some View {
|
if isProLocked {
|
Button(action: action) {
|
HStack(spacing: 8) {
|
Text(title)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
|
ProStatusPill(text: proBadgeTitle, style: .locked, compact: true)
|
}
|
}
|
.buttonStyle(.bordered)
|
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
} else {
|
Button(action: action) {
|
Text(title)
|
.font(.system(size: 11, weight: .semibold))
|
.foregroundStyle(Color.white)
|
.lineLimit(1)
|
.padding(.horizontal, 12)
|
.padding(.vertical, 6)
|
.background(
|
Capsule(style: .continuous)
|
.fill(Color.accentColor)
|
)
|
}
|
.buttonStyle(.plain)
|
.contentShape(Capsule(style: .continuous))
|
}
|
}
|
}
|
|
private struct HotkeySecondaryActionButton: View {
|
let title: String
|
let action: () -> Void
|
|
var body: some View {
|
Button(action: action) {
|
Text(title)
|
.font(.system(size: 11, weight: .semibold))
|
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
.padding(.horizontal, 12)
|
.padding(.vertical, 6)
|
.background(
|
Capsule(style: .continuous)
|
.fill(Color.secondary.opacity(0.12))
|
)
|
}
|
.buttonStyle(.plain)
|
.contentShape(Capsule(style: .continuous))
|
}
|
}
|
|
private struct SettingsConfirmationView: View {
|
let title: String
|
let message: String
|
let confirmTitle: String
|
let cancelTitle: String
|
let onConfirm: () -> Void
|
let onCancel: () -> Void
|
|
var body: some View {
|
ZStack {
|
Color.black.opacity(0.20)
|
.contentShape(Rectangle())
|
.onTapGesture { }
|
|
VStack(alignment: .leading, spacing: 20) {
|
HStack(alignment: .top, spacing: 12) {
|
ZStack {
|
Circle()
|
.fill(Color.orange.opacity(0.16))
|
.frame(width: 38, height: 38)
|
Image(systemName: "exclamationmark.triangle.fill")
|
.font(.system(size: 19, weight: .semibold))
|
.foregroundStyle(Color.orange)
|
}
|
.frame(width: 40, height: 40)
|
|
VStack(alignment: .leading, spacing: 8) {
|
Text(title)
|
.font(.system(size: 16, weight: .semibold))
|
.foregroundStyle(.primary)
|
.fixedSize(horizontal: false, vertical: true)
|
Text(message)
|
.font(.system(size: 13))
|
.foregroundStyle(Color.primary.opacity(0.78))
|
.lineSpacing(2)
|
.fixedSize(horizontal: false, vertical: true)
|
}
|
}
|
|
HStack(spacing: 10) {
|
Spacer()
|
ConfirmationActionButton(
|
title: cancelTitle,
|
prominent: false,
|
action: onCancel
|
)
|
.keyboardShortcut(.cancelAction)
|
ConfirmationActionButton(
|
title: confirmTitle,
|
prominent: true,
|
action: onConfirm
|
)
|
.keyboardShortcut(.defaultAction)
|
}
|
}
|
.padding(22)
|
.frame(width: 440)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(Color(nsColor: .windowBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(Color.primary.opacity(0.16), lineWidth: 1)
|
)
|
.shadow(color: .black.opacity(0.24), radius: 20, x: 0, y: 10)
|
}
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}
|
}
|
|
private struct ConfirmationActionButton: View {
|
let title: String
|
let prominent: Bool
|
let action: () -> Void
|
|
var body: some View {
|
Button(action: action) {
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(prominent ? Color.white : Color.primary)
|
.lineLimit(1)
|
.minimumScaleFactor(0.86)
|
.frame(minWidth: 82, minHeight: 32)
|
.padding(.horizontal, 10)
|
.background(
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
.fill(prominent ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
.stroke(
|
prominent ? Color.accentColor.opacity(0.40) : Color.primary.opacity(0.16),
|
lineWidth: 1
|
)
|
)
|
}
|
.buttonStyle(.plain)
|
.contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
}
|
}
|
|
private enum ThemeOptionAccessory {
|
case checkmark
|
case circle
|
case countdown(text: String)
|
case pill(text: String, style: ProStatusPillStyle)
|
}
|
|
private struct ThemeOptionButton: View {
|
let theme: AppGridTheme
|
let title: String
|
let isSelected: Bool
|
let previewTuning: Double
|
let accessory: ThemeOptionAccessory
|
let action: () -> Void
|
|
var body: some View {
|
Button(action: action) {
|
HStack(spacing: 12) {
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(
|
LinearGradient(
|
colors: theme.previewColors(tuning: previewTuning),
|
startPoint: .topLeading,
|
endPoint: .bottomTrailing
|
)
|
)
|
.frame(width: 70, height: 42)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(previewStrokeColor, lineWidth: 1)
|
)
|
.shadow(
|
color: isSelected ? Color.accentColor.opacity(0.22) : Color.clear,
|
radius: 10,
|
x: 0,
|
y: 4
|
)
|
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
.foregroundStyle(.primary)
|
.lineLimit(1)
|
.minimumScaleFactor(0.82)
|
|
Spacer(minLength: 0)
|
|
accessoryView
|
}
|
.padding(10)
|
.frame(maxWidth: .infinity, minHeight: 64)
|
.background(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.fill(isSelected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
.stroke(isSelected ? Color.accentColor.opacity(0.32) : Color.secondary.opacity(0.16), lineWidth: 1)
|
)
|
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
}
|
.buttonStyle(.plain)
|
.accessibilityLabel(title)
|
.accessibilityIdentifier("theme-option-\(theme.rawValue)")
|
}
|
|
private var previewStrokeColor: Color {
|
theme.isDefaultLight
|
? Color.secondary.opacity(0.26)
|
: Color.white.opacity(0.24)
|
}
|
|
@ViewBuilder
|
private var accessoryView: some View {
|
switch accessory {
|
case .checkmark:
|
Image(systemName: "checkmark.circle.fill")
|
.font(.system(size: 16, weight: .semibold))
|
.foregroundStyle(Color.accentColor)
|
case .circle:
|
Image(systemName: "circle")
|
.font(.system(size: 16, weight: .semibold))
|
.foregroundStyle(Color.secondary.opacity(0.42))
|
case .countdown(let text):
|
Text(text)
|
.font(.system(size: 10, weight: .semibold, design: .monospaced))
|
.foregroundStyle(Color(red: 0.30, green: 0.22, blue: 0.08))
|
.lineLimit(1)
|
.frame(minWidth: 42)
|
.padding(.horizontal, 8)
|
.padding(.vertical, 4)
|
.background(
|
Capsule(style: .continuous)
|
.fill(Color.orange.opacity(0.14))
|
)
|
.overlay(
|
Capsule(style: .continuous)
|
.stroke(Color.orange.opacity(0.28), lineWidth: 1)
|
)
|
case .pill(let text, let style):
|
ProStatusPill(text: text, style: style, compact: true)
|
}
|
}
|
}
|
|
private struct DisplayModeOptionButton: View {
|
let mode: String
|
let title: String
|
let isSelected: Bool
|
let accessory: ThemeOptionAccessory
|
let action: () -> Void
|
|
var body: some View {
|
Button(action: action) {
|
HStack(spacing: 8) {
|
DisplayModeGlyph(mode: mode, isSelected: isSelected)
|
.frame(width: 24, height: 18)
|
Text(title)
|
.font(.system(size: 13, weight: .semibold))
|
.multilineTextAlignment(.leading)
|
.lineLimit(2)
|
.minimumScaleFactor(0.82)
|
.foregroundStyle(isSelected ? Color.white : Color.primary)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
.layoutPriority(1)
|
|
accessoryView
|
.frame(width: 64, alignment: .trailing)
|
}
|
.frame(maxWidth: .infinity, minHeight: 38)
|
.padding(.horizontal, 9)
|
.padding(.vertical, 2)
|
.background(
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
.fill(isSelected ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
.stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.18), lineWidth: 1)
|
)
|
.contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
}
|
.buttonStyle(.plain)
|
.accessibilityLabel(title)
|
}
|
|
@ViewBuilder
|
private var accessoryView: some View {
|
switch accessory {
|
case .checkmark:
|
Image(systemName: "checkmark.circle.fill")
|
.font(.system(size: 16, weight: .semibold))
|
.foregroundStyle(isSelected ? Color.white : Color.accentColor)
|
case .circle:
|
Image(systemName: "circle")
|
.font(.system(size: 16, weight: .semibold))
|
.foregroundStyle(isSelected ? Color.white.opacity(0.74) : Color.secondary.opacity(0.42))
|
.opacity(0.001)
|
case .countdown(let text):
|
Text(text)
|
.font(.system(size: 10, weight: .semibold, design: .monospaced))
|
.foregroundStyle(Color(red: 0.30, green: 0.22, blue: 0.08))
|
.lineLimit(1)
|
.frame(minWidth: 42)
|
.padding(.horizontal, 8)
|
.padding(.vertical, 4)
|
.background(
|
Capsule(style: .continuous)
|
.fill(Color.orange.opacity(0.14))
|
)
|
.overlay(
|
Capsule(style: .continuous)
|
.stroke(Color.orange.opacity(0.28), lineWidth: 1)
|
)
|
case .pill(let text, let style):
|
ProStatusPill(text: text, style: style, compact: true)
|
}
|
}
|
}
|
|
private struct DisplayModeGlyph: View {
|
let mode: String
|
let isSelected: Bool
|
|
var body: some View {
|
switch mode {
|
case "flat":
|
HStack(spacing: 2) {
|
cell(index: 0, width: 5, height: 5, colored: false)
|
cell(index: 1, width: 5, height: 5, colored: false)
|
cell(index: 2, width: 5, height: 5, colored: false)
|
}
|
case "gridContainer", "coloredGridContainer":
|
let colored = mode == "coloredGridContainer"
|
VStack(spacing: 2) {
|
HStack(spacing: 2) {
|
cell(index: 0, width: 7, height: 6, colored: colored)
|
cell(index: 1, width: 7, height: 6, colored: colored)
|
}
|
HStack(spacing: 2) {
|
cell(index: 2, width: 7, height: 6, colored: colored)
|
cell(index: 3, width: 7, height: 6, colored: colored)
|
}
|
}
|
default:
|
let colored = mode == "coloredContainer"
|
HStack(alignment: .bottom, spacing: 2) {
|
cell(index: 0, width: 5, height: 8, colored: colored)
|
cell(index: 1, width: 5, height: 14, colored: colored)
|
cell(index: 2, width: 5, height: 10, colored: colored)
|
}
|
}
|
}
|
|
private func cell(index: Int, width: CGFloat, height: CGFloat, colored: Bool) -> some View {
|
let fill = fillColor(index: index, colored: colored)
|
let stroke = isSelected ? Color.white.opacity(0.95) : Color.secondary.opacity(0.35)
|
return RoundedRectangle(cornerRadius: 1.8, style: .continuous)
|
.fill(fill)
|
.overlay(
|
RoundedRectangle(cornerRadius: 1.8, style: .continuous)
|
.stroke(stroke, lineWidth: 0.8)
|
)
|
.frame(width: width, height: height)
|
}
|
|
private func fillColor(index: Int, colored: Bool) -> Color {
|
if isSelected {
|
return Color.white.opacity(colored ? 0.9 : 0.18)
|
}
|
guard colored else {
|
return Color.secondary.opacity(0.12)
|
}
|
let palette: [Color] = [.green, .purple, .blue, .orange]
|
return palette[index % palette.count].opacity(0.78)
|
}
|
}
|