Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CoolMyMac-App/App/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
145 changes: 111 additions & 34 deletions CoolMyMac-App/App/Sources/Views/MenuBarIconView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 12 additions & 0 deletions CoolMyMac-App/App/Sources/Views/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down