Skip to content
Merged
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
108 changes: 78 additions & 30 deletions Sources/SMCtlMenuBar/BatteryMenuSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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 }
Expand All @@ -83,12 +119,24 @@ 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 {
draft = Double(newValue)
}
}
}

private var shown: Int {
Int(draft.rounded())
}
}
39 changes: 26 additions & 13 deletions Sources/SMCtlMenuBar/FanMenuSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
94 changes: 84 additions & 10 deletions Sources/SMCtlMenuBar/MenuBarRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: " · ")
}
}
Loading
Loading