| | |
| | | } |
| | | } |
| | | |
| | | 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 generalContentWidth: CGFloat = 720 |
| | | private let generalLabelWidth: CGFloat = 190 |
| | | private let generalControlWidth: CGFloat = 500 |
| | | private let compactPickerWidth: CGFloat = 320 |
| | | private let dataPanelWidth: CGFloat = 760 |
| | | private let dataLabelWidth: CGFloat = 220 |
| | | private let dataActionWidth: CGFloat = 112 |
| | | 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 |
| | |
| | | @State private var tagColors: [String: Int] = [:] |
| | | @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 selectedTab: SettingsTab = .general |
| | | @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart |
| | | |
| | | private func scanApps() { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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") |
| | | ) |
| | | } |
| | | } |
| | | } |
| | |
| | | .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() { |
| | |
| | | } |
| | | |
| | | private var settingsTabBar: some View { |
| | | HStack(spacing: 18) { |
| | | HStack(spacing: 8) { |
| | | ForEach(SettingsTab.allCases) { tab in |
| | | settingsTabButton(tab) |
| | | } |
| | | } |
| | | .frame(height: 104, alignment: .center) |
| | | .frame(height: 96, alignment: .center) |
| | | .frame(maxWidth: .infinity) |
| | | .background(Color(nsColor: .windowBackgroundColor)) |
| | | } |
| | |
| | | .frame(width: 32, height: 32) |
| | | Text(tr(tab.titleKey)) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .lineLimit(2) |
| | | .multilineTextAlignment(.center) |
| | | .minimumScaleFactor(0.8) |
| | | } |
| | | .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) |
| | | .frame(width: 72, height: 70) |
| | | .frame(width: 108, height: 74) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(isSelected ? Color.accentColor.opacity(0.10) : Color.clear) |
| | |
| | | case .data: |
| | | // Tab 5: Data |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 32) |
| | | Spacer(minLength: 36) |
| | | |
| | | VStack(alignment: .center, spacing: 0) { |
| | | VStack(alignment: .leading, spacing: 12) { |
| | | VStack(alignment: .center, spacing: 24) { |
| | | dataSection(minHeight: 124) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(tr("settings.systemScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | dataSectionTitle(tr("settings.initialLayout")) |
| | | |
| | | Text(SmartStartService.systemInitialSchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | HStack(spacing: 22) { |
| | | initialLayoutOption( |
| | | tr("settings.initialLayoutUncategorized"), |
| | | mode: .uncategorized |
| | | ) |
| | | initialLayoutOption( |
| | | tr("settings.initialLayoutSmart"), |
| | | mode: .smart |
| | | ) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(0) |
| | | .layoutPriority(1) |
| | | |
| | | dataActionButton( |
| | | tr("settings.applyScheme"), |
| | | prominent: true, |
| | | enabled: !isApplyingSystemScheme, |
| | | action: applySystemInitialScheme |
| | | enabled: !isApplyingSystemScheme && !isResettingToUncategorized, |
| | | action: applySelectedInitialLayout |
| | | ) |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | } |
| | | |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(tr("settings.previousScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | Text(previousCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(canRestorePreviousScheme ? .primary : .tertiary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(0) |
| | | 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(tr("settings.export")) { exportTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button(tr("settings.import")) { importTags() } |
| | | .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"), |
| | |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | |
| | | HStack(alignment: .firstTextBaseline, spacing: 12) { |
| | | Text(tr("settings.currentScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | Text(currentCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | Color.clear |
| | | .frame(width: dataActionWidth, height: 1) |
| | | } |
| | | |
| | | Divider().opacity(0.35) |
| | | |
| | | VStack(alignment: .center, spacing: 8) { |
| | | HStack(spacing: 12) { |
| | | Button(tr("settings.export")) { exportTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button(tr("settings.import")) { importTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | } |
| | | Text(tr("settings.backupDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(maxWidth: dataPanelWidth - 72, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | } |
| | | .padding(.horizontal, 20) |
| | | .padding(.vertical, 16) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | .frame(width: dataPanelWidth, alignment: .center) |
| | | |
| | | Spacer(minLength: 22) |
| | | Spacer(minLength: 30) |
| | | |
| | | HStack(alignment: .center, spacing: 14) { |
| | | Text(tr("settings.bubbleDisplayScope")) |
| | |
| | | } |
| | | |
| | | if showApplySystemSchemeConfirmation { |
| | | ApplySystemSchemeConfirmationView( |
| | | SettingsConfirmationView( |
| | | title: tr("settings.applySystemSchemeWarningTitle"), |
| | | message: tr("settings.applySystemSchemeWarningMessage"), |
| | | confirmTitle: tr("settings.confirmApplyScheme"), |
| | |
| | | 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 |
| | | } |
| | | } |
| | | ) |
| | |
| | | .transition(.opacity.combined(with: .move(edge: .bottom))) |
| | | } |
| | | } |
| | | .frame(width: settingsWindowWidth, height: 480) |
| | | .frame(width: settingsWindowWidth, height: settingsWindowHeight) |
| | | .onAppear { |
| | | syncSelectedLanguage() |
| | | showPendingHotkeyWarningIfNeeded() |
| | |
| | | } |
| | | } |
| | | |
| | | private struct ApplySystemSchemeConfirmationView: View { |
| | | private struct SettingsConfirmationView: View { |
| | | let title: String |
| | | let message: String |
| | | let confirmTitle: String |