Ein HStack zentriert über die volle Höhe seiner Inhalte. Die Beschriftung unter dem Ring gehört dazu, also rutschte alles daneben um deren halbe Höhe nach unten. Bei drei Zeilen fällt das kaum auf, bei einer einzelnen schon: „Lädt" stand sichtbar tiefer als die Mitte, auf die man schaut. Eine eigene Ausrichtung löst das an der Stelle, an der es entsteht: der Ring meldet seine Mitte, die Spalte daneben richtet sich danach — unabhängig davon, wie viele Zeilen sie hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
18 KiB
Swift
440 lines
18 KiB
Swift
import SwiftUI
|
|
import OnyxDesign
|
|
|
|
/// Die Detailansichten hinter den Menüleisten-Symbolen.
|
|
///
|
|
/// Hier ist ausdrücklich Platz — anders als in der Menüleiste, wo jede Zahl um
|
|
/// Breite kämpft. Ein Popover, das nur den Wert größer wiederholt, den man
|
|
/// gerade angeklickt hat, ist verschenkt.
|
|
@MainActor
|
|
struct MetricPopoverContent: View {
|
|
let model: MetricsModel
|
|
let metric: MetricKind
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
|
|
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
|
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
|
.font(.headline)
|
|
|
|
switch metric {
|
|
case .cpu, .gpu: ProcessorDetail(model: model, metric: metric)
|
|
case .memory: MemoryDetail(snapshot: model.snapshot)
|
|
case .battery: BatteryDetail(snapshot: model.snapshot, control: model.chargeControl)
|
|
case .temperature: SensorDetail(snapshot: model.snapshot)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(width: 280)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bausteine
|
|
|
|
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
|
|
/// lange Werteliste.
|
|
private struct SectionTitle: View {
|
|
let text: LocalizedStringKey
|
|
|
|
var body: some View {
|
|
Text(text, bundle: .module)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tint)
|
|
.textCase(.uppercase)
|
|
}
|
|
}
|
|
|
|
private struct Row: View {
|
|
let label: String
|
|
let value: String
|
|
var emphasis: Color?
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label).font(.callout)
|
|
Spacer(minLength: 12)
|
|
Text(value)
|
|
.font(.callout.monospacedDigit())
|
|
.foregroundStyle(emphasis ?? .secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Die Mitte des **Rings** — nicht die des Rings samt Beschriftung.
|
|
///
|
|
/// Ein `HStack` zentriert seine Inhalte über die volle Höhe. Die Beschriftung
|
|
/// unter dem Ring gehört aber dazu, und dadurch rutscht alles daneben um deren
|
|
/// halbe Höhe nach unten. Bei einer einzelnen Zeile fällt das auf: sie steht
|
|
/// dann sichtbar tiefer als die Mitte, auf die man schaut.
|
|
private extension VerticalAlignment {
|
|
enum DialCenterID: AlignmentID {
|
|
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
|
context[VerticalAlignment.center]
|
|
}
|
|
}
|
|
static let dialCenter = VerticalAlignment(DialCenterID.self)
|
|
}
|
|
|
|
/// Ein Ring mit Zahl in der Mitte.
|
|
private struct Dial: View {
|
|
let value: Double
|
|
let caption: String
|
|
let text: String
|
|
var tint: Color = .accentColor
|
|
|
|
var body: some View {
|
|
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
|
|
// sah aus, als gehörte sie noch hinein.
|
|
VStack(spacing: 6) {
|
|
ZStack {
|
|
Circle().stroke(.quaternary, lineWidth: 5)
|
|
Circle()
|
|
.trim(from: 0, to: min(max(value, 0), 1))
|
|
.stroke(tint, style: .init(lineWidth: 5, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
|
|
}
|
|
.frame(width: 62, height: 62)
|
|
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
|
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
.padding(.bottom, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Prozessor
|
|
|
|
private struct ProcessorDetail: View {
|
|
let model: MetricsModel
|
|
let metric: MetricKind
|
|
|
|
var body: some View {
|
|
let snapshot = model.snapshot
|
|
|
|
HStack(spacing: 14) {
|
|
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
|
|
caption: metric == .cpu ? "CPU" : "GPU",
|
|
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
|
|
: (snapshot.gpu ?? 0)))
|
|
if let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature {
|
|
Dial(value: celsius / 100,
|
|
caption: String(localized: "popover.temperature", bundle: .module),
|
|
text: MetricFormat.temperature(celsius),
|
|
tint: MetricSummary.temperatureColor(celsius))
|
|
}
|
|
if let fan = snapshot.fans.first {
|
|
Dial(value: fan.fraction,
|
|
caption: String(localized: "popover.fans", bundle: .module),
|
|
text: MetricFormat.percent(fan.fraction))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
|
|
SectionTitle(text: "popover.perCore")
|
|
CoreGrid(values: model.snapshot.cpu.perCore)
|
|
}
|
|
|
|
SectionTitle(text: "popover.power")
|
|
if let cpu = snapshot.power.cpu {
|
|
Row(label: String(localized: "popover.power.cpu", bundle: .module),
|
|
value: MetricFormat.watts(cpu))
|
|
}
|
|
if let display = snapshot.power.display {
|
|
Row(label: String(localized: "popover.power.display", bundle: .module),
|
|
value: MetricFormat.watts(display))
|
|
}
|
|
if let total = snapshot.power.total {
|
|
Row(label: String(localized: "popover.power.total", bundle: .module),
|
|
value: MetricFormat.watts(total), emphasis: .primary)
|
|
}
|
|
|
|
if !snapshot.fans.isEmpty {
|
|
SectionTitle(text: "popover.fans")
|
|
ForEach(snapshot.fans) { fan in
|
|
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
Picker("", selection: Binding(
|
|
get: { model.displayMode(for: metric) },
|
|
set: { model.setDisplayMode($0, for: metric) })) {
|
|
Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage)
|
|
Text("metric.mode.temperature", bundle: .module).tag(MetricDisplayMode.temperature)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
|
|
/// Ein Kästchen je Kern statt einer Balkenreihe.
|
|
///
|
|
/// Bei fünfzehn Kernen wird eine Reihe unleserlich schmal. Als Raster bleibt
|
|
/// jeder Kern erkennbar, und man sieht auf einen Blick, ob eine einzelne Last
|
|
/// läuft oder alles arbeitet.
|
|
private struct CoreGrid: View {
|
|
let values: [Double]
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
|
|
spacing: 3) {
|
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.18))
|
|
.overlay(alignment: .bottom) {
|
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
.fill(Color.accentColor)
|
|
.frame(height: max(14 * value, 1.5))
|
|
}
|
|
.frame(height: 14)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Speicher
|
|
|
|
private struct MemoryDetail: View {
|
|
let snapshot: MetricsSnapshot
|
|
|
|
var body: some View {
|
|
let memory = snapshot.memory
|
|
|
|
HStack(spacing: 14) {
|
|
Dial(value: memory.usedFraction,
|
|
caption: String(localized: "popover.memory.used", bundle: .module),
|
|
text: MetricFormat.percent(memory.usedFraction),
|
|
tint: pressureColor)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
legend(.accentColor, "popover.memory.app", memory.app)
|
|
legend(.purple, "popover.memory.wired", memory.wired)
|
|
legend(.teal, "popover.memory.compressed", memory.compressed)
|
|
legend(.gray, "popover.memory.free", memory.free)
|
|
}
|
|
}
|
|
|
|
SectionTitle(text: "popover.memory.swap")
|
|
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
|
|
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
|
|
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
|
|
value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))",
|
|
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? .orange : nil)
|
|
|
|
if !snapshot.topMemoryProcesses.isEmpty {
|
|
SectionTitle(text: "popover.processes")
|
|
ForEach(snapshot.topMemoryProcesses) { process in
|
|
Row(label: process.name, value: bytes(process.bytes))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var pressureColor: Color {
|
|
switch snapshot.memory.pressure {
|
|
case .normal: .green
|
|
case .warning: .orange
|
|
case .critical: .red
|
|
}
|
|
}
|
|
|
|
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
|
|
HStack(spacing: 5) {
|
|
Circle().fill(color).frame(width: 7, height: 7)
|
|
Text(key, bundle: .module).font(.caption)
|
|
Spacer(minLength: 8)
|
|
Text(bytes(value)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func bytes(_ value: UInt64) -> String {
|
|
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Akku
|
|
|
|
private struct BatteryDetail: View {
|
|
let snapshot: MetricsSnapshot
|
|
let control: (any ChargeLimitReading)?
|
|
|
|
var body: some View {
|
|
if let battery = snapshot.battery {
|
|
HStack(alignment: .dialCenter, spacing: 14) {
|
|
Dial(value: battery.charge,
|
|
caption: String(localized: "metric.battery", bundle: .module),
|
|
text: MetricFormat.percent(battery.charge),
|
|
tint: battery.isCharging ? .green
|
|
: (battery.charge < 0.2 ? .red : .accentColor))
|
|
// Beides gehört neben den Ring: die Schätzung und der Grund
|
|
// dafür. „Am Netzteil" über dem Ring und „Am Netz, lädt nicht"
|
|
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
|
|
// und die zweite stand allein unter dem Diagramm, wo sie
|
|
// aussah, als gehörte sie zu dessen Beschriftung.
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if let remaining = battery.timeRemaining {
|
|
Text(duration(remaining)).font(.title3.monospacedDigit())
|
|
Text(battery.isCharging ? "popover.battery.untilFull"
|
|
: "popover.battery.remaining",
|
|
bundle: .module)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
} else if !battery.isPluggedIn {
|
|
// Am Netz braucht es keine Entschuldigung für die
|
|
// fehlende Schätzung — die Zeile darunter sagt ohnehin,
|
|
// was los ist.
|
|
Text("popover.battery.calculating", bundle: .module)
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
|
|
// Was gerade geschieht, in Worten. „Am Netz" allein
|
|
// beantwortet die Frage nicht, die man hat, wenn der
|
|
// Ladestand stehen bleibt.
|
|
activityLine(battery)
|
|
}
|
|
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
|
}
|
|
|
|
// Das Ladelimit steht hier und nicht in den Einstellungen: es ist
|
|
// eine Akkusache, und man greift danach, während man auf den
|
|
// Ladestand schaut.
|
|
if let control {
|
|
Divider().overlay(Onyx.Color.hairline)
|
|
ChargeLimitControl(control: control, roomy: true)
|
|
}
|
|
|
|
SectionTitle(text: "popover.battery.condition")
|
|
if let health = battery.health {
|
|
Row(label: String(localized: "popover.battery.health", bundle: .module),
|
|
value: MetricFormat.percent(health))
|
|
}
|
|
if let cycles = battery.cycleCount {
|
|
Row(label: String(localized: "popover.battery.cycles", bundle: .module),
|
|
value: "\(cycles)")
|
|
}
|
|
if let sensor = snapshot.sensors.first(where: { $0.key == "TB0T" }) {
|
|
Row(label: String(localized: "popover.temperature", bundle: .module),
|
|
value: MetricFormat.temperature(sensor.celsius))
|
|
}
|
|
|
|
SectionTitle(text: "popover.power")
|
|
if let watts = battery.watts {
|
|
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
|
|
: "popover.battery.draining",
|
|
bundle: .module),
|
|
value: MetricFormat.watts(abs(watts)), emphasis: .primary)
|
|
}
|
|
if let adapter = snapshot.power.adapter, adapter > 0 {
|
|
Row(label: String(localized: "popover.power.adapter", bundle: .module),
|
|
value: MetricFormat.watts(adapter))
|
|
}
|
|
if let voltage = snapshot.power.busVoltage {
|
|
Row(label: String(localized: "popover.battery.voltage", bundle: .module),
|
|
value: String(format: "%.2f V", voltage))
|
|
}
|
|
|
|
Divider()
|
|
// Ehrlich benennen, was fehlt: das Ladelimit braucht Schreibzugriff
|
|
// auf den SMC und damit den privilegierten Helfer — und der Key ist
|
|
// auf diesem Modell noch nicht identifiziert.
|
|
Text("popover.battery.limitComing", bundle: .module)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} else {
|
|
Text("popover.battery.none", bundle: .module).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func activityLine(_ battery: BatteryReading) -> some View {
|
|
let activity = ChargeActivity.resolve(isCharging: battery.isCharging,
|
|
isPluggedIn: battery.isPluggedIn,
|
|
charge: battery.charge,
|
|
limit: control?.chargeLimit)
|
|
HStack(spacing: 6) {
|
|
Image(systemName: symbol(for: activity))
|
|
.font(.caption)
|
|
.foregroundStyle(tint(for: activity))
|
|
switch activity {
|
|
case .charging:
|
|
Text("popover.battery.charging", bundle: .module)
|
|
case .pausedAtLimit(let limit):
|
|
Text("popover.battery.pausedAtLimit \(limit)", bundle: .module)
|
|
case .pluggedNotCharging:
|
|
Text("popover.battery.notCharging", bundle: .module)
|
|
case .onBattery:
|
|
Text("popover.battery.onBattery", bundle: .module)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.font(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
private func symbol(for activity: ChargeActivity) -> String {
|
|
switch activity {
|
|
case .charging: "bolt.fill"
|
|
case .pausedAtLimit: "pause.fill"
|
|
case .pluggedNotCharging: "powerplug.fill"
|
|
case .onBattery: "battery.50percent"
|
|
}
|
|
}
|
|
|
|
private func tint(for activity: ChargeActivity) -> Color {
|
|
// Farbe nur, wo sie etwas meldet: Laden ist ein Zustand, den man
|
|
// sucht. Alles andere bleibt zurückhaltend.
|
|
activity == .charging ? .green : .secondary
|
|
}
|
|
|
|
private func duration(_ seconds: TimeInterval) -> String {
|
|
let total = Int(seconds / 60)
|
|
return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min"
|
|
}
|
|
}
|
|
|
|
// MARK: - Sensoren
|
|
|
|
private struct SensorDetail: View {
|
|
let snapshot: MetricsSnapshot
|
|
|
|
var body: some View {
|
|
SectionTitle(text: "popover.temperature")
|
|
ForEach(snapshot.sensors) { sensor in
|
|
HStack {
|
|
Text(sensor.name).font(.callout)
|
|
Spacer(minLength: 12)
|
|
Text(MetricFormat.temperature(sensor.celsius))
|
|
.font(.callout.monospacedDigit())
|
|
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
|
|
// Der Ring macht aus einer Zahl einen Vergleich: 45° neben 79°
|
|
// sieht man schneller als man es liest.
|
|
Circle()
|
|
.trim(from: 0, to: min(sensor.celsius / 100, 1))
|
|
.stroke(MetricSummary.temperatureColor(sensor.celsius),
|
|
style: .init(lineWidth: 2, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.frame(width: 12, height: 12)
|
|
}
|
|
}
|
|
|
|
if !snapshot.fans.isEmpty {
|
|
SectionTitle(text: "popover.fans")
|
|
ForEach(snapshot.fans) { fan in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Text(fan.name).font(.callout)
|
|
Spacer(minLength: 12)
|
|
Text("\(Int(fan.rpm)) U/min").font(.callout.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
// Ohne Bezug sagt eine Drehzahl nichts: der Balken zeigt sie
|
|
// zwischen dem Minimum und Maximum der Firmware.
|
|
ProgressView(value: fan.fraction).controlSize(.small)
|
|
}
|
|
}
|
|
Text("metric.fan.controlComing", bundle: .module)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|