From c45217d18fe55b43322da9f4c5076f832b56f6e2 Mon Sep 17 00:00:00 2001 From: HerbertGao Date: Thu, 27 Aug 2026 13:40:21 +0800 Subject: [PATCH] feat: stabilize menu bar status display --- CoolMyMac-App/App/Sources/AppState.swift | 14 ++ .../App/Sources/Views/MenuBarIconView.swift | 145 ++++++++++++++---- .../App/Sources/Views/PreferencesView.swift | 12 ++ 3 files changed, 137 insertions(+), 34 deletions(-) diff --git a/CoolMyMac-App/App/Sources/AppState.swift b/CoolMyMac-App/App/Sources/AppState.swift index adae772..f9382f8 100644 --- a/CoolMyMac-App/App/Sources/AppState.swift +++ b/CoolMyMac-App/App/Sources/AppState.swift @@ -54,6 +54,13 @@ final class AppState { didSet { defaults.set(iconDisplayMode.rawValue, forKey: "iconDisplayMode") } } + var menuBarItemLayout: MenuBarItemLayout = { + let saved = UserDefaults.standard.string(forKey: "menuBarItemLayout") ?? "" + return MenuBarItemLayout(rawValue: saved) ?? .horizontal + }() { + didSet { defaults.set(menuBarItemLayout.rawValue, forKey: "menuBarItemLayout") } + } + // Stored (not computed) for the same reason as launchAtLogin below — a computed // get/set backed directly by UserDefaults is invisible to Observation, so neither // the toggle nor the icon's live color would update when this changed. @@ -450,6 +457,13 @@ enum IconDisplayMode: String, CaseIterable { } } +enum MenuBarItemLayout: String, CaseIterable { + case horizontal + case vertical + + var label: String { rawValue.capitalized } +} + enum DaemonInstallStatus { case installed case notInstalled diff --git a/CoolMyMac-App/App/Sources/Views/MenuBarIconView.swift b/CoolMyMac-App/App/Sources/Views/MenuBarIconView.swift index 5afe1aa..9d74cfe 100644 --- a/CoolMyMac-App/App/Sources/Views/MenuBarIconView.swift +++ b/CoolMyMac-App/App/Sources/Views/MenuBarIconView.swift @@ -1,61 +1,138 @@ // MenuBarIconView.swift // The icon shown in the menu bar — supports icon-only, +temp, +RPM, and dynamic color. +import AppKit import SwiftUI import SMCKit +import os struct MenuBarIconView: View { var state: AppState @AppStorage("decimalResolution") private var decimalResolution: Int = 0 @Environment(\.colorScheme) private var colorScheme + @Environment(\.displayScale) private var displayScale private static let minTemp = 60.0 private static let maxTemp = 90.0 + private static let logger = Logger(subsystem: "com.coolmymac.app", category: "MenuBarIconView") + + private var usesVerticalLayout: Bool { + state.menuBarItemLayout == .vertical && state.iconDisplayMode != .iconOnly + } var body: some View { - HStack(spacing: 4) { - Image(systemName: "wind") - .symbolRenderingMode(state.dynamicIconEnabled ? .palette : .monochrome) - .foregroundStyle( - state.dynamicIconEnabled ? thermalColor : Color.primary, - state.dynamicIconEnabled ? thermalColor.opacity(0.6) : Color.primary - ) - .font(.system(size: 14, weight: .medium)) - - switch state.iconDisplayMode { - case .iconOnly: - EmptyView() - case .iconAndTemp: - if let temp = state.cpuTemp { - let fmt = String(format: decimalResolution == 1 ? "%.1f°" : "%.0f°", temp) - let anchor = decimalResolution == 1 ? "99.9°" : "99°" - ZStack { - Text(anchor).hidden() - Text(fmt) - } - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(state.dynamicIconEnabled ? thermalColor : Color.primary) - } - case .iconAndRPM: - if let rpm = state.fans.first?.currentRPM { - ZStack { - Text("9999").hidden() - Text("\(rpm)") - } - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(state.dynamicIconEnabled ? thermalColor : Color.primary) - } + Image(nsImage: renderedImage) + .renderingMode(.original) + .accessibilityLabel(Text(accessibilityLabel)) + } + + private var renderedImage: NSImage { + let renderer = ImageRenderer(content: labelContent.environment(\.colorScheme, colorScheme)) + renderer.scale = displayScale + guard let image = renderer.nsImage else { + Self.logger.error("Failed to render the menu bar label") + let configuration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium) + .applying(NSImage.SymbolConfiguration(hierarchicalColor: .labelColor)) + let fallback = NSImage(systemSymbolName: "wind", accessibilityDescription: "CoolMyMac")? + .withSymbolConfiguration(configuration) ?? NSImage(size: NSSize(width: 14, height: 14)) + fallback.isTemplate = false + return fallback + } + image.isTemplate = false + return image + } + + @ViewBuilder + private var labelContent: some View { + if usesVerticalLayout { + VStack(spacing: -2) { + content + } + .fixedSize() + } else { + HStack(spacing: 2) { + content + } + .fixedSize() + } + } + + @ViewBuilder + private var content: some View { + Image(systemName: "wind") + .symbolRenderingMode(state.dynamicIconEnabled ? .palette : .monochrome) + .foregroundStyle( + state.dynamicIconEnabled ? thermalColor : Color.primary, + state.dynamicIconEnabled ? thermalColor.opacity(0.6) : Color.primary + ) + .font(.system(size: usesVerticalLayout ? 11 : 14, weight: .medium)) + + switch state.iconDisplayMode { + case .iconOnly: + EmptyView() + case .iconAndTemp: + reading( + state.cpuTemp.map { String(format: temperatureFormat, $0) } ?? "", + anchor: temperatureAnchor + ) + case .iconAndRPM: + reading(state.fans.first.map { "\($0.currentRPM)" } ?? "", anchor: "99999") + } + } + + private var temperatureFormat: String { + if usesVerticalLayout { + return decimalResolution == 1 ? "%.1f" : "%.0f" + } + return decimalResolution == 1 ? "%.1f°" : "%.0f°" + } + + private var temperatureAnchor: String { + if usesVerticalLayout { + return decimalResolution == 1 ? "100.0" : "100" + } + return decimalResolution == 1 ? "100.0°" : "100°" + } + + private func reading(_ value: String, anchor: String) -> some View { + readingText(anchor) + .opacity(0) + .accessibilityHidden(true) + .overlay { + readingText(value) + .lineLimit(1) } + .fixedSize(horizontal: true, vertical: false) + .foregroundStyle(Color.primary) + } + + private func readingText(_ value: String) -> some View { + Text(value) + .font(.system(size: usesVerticalLayout ? 9 : 12, weight: .medium, design: .monospaced)) + .monospacedDigit() + } + + private var accessibilityLabel: String { + switch state.iconDisplayMode { + case .iconOnly: + return "CoolMyMac" + case .iconAndTemp: + guard let temp = state.cpuTemp else { return "CoolMyMac, CPU temperature unavailable" } + let value = String(format: decimalResolution == 1 ? "%.1f°" : "%.0f°", temp) + return "CoolMyMac, CPU temperature \(value)" + case .iconAndRPM: + guard let rpm = state.fans.first?.currentRPM else { return "CoolMyMac, fan speed unavailable" } + return "CoolMyMac, fan speed \(rpm) RPM" } } /// Continuous green→red gradient across 60–90°C using hue interpolation. private var thermalColor: Color { let temp = state.hottestTemp - let t = max(0, min(1, (temp - Self.minTemp) / (Self.maxTemp - Self.minTemp))) + let normalizedTemp = max(0, min(1, (temp - Self.minTemp) / (Self.maxTemp - Self.minTemp))) // Hue: 0.33 = green, 0.0 = red. Shift linearly. - let hue = 0.33 * (1.0 - t) + let hue = 0.33 * (1.0 - normalizedTemp) // Ensure high contrast: darker in light mode, brighter in dark mode let brightness = colorScheme == .dark ? 0.95 : 0.65 let saturation = colorScheme == .dark ? 0.85 : 1.0 diff --git a/CoolMyMac-App/App/Sources/Views/PreferencesView.swift b/CoolMyMac-App/App/Sources/Views/PreferencesView.swift index 876ade6..fd59089 100644 --- a/CoolMyMac-App/App/Sources/Views/PreferencesView.swift +++ b/CoolMyMac-App/App/Sources/Views/PreferencesView.swift @@ -127,6 +127,18 @@ struct GeneralPrefsView: View { } .pickerStyle(.segmented) + if state.iconDisplayMode != .iconOnly { + Picker("Layout", selection: Binding( + get: { state.menuBarItemLayout }, + set: { state.menuBarItemLayout = $0 } + )) { + ForEach(MenuBarItemLayout.allCases, id: \.self) { layout in + Text(layout.label).tag(layout) + } + } + .pickerStyle(.segmented) + } + Toggle("Dynamic color icon (green → red based on temp)", isOn: Binding( get: { state.dynamicIconEnabled }, set: { state.dynamicIconEnabled = $0 }