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
2 changes: 2 additions & 0 deletions ChillMac/App/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ final class AppSettings: ObservableObject {
@AppStorage("forcePerformanceOnBattery") var forcePerformanceOnBattery = false
@AppStorage("keepFansOnScreenSleep") var keepFansOnScreenSleep = false
@AppStorage("showFPS") var showFPS = false
/// When on, the menu bar shows a compact temp next to the fan only while Warm/Hot.
@AppStorage("showMenuBarTemp") var showMenuBarTemp = true

static let popoverMinHeight: CGFloat = 400
static let popoverMaxHeight: CGFloat = 900
Expand Down
35 changes: 34 additions & 1 deletion ChillMac/App/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ final class StatusBarController: NSObject {
private let popover: NSPopover
private var eventMonitor: Any?
private var settingsSub: AnyCancellable?
private var statusItemSubs = Set<AnyCancellable>()
private var heightObserver: Any?
private var detailResetObserver: Any?
private var detailPanelObserver: Any?
private var lastPopoverHeight: CGFloat = 0
private var lastStatusItemSignature: String?

private let detailPanel = DetailPanelController()
private let memoryInfo: MemoryInfo
Expand Down Expand Up @@ -46,10 +48,11 @@ final class StatusBarController: NSObject {
popover.contentSize = NSSize(width: 420, height: CGFloat(AppSettings.shared.popoverHeight))

if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "fan.fill", accessibilityDescription: "ChillMac")
button.imagePosition = .imageLeft
button.action = #selector(togglePopover(_:))
button.target = self
}
updateStatusItem()

// Close popover when clicking outside both the popover and detail panel
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
Expand Down Expand Up @@ -83,6 +86,7 @@ final class StatusBarController: NSObject {
guard let self else { return }
self.popover.appearance = AppSettings.shared.nsAppearance
self.popover.contentViewController?.view.appearance = AppSettings.shared.nsAppearance
self.updateStatusItem()

// Handle height changes from settings (e.g. Reset button), not during live drag
let newHeight = CGFloat(AppSettings.shared.popoverHeight)
Expand All @@ -94,6 +98,11 @@ final class StatusBarController: NSObject {
}
}

fanMonitor.$peakTemperature
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &statusItemSubs)

// Live resize during drag — bypasses AppSettings for smooth performance
heightObserver = NotificationCenter.default.addObserver(forName: .popoverHeightChanged, object: nil, queue: .main) { [weak self] notification in
guard let self, let height = notification.userInfo?["height"] as? CGFloat else { return }
Expand All @@ -116,6 +125,30 @@ final class StatusBarController: NSObject {
}
}

/// Template fan always; compact temp title only when setting is on and status is Warm/Hot.
private func updateStatusItem() {
guard let button = statusItem.button else { return }

let peak = fanMonitor.peakTemperature
let thermal = ThermalStatus.from(peakCelsius: peak)
let settings = AppSettings.shared
let showTemp = settings.showMenuBarTemp && thermal.showsMenuBarTemperature
let title = showTemp
? ThermalStatus.menuBarTemperatureText(celsius: peak, useFahrenheit: settings.useFahrenheit)
: ""
let signature = "\(showTemp)|\(title)|\(settings.useFahrenheit)|\(thermal.label)"
guard signature != lastStatusItemSignature else { return }
lastStatusItemSignature = signature

let image = NSImage(systemSymbolName: "fan.fill", accessibilityDescription: nil)
image?.isTemplate = true
button.image = image
button.title = title
button.imagePosition = showTemp ? .imageLeft : .imageOnly
button.toolTip = showTemp ? "ChillMac — \(thermal.label) \(title)" : "ChillMac"
button.setAccessibilityLabel(showTemp ? "ChillMac, \(thermal.label), \(title)" : "ChillMac")
}

deinit {
if let monitor = eventMonitor {
NSEvent.removeMonitor(monitor)
Expand Down
39 changes: 39 additions & 0 deletions ChillMac/Fan/ThermalStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

/// Shared Good / Warm / Hot vocabulary for popover + menu bar.
enum ThermalStatus: Equatable {
case unknown
case good
case warm
case hot

/// Same thresholds as the popover header: Warm ≥ 75°C, Hot ≥ 90°C.
static func from(peakCelsius: Double) -> ThermalStatus {
guard peakCelsius > 0 else { return .unknown }
if peakCelsius >= 90 { return .hot }
if peakCelsius >= 75 { return .warm }
return .good
}

var label: String {
switch self {
case .unknown, .good: return "Good"
case .warm: return "Warm"
case .hot: return "Hot"
}
}

/// Menu bar stays quiet unless the machine is Warm or Hot.
var showsMenuBarTemperature: Bool {
self == .warm || self == .hot
}

/// Compact monochrome readout for the status item (integer degrees).
static func menuBarTemperatureText(celsius: Double, useFahrenheit: Bool) -> String {
if useFahrenheit {
let f = celsius * 9.0 / 5.0 + 32.0
return "\(Int(f.rounded()))°"
}
return "\(Int(celsius.rounded()))°"
}
}
24 changes: 14 additions & 10 deletions ChillMac/Views/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,21 +138,25 @@ struct PopoverView: View {
.animation(.easeOut(duration: 0.3), value: appeared)
}

private var currentThermalStatus: ThermalStatus {
guard !monitor.sensors.isEmpty,
let maxTemp = monitor.sensors.map(\.temperature).max() else {
return .unknown
}
return ThermalStatus.from(peakCelsius: maxTemp)
}

private var thermalStatus: String {
guard !monitor.sensors.isEmpty else { return "Good" }
let maxTemp = monitor.sensors.map(\.temperature).max() ?? 0
if maxTemp >= 90 { return "Hot" }
if maxTemp >= 75 { return "Warm" }
return "Good"
currentThermalStatus.label
}

private var thermalStatusColor: Color {
guard !monitor.sensors.isEmpty else { return .green }
let isLight = (settings.preferredColorScheme ?? colorScheme) == .light
let maxTemp = monitor.sensors.map(\.temperature).max() ?? 0
if maxTemp >= 90 { return .red }
if maxTemp >= 75 { return isLight ? Color(red: 0.80, green: 0.45, blue: 0.0) : .orange }
return .green
switch currentThermalStatus {
case .hot: return .red
case .warm: return isLight ? Color(red: 0.80, green: 0.45, blue: 0.0) : .orange
case .good, .unknown: return .green
}
}

private var maxTempDisplay: String {
Expand Down
28 changes: 28 additions & 0 deletions ChillMac/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,34 @@ struct SettingsView: View {
.padding(.horizontal, 14)
.padding(.vertical, 10)

Divider()
.background(theme.dividerSubtle)

// Menu bar temperature (Warm/Hot only)
HStack {
Image(systemName: "menubar.arrow.up.rectangle")
.font(.system(size: 16))
.foregroundColor(theme.textTertiary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("Show Temp in Menu Bar")
.font(.system(size: 13, weight: .medium))
.foregroundColor(theme.textPrimary)
Text("Only when Warm or Hot — stays quiet when Good")
.font(.system(size: 11))
.foregroundColor(theme.textQuaternary)
}
Spacer()
Toggle(isOn: $settings.showMenuBarTemp) {
EmptyView()
}
.toggleStyle(.switch)
.controlSize(.small)
.tint(.teal)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)

Divider()
.background(theme.dividerSubtle)

Expand Down