diff --git a/Sources/ProcessBarMonitor/MonitorViewModel.swift b/Sources/ProcessBarMonitor/MonitorViewModel.swift index df4dd52..9d0f744 100644 --- a/Sources/ProcessBarMonitor/MonitorViewModel.swift +++ b/Sources/ProcessBarMonitor/MonitorViewModel.swift @@ -64,6 +64,16 @@ final class MonitorViewModel: ObservableObject { } } } + @Published var displayTemplate: MenuBarDisplayTemplate { + didSet { + settings.set(displayTemplate.rawValue, forKey: Keys.displayTemplate) + } + } + @Published var moduleVisibility: PopupModuleVisibility { + didSet { + settings.set(moduleVisibility.rawValue, forKey: Keys.moduleVisibility) + } + } private var currentSummaryRefreshInterval: UInt64 = 2_000_000_000 private var currentProcessRefreshInterval: TimeInterval = 10 @@ -82,6 +92,8 @@ final class MonitorViewModel: ObservableObject { static let temperatureMode = "temperatureMode" static let menuBarDisplayMode = "menuBarDisplayMode" static let refreshRatePreset = "refreshRatePreset" + static let displayTemplate = "displayTemplate" + static let moduleVisibility = "moduleVisibility" } init(defaults: UserDefaults = .standard) { @@ -111,6 +123,24 @@ final class MonitorViewModel: ObservableObject { } else { refreshRatePreset = .balanced } + + if let rawDisplayTemplate = settings.string(forKey: Keys.displayTemplate), + let parsedTemplate = MenuBarDisplayTemplate(savedValue: rawDisplayTemplate) { + displayTemplate = parsedTemplate + } else { + displayTemplate = .standard + } + + if let savedRaw = settings.int(forKey: Keys.moduleVisibility) { + let visibility = PopupModuleVisibility(rawValue: savedRaw) + if visibility.isEmpty || (visibility.rawValue & ~PopupModuleVisibility.all.rawValue) != 0 { + moduleVisibility = .defaultVisibility + } else { + moduleVisibility = visibility + } + } else { + moduleVisibility = .defaultVisibility + } } var menuBarTitle: String { @@ -118,12 +148,12 @@ final class MonitorViewModel: ObservableObject { let mem = String(format: "%.0f%%", summary.memoryPressurePercent) let temp = summary.cpuTemperatureC.map { String(format: "%.0f°", $0) } ?? "--°" - switch menuBarDisplayMode { - case .compact: + switch displayTemplate { + case .minimal: return L10n.format("menu_bar_title.compact", cpu, mem, temp) - case .labeled: + case .standard: return L10n.format("menu_bar_title.labeled", cpu, mem, temp) - case .temperatureFirst: + case .detailed: return L10n.format("menu_bar_title.temperature_first", temp, cpu, mem) } } diff --git a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings index 2d33fae..cde49f2 100644 --- a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings @@ -71,3 +71,13 @@ "refresh_rate.power_saving" = "Power Saving"; "refresh_rate.balanced" = "Balanced"; "refresh_rate.real_time" = "Real-time"; +"picker.display_template" = "Display Template"; +"template.minimal" = "Minimal"; +"template.standard" = "Standard"; +"template.detailed" = "Detailed"; +"settings.section.modules" = "Popup Modules"; +"toggle.sparklines" = "Sparklines"; +"toggle.top_cpu" = "Top CPU Apps"; +"toggle.top_memory" = "Top Memory Apps"; +"toggle.temperature_hint" = "Temperature Hint"; +"toggle.diagnostics" = "Diagnostics"; diff --git a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings index 8084e0f..f907113 100644 --- a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings @@ -71,3 +71,13 @@ "refresh_rate.power_saving" = "省电"; "refresh_rate.balanced" = "平衡"; "refresh_rate.real_time" = "实时"; +"picker.display_template" = "显示模板"; +"template.minimal" = "极简"; +"template.standard" = "标准"; +"template.detailed" = "详情"; +"settings.section.modules" = "弹窗模块"; +"toggle.sparklines" = "趋势图"; +"toggle.top_cpu" = "CPU 占用最高应用"; +"toggle.top_memory" = "内存占用最高应用"; +"toggle.temperature_hint" = "温度提示"; +"toggle.diagnostics" = "诊断信息"; diff --git a/Sources/ProcessBarMonitor/SettingsView.swift b/Sources/ProcessBarMonitor/SettingsView.swift index 9577f50..47ac53c 100644 --- a/Sources/ProcessBarMonitor/SettingsView.swift +++ b/Sources/ProcessBarMonitor/SettingsView.swift @@ -30,10 +30,41 @@ struct SettingsView: View { Text(preset.title).tag(preset) } } + + Picker(L10n.string("picker.display_template"), selection: $viewModel.displayTemplate) { + ForEach(MenuBarDisplayTemplate.allCases) { template in + Text(template.title).tag(template) + } + } } header: { Text(L10n.string("settings.section.display")) } + Section { + Toggle(L10n.string("toggle.sparklines"), isOn: Binding( + get: { viewModel.moduleVisibility.contains(.sparklines) }, + set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.sparklines) : viewModel.moduleVisibility.subtracting(.sparklines) } + )) + Toggle(L10n.string("toggle.top_cpu"), isOn: Binding( + get: { viewModel.moduleVisibility.contains(.topCPU) }, + set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.topCPU) : viewModel.moduleVisibility.subtracting(.topCPU) } + )) + Toggle(L10n.string("toggle.top_memory"), isOn: Binding( + get: { viewModel.moduleVisibility.contains(.topMemory) }, + set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.topMemory) : viewModel.moduleVisibility.subtracting(.topMemory) } + )) + Toggle(L10n.string("toggle.temperature_hint"), isOn: Binding( + get: { viewModel.moduleVisibility.contains(.temperatureHint) }, + set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.temperatureHint) : viewModel.moduleVisibility.subtracting(.temperatureHint) } + )) + Toggle(L10n.string("toggle.diagnostics"), isOn: Binding( + get: { viewModel.moduleVisibility.contains(.diagnostics) }, + set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.diagnostics) : viewModel.moduleVisibility.subtracting(.diagnostics) } + )) + } header: { + Text(L10n.string("settings.section.modules")) + } + Section { Toggle(L10n.string("toggle.launch_at_login"), isOn: Binding( get: { viewModel.launchAtLogin.isEnabled }, diff --git a/Sources/ProcessBarMonitor/SystemModels.swift b/Sources/ProcessBarMonitor/SystemModels.swift index 5375a5e..666fc13 100644 --- a/Sources/ProcessBarMonitor/SystemModels.swift +++ b/Sources/ProcessBarMonitor/SystemModels.swift @@ -123,6 +123,55 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { } } +enum MenuBarDisplayTemplate: String, CaseIterable, Identifiable { + case minimal + case standard + case detailed + + var id: String { rawValue } + + var title: String { + switch self { + case .minimal: + return L10n.string("template.minimal") + case .standard: + return L10n.string("template.standard") + case .detailed: + return L10n.string("template.detailed") + } + } + + init?(savedValue: String) { + if let template = MenuBarDisplayTemplate(rawValue: savedValue) { + self = template + return + } + + switch savedValue { + case "Minimal": self = .minimal + case "Standard": self = .standard + case "Detailed": self = .detailed + default: return nil + } + } +} + +struct PopupModuleVisibility: OptionSet, Equatable { + let rawValue: Int + + static let sparklines = PopupModuleVisibility(rawValue: 1 << 0) + static let topCPU = PopupModuleVisibility(rawValue: 1 << 1) + static let topMemory = PopupModuleVisibility(rawValue: 1 << 2) + static let temperatureHint = PopupModuleVisibility(rawValue: 1 << 3) + static let diagnostics = PopupModuleVisibility(rawValue: 1 << 4) + + static let all: PopupModuleVisibility = [.sparklines, .topCPU, .topMemory, .temperatureHint, .diagnostics] + + static var defaultVisibility: PopupModuleVisibility { + [.sparklines, .topCPU, .topMemory] + } +} + struct ProcessChildStat: Identifiable, Hashable { let pid: Int let command: String diff --git a/Sources/ProcessBarMonitor/Views.swift b/Sources/ProcessBarMonitor/Views.swift index 678a6d4..92eb894 100644 --- a/Sources/ProcessBarMonitor/Views.swift +++ b/Sources/ProcessBarMonitor/Views.swift @@ -179,49 +179,53 @@ struct MenuBarContentView: View { SummaryCardView(title: L10n.string("summary.thermal"), value: viewModel.thermalText(viewModel.summary.thermalState), accent: .pink) } - VStack(alignment: .leading, spacing: 4) { - Text(healthLine) - .font(.subheadline.weight(.medium)) - Text(viewModel.summary.architectureNote) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - if viewModel.summary.cpuTemperatureC == nil, let hint = viewModel.summary.temperatureHint { - Text(hint) - .font(.caption.weight(.medium)) - .foregroundStyle(.orange) + if viewModel.moduleVisibility.contains(.temperatureHint) { + VStack(alignment: .leading, spacing: 4) { + Text(healthLine) + .font(.subheadline.weight(.medium)) + Text(viewModel.summary.architectureNote) + .font(.caption) + .foregroundStyle(.secondary) .lineLimit(2) + if viewModel.summary.cpuTemperatureC == nil, let hint = viewModel.summary.temperatureHint { + Text(hint) + .font(.caption.weight(.medium)) + .foregroundStyle(.orange) + .lineLimit(2) + } } } - HStack(spacing: 8) { - SparklineView( - title: L10n.string("trend.cpu"), - points: viewModel.cpuHistory, - color: .primary, - valueText: String(format: "%.1f%%", viewModel.summary.cpuPercent), - fixedMax: 100, - warningThreshold: 60, - criticalThreshold: 85 - ) - SparklineView( - title: L10n.string("trend.ram"), - points: viewModel.memoryHistory, - color: .blue, - valueText: String(format: "%.0f%%", viewModel.summary.memoryPressurePercent), - fixedMax: 100, - warningThreshold: 75, - criticalThreshold: 90 - ) - SparklineView( - title: L10n.string("trend.temp"), - points: viewModel.temperatureHistory, - color: .green, - valueText: viewModel.summary.cpuTemperatureC.map { String(format: "%.1f°C", $0) } ?? "--", - fixedMax: nil, - warningThreshold: 70, - criticalThreshold: 85 - ) + if viewModel.moduleVisibility.contains(.sparklines) { + HStack(spacing: 8) { + SparklineView( + title: L10n.string("trend.cpu"), + points: viewModel.cpuHistory, + color: .primary, + valueText: String(format: "%.1f%%", viewModel.summary.cpuPercent), + fixedMax: 100, + warningThreshold: 60, + criticalThreshold: 85 + ) + SparklineView( + title: L10n.string("trend.ram"), + points: viewModel.memoryHistory, + color: .blue, + valueText: String(format: "%.0f%%", viewModel.summary.memoryPressurePercent), + fixedMax: 100, + warningThreshold: 75, + criticalThreshold: 90 + ) + SparklineView( + title: L10n.string("trend.temp"), + points: viewModel.temperatureHistory, + color: .green, + valueText: viewModel.summary.cpuTemperatureC.map { String(format: "%.1f°C", $0) } ?? "--", + fixedMax: nil, + warningThreshold: 70, + criticalThreshold: 85 + ) + } } Divider() @@ -277,49 +281,53 @@ struct MenuBarContentView: View { } } - Divider() +Divider() - VStack(alignment: .leading, spacing: 4) { - Text(L10n.string("section.top_apps_cpu")) - .font(.headline) - Text(L10n.string("section.top_apps_cpu_desc")) - .font(.caption) - .foregroundStyle(.secondary) - processHeader - ForEach(viewModel.topCPUProcesses) { process in - ProcessRowView( - process: process, - expanded: expandedCPUApps.contains(process.id), - onToggle: { - if expandedCPUApps.contains(process.id) { expandedCPUApps.remove(process.id) } - else { expandedCPUApps.insert(process.id) } - } - ) + if viewModel.moduleVisibility.contains(.topCPU) { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("section.top_apps_cpu")) + .font(.headline) + Text(L10n.string("section.top_apps_cpu_desc")) + .font(.caption) + .foregroundStyle(.secondary) + processHeader + ForEach(viewModel.topCPUProcesses) { process in + ProcessRowView( + process: process, + expanded: expandedCPUApps.contains(process.id), + onToggle: { + if expandedCPUApps.contains(process.id) { expandedCPUApps.remove(process.id) } + else { expandedCPUApps.insert(process.id) } + } + ) + } } - } - Divider() + Divider() + } - VStack(alignment: .leading, spacing: 4) { - Text(L10n.string("section.top_apps_memory")) - .font(.headline) - Text(L10n.string("section.top_apps_memory_desc")) - .font(.caption) - .foregroundStyle(.secondary) - processHeader - ForEach(viewModel.topMemoryProcesses) { process in - ProcessRowView( - process: process, - expanded: expandedMemoryApps.contains(process.id), - onToggle: { - if expandedMemoryApps.contains(process.id) { expandedMemoryApps.remove(process.id) } - else { expandedMemoryApps.insert(process.id) } - } - ) + if viewModel.moduleVisibility.contains(.topMemory) { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("section.top_apps_memory")) + .font(.headline) + Text(L10n.string("section.top_apps_memory_desc")) + .font(.caption) + .foregroundStyle(.secondary) + processHeader + ForEach(viewModel.topMemoryProcesses) { process in + ProcessRowView( + process: process, + expanded: expandedMemoryApps.contains(process.id), + onToggle: { + if expandedMemoryApps.contains(process.id) { expandedMemoryApps.remove(process.id) } + else { expandedMemoryApps.insert(process.id) } + } + ) + } } - } - Divider() + Divider() + } HStack { Button(action: { Task { await viewModel.refresh(forceProcesses: true) } }) { @@ -327,8 +335,10 @@ struct MenuBarContentView: View { } .disabled(viewModel.isRefreshing) - Button(L10n.string("button.copy_diagnostics")) { - viewModel.copyDiagnosticsToPasteboard() + if viewModel.moduleVisibility.contains(.diagnostics) { + Button(L10n.string("button.copy_diagnostics")) { + viewModel.copyDiagnosticsToPasteboard() + } } Button(L10n.string("button.quit")) { diff --git a/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift b/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift index 31fecc2..9356e04 100644 --- a/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift +++ b/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift @@ -190,4 +190,112 @@ final class MonitorViewModelTests: XCTestCase { let vm = MonitorViewModel(defaults: defaults) XCTAssertEqual(vm.refreshRatePreset, .balanced, "invalid value should fall back to .balanced") } + + // MARK: - Display template defaults and migration + + @MainActor + func testDefault_displayTemplate_isStandard() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .standard) + } + + @MainActor + func testMigration_modernMinimalTemplate() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("minimal", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .minimal) + } + + @MainActor + func testMigration_modernStandardTemplate() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("standard", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .standard) + } + + @MainActor + func testMigration_modernDetailedTemplate() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("detailed", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .detailed) + } + + @MainActor + func testMigration_legacyMinimalTemplate() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("Minimal", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .minimal) + } + + @MainActor + func testMigration_invalidDisplayTemplateFallsBack() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("foobar", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .standard, "invalid value should fall back to .standard") + } + + // MARK: - Module visibility defaults and migration + + @MainActor + func testDefault_moduleVisibility_isDefaultVisibility() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.moduleVisibility, .defaultVisibility) + } + + @MainActor + func testMigration_savedModuleVisibility() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set(PopupModuleVisibility.sparklines.rawValue, forKey: "moduleVisibility") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.moduleVisibility, .sparklines) + } + + @MainActor + func testMigration_allModulesEnabled() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set(PopupModuleVisibility.all.rawValue, forKey: "moduleVisibility") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.moduleVisibility, .all) + } + + @MainActor + func testMigration_invalidModuleVisibilityFallsBack() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set(99, forKey: "moduleVisibility") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.moduleVisibility, .defaultVisibility) + } + + // MARK: - displayTemplate drives menuBarTitle format + + @MainActor + func testMenuBarTitle_usesDisplayTemplate_minimal() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("minimal", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .minimal) + } + + @MainActor + func testMenuBarTitle_usesDisplayTemplate_standard() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("standard", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .standard) + } + + @MainActor + func testMenuBarTitle_usesDisplayTemplate_detailed() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("detailed", forKey: "displayTemplate") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.displayTemplate, .detailed) + } }