From 52cb3a00257a4c53c097bf995db7b80481c74546 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 19 Sep 2026 13:34:05 +0800 Subject: [PATCH] style(menubar): group charge and fan controls into cards Co-authored-by: Cursor --- Sources/SMCtlMenuBar/BatteryMenuSection.swift | 108 +++++++++++++----- Sources/SMCtlMenuBar/FanMenuSection.swift | 39 ++++--- Sources/SMCtlMenuBar/MenuBarRootView.swift | 94 +++++++++++++-- Sources/SMCtlMenuBar/MenuChrome.swift | 62 ++++++++++ 4 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 Sources/SMCtlMenuBar/MenuChrome.swift diff --git a/Sources/SMCtlMenuBar/BatteryMenuSection.swift b/Sources/SMCtlMenuBar/BatteryMenuSection.swift index ff88f70..e9b847f 100644 --- a/Sources/SMCtlMenuBar/BatteryMenuSection.swift +++ b/Sources/SMCtlMenuBar/BatteryMenuSection.swift @@ -10,45 +10,74 @@ public struct BatteryMenuSection: View { } public var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(battery.chargeLine) - .accessibilityIdentifier("battery-charge") - Text(battery.limitLine) - .accessibilityIdentifier("battery-limit") + MenuPanel(title: "Charge", systemImage: "battery.100") { if let daemonMessage = battery.daemonMessage, !daemonMessage.isEmpty { - Text(daemonMessage) + notice(daemonMessage) } if let lastWriteError = battery.lastWriteError, !lastWriteError.isEmpty { - Text(lastWriteError) + notice(lastWriteError, isError: true) .accessibilityIdentifier("battery-write-error") } if battery.chargingControlSupported { - Divider() ChargeLimitSliderRow(seed: battery.sliderCap, perform: perform) - ForEach(MaintainPreset.allCases, id: \.self) { preset in - Button(title(for: preset)) { + Text("Maintain") + .font(.caption) + .foregroundStyle(.secondary) + MenuChoiceRow(choices: MaintainPreset.allCases.map { preset in + MenuChoice( + id: preset.rawValue, + title: shortTitle(for: preset), + accessibilityLabel: preset.menuTitle, + accessibilityIdentifier: "maintain-\(preset.rawValue)", + isSelected: battery.selectedMaintain == preset + ) { perform(.maintain(preset)) } - .accessibilityIdentifier("maintain-\(preset.rawValue)") - } - Button("Charging on") { - perform(.setCharging(true)) - } - .accessibilityIdentifier("charging-on") - Button("Charging off") { - perform(.setCharging(false)) - } - .accessibilityIdentifier("charging-off") + }) + Text("Charging") + .font(.caption) + .foregroundStyle(.secondary) + MenuChoiceRow(choices: [ + MenuChoice( + id: "charging-on", + title: "On", + accessibilityLabel: "Charging on", + accessibilityIdentifier: "charging-on", + isSelected: false + ) { + perform(.setCharging(true)) + }, + MenuChoice( + id: "charging-off", + title: "Off", + accessibilityLabel: "Charging off", + accessibilityIdentifier: "charging-off", + isSelected: false + ) { + perform(.setCharging(false)) + }, + ]) } } } - private func title(for preset: MaintainPreset) -> String { - if battery.selectedMaintain == preset { - return "✓ \(preset.menuTitle)" + private func shortTitle(for preset: MaintainPreset) -> String { + switch preset { + case .cap80: + return "80" + case .band7080: + return "70–80" + case .stop: + return "Off" } - return preset.menuTitle + } + + private func notice(_ text: String, isError: Bool = false) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(isError ? Color.red : Color.secondary) + .fixedSize(horizontal: false, vertical: true) } } @@ -66,13 +95,20 @@ private struct ChargeLimitSliderRow: View { } var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Charge limit \(Int(draft.rounded()))%") - .accessibilityIdentifier("charge-limit-label") + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Limit") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("\(shown)%") + .font(.subheadline.weight(.semibold)) + .monospacedDigit() + .accessibilityIdentifier("charge-limit-label") + } Slider( value: $draft, - in: Double(ChargeLimitCap.range.lowerBound)...Double(ChargeLimitCap.range.upperBound), - step: 1 + in: Double(ChargeLimitCap.range.lowerBound)...Double(ChargeLimitCap.range.upperBound) ) { editing in isEditing = editing guard !editing else { return } @@ -83,7 +119,15 @@ private struct ChargeLimitSliderRow: View { } } .accessibilityIdentifier("charge-limit-slider") - .accessibilityValue("\(Int(draft.rounded())) percent") + .accessibilityValue("\(shown) percent") + HStack { + Text("\(ChargeLimitCap.range.lowerBound)") + Spacer() + Text("\(ChargeLimitCap.range.upperBound)") + } + .font(.caption2) + .foregroundStyle(.tertiary) + .monospacedDigit() } .onChange(of: seed) { _, newValue in if !isEditing { @@ -91,4 +135,8 @@ private struct ChargeLimitSliderRow: View { } } } + + private var shown: Int { + Int(draft.rounded()) + } } diff --git a/Sources/SMCtlMenuBar/FanMenuSection.swift b/Sources/SMCtlMenuBar/FanMenuSection.swift index e20f68e..cd9a03a 100644 --- a/Sources/SMCtlMenuBar/FanMenuSection.swift +++ b/Sources/SMCtlMenuBar/FanMenuSection.swift @@ -10,46 +10,59 @@ public struct FanMenuSection: View { } public var body: some View { - VStack(alignment: .leading, spacing: 6) { + MenuPanel(title: "Fans", systemImage: "fan.fill") { Text(fans.profileLine) + .font(.subheadline.weight(.medium)) .accessibilityIdentifier("fan-profile") if fans.rows.isEmpty { Text(fans.emptyLine) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("fan-empty") } else { ForEach(fans.rows, id: \.index) { row in Text(row.line) + .font(.subheadline) + .monospacedDigit() .accessibilityIdentifier("fan-\(row.index)") } if let daemonMessage = fans.daemonMessage, !daemonMessage.isEmpty { Text(daemonMessage) + .font(.caption) + .foregroundStyle(.secondary) } } if let lastWriteError = fans.lastWriteError, !lastWriteError.isEmpty { Text(lastWriteError) + .font(.caption) + .foregroundStyle(.red) .accessibilityIdentifier("fan-write-error") } Text(FanSnapshot.thermalGuardLine) + .font(.caption) + .foregroundStyle(.secondary) .accessibilityIdentifier("fan-thermal-guard") - Divider() - ForEach(FanProfileChoice.allCases, id: \.self) { choice in - Button(title(for: choice)) { + MenuChoiceRow(choices: FanProfileChoice.allCases.map { choice in + MenuChoice( + id: choice.rawValue, + title: choice.menuTitle, + accessibilityLabel: choice.menuTitle, + accessibilityIdentifier: "fan-profile-\(choice.rawValue)", + isSelected: fans.selectedProfile == choice + ) { perform(choice.command) } - .accessibilityIdentifier("fan-profile-\(choice.rawValue)") - } + }) Button("Return to auto") { perform(.returnToAuto) } + .buttonStyle(.borderless) + .controlSize(.small) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("fan-return-to-auto") } } - - private func title(for choice: FanProfileChoice) -> String { - if fans.selectedProfile == choice { - return "✓ \(choice.menuTitle)" - } - return choice.menuTitle - } } diff --git a/Sources/SMCtlMenuBar/MenuBarRootView.swift b/Sources/SMCtlMenuBar/MenuBarRootView.swift index 06a7b55..6462da6 100644 --- a/Sources/SMCtlMenuBar/MenuBarRootView.swift +++ b/Sources/SMCtlMenuBar/MenuBarRootView.swift @@ -9,34 +9,108 @@ public struct MenuBarRootView: View { } public var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { Group { switch session.snapshot { case .idle, .loading: - Text("Connecting…") + connecting case .connected(let pingLine, let battery, let fans): - Text(pingLine) - .accessibilityIdentifier("ping-line") + statusHeader(pingLine: pingLine, battery: battery) BatteryMenuSection(battery: battery, perform: session.perform) - Divider() FanMenuSection(fans: fans, perform: session.perform) case .disconnected(let message): - Text("Not connected") - Text(message) + disconnected(message) } } .onAppear { session.refresh() } - Divider() + footer + } + .padding(14) + .frame(width: 312) + } + + private var connecting: some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Connecting…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } + + private func disconnected(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label("Not connected", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(.orange) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func statusHeader(pingLine: String, battery: BatterySnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(pingLine) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("ping-line") + Text(battery.headline) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .accessibilityLabel(battery.chargeLine) + .accessibilityIdentifier("battery-charge") + Text(battery.statusDetail) + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibilityIdentifier("battery-limit") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private var footer: some View { + HStack { Button("Refresh") { session.refresh() } + .buttonStyle(.bordered) + .controlSize(.small) + Spacer() Button("Quit") { NSApplication.shared.terminate(nil) } + .buttonStyle(.borderless) + .controlSize(.small) + .foregroundStyle(.secondary) + } + } +} + +extension BatterySnapshot { + var headline: String { + guard let percent = chargePercent else { + return "—" + } + return "\(percent)%" + } + + var statusDetail: String { + var parts: [String] = [] + if chargePercent == nil { + parts.append("Battery unavailable") + } else if isCharging == true { + parts.append("Charging") + } else if pluggedIn == true { + parts.append("Plugged in") + } else { + parts.append("On battery") } - .padding(12) - .frame(width: 280) + parts.append(limitLine) + return parts.joined(separator: " · ") } } diff --git a/Sources/SMCtlMenuBar/MenuChrome.swift b/Sources/SMCtlMenuBar/MenuChrome.swift new file mode 100644 index 0000000..8aea930 --- /dev/null +++ b/Sources/SMCtlMenuBar/MenuChrome.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct MenuPanel: View { + var title: String + var systemImage: String + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + content() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } +} + +struct MenuChoice: Identifiable { + var id: String + var title: String + var accessibilityLabel: String + var accessibilityIdentifier: String + var isSelected: Bool + var action: () -> Void +} + +struct MenuChoiceRow: View { + var choices: [MenuChoice] + + var body: some View { + HStack(spacing: 6) { + ForEach(choices) { choice in + choiceButton(choice) + } + } + } + + @ViewBuilder + private func choiceButton(_ choice: MenuChoice) -> some View { + let button = Button(action: choice.action) { + Text(choice.title) + .font(.subheadline.weight(choice.isSelected ? .semibold : .medium)) + .frame(maxWidth: .infinity) + } + .controlSize(.small) + .accessibilityLabel(choice.accessibilityLabel) + .accessibilityIdentifier(choice.accessibilityIdentifier) + .accessibilityAddTraits(choice.isSelected ? .isSelected : []) + + if choice.isSelected { + button.buttonStyle(.borderedProminent) + } else { + button.buttonStyle(.bordered) + } + } +}