Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und `.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt. Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu das Paar für Herunter- und Hochladen samt Tauschknopf. Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku, Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören; ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem, sonst sucht man die Warnfarbe im Reiter und findet sie nirgends. Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent` ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen, Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen. Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt las sich das als englisches „J-E". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
444 lines
18 KiB
Swift
444 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(Onyx.Color.accent)
|
|
.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
|
|
/// Ohne Angabe die Akzentfarbe. Als Standardwert ginge sie nicht: der
|
|
/// wird außerhalb des Hauptthreads erzeugt, die Farbe liegt aber dort.
|
|
var tint: Color?
|
|
|
|
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 ?? Onyx.Color.accent,
|
|
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(Onyx.Color.accent.opacity(0.18))
|
|
.overlay(alignment: .bottom) {
|
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
.fill(Onyx.Color.accent)
|
|
.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(Onyx.Color.accent, "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 ? Onyx.Color.warning : 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: Onyx.Color.positive
|
|
case .warning: Onyx.Color.warning
|
|
case .critical: Onyx.Color.critical
|
|
}
|
|
}
|
|
|
|
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 ? Onyx.Color.positive
|
|
: (battery.charge < 0.2 ? Onyx.Color.critical
|
|
: Onyx.Color.accent))
|
|
// 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 ? Onyx.Color.positive : .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)
|
|
}
|
|
}
|
|
}
|