Kacheln und Fenster sprechen dieselbe Sprache

Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
+85
View File
@@ -20,6 +20,9 @@ final class AppModel {
var onLayoutChanged: (([WidgetPlacement]) -> Void)? var onLayoutChanged: (([WidgetPlacement]) -> Void)?
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen. /// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)? var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
var onShowsIdleIslandChanged: ((Bool) -> Void)?
var onNotchStyleChanged: ((NotchStyle) -> Void)?
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
var layout: [WidgetPlacement] { var layout: [WidgetPlacement] {
didSet { didSet {
@@ -37,6 +40,68 @@ final class AppModel {
} }
} }
/// Abweichende Inselbreiten, je Bildschirm — leer heißt „Standardbreite".
///
/// Als Wörterbuch über die `CGDirectDisplayID` und nicht als Liste über den
/// Index in `NSScreen.screens`: der Index ändert sich beim Umstecken, die
/// Kennung nicht. Sonst gälte die Einstellung des einen Monitors plötzlich
/// für den anderen.
private(set) var notchWidths: [CGDirectDisplayID: CGFloat] = [:]
/// Die eingestellte Breite dieses Bildschirms, falls es eine gibt.
func notchWidth(for displayID: CGDirectDisplayID) -> CGFloat? {
notchWidths[displayID]
}
/// Setzt eine abweichende Breite — oder nimmt sie mit `nil` zurück.
func setNotchWidth(_ width: CGFloat?, for displayID: CGDirectDisplayID) {
if let width { notchWidths[displayID] = width } else { notchWidths[displayID] = nil }
// Als Zeichenkette gespeichert, weil `UserDefaults` keine Wörterbücher
// mit Zahlenschlüsseln kann.
let raw = notchWidths.reduce(into: [String: Double]()) { $0[String($1.key)] = Double($1.value) }
defaults.set(raw, forKey: Keys.notchWidths)
onNotchWidthsChanged?(notchWidths)
}
/// Was links neben der Notch steht.
var bandLeading: BandAccessory {
didSet {
guard bandLeading != oldValue else { return }
defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading)
}
}
/// Was rechts daneben steht.
var bandTrailing: BandAccessory {
didSet {
guard bandTrailing != oldValue else { return }
defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing)
}
}
/// Notch oder Insel — die Form, die Onyx oben annimmt.
var notchStyle: NotchStyle {
didSet {
guard notchStyle != oldValue else { return }
defaults.set(notchStyle.rawValue, forKey: Keys.notchStyle)
onNotchStyleChanged?(notchStyle)
}
}
/// Ob die eingeklappte Insel sichtbar stehen bleibt.
///
/// Standardmäßig an: ohne sie wächst das Panel auf externen Bildschirmen
/// aus dem Nichts. Abschaltbar, weil ein schwarzer Balken auf vier
/// Monitoren eine Geschmacksfrage ist und keine Voreinstellung sein darf,
/// von der man nicht wegkommt.
var showsIdleIsland: Bool {
didSet {
guard showsIdleIsland != oldValue else { return }
defaults.set(showsIdleIsland, forKey: Keys.showsIdleIsland)
onShowsIdleIslandChanged?(showsIdleIsland)
}
}
var showsMenuBarIcon: Bool { var showsMenuBarIcon: Bool {
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) } didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
} }
@@ -86,6 +151,11 @@ final class AppModel {
private enum Keys { private enum Keys {
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" } static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy" static let displayPolicy = "onyx.displayPolicy"
static let showsIdleIsland = "onyx.showsIdleIsland"
static let notchStyle = "onyx.notchStyle"
static let notchWidths = "onyx.notchWidths"
static let bandLeading = "onyx.band.leading"
static let bandTrailing = "onyx.band.trailing"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon" static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon" static let showsDockIcon = "onyx.showsDockIcon"
static let triggerArea = "onyx.triggerArea" static let triggerArea = "onyx.triggerArea"
@@ -101,6 +171,21 @@ final class AppModel {
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy) self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
.flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly .flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
self.notchStyle = defaults.string(forKey: Keys.notchStyle)
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic
// Die bisherige feste Verdrahtung als Voreinstellung: wer nichts
// einstellt, sieht, was vorher auch dastand.
self.bandLeading = defaults.string(forKey: Keys.bandLeading)
.flatMap(BandAccessory.init(rawValue:)) ?? .weather
self.bandTrailing = defaults.string(forKey: Keys.bandTrailing)
.flatMap(BandAccessory.init(rawValue:)) ?? .battery
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
guard let id = CGDirectDisplayID(entry.key) else { return }
result[id] = CGFloat(entry.value)
}
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg // Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
// zurück, wenn die Notch einmal nicht reagiert. // zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
+198
View File
@@ -0,0 +1,198 @@
import SwiftUI
import OnyxDesign
/// Was am Aussehen einstellbar ist: die Farben und die Bewegung.
///
/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet,
/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst
/// nicht zur Wahl — Gelb heißt Warnung, Rot heißt kritisch, und wer das
/// umstellen kann, kann die Bedeutung zerstören.
///
/// Die Bewegung kommt als **Profil**, nicht als Federkonstanten. Ausfahren und
/// Einfahren müssen zueinander passen; diese Beziehung wählt man, nicht vier
/// Zahlen.
struct AppearanceSettingsView: View {
@Bindable var theme: OnyxTheme
/// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss.
///
/// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung
/// darauf sticht, statt zu leiten.
private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [
("color.suggestion.onyx", "#7FA8FF"),
("color.suggestion.mint", "#6FCF97"),
("color.suggestion.amber", "#E8B44A"),
("color.suggestion.coral", "#E5675C"),
("color.suggestion.lilac", "#B79CFF"),
("color.suggestion.slate", "#9AA6B2"),
]
var body: some View {
SettingsPage {
accent
network
material
motion
fixed
}
}
private var accent: some View {
OnyxFormCard(String(localized: "color.section.accent")) {
OnyxFormRow(String(localized: "color.accent"),
hint: String(localized: "color.accent.hint")) {
ColorPicker("", selection: $theme.accent, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.suggestions")) {
HStack(spacing: Onyx.Space.row) {
ForEach(Self.suggestions, id: \.hex) { suggestion in
Swatch(hex: suggestion.hex,
name: suggestion.name,
isSelected: theme.accent.onyxHex == suggestion.hex) {
theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent
}
}
}
}
// Mit dem Farbfleck daneben: welche Farbe das System gerade führt,
// weiß man nicht auswendig — und dann wirkt das Ergebnis wie ein
// Fehler statt wie eine Übernahme.
OnyxFormRow(String(localized: "color.adoptSystem")) {
Button { theme.adoptSystemAccent() } label: {
HStack(spacing: Onyx.Space.sm) {
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
Text("settings.action.adopt")
}
}
}
}
}
private var network: some View {
OnyxFormCard(String(localized: "color.section.network")) {
OnyxFormRow(String(localized: "color.download"),
hint: String(localized: "color.network.hint")) {
ColorPicker("", selection: $theme.download, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.upload")) {
ColorPicker("", selection: $theme.upload, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.swap")) {
Button("settings.action.swap") {
let previous = theme.download
theme.download = theme.upload
theme.upload = previous
}
}
}
}
private var material: some View {
OnyxFormCard(String(localized: "settings.appearance.material"),
hint: String(localized: "settings.appearance.material.hint")) {
OnyxFormRow(String(localized: "settings.appearance.surface")) {
Picker("", selection: $theme.material) {
ForEach(NotchMaterial.allCases, id: \.self) { material in
Text(material.localizedName).tag(material)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 240)
}
}
}
private var motion: some View {
OnyxFormCard(String(localized: "settings.appearance.motion"),
hint: String(localized: "settings.appearance.motion.hint")) {
OnyxFormRow(String(localized: "settings.appearance.profile")) {
Picker("", selection: $theme.motion) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.localizedName).tag(profile)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 240)
}
}
}
private var fixed: some View {
OnyxFormCard(String(localized: "color.section.fixed"),
hint: String(localized: "color.fixed.hint")) {
// Sichtbar, aber nicht bedienbar: sonst sucht man die Warnfarbe
// hier und findet sie nirgends.
OnyxFormRow(String(localized: "color.fixed.positive")) {
Circle().fill(Onyx.Color.positive).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.fixed.warning")) {
Circle().fill(Onyx.Color.warning).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.fixed.critical")) {
Circle().fill(Onyx.Color.critical).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.reset")) {
Button("settings.action.reset") { theme.reset() }
.disabled(theme.isDefault)
}
}
}
}
extension NotchMaterial {
var localizedName: LocalizedStringKey {
switch self {
case .stone: "settings.material.stone"
case .blur: "settings.material.blur"
case .glass: "settings.material.glass"
}
}
}
extension MotionProfile {
var localizedName: LocalizedStringKey {
switch self {
case .snappy: "settings.motion.snappy"
case .bouncy: "settings.motion.bouncy"
case .calm: "settings.motion.calm"
}
}
}
/// Ein Farbfeld zum Anklicken.
private struct Swatch: View {
let hex: String
let name: LocalizedStringKey
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(Color(onyxHex: hex) ?? .gray)
.frame(width: 20, height: 20)
.overlay {
// Der Ring statt eines Hakens: ein Haken auf einer dunklen
// Farbe ist nicht zu sehen, auf einer hellen nicht auf
// einer dunklen Fläche.
Circle()
.strokeBorder(.primary, lineWidth: isSelected ? 2 : 0)
.padding(-3)
}
}
.buttonStyle(.plain)
.help(Text(name))
}
}
+36 -42
View File
@@ -12,7 +12,7 @@ struct FanSettingsView: View {
@Bindable var control: FanControl @Bindable var control: FanControl
var body: some View { var body: some View {
Form { SettingsPage {
switch control.installState { switch control.installState {
case .notInstalled, .failed: case .notInstalled, .failed:
installSection installSection
@@ -23,16 +23,13 @@ struct FanSettingsView: View {
// Das Ladelimit hängt am selben Helfer wie die Lüfter — beides // Das Ladelimit hängt am selben Helfer wie die Lüfter — beides
// ist Hardwaresteuerung über den SMC, und beides ist erst // ist Hardwaresteuerung über den SMC, und beides ist erst
// erreichbar, wenn der Helfer läuft. // erreichbar, wenn der Helfer läuft.
Section("charge.section") { OnyxFormCard(String(localized: "charge.section")) {
ChargeLimitControl(control: control, roomy: true) ChargeLimitControl(control: control, roomy: true)
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.bottom, OnyxForm.rowPadding)
} }
} }
} }
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
.onAppear { .onAppear {
control.refreshInstallState() control.refreshInstallState()
control.start() control.start()
@@ -43,37 +40,33 @@ struct FanSettingsView: View {
// MARK: - Einrichtung // MARK: - Einrichtung
private var installSection: some View { private var installSection: some View {
Section { OnyxFormCard(String(localized: "fans.group.setup"),
Text("fans.intro") hint: String(localized: "fans.intro")) {
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
// Das Risiko benennen, bevor jemand zustimmt — nicht danach. // Das Risiko benennen, bevor jemand zustimmt — nicht danach.
Label("fans.warning", systemImage: "exclamationmark.triangle.fill") OnyxFormRow(String(localized: "fans.warning"),
.font(.callout) hint: String(localized: "fans.safety"),
.foregroundStyle(Onyx.Color.warning) symbol: "exclamationmark.triangle.fill",
.fixedSize(horizontal: false, vertical: true) tone: .warning)
Text("fans.safety")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if case .failed(let message) = control.installState { if case .failed(let message) = control.installState {
Text(message).font(.callout).foregroundStyle(Onyx.Color.critical) OnyxFormRow(message, symbol: "xmark.octagon", tone: .critical)
} }
OnyxFormRow(String(localized: "fans.install")) {
Button("fans.install") { control.install() } Button("fans.install") { control.install() }
} }
} }
}
private var approvalSection: some View { private var approvalSection: some View {
Section { OnyxFormCard(String(localized: "fans.group.setup")) {
Label("fans.approval.needed", systemImage: "hand.raised") OnyxFormRow(String(localized: "fans.approval.needed"),
.font(.callout) symbol: "hand.raised") {
.fixedSize(horizontal: false, vertical: true)
Button("fans.approval.open") { control.openApprovalSettings() } Button("fans.approval.open") { control.openApprovalSettings() }
Button("fans.approval.recheck") { control.refreshInstallState() } }
OnyxFormRow(String(localized: "fans.action.recheck")) {
Button("fans.action.recheck") { control.refreshInstallState() }
}
} }
} }
@@ -82,14 +75,13 @@ struct FanSettingsView: View {
@ViewBuilder @ViewBuilder
private var controlSection: some View { private var controlSection: some View {
if let report = control.report { if let report = control.report {
Section { OnyxFormCard(String(localized: "fans.group.control")) {
if let hottest = report.hottestCelsius { if let hottest = report.hottestCelsius {
HStack { OnyxFormRow(String(localized: "fans.hottest"), symbol: "thermometer") {
Label("fans.hottest", systemImage: "thermometer")
Spacer()
Text("\(Int(hottest))°").monospacedDigit() Text("\(Int(hottest))°").monospacedDigit()
.foregroundStyle(hottest >= FanSafety.criticalCelsius .foregroundStyle(hottest >= FanSafety.criticalCelsius
? Onyx.Color.critical : .secondary) ? AnyShapeStyle(Onyx.Color.critical)
: AnyShapeStyle(.secondary))
} }
} }
ForEach(report.fans, id: \.index) { fan in ForEach(report.fans, id: \.index) { fan in
@@ -97,22 +89,21 @@ struct FanSettingsView: View {
} }
} }
Section { OnyxFormCard(String(localized: "fans.group.danger"),
Text("fans.safety.active") hint: String(localized: "fans.safety.active")) {
.font(.callout).foregroundStyle(.secondary) OnyxFormRow(String(localized: "fans.uninstall")) {
.fixedSize(horizontal: false, vertical: true) Button("settings.action.remove", role: .destructive) {
Button("fans.uninstall", role: .destructive) {
Task { await control.uninstall() } Task { await control.uninstall() }
} }
} }
}
} else { } else {
Section { OnyxFormCard(String(localized: "fans.group.control")) {
HStack { OnyxFormRow(String(localized: "fans.connecting")) {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("fans.connecting").foregroundStyle(.secondary)
} }
if let error = control.lastError { if let error = control.lastError {
Text(error).font(.callout).foregroundStyle(Onyx.Color.critical) OnyxFormRow(error, symbol: "xmark.octagon", tone: .critical)
} }
} }
} }
@@ -127,7 +118,7 @@ private struct FanRow: View {
@State private var editing = false @State private var editing = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: Onyx.Space.sm) {
HStack { HStack {
Label(name, systemImage: "fan") Label(name, systemImage: "fan")
Spacer() Spacer()
@@ -173,6 +164,9 @@ private struct FanRow: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
// Die Karte polstert nur `OnyxFormRow`; dieser Block ist ein eigener.
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.vertical, Onyx.Space.row)
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } } .onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } } .onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
} }
+25 -51
View File
@@ -16,7 +16,7 @@ struct FanView: View {
let roomy: Bool let roomy: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: Onyx.Space.row) {
switch control.installState { switch control.installState {
case .installed: case .installed:
if let report = control.report, !report.fans.isEmpty { if let report = control.report, !report.fans.isEmpty {
@@ -24,10 +24,10 @@ struct FanView: View {
FanRow(control: control, index: index, fan: fan, roomy: roomy) FanRow(control: control, index: index, fan: fan, roomy: roomy)
} }
if let hottest = report.hottestCelsius { if let hottest = report.hottestCelsius {
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
HStack(spacing: 5) { HStack(spacing: Onyx.Space.sm) {
Image(systemName: "thermometer.medium") Image(systemName: "thermometer.medium")
.font(.system(size: 10)) .font(.system(size: Onyx.Symbol.small))
Text("\(Int(hottest.rounded())) °C") Text("\(Int(hottest.rounded())) °C")
.font(Onyx.Font.metricSmall).monospacedDigit() .font(Onyx.Font.metricSmall).monospacedDigit()
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -35,14 +35,24 @@ struct FanView: View {
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
} }
} else { } else {
notice("fans.noData", symbol: "fan") OnyxNotice(symbol: "fan",
text: String(localized: "fans.noData"),
placement: .leading)
} }
case .requiresApproval: case .requiresApproval:
notice("fans.approval.needed", symbol: "hand.raised") { OnyxNotice(symbol: "hand.raised",
text: String(localized: "fans.approval.needed"),
placement: .leading) {
OnyxPillButton(String(localized: "fans.install")) {
control.openApprovalSettings() control.openApprovalSettings()
} }
}
case .notInstalled: case .notInstalled:
notice("fans.notInstalled", symbol: "fan") { control.install() } OnyxNotice(symbol: "fan",
text: String(localized: "fans.notInstalled"),
placement: .leading) {
OnyxPillButton(String(localized: "fans.install")) { control.install() }
}
case .failed(let reason): case .failed(let reason):
Text(reason) Text(reason)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
@@ -54,29 +64,6 @@ struct FanView: View {
.onAppear { control.refreshInstallState() } .onAppear { control.refreshInstallState() }
} }
@ViewBuilder
private func notice(_ key: LocalizedStringKey, symbol: String,
action: (() -> Void)? = nil) -> some View {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 15, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary)
Text(key)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if let action {
Button(action: action) {
Text("fans.install")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
}
}
}
} }
private struct FanRow: View { private struct FanRow: View {
@@ -86,8 +73,8 @@ private struct FanRow: View {
let roomy: Bool let roomy: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
HStack(spacing: 6) { HStack(spacing: Onyx.Space.sm) {
Text("fans.fan \(fan.index + 1)") Text("fans.fan \(fan.index + 1)")
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
@@ -98,29 +85,16 @@ private struct FanRow: View {
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
} }
HStack(spacing: 8) { HStack(spacing: Onyx.Space.md) {
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt: // Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen. // wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht // Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
// aus wie ein Link und nicht wie ein Zustand. // aus wie ein Link und nicht wie ein Zustand.
Button { control.setAutomatic(index: index) } label: { OnyxPillButton(String(localized: "fans.auto"),
Text("fans.auto") isFilled: !fan.isManual,
.font(Onyx.Font.metricSmall) size: .compact) {
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary control.setAutomatic(index: index)
: Onyx.Color.surface)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background {
Capsule()
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
.overlay {
Capsule().strokeBorder(
fan.isManual ? Onyx.Color.hairline : .clear,
lineWidth: 1)
} }
}
}
.buttonStyle(.plain)
.disabled(!fan.isManual) .disabled(!fan.isManual)
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der // Ein Regler über einen leeren Bereich stürzt ab. Meldet der
@@ -184,7 +158,7 @@ final class FanMenuBarModule: MenuBarModule {
func makePopoverView() -> AnyView { func makePopoverView() -> AnyView {
AnyView(FanView(control: control, roomy: true) AnyView(FanView(control: control, roomy: true)
.padding(14) .padding(Onyx.Space.xl)
.frame(width: 260)) .frame(width: 260))
} }
+568
View File
@@ -0,0 +1,568 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die
/// Notch anzufahren.
///
/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt
/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene
/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf
/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese
/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also
/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher
/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle
/// statt erfundener.
///
/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug,
/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten
/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu
/// übersetzen.
struct GalleryView: View {
let model: AppModel
/// Bleibt stehen, wo man zuletzt war.
///
/// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt
/// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den
/// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne
/// Zwischenklick.
@AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles
var body: some View {
VStack(spacing: 0) {
Picker("", selection: $section) {
ForEach(GallerySection.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, Onyx.Space.window)
.padding(.top, Onyx.Space.row)
.padding(.bottom, Onyx.Space.xl)
Divider()
ScrollView {
switch section {
case .tiles: TileGallery()
case .panel: PanelGallery(model: model)
case .components: ComponentGallery()
case .tokens: TokenGallery()
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
.frame(minWidth: 760, minHeight: 520)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
}
}
enum GallerySection: String, CaseIterable, Identifiable {
case tiles, panel, components, tokens
var id: Self { self }
var title: String {
switch self {
case .tiles: "Kacheln"
case .panel: "Panel"
case .components: "Bauteile"
case .tokens: "Token"
}
}
}
// MARK: - Kacheln
/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche.
///
/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in
/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200
/// auf schwarzem Stein ist der Ort, an dem sie später steht.
private struct TileGallery: View {
private struct Entry: Identifiable {
let id: String
let widget: any OnyxWidget
}
var body: some View {
let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) }
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
if entries.isEmpty {
Text("Keine Kacheln angemeldet.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width,
maximum: LayoutEngine.tileSize.width),
spacing: Onyx.Metric.tileSpacing)],
alignment: .leading,
spacing: Onyx.Space.xxl
) {
ForEach(entries) { entry in
VStack(spacing: Onyx.Space.sm) {
// Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel,
// dann Rahmen, dann beschneiden. Wer erst
// beschneidet, sieht Überlauf nicht, den das Panel
// später zeigt.
OnyxTile { entry.widget.makeView() }
.frame(width: LayoutEngine.tileSize.width,
height: LayoutEngine.tileSize.height)
.clipped()
Text(entry.id)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Panel
/// Das ganze Panel, so breit, wie man es einstellt.
///
/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine`
/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen,
/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen,
/// ohne dafür einen Bildschirm anzustecken.
private struct PanelGallery: View {
let model: AppModel
@State private var availableWidth: Double = 1680
var body: some View {
let width = CGFloat(availableWidth)
let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width)
VStack(alignment: .leading, spacing: Onyx.Space.xl) {
HStack(spacing: Onyx.Space.lg) {
Text("Verfügbare Breite")
.font(.callout)
Slider(value: $availableWidth, in: 400...3000)
.frame(maxWidth: 320)
Text("\(Int(availableWidth)) pt")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · "
+ "\(LayoutEngine.columns(availableWidth: width)) Spalten · "
+ "\(model.layout.count) Kacheln")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
WidgetGrid(placements: model.layout, availableWidth: width)
.background(OnyxSurface())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Bauteile
/// Jedes Bauteil in jedem Zustand.
///
/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle
/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von
/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier
/// kann man ihn also einfach hinschreiben.
private struct ComponentGallery: View {
@State private var volume = 0.62
@State private var overdrive = 1.8
@State private var targeted = true
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
group("OnyxCard mit OnyxHairline und OnyxSectionTitle") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
OnyxSectionTitle("System")
OnyxCard {
VStack(spacing: 0) {
row("MacBook Pro-Lautsprecher", "41 %")
OnyxHairline()
row("MacBook Pro-Mikrofon", "27 %")
}
}
}
.frame(width: 260)
}
group("OnyxNotice") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") }
tile {
OnyxNotice(symbol: "hand.raised",
text: "Der Helfer wartet auf deine Freigabe.") {
OnyxPillButton("Einstellungen") {}
}
}
tile {
OnyxNotice(symbol: "fan",
text: "Noch keine Lüfterdaten.",
placement: .leading)
}
tile {
OnyxNotice(symbol: "fan",
text: "Der Helfer ist nicht installiert.",
placement: .leading) {
OnyxPillButton("Installieren") {}
}
}
}
}
group("OnyxPillButton") {
HStack(spacing: Onyx.Space.lg) {
OnyxPillButton("Einschalten") {}
OnyxPillButton("Automatik", isFilled: false) {}
OnyxPillButton("Auto", size: .compact) {}
OnyxPillButton("Auto", isFilled: false, size: .compact) {}
}
}
group("OnyxSlider") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
sliderRow("normal") {
OnyxSlider(value: volume) { volume = $0 }
}
sliderRow("mit Pegel") {
OnyxSlider(value: volume, level: 0.35) { volume = $0 }
}
sliderRow("stumm") {
OnyxSlider(value: volume, isActive: false) { volume = $0 }
}
sliderRow("Overdrive") {
OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 }
}
}
.frame(width: 320)
}
group("OnyxDropZone") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Toggle("Zeiger schwebt darüber", isOn: $targeted)
.font(Onyx.Font.body)
.foregroundStyle(Onyx.Color.textSecondary)
HStack(spacing: Onyx.Space.lg) {
tile {
OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen")
.onyxDropZone(isTargeted: targeted, style: .always)
}
tile {
OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an")
.onyxDropZone(isTargeted: targeted, style: .whenTargeted)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private func row(_ name: String, _ value: String) -> some View {
HStack {
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
Spacer(minLength: Onyx.Space.md)
Text(value)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.md)
}
/// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein
/// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun
/// kann.
private func tile(@ViewBuilder content: () -> some View) -> some View {
OnyxTile { content() }
.frame(width: LayoutEngine.tileSize.width, height: 130)
}
private func sliderRow(_ name: String,
@ViewBuilder content: () -> some View) -> some View {
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 72, alignment: .leading)
content()
}
}
private func group(_ title: String,
@ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
// MARK: - Token
/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind.
///
/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline`
/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da.
private struct TokenGallery: View {
@State private var moved = false
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
colors
spaces
radii
fonts
metrics
motion
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private var colors: some View {
let swatches: [(String, Color)] = [
("surface", Onyx.Color.surface),
("elevated", Onyx.Color.elevated),
("hairline", Onyx.Color.hairline),
("textPrimary", Onyx.Color.textPrimary),
("textSecondary", Onyx.Color.textSecondary),
("textTertiary", Onyx.Color.textTertiary),
("accent", Onyx.Color.accent),
("download", Onyx.Color.download),
("upload", Onyx.Color.upload),
("positive", Onyx.Color.positive),
("warning", Onyx.Color.warning),
("critical", Onyx.Color.critical),
]
return section("Farben") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)],
alignment: .leading, spacing: Onyx.Space.row) {
ForEach(swatches, id: \.0) { name, color in
HStack(spacing: Onyx.Space.md) {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(color)
.overlay {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 28, height: 28)
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var spaces: some View {
let steps: [(String, CGFloat)] = [
("xxs", Onyx.Space.xxs),
("xs", Onyx.Space.xs),
("sm", Onyx.Space.sm),
("md", Onyx.Space.md),
("lg", Onyx.Space.lg),
("xl", Onyx.Space.xl),
("xxl", Onyx.Space.xxl),
]
return section("Abstände") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(steps, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 40, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: value, height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var radii: some View {
let steps: [(String, CGFloat)] = [
("chip", Onyx.Radius.chip),
("control", Onyx.Radius.control),
("card", Onyx.Radius.card),
("tile", Onyx.Radius.tile),
("notch", Onyx.Radius.notch),
("panel", Onyx.Radius.panel),
]
return section("Rundungen") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
ForEach(steps, id: \.0) { name, value in
VStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: value, style: .continuous)
.fill(Onyx.Color.elevated)
.overlay {
RoundedRectangle(cornerRadius: value, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 64, height: 64)
Text("\(name) \(Int(value))")
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
}
}
private var fonts: some View {
let specimens: [(String, Font)] = [
("micro", Onyx.Font.micro),
("caption", Onyx.Font.caption),
("footnote", Onyx.Font.footnote),
("body", Onyx.Font.body),
("title", Onyx.Font.title),
("headline", Onyx.Font.headline),
("display", Onyx.Font.display),
("displayLarge", Onyx.Font.displayLarge),
("metricMicro", Onyx.Font.metricMicro),
("metricSmall", Onyx.Font.metricSmall),
("metric", Onyx.Font.metric),
("metricLarge", Onyx.Font.metricLarge),
("symbolHero", Onyx.Font.symbolHero),
]
return section("Schrift") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(specimens, id: \.0) { name, font in
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Text("Onyx 0123456789")
.font(font)
.foregroundStyle(Onyx.Color.textPrimary)
}
}
}
}
}
private var metrics: some View {
let values: [(String, CGFloat)] = [
("panelPadding", Onyx.Metric.panelPadding),
("tileSpacing", Onyx.Metric.tileSpacing),
("hairlineWidth", Onyx.Metric.hairlineWidth),
]
return section("Panelgeometrie") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(values, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: max(value, 1), height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
/// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf
/// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten.
private var motion: some View {
let curves: [(String, Animation)] = [
("expand", Onyx.Motion.expand),
("collapse", Onyx.Motion.collapse),
("value", Onyx.Motion.value),
]
return section("Bewegung") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Picker("Profil", selection: Binding(
get: { OnyxTheme.shared.motion },
set: { OnyxTheme.shared.motion = $0 })) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.rawValue).tag(profile)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 280)
ForEach(curves, id: \.0) { name, animation in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.hairline)
.frame(height: 4)
Circle()
.fill(Onyx.Color.accent)
.frame(width: 14, height: 14)
.offset(x: moved ? 246 : 0)
.animation(animation, value: moved)
}
.frame(width: 260, height: 14)
}
}
Button(moved ? "Zurück" : "Los") { moved.toggle() }
}
}
}
@ViewBuilder
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import AppKit
import SwiftUI
/// Führt das Schaufenster.
///
/// Aufgebaut wie `SettingsWindowController` — aus demselben Grund: SwiftUIs
/// eigene Fensterszenen kommen bei einer App ohne Menüleiste unzuverlässig an.
@MainActor
final class GalleryWindowController: NSObject, NSWindowDelegate {
private var window: NSWindow?
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
/// zurückgeschaltet wird.
private var keepsDockIcon = false
/// Ob das Schaufenster überhaupt erreichbar ist.
///
/// Im Debug immer, im Release nur auf ausdrücklichen Wunsch. Es ist
/// Werkzeug, kein Programmteil — und ein Menüeintrag, den niemand braucht,
/// ist selbst schon Unordnung.
///
/// ```
/// defaults write com.scarriffleservices.onyx onyx.gallery -bool YES
/// ```
static var isAvailable: Bool {
#if DEBUG
true
#else
UserDefaults.standard.bool(forKey: "onyx.gallery")
#endif
}
/// Ob es beim Start gleich aufgehen soll.
///
/// Der Werkstatt-Kreislauf ist bauen, starten, hinsehen. Ohne das ist der
/// dritte Schritt ein Griff zur Maus und zwei Klicks ins Statusmenü — bei
/// jedem Durchlauf. `UserDefaults` liest Startargumente von selbst mit:
///
/// ```
/// open Onyx.app --args -onyx.gallery.open YES
/// ```
static var opensAtLaunch: Bool {
isAvailable && UserDefaults.standard.bool(forKey: "onyx.gallery.open")
}
func show(model: AppModel) {
keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock — sonst ist es
// weder über den Dock noch über ⌘-Tab zurückzuholen.
NSApp.setActivationPolicy(.regular)
if window == nil {
let hosting = NSHostingController(rootView: GalleryView(model: model))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx — Schaufenster"
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 1040, height: 720))
window.contentMinSize = NSSize(width: 780, height: 540)
window.isReleasedWhenClosed = false
window.delegate = self
OnyxWindowPlacement.center(window)
window.setFrameAutosaveName("onyx.gallery")
self.window = window
}
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}
/// Der rote Knopf blendet aus, statt zu schließen — wie bei den
/// Einstellungen, und aus demselben Grund: Onyx hat kein Hauptfenster, und
/// manche Hilfsprogramme beenden eine App, sobald ihr letztes Fenster
/// verschwindet.
func windowShouldClose(_ sender: NSWindow) -> Bool {
hide()
return false
}
private func hide() {
window?.orderOut(nil)
// Nur zurück in den Hintergrund, wenn wirklich nichts mehr offen ist.
// Das Einstellungsfenster kann noch stehen — es hier mit auszublenden
// sähe aus wie ein Absturz.
let otherVisible = NSApp.windows.contains {
$0 !== window && $0.isVisible && $0.styleMask.contains(.titled)
}
guard !otherVisible else { return }
if !keepsDockIcon {
NSApp.setActivationPolicy(.accessory)
}
NSApp.hide(nil)
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -46,7 +46,7 @@ final class MixerMenuBarModule: MenuBarModule {
AnyView( AnyView(
ScrollView { ScrollView {
MixerView(mixer: mixer, system: system) MixerView(mixer: mixer, system: system)
.padding(16) .padding(Onyx.Space.xl)
} }
.scrollIndicators(.automatic) .scrollIndicators(.automatic)
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause. // Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
+18 -27
View File
@@ -33,7 +33,7 @@ struct OnboardingView: View {
Divider() Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { OnyxFormCard {
OnboardingRow( OnboardingRow(
symbol: "calendar", symbol: "calendar",
title: "onboarding.calendar", title: "onboarding.calendar",
@@ -74,9 +74,10 @@ struct OnboardingView: View {
fanControl.openApprovalSettings() fanControl.openApprovalSettings()
} }
} }
.padding(.horizontal, 22) .padding(.horizontal, Onyx.Space.xxl)
.padding(.vertical, 6) .padding(.vertical, Onyx.Space.lg)
} }
.scrollContentBackground(.hidden)
Divider() Divider()
footer footer
@@ -95,10 +96,10 @@ struct OnboardingView: View {
// MARK: - Kopf und Fuß // MARK: - Kopf und Fuß
private var header: some View { private var header: some View {
HStack(spacing: 14) { HStack(spacing: Onyx.Space.xl) {
Image(nsImage: NSApp.applicationIconImage) Image(nsImage: NSApp.applicationIconImage)
.resizable().frame(width: 52, height: 52) .resizable().frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
Text("onboarding.title").font(.title2).fontWeight(.semibold) Text("onboarding.title").font(.title2).fontWeight(.semibold)
Text("onboarding.subtitle") Text("onboarding.subtitle")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
@@ -106,7 +107,7 @@ struct OnboardingView: View {
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.padding(22) .padding(Onyx.Space.xxl)
} }
private var footer: some View { private var footer: some View {
@@ -128,7 +129,7 @@ struct OnboardingView: View {
Button("onboarding.done", action: onFinish) Button("onboarding.done", action: onFinish)
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
.padding(22) .padding(Onyx.Space.xxl)
} }
// MARK: - Zustände // MARK: - Zustände
@@ -169,33 +170,25 @@ private struct OnboardingRow: View {
enum State { case granted, denied, undetermined } enum State { case granted, denied, undetermined }
let symbol: String let symbol: String
let title: LocalizedStringKey let title: String.LocalizationValue
let detail: LocalizedStringKey let detail: String.LocalizationValue
let state: State let state: State
let request: () async -> Void let request: () async -> Void
let openSettings: () -> Void let openSettings: () -> Void
/// Dieselbe Zeile wie in den Einstellungen. Vorher war es eine eigene, die
/// zufällig gleich aussah — bis auf die Trennlinie, die sie sich selbst
/// unterlegte, und die deshalb auch unter der letzten stand.
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 14) { OnyxFormRow(String(localized: title),
Image(systemName: symbol) hint: String(localized: detail),
.font(.system(size: 15)) symbol: symbol,
.foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary) tone: state == .granted ? .normal : .normal) {
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title).fontWeight(.medium)
Text(detail)
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
switch state { switch state {
case .granted: case .granted:
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive) .foregroundStyle(Onyx.Color.positive)
.font(.system(size: 16)) .font(.system(size: Onyx.Symbol.large))
case .undetermined: case .undetermined:
Button("onboarding.allow") { Task { await request() } } Button("onboarding.allow") { Task { await request() } }
case .denied: case .denied:
@@ -204,7 +197,5 @@ private struct OnboardingRow: View {
Button("onboarding.openSettings", action: openSettings) Button("onboarding.openSettings", action: openSettings)
} }
} }
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Divider() }
} }
} }
+4 -1
View File
@@ -46,7 +46,10 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin, mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
onFinish: { [weak self] in self?.finish() })) onFinish: { [weak self] in self?.finish() }))
let window = NSWindow(contentViewController: hosting) // Derselbe Grund wie im Einstellungsfenster — siehe
// `OnyxBackdropController`.
let window = NSWindow(contentViewController:
OnyxBackdropController(content: hosting))
window.title = "Onyx" window.title = "Onyx"
window.styleMask = [.titled, .closable] window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
+263 -6
View File
@@ -12,6 +12,9 @@ import MetricsProvider
import NetworkProvider import NetworkProvider
import AudioProvider import AudioProvider
import ShelfProvider import ShelfProvider
import ClipboardProvider
import NotesProvider
import ActivityProvider
import OSLog import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App") private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
@@ -42,18 +45,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var weatherModel: WeatherModel? private var weatherModel: WeatherModel?
private var mediaModel: MediaModel? private var mediaModel: MediaModel?
private var metricsModel: MetricsModel? private var metricsModel: MetricsModel?
/// Woran erkannt wird, dass sich etwas **geändert** hat. Ohne dieses
/// Gedächtnis meldete jede Messung dasselbe noch einmal.
/// Entscheidet, ob ein Messwert überhaupt ein Ereignis ist. Geprüft in
/// `PowerWatcherTests`.
private var powerWatcher = PowerWatcher()
/// Woran ein Titelwechsel erkannt wird — Titel und Interpret, nicht der
/// ganze Zustand: die Abspielposition wandert im Sekundentakt weiter, und
/// das ist kein Ereignis.
private var lastTrack: String?
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
/// Helligkeit bleibt sie leer und meldet nie etwas.
private let brightness = DisplayBrightness()
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
private var lastBrightness: Double?
private var networkModel: NetworkModel? private var networkModel: NetworkModel?
private var shelfStore: ShelfStore? private var shelfStore: ShelfStore?
private var clipboardStore: ClipboardStore?
private var notesStore: NotesStore?
private let settingsWindow = SettingsWindowController() private let settingsWindow = SettingsWindowController()
private let fanControl = FanControl() private let fanControl = FanControl()
private let audioMixer = AudioMixer() private let audioMixer = AudioMixer()
private let systemAudio = SystemAudio() private let systemAudio = SystemAudio()
private let launchAtLogin = LaunchAtLogin() private let launchAtLogin = LaunchAtLogin()
private let onboarding = OnboardingWindowController() private let onboarding = OnboardingWindowController()
private let galleryWindow = GalleryWindowController()
private let activities = ActivityPresenter()
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
// Werkstattschalter: die Erscheinung **nur dieser App** umstellen, um
// Fenster hell und dunkel nebeneinander prüfen zu können. Die
// Systemeinstellung dafür zu drehen wäre ein Eingriff in die Umgebung
// des Nutzers — für einen Blick auf ein Fenster zu viel.
//
// open Onyx.app --args -onyx.appearance light
//
// **Nur im Debug.** Der Schalter liegt im flüchtigen Argument-Bereich
// und überlebt keinen Neustart — aber eine ausgelieferte App, die sich
// über die Erscheinungswahl des Systems hinwegsetzen *kann*, ist eine
// Falle, die irgendwann jemand auslöst.
#if DEBUG
switch UserDefaults.standard.string(forKey: "onyx.appearance") {
case "light": NSApp.appearance = NSAppearance(named: .aqua)
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
default: break
}
#endif
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen // Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe // bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
// Notch — das sieht aus wie ein Ruckler und ist keiner. // Notch — das sieht aus wie ein Ruckler und ist keiner.
@@ -74,14 +117,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let coordinator = NotchCoordinator(policy: model.displayPolicy, let coordinator = NotchCoordinator(policy: model.displayPolicy,
trigger: model.triggerArea) { displayID, presentation in trigger: model.triggerArea) { displayID, presentation in
NotchHostingView( NotchHostingView(
rootView: NotchContainer(presentation: presentation) { rootView: NotchContainer(
NotchPanelContent(model: model, displayID: displayID) presentation: presentation,
leading: { [weak self] in
NotchBandSlot(presentation: presentation) {
BandSlotView(model: model, edge: .leading,
weather: self?.weatherModel,
metrics: self?.metricsModel)
}
}, },
trailing: { [weak self] in
NotchBandSlot(presentation: presentation) {
BandSlotView(model: model, edge: .trailing,
weather: self?.weatherModel,
metrics: self?.metricsModel)
}
},
ambient: { [weak self] in
if let self { NotchAmbientSlot(presenter: activities) }
},
content: { NotchPanelContent(model: model, displayID: displayID) }),
presentation: presentation) presentation: presentation)
} }
coordinator.showsIdleIsland = model.showsIdleIsland
coordinator.style = model.notchStyle
coordinator.virtualWidthOverrides = model.notchWidths
coordinator.start() coordinator.start()
self.coordinator = coordinator self.coordinator = coordinator
// Die Live Activities: der Presenter entscheidet, die Controller
// führen aus. Beides je Bildschirm, weil jede Notch ihr eigenes
// Fenster hat.
activities.onChange = { [weak self] activity in
guard let self, let coordinator = self.coordinator else { return }
for controller in coordinator.activeControllers {
controller.setAmbient(activity == nil ? nil : self.activities.contentSize)
}
}
observeVolume()
observePower()
observeMedia()
observeBrightness()
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, selector: #selector(screensChanged), self, selector: #selector(screensChanged),
name: NSApplication.didChangeScreenParametersNotification, object: nil) name: NSApplication.didChangeScreenParametersNotification, object: nil)
@@ -97,6 +174,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.coordinator?.policy = policy self?.coordinator?.policy = policy
self?.applyContentSize() self?.applyContentSize()
} }
model.onShowsIdleIslandChanged = { [weak self] shows in
self?.coordinator?.showsIdleIsland = shows
}
model.onNotchStyleChanged = { [weak self] style in
self?.coordinator?.style = style
}
model.onNotchWidthsChanged = { [weak self] widths in
self?.coordinator?.virtualWidthOverrides = widths
}
let menuBar = MenuBarController() let menuBar = MenuBarController()
if let metricsModel { if let metricsModel {
@@ -133,6 +219,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
installOnyxStatusItem() installOnyxStatusItem()
observeMenuBarPreference() observeMenuBarPreference()
requestPermissions() requestPermissions()
if GalleryWindowController.opensAtLaunch {
galleryWindow.show(model: model)
}
// Derselbe Werkstattgriff wie beim Schaufenster: bauen, starten,
// hinsehen — ohne den Umweg übers Statusmenü.
//
// open Onyx.app --args -onyx.settings.open YES \
// -onyx.settings.section appearance
if UserDefaults.standard.bool(forKey: "onyx.settings.open") {
let name = UserDefaults.standard.string(forKey: "onyx.settings.section") ?? ""
showSettings(section: SettingsSection(rawValue: name) ?? .general)
}
} }
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar /// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
@@ -178,7 +278,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die // Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann // Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
// genügt der Berechtigungsbereich der Einstellungen. // genügt der Berechtigungsbereich der Einstellungen.
showSettings(tab: .permissions) showSettings(section: .permissions)
} }
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus. /// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
@@ -246,6 +346,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je // AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
// eine leere Ablegefläche waren ein Drittel des Panels. // eine leere Ablegefläche waren ein Drittel des Panels.
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore)) WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
let clipboardStore = ClipboardStore()
self.clipboardStore = clipboardStore
WidgetRegistry.shared.register(ClipboardWidget(store: clipboardStore))
let notesStore = NotesStore()
self.notesStore = notesStore
WidgetRegistry.shared.register(NotesWidget(store: notesStore))
} }
/// Je Bildschirm eine eigene Größe. /// Je Bildschirm eine eigene Größe.
@@ -292,6 +400,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
action: #selector(openSettings), keyEquivalent: ",").target = self action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(withTitle: String(localized: "settings.onboarding.show"), menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
action: #selector(openOnboarding), keyEquivalent: "").target = self action: #selector(openOnboarding), keyEquivalent: "").target = self
// Werkzeug, kein Programmteil: unlokalisiert, und im Release nur, wenn
// jemand den Schlüssel ausdrücklich setzt. Siehe
// `GalleryWindowController.isAvailable`.
if GalleryWindowController.isAvailable {
menu.addItem(withTitle: "Schaufenster",
action: #selector(openGallery), keyEquivalent: "").target = self
}
menu.addItem(.separator()) menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"), menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self action: #selector(quit), keyEquivalent: "q").target = self
@@ -302,6 +417,148 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openOnboarding() { showOnboarding() } @objc private func openOnboarding() { showOnboarding() }
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
///
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
/// `SystemAudio` hängt bereits mit CoreAudio-Listenern an Lautstärke und
/// Stummschaltung. Ein zweiter Weg dorthin wäre eine zweite Wahrheit.
///
/// Die Meldung kommt **vor** dem Schreiben; der neue Wert steht erst
/// danach. Deshalb wird er in einer Aufgabe gelesen, nicht im Rückruf.
private func observeVolume() {
withObservationTracking {
_ = systemAudio.outputVolume
_ = systemAudio.outputMuted
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
activities.raise(VolumeActivity(level: systemAudio.outputVolume,
isMuted: systemAudio.outputMuted))
observeVolume()
}
}
}
/// Meldet jeden **Wechsel** am Strom als Live Activity an.
///
/// Nicht jeden Messwert: der Ladestand ändert sich ständig, und eine Notch,
/// die das begleitet, wäre eine Leuchtreklame. Angemeldet wird nur, wenn
/// sich der Zustand ändert — und der Zustand ist das, was ``ChargeActivity``
/// aus Laden, Netz, Ladestand und Limit ableitet.
///
/// Der erste gelesene Zustand meldet **nichts**. Sonst begrüßte Onyx jeden
/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen
/// ist.
private func observePower() {
guard let metricsModel else { return }
withObservationTracking {
_ = metricsModel.snapshot.battery
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observePower() }
guard let battery = self.metricsModel?.snapshot.battery else { return }
guard let event = self.powerWatcher.observe(
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
charge: battery.charge,
limit: self.metricsModel?.chargeControl?.chargeLimit)
else { return }
let kind: PowerActivity.Kind = switch event {
case .charging: .charging
case .plugged: .plugged
case .unplugged: .unplugged
case .atLimit(let limit): .atLimit(limit)
case .low: .low
}
self.activities.raise(PowerActivity(kind: kind,
charge: battery.charge,
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn))
}
}
}
/// Meldet jede Helligkeitsänderung als Live Activity an.
///
/// Nur, wenn dieser Rechner überhaupt eine lesbare Helligkeit hat — auf
/// einem Mini am Monitor gibt es keine, und dann soll auch nichts
/// erscheinen. Siehe ``DisplayBrightness``.
private func observeBrightness() {
guard brightness.isAvailable else { return }
withObservationTracking {
_ = brightness.level
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observeBrightness() }
guard let level = self.brightness.level else { return }
guard let previous = self.lastBrightness else {
self.lastBrightness = level
return
}
self.lastBrightness = level
// Ein Hauch Unterschied ist kein Ereignis: die Meldung kommt
// auch bei automatischer Anpassung an das Umgebungslicht, und
// die kriecht in winzigen Schritten.
guard abs(level - previous) >= Self.brightnessStep else { return }
self.activities.raise(BrightnessActivity(level: level))
}
}
}
/// Ab welchem Sprung eine Helligkeitsänderung als Handgriff gilt.
///
/// Ein Tastendruck ändert sie um rund ein Sechzehntel; das Umgebungslicht
/// kriecht in viel kleineren Schritten.
private static let brightnessStep = 0.02
/// Meldet jeden **Titelwechsel** als Live Activity an.
///
/// Nicht jede Änderung an `nowPlaying`: dort wandert die Abspielposition
/// im Sekundentakt weiter, und das ist kein Ereignis. Verglichen wird
/// deshalb Titel und Interpret, nicht der ganze Zustand.
///
/// Auch hier meldet der erste gelesene Titel nichts — beim Start läuft
/// vielleicht seit einer Stunde dasselbe Stück.
private func observeMedia() {
guard let mediaModel else { return }
withObservationTracking {
_ = mediaModel.nowPlaying
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observeMedia() }
guard let playing = self.mediaModel?.nowPlaying, !playing.title.isEmpty else {
self.lastTrack = nil
return
}
let track = "\(playing.title)\u{1}\(playing.artist)"
guard let previous = self.lastTrack else {
self.lastTrack = track
return
}
guard track != previous else { return }
self.lastTrack = track
// Ein pausierter Titel wechselt nicht von selbst — was hier
// ankommt, während nichts läuft, ist ein Nachtrag der Quelle
// und kein Ereignis.
guard playing.isPlaying else { return }
self.activities.raise(MediaActivity(title: playing.title,
artist: playing.artist,
artwork: playing.artwork))
}
}
}
@objc private func openGallery() {
guard let model else { return }
galleryWindow.show(model: model)
}
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht. /// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
/// ///
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt /// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
@@ -362,16 +619,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() } @objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
@objc private func openSettings() { showSettings(tab: .widgets) } @objc private func openSettings() { showSettings(section: .general) }
private func showSettings(tab: SettingsTab) { private func showSettings(section: SettingsSection) {
guard let model, let calendarModel, let weatherModel else { return } guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel, settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, metricsModel: metricsModel, weatherModel: weatherModel, metricsModel: metricsModel,
shelfStore: shelfStore, fanControl: fanControl, shelfStore: shelfStore, fanControl: fanControl,
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
showOnboarding: { [weak self] in self?.showOnboarding() }, showOnboarding: { [weak self] in self?.showOnboarding() },
tab: tab) section: section)
} }
@objc private func quit() { NSApp.terminate(nil) } @objc private func quit() { NSApp.terminate(nil) }
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
/// Die Bereiche des Einstellungsfensters.
///
/// **Seitenleiste statt Reiterband.** Sieben deutsche Reiterbeschriftungen
/// brauchten rund 880 Punkte nebeneinander — das Fenster hatte deshalb eine
/// Mindestbreite, die nichts mit seinem Inhalt zu tun hatte, sondern nur damit,
/// dass sonst der erste und der letzte Reiter über die Kante hinausragten. Eine
/// Seitenleiste wächst nach unten, und dort ist Platz.
///
/// **Keine leeren Einträge auf Vorrat.** Sperrbildschirm, Zwischenablage und
/// Notizen kommen mit ihren Phasen. Ein Eintrag, der sich öffnen lässt und
/// nichts enthält, ist ein Versprechen, das die App nicht hält.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case general
case widgets
case widgetOptions
case notch
case appearance
case menubar
case fans
case permissions
var id: Self { self }
var title: String {
switch self {
case .general: String(localized: "settings.general")
case .widgets: String(localized: "settings.tab.widgets")
case .widgetOptions: String(localized: "settings.tab.options")
case .notch: String(localized: "settings.section.notch")
case .appearance: String(localized: "settings.section.appearance")
case .menubar: String(localized: "settings.tab.menubar")
case .fans: String(localized: "settings.tab.fans")
case .permissions: String(localized: "settings.tab.permissions")
}
}
var symbol: String {
switch self {
case .general: "gearshape.fill"
case .widgets: "square.grid.2x2.fill"
case .widgetOptions: "wrench.adjustable.fill"
case .notch: "macbook"
case .appearance: "paintpalette.fill"
case .menubar: "menubar.rectangle"
case .fans: "fan.fill"
case .permissions: "lock.fill"
}
}
/// Die Farbe des Plättchens.
///
/// Fest und **nicht** die Akzentfarbe: acht Einträge in derselben Farbe
/// wären wieder acht gleich aussehende Zeilen. Die Farbe unterscheidet
/// hier, sie schmückt nicht.
var tint: Color {
switch self {
case .general: Color(red: 0.44, green: 0.47, blue: 0.51)
case .widgets: Color(red: 0.31, green: 0.53, blue: 0.93)
case .widgetOptions: Color(red: 0.29, green: 0.68, blue: 0.66)
case .notch: Color(red: 0.55, green: 0.44, blue: 0.85)
case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28)
case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66)
case .fans: Color(red: 0.33, green: 0.68, blue: 0.85)
case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35)
}
}
}
+368 -216
View File
@@ -9,10 +9,6 @@ import MetricsProvider
import OnyxHelperProtocol import OnyxHelperProtocol
import WeatherProvider import WeatherProvider
enum SettingsTab: Hashable {
case widgets, display, colors, sources, menubar, fans, permissions
}
struct SettingsView: View { struct SettingsView: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let calendarModel: CalendarModel let calendarModel: CalendarModel
@@ -22,87 +18,215 @@ struct SettingsView: View {
let fanControl: FanControl let fanControl: FanControl
let launchAtLogin: LaunchAtLogin let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void let showOnboarding: () -> Void
/// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes /// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt. /// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
@Binding var selectedTab: SettingsTab @Binding var section: SettingsSection
/// Die Reiter in der Reihenfolge, in der sie oben stehen.
private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [
(.widgets, "settings.tab.widgets"),
(.display, "settings.tab.display"),
(.colors, "settings.tab.colors"),
(.sources, "settings.tab.options"),
(.menubar, "settings.tab.menubar"),
(.fans, "settings.tab.fans"),
(.permissions, "settings.tab.permissions"),
]
var body: some View { var body: some View {
// Eigene Reiterleiste statt `TabView`. // **Zweiteilung von Hand, kein `NavigationSplitView`.**
// //
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs // Der malt Seitenleiste und Inhaltsfläche selbst deckend zu. Die
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf. // Vibrancy-Ebene des Fensters kam deshalb nur unter dem Titelbalken
// Abstand nach oben half nicht: der Trenner wandert mit. Also die // durch — oben durchsichtig, darunter eine Platte.
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst. // `.scrollContentBackground(.hidden)` erreicht sie nicht: es gilt für
VStack(spacing: 0) { // Listen und Scrollflächen, nicht für den Rahmen darum.
Picker("", selection: $selectedTab) { //
ForEach(Self.tabs, id: \.tab) { entry in // Gebraucht wird hier ohnehin keine Navigation, sondern eine Auswahl
Text(entry.label).tag(entry.tab) // und zwei Spalten.
} HStack(spacing: 0) {
} sidebar
.pickerStyle(.segmented) .frame(width: 200)
.labelsHidden()
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund Divider()
// 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch
// dann, wenn das Fenster schmaler war — sie ragte dann links und
// rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt
// dafür, dass hier trotzdem nichts gestaucht wird.
.frame(maxWidth: 1000)
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 14)
content content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} }
// Eine Fläche für alles.
//
// Vorher hatte jeder Bereich seinen eigenen Untergrund: die
// Formulare bringen einen mit, der Rest des Fensters hat den des
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
.background(Color(nsColor: .windowBackgroundColor))
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
//
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
// View — SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
// galt, während der Farbwähler daneben schon die neue zeigte.
.tint(Onyx.Color.accent) .tint(Onyx.Color.accent)
.labelStyle(.onyx) .labelStyle(.onyx)
// Außerhalb eines `Form` zeichnet macOS jeden `Toggle` als
// Ankreuzfeld. In einer Karte mit Schaltern sieht das aus wie ein
// Formular von 2009 — und schlimmer: die beiden Formen bedeuten
// dasselbe und sehen verschieden aus.
.toggleStyle(.switch)
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster // Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
// wachsen, der Inhalt aber nicht mit. // wachsen, der Inhalt aber nicht mit.
.frame(minWidth: 740, minHeight: 460) .frame(minWidth: 760, minHeight: 480)
}
private var sidebar: some View {
ScrollView {
VStack(spacing: Onyx.Space.xxs) {
ForEach(SettingsSection.allCases) { entry in
Button { section = entry } label: {
OnyxSidebarLabel(entry.title, symbol: entry.symbol, tint: entry.tint)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.sm)
.background {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(section == entry ? AnyShapeStyle(Onyx.Color.accent)
: AnyShapeStyle(.clear))
}
// Auf der Akzentfarbe steht Weiß, nicht die
// Textfarbe des Systems — die wäre im Hellmodus
// schwarz auf kräftigem Blau.
.foregroundStyle(section == entry ? AnyShapeStyle(.white)
: AnyShapeStyle(.primary))
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.row)
}
.scrollContentBackground(.hidden)
} }
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
switch selectedTab { switch section {
case .general:
GeneralSettings(model: model, launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding)
case .widgets: case .widgets:
WidgetSettings(model: model, shelfStore: shelfStore) WidgetSettings(model: model)
case .display: case .widgetOptions:
DisplaySettings(model: model)
case .colors:
ColorSettingsView(theme: OnyxTheme.shared)
case .sources:
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel, WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
shelfStore: shelfStore) shelfStore: shelfStore)
case .notch:
NotchSettings(model: model)
case .appearance:
AppearanceSettingsView(theme: OnyxTheme.shared)
case .menubar: case .menubar:
MenuBarSettings(model: model, metricsModel: metricsModel) MenuBarSettings(model: model, metricsModel: metricsModel)
case .fans: case .fans:
FanSettingsView(control: fanControl) FanSettingsView(control: fanControl)
case .permissions: case .permissions:
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel, PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding) }
}
}
// MARK: - Allgemein
private struct GeneralSettings: View {
@Bindable var model: AppModel
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
var body: some View {
SettingsPage {
OnyxFormCard(String(localized: "settings.general.behaviour")) {
OnyxFormRow(String(localized: "settings.launchAtLogin")) {
Toggle("", isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { _ = launchAtLogin.set($0) }))
.labelsHidden()
.disabled(launchAtLogin.needsSystemSettings)
}
// Der Zustand, der sonst in eine Schleife führt: der Schalter
// steht auf aus, Drücken bewirkt nichts, weil das System den
// Eintrag blockiert. Also steht hier, wo es weitergeht.
if launchAtLogin.needsSystemSettings {
OnyxFormRow(String(localized: "settings.launchAtLogin.blocked")) {
Button("settings.action.open") {
launchAtLogin.openSystemSettings()
}
}
}
OnyxFormRow(String(localized: "settings.onboarding.title"),
hint: String(localized: "settings.onboarding.hint")) {
Button("settings.action.show", action: showOnboarding)
}
}
OnyxFormCard(String(localized: "settings.general.appearanceGroup")) {
OnyxFormRow(String(localized: "settings.display.menuBarIcon"),
hint: String(localized: "settings.display.menuBarIcon.hint")) {
Toggle("", isOn: $model.showsMenuBarIcon).labelsHidden()
}
OnyxFormRow(String(localized: "settings.display.dockIcon"),
hint: String(localized: "settings.display.dockIcon.hint")) {
Toggle("", isOn: $model.showsDockIcon).labelsHidden()
}
}
}
.task { launchAtLogin.refresh() }
}
}
// MARK: - Notch
private struct NotchSettings: View {
@Bindable var model: AppModel
var body: some View {
SettingsPage {
OnyxFormCard(String(localized: "settings.notch.displays")) {
OnyxFormRow(String(localized: "settings.display.policy"),
hint: String(localized: "settings.display.hint")) {
Picker("", selection: $model.displayPolicy) {
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
Text("settings.display.mainDisplay").tag(DisplayPolicy.mainDisplay)
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
}
.labelsHidden()
.frame(width: 200)
}
OnyxFormRow(String(localized: "settings.notch.style"),
hint: String(localized: "settings.notch.style.hint")) {
Picker("", selection: $model.notchStyle) {
Text("settings.notch.style.automatic").tag(NotchStyle.automatic)
Text("settings.notch.style.notch").tag(NotchStyle.notch)
Text("settings.notch.style.island").tag(NotchStyle.island)
}
.labelsHidden()
.frame(width: 200)
}
OnyxFormRow(String(localized: "settings.island.show"),
hint: String(localized: "settings.island.hint")) {
Toggle("", isOn: $model.showsIdleIsland).labelsHidden()
}
}
OnyxFormCard(String(localized: "settings.band"),
hint: String(localized: "settings.band.hint")) {
OnyxFormRow(String(localized: "settings.band.leading")) {
Picker("", selection: $model.bandLeading) {
ForEach(BandAccessory.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
}
.labelsHidden()
.frame(width: 200)
}
OnyxFormRow(String(localized: "settings.band.trailing")) {
Picker("", selection: $model.bandTrailing) {
ForEach(BandAccessory.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
}
.labelsHidden()
.frame(width: 200)
}
}
NotchWidthSettings(model: model)
OnyxFormCard(String(localized: "settings.notch.triggerArea")) {
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
// niemandem etwas, ein nachgebildeter Bildschirm schon.
TriggerAreaSettings(trigger: $model.triggerArea)
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.bottom, OnyxForm.rowPadding)
}
} }
} }
} }
@@ -118,8 +242,6 @@ struct SettingsView: View {
private struct PermissionSettings: View { private struct PermissionSettings: View {
let calendarModel: CalendarModel let calendarModel: CalendarModel
let weatherModel: WeatherModel let weatherModel: WeatherModel
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
@State private var calendarGranted = false @State private var calendarGranted = false
private var locationState: PermissionRow.State { private var locationState: PermissionRow.State {
@@ -131,67 +253,29 @@ private struct PermissionSettings: View {
} }
var body: some View { var body: some View {
Form { SettingsPage {
Section("settings.general") { OnyxFormCard(String(localized: "settings.permissions.group")) {
Toggle(isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { _ = launchAtLogin.set($0) })) {
Text("settings.launchAtLogin")
}
.disabled(launchAtLogin.needsSystemSettings)
// Der Zustand, der sonst in eine Schleife führt: der Schalter
// steht auf aus, Drücken bewirkt nichts, weil das System den
// Eintrag blockiert. Also steht hier, wo es weitergeht.
if launchAtLogin.needsSystemSettings {
HStack {
Text("settings.launchAtLogin.blocked")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button("settings.permissions.openSystem") {
launchAtLogin.openSystemSettings()
}
}
}
HStack {
Spacer()
Button("settings.onboarding.show", action: showOnboarding)
.buttonStyle(.borderless)
}
}
Section {
PermissionRow( PermissionRow(
title: "settings.permissions.calendar", title: String(localized: "settings.permissions.calendar"),
symbol: "calendar", symbol: "calendar",
state: calendarGranted ? .granted state: calendarGranted ? .granted
: (calendarModel.authorization == .denied ? .denied : .undetermined), : (calendarModel.authorization == .denied ? .denied : .undetermined),
explanation: "settings.permissions.calendar.why", explanation: String(localized: "settings.permissions.calendar.why"),
pane: .calendar, pane: .calendar,
request: { calendarGranted = await calendarModel.requestAccessNow() }) request: { calendarGranted = await calendarModel.requestAccessNow() })
}
Section {
PermissionRow( PermissionRow(
title: "settings.permissions.location", title: String(localized: "settings.permissions.location"),
symbol: "location", symbol: "location",
// Direkt aus dem beobachtbaren Wert: der Zustand ändert // Direkt aus dem beobachtbaren Wert: der Zustand ändert
// sich, während dieses Fenster offen steht. // sich, während dieses Fenster offen steht.
state: locationState, state: locationState,
explanation: "settings.permissions.location.why", explanation: String(localized: "settings.permissions.location.why"),
pane: .location, pane: .location,
request: { weatherModel.requestLocationAuthorization() }) request: { weatherModel.requestLocationAuthorization() })
} }
} }
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
.task { .task {
launchAtLogin.refresh()
calendarGranted = calendarModel.hasAccess calendarGranted = calendarModel.hasAccess
guard !calendarGranted, calendarModel.authorization == .undetermined else { return } guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den // Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
@@ -226,17 +310,15 @@ private struct PermissionRow: View {
} }
} }
let title: LocalizedStringKey let title: String
let symbol: String let symbol: String
let state: State let state: State
let explanation: LocalizedStringKey let explanation: String
let pane: Pane let pane: Pane
let request: () async -> Void let request: () async -> Void
var body: some View { var body: some View {
HStack { OnyxFormRow(title, hint: explanation, symbol: symbol) {
Label(title, systemImage: symbol)
Spacer()
switch state { switch state {
case .granted: case .granted:
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der // Die Farbe sitzt am Symbol, nicht am ganzen Label: der
@@ -260,10 +342,6 @@ private struct PermissionRow: View {
} }
} }
} }
Text(explanation)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -278,7 +356,7 @@ private struct MenuBarSettings: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let metricsModel: MetricsModel? let metricsModel: MetricsModel?
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [ private static let modules: [(id: String, label: String.LocalizationValue, symbol: String)] = [
("metric.cpu", "metric.cpu", "cpu"), ("metric.cpu", "metric.cpu", "cpu"),
("metric.gpu", "metric.gpu", "cpu.fill"), ("metric.gpu", "metric.gpu", "cpu.fill"),
("metric.memory", "metric.memory", "memorychip"), ("metric.memory", "metric.memory", "memorychip"),
@@ -290,22 +368,20 @@ private struct MenuBarSettings: View {
] ]
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { SettingsPage {
Text("settings.menubar.hint") Text("settings.menubar.hint")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
List { OnyxFormCard {
ForEach(Self.modules, id: \.id) { module in ForEach(Self.modules, id: \.id) { module in
ModuleRow(model: model, id: module.id, ModuleRow(model: model, id: module.id,
label: module.label, symbol: module.symbol, label: module.label, symbol: module.symbol,
metricsModel: metricsModel) metricsModel: metricsModel)
} }
} }
.listStyle(.inset)
} }
.padding()
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul // Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten. // ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
.id(model.menuBarRevision) .id(model.menuBarRevision)
@@ -315,7 +391,7 @@ private struct MenuBarSettings: View {
private struct ModuleRow: View { private struct ModuleRow: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let id: String let id: String
let label: LocalizedStringKey let label: String.LocalizationValue
let symbol: String let symbol: String
let metricsModel: MetricsModel? let metricsModel: MetricsModel?
@@ -344,22 +420,11 @@ private struct ModuleRow: View {
var body: some View { var body: some View {
let settings = model.menuBarSettings(for: id) let settings = model.menuBarSettings(for: id)
HStack { OnyxFormRow(String(localized: label), symbol: symbol) {
Toggle(isOn: Binding( HStack(spacing: Onyx.Space.md) {
get: { settings.isEnabled }, // Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort
set: { enabled in // das stille Maximum über alle Sensoren — eine Zahl, von der
var updated = settings // niemand weiß, woher sie kommt.
updated.isEnabled = enabled
model.setMenuBarSettings(updated, for: id)
})) {
Label(label, systemImage: symbol)
}
Spacer()
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das
// stille Maximum über alle Sensoren — eine Zahl, von der niemand
// weiß, woher sie kommt.
if id == "metric.temperature", let metricsModel { if id == "metric.temperature", let metricsModel {
Picker("", selection: Binding( Picker("", selection: Binding(
get: { metricsModel.selectedSensorKey ?? "" }, get: { metricsModel.selectedSensorKey ?? "" },
@@ -374,9 +439,9 @@ private struct ModuleRow: View {
.disabled(!settings.isEnabled) .disabled(!settings.isEnabled)
} }
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte // Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide
// stammen aus derselben Messung — ein zweites Element in der // Werte stammen aus derselben Messung — ein zweites Element in
// ohnehin knappen Menüleiste wäre Verschwendung. // der ohnehin knappen Menüleiste wäre Verschwendung.
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel { if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
Picker("", selection: Binding( Picker("", selection: Binding(
get: { metricsModel.displayMode(for: metric) }, get: { metricsModel.displayMode(for: metric) },
@@ -390,8 +455,8 @@ private struct ModuleRow: View {
} }
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat // Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen // keinen einzelnen Messwert, den man als Zahl oder Verlauf
// könnte — eine Auswahl ohne Wirkung wäre eine Falle. // zeigen könnte — eine Auswahl ohne Wirkung wäre eine Falle.
if !Self.presentations(for: id).isEmpty { if !Self.presentations(for: id).isEmpty {
Picker("", selection: Binding( Picker("", selection: Binding(
get: { settings.presentation }, get: { settings.presentation },
@@ -408,6 +473,16 @@ private struct ModuleRow: View {
.frame(width: 150) .frame(width: 150)
.disabled(!settings.isEnabled) .disabled(!settings.isEnabled)
} }
Toggle("", isOn: Binding(
get: { settings.isEnabled },
set: { enabled in
var updated = settings
updated.isEnabled = enabled
model.setMenuBarSettings(updated, for: id)
}))
.labelsHidden()
}
} }
} }
} }
@@ -430,22 +505,30 @@ extension MenuBarPresentation {
private struct WidgetSettings: View { private struct WidgetSettings: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let shelfStore: ShelfStore?
/// Welches Widget rechts gezeigt wird.
///
/// Der Name allein sagt nicht, was eine Kachel zeigt — „Sensoren" kann
/// vieles heißen. Und ausprobieren hieß bisher: hinzufügen, Notch
/// anfahren, ansehen, wieder entfernen.
@State private var previewID: String?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { HStack(spacing: 0) {
Text("settings.widgets.hint") // Eine Liste, keine Karte: das Ziehen zum Umsortieren kommt von
.font(.callout) // `List.onMove`. Und **nur** die Liste, ohne Rahmen darum — eine
.foregroundStyle(.secondary) // Liste in einer Spalte mit eigenem Rand lief unter den
// Fensterknöpfen hindurch, weil sie ihre eigene Scrollfläche
List { // mitbringt und die dann zwei Ränder gegeneinander schieben.
Section("settings.widgets.inPanel") { List(selection: $previewID) {
Section {
// Die Reihenfolge hier ist die Reihenfolge im Panel: die // Die Reihenfolge hier ist die Reihenfolge im Panel: die
// Layout-Engine setzt die Widgets von oben links der Reihe // Layout-Engine setzt die Widgets von oben links der Reihe
// nach ein. // nach ein.
ForEach(model.layout) { placement in ForEach(model.layout) { placement in
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) { if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
PlacementRow(model: model, placement: placement, widget: widget) PlacementRow(model: model, placement: placement, widget: widget)
.tag(placement.widgetID)
} }
} }
.onMove { model.move(fromOffsets: $0, toOffset: $1) } .onMove { model.move(fromOffsets: $0, toOffset: $1) }
@@ -454,6 +537,13 @@ private struct WidgetSettings: View {
Text("settings.widgets.empty") Text("settings.widgets.empty")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} header: {
Text("settings.widgets.inPanel")
} footer: {
Text("settings.widgets.hintShort")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) } let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) }
@@ -466,47 +556,69 @@ private struct WidgetSettings: View {
Button("settings.widgets.add") { model.add(widget) } Button("settings.widgets.add") { model.add(widget) }
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
.tag(widget.id)
} }
} }
} }
} }
.listStyle(.inset) .listStyle(.inset)
.scrollContentBackground(.hidden)
Divider()
WidgetPreview(widgetID: previewID)
.frame(width: 252)
}
.task {
// Beim Öffnen gleich etwas zeigen: eine leere Vorschau neben einer
// vollen Liste sieht aus, als wäre sie kaputt.
guard previewID == nil else { return }
previewID = model.layout.first?.widgetID ?? WidgetRegistry.shared.all.first?.id
} }
.padding()
} }
} }
/// Einstellungen der Ablage. /// Die gewählte Kachel, so wie sie in der Notch aussieht.
/// ///
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus: /// Echte Größe (180 × 200) auf echter Paneloberfläche und mit den echten
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein /// Modellen — eine verkleinerte oder erfundene Vorschau wäre eine Behauptung.
/// Papierkorb mit Zeitschaltuhr. /// Dieselben Schritte wie in `WidgetGrid`: Kachel, Rahmen, beschneiden.
private struct ShelfSettingsRows: View { private struct WidgetPreview: View {
@Bindable var store: ShelfStore let widgetID: String?
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
var body: some View { var body: some View {
Picker(selection: $store.autoCleanupDays) { VStack(spacing: Onyx.Space.row) {
ForEach(Self.choices, id: \.self) { days in if let widgetID, let widget = WidgetRegistry.shared.widget(id: widgetID) {
if let days { OnyxTile { widget.makeView() }
Text("settings.shelf.days \(days)").tag(Optional(days)) .frame(width: LayoutEngine.tileSize.width,
height: LayoutEngine.tileSize.height)
.clipped()
.padding(Onyx.Metric.panelPadding)
// Die Fläche bleibt schwarzer Stein, auch wenn das Fenster
// hell ist — sie zeigt die Notch, nicht das Fenster.
.background(OnyxSurface(cornerRadius: Onyx.Radius.tile))
.tint(Onyx.Color.accent)
VStack(spacing: Onyx.Space.xxs) {
Text(widget.displayName)
.font(.callout)
Text("settings.widgets.preview.hint")
.font(.caption)
.foregroundStyle(.secondary)
}
.multilineTextAlignment(.center)
} else { } else {
Text("settings.shelf.never").tag(Int?.none) Text("settings.widgets.preview.none")
} .font(.callout)
} .foregroundStyle(.secondary)
} label: { .multilineTextAlignment(.center)
Text("settings.shelf.cleanup") .fixedSize(horizontal: false, vertical: true)
} }
HStack { Spacer(minLength: 0)
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
} }
.frame(maxWidth: .infinity, alignment: .top)
.padding(OnyxForm.pagePadding)
} }
} }
@@ -532,50 +644,90 @@ private struct PlacementRow: View {
} }
} }
// MARK: - Anzeige // MARK: - Seite
private struct DisplaySettings: View { /// Der Rahmen eines Bereichs, der aus Karten besteht.
///
/// Die `ScrollView` sitzt hier und nicht im Fenster: Bereiche mit einer `List`
/// oder einem `Form` bringen ihre eigene mit, und zwei ineinander ergeben eine
/// Fläche, die beim Ziehen mal die eine und mal die andere bewegt.
struct SettingsPage<Content: View>: View {
private let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: OnyxForm.cardSpacing) {
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(OnyxForm.pagePadding)
}
.scrollContentBackground(.hidden)
}
}
/// Die Inselbreite, je Bildschirm.
///
/// **Nur Breite, keine Höhe.** Die Höhe ist nicht mehr frei: bei verschmolzener
/// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste, damit
/// die Insel darin liegt statt darunter herauszuhängen. Ein Höhenregler wäre
/// ein Bedienelement, das nichts tut.
///
/// Bildschirme mit echter Notch bekommen keinen Regler, sondern die Auskunft,
/// warum es keinen gibt — sonst sucht man den Fehler bei sich.
private struct NotchWidthSettings: View {
@Bindable var model: AppModel @Bindable var model: AppModel
var body: some View { var body: some View {
Form { OnyxFormCard(String(localized: "settings.notch.width"),
Picker("settings.display.policy", selection: $model.displayPolicy) { hint: String(localized: "settings.notch.width.hint")) {
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly) ForEach(NSScreen.screens, id: \.displayID) { screen in
Text("settings.display.all").tag(DisplayPolicy.allDisplays) row(for: screen)
} }
.pickerStyle(.radioGroup)
Text("settings.display.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
// niemandem etwas, ein nachgebildeter Bildschirm schon.
TriggerAreaSettings(trigger: $model.triggerArea)
Divider()
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
Text("settings.display.menuBarIcon.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
Text("settings.display.dockIcon.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
.formStyle(.grouped) }
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante. @ViewBuilder
.scrollContentBackground(.hidden) private func row(for screen: NSScreen) -> some View {
.padding() let id = screen.displayID
// Dieselbe Frage, die auch die Geometrie stellt — und dieselbe Antwort.
let hasHardware = NotchGeometry.hardwareRect(screen.onyxMetrics) != nil
let merged = model.notchStyle.merges(withHardware: hasHardware)
OnyxFormRow(screen.localizedName,
hint: merged ? String(localized: "settings.notch.width.hardware") : nil) {
if merged {
Text("settings.notch.width.fixed")
.foregroundStyle(.secondary)
} else {
HStack(spacing: Onyx.Space.lg) {
Slider(value: binding(for: id),
in: NotchCoordinator.widthRange, step: 2)
.frame(width: 180)
Text(verbatim: "\(Int(model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width))")
.font(.body.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .trailing)
Button {
model.setNotchWidth(nil, for: id)
} label: {
Image(systemName: "arrow.counterclockwise")
}
.buttonStyle(.borderless)
.disabled(model.notchWidth(for: id) == nil)
.help(Text("settings.notch.width.reset"))
}
}
}
}
private func binding(for id: CGDirectDisplayID) -> Binding<CGFloat> {
Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width },
set: { model.setNotchWidth($0, for: id) })
} }
} }
+149 -47
View File
@@ -27,8 +27,9 @@ import ShelfProvider
final class SettingsWindowController: NSObject, NSWindowDelegate { final class SettingsWindowController: NSObject, NSWindowDelegate {
@ObservationIgnored private var window: NSWindow? @ObservationIgnored private var window: NSWindow?
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann. /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Bereich
private var selectedTab: SettingsTab = .widgets /// setzen kann.
private var section: SettingsSection = .general
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen /// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
/// zurückgeschaltet wird. /// zurückgeschaltet wird.
@@ -37,8 +38,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl, metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void, launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
tab: SettingsTab = .widgets) { section: SettingsSection = .general) {
selectedTab = tab self.section = section
keepsDockIcon = model.showsDockIcon keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock. // Solange ein Fenster offen ist, gehört Onyx in den Dock.
@@ -59,39 +60,48 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
fanControl: fanControl, fanControl: fanControl,
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding, showOnboarding: showOnboarding,
selectedTab: Binding( section: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets }, get: { [weak self] in self?.section ?? .general },
set: { [weak self] in self?.selectedTab = $0 }))) set: { [weak self] in self?.section = $0 })))
let window = NSWindow(contentViewController: hosting) let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting))
window.title = "Onyx" window.title = "Onyx"
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie // **Kein durchsichtiger Titelbalken mehr.**
// verschwindet.
// //
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht // Er war für die selbstgebaute Reiterleiste da: macOS zieht unter
// unter dem Titelbalken zusätzlich einen Trenner quer durchs // dem Titelbalken einen Trenner quer durchs Fenster, und die
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen // Leiste saß mittig darauf. Die Leiste gibt es nicht mehr — und
// Titel auf eine Trennlinie. // durchsichtig ist er jetzt schädlich: Seitenleiste und Inhalt
// zeichnen darunter weiter, statt darunter zu beginnen. Sichtbar
// wurde das im Widgets-Bereich, wo die Liste unter den
// Fensterknöpfen hindurchlief.
window.titlebarSeparatorStyle = .automatic
// **Kein `isOpaque = false`, kein durchsichtiger Hintergrund.**
//
// Beides klingt nach dem Weg zur Durchlässigkeit und ist ihr
// Gegenteil, sobald die Vibrancy-Ebene selbst der Fensterinhalt
// ist: erklärt sich das Fenster für durchsichtig, stellt der
// Fensterserver ihm keinen Hintergrund mehr zum Abtasten bereit,
// und `behindWindow` hat nichts, woraus es Unschärfe machen könnte.
// Der Titelbalken teilt sich die Fläche mit dem Inhalt. Ohne das
// zeichnet macOS dort seine **eigene**, deutlich durchlässigere
// Vibrancy — sichtbar als heller Streifen quer über den oberen
// Rand, durch den man den Hintergrund lesen kann.
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit // Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht // der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der // mehr, und ein Fenster, in dem man scrollen muss, obwohl der
// Bildschirm frei ist, ist eine Zumutung. // Bildschirm frei ist, ist eine Zumutung.
window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 920, height: 680)) window.setContentSize(NSSize(width: 940, height: 700))
// Breit genug für die Reiterleiste. // Seit die Bereiche in einer Seitenleiste stehen, hängt die
// // Mindestbreite am Inhalt statt an der Beschriftung: das
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880 // Reiterband brauchte 900 Punkte, nur damit der erste und der
// Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten // letzte Reiter nicht über die Kante hinausragten.
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner window.contentMinSize = NSSize(width: 780, height: 480)
// dazu. Ist das Fenster schmaler, ragt die Leiste über beide
// Kanten hinaus, und der erste wie der letzte Reiter sind nicht
// mehr erreichbar.
window.contentMinSize = NSSize(width: 900, height: 460)
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.delegate = self window.delegate = self
Self.place(window) OnyxWindowPlacement.center(window)
// Über die Position freuen sich Nutzer mit mehreren Displays: // Über die Position freuen sich Nutzer mit mehreren Displays:
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben. // beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
window.setFrameAutosaveName("onyx.settings") window.setFrameAutosaveName("onyx.settings")
@@ -104,27 +114,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window?.makeKeyAndOrderFront(nil) window?.makeKeyAndOrderFront(nil)
} }
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
/// klebt.
///
/// `center()` setzt etwas oberhalb der Mitte — bei einem 452 Punkt hohen
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
/// plötzlich das Panel vor sich.
private static func place(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
// Wirklich mittig, nicht „etwas unterhalb der Notch".
//
// `center()` setzt oberhalb der Bildschirmmitte, und mein erster
// Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag
// weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die
// Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete.
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
/// Der rote Knopf blendet aus, statt zu schließen. /// Der rote Knopf blendet aus, statt zu schließen.
/// ///
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie /// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
@@ -147,3 +136,116 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
NSApp.hide(nil) NSApp.hide(nil)
} }
} }
/// Legt eine Vibrancy-Ebene **unter das ganze Fenster**, Titelbalken
/// eingeschlossen.
///
/// In SwiftUI ginge das nicht: ein `.background` dort endet an der sicheren
/// Fläche, und über dem oberen Rand blieb ein Streifen, durch den man den
/// Hintergrund lesen konnte. Hier liegt die Ebene im Fensterinhalt und wächst
/// mit ihm.
///
/// Zwei Schichten, nicht eine: die Vibrancy allein ist zu durchlässig — man
/// liest den Schreibtisch mit, statt die Einstellung. Der getönte Grund darüber
/// lässt die Unschärfe stehen und nimmt ihr die Lesbarkeit.
@MainActor
final class OnyxBackdropController: NSViewController {
/// Wie viel vom Fensterhintergrund über der Vibrancy stehen bleibt.
///
/// 1 wäre undurchlässig, 0 ließe das nackte Material stehen — dann ist der
/// Hintergrund stellenweise mitzulesen. **Das ist die Schraube**, wenn es
/// zu durchlässig oder zu deckend wirkt.
static let tintOpacity: CGFloat = 0
private let content: NSViewController
init(content: NSViewController) {
self.content = content
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") }
override func loadView() {
// Die Vibrancy-Ebene **ist** der Fensterinhalt, sie liegt nicht darin.
//
// Als Unteransicht in einem gewöhnlichen `NSView` blieb sie wirkungslos
// — sichtbar wurde nur der Titelbalken, den macOS selbst zeichnet. Als
// `contentView` greift `behindWindow` zuverlässig, und zusammen mit
// `.fullSizeContentView` am Fenster reicht sie auch unter den
// Titelbalken.
let container = NSVisualEffectView()
// `.sidebar` und nicht `.underWindowBackground`: letzteres ist so
// deckend, dass man von der Unschärfe nichts sieht, sobald hinter dem
// Fenster etwas Dunkles liegt. `.sidebar` ist das Material, das man von
// Finder und Systemeinstellungen kennt.
container.material = .sidebar
container.blendingMode = .behindWindow
// `.active` statt `.followsWindowActiveState`: sonst fällt die Ebene
// zurück, sobald das Fenster nicht vorn ist — dann sieht dasselbe
// Fenster je nach Aufmerksamkeit verschieden aus, und das liest sich
// als Fehler, nicht als Zustand.
container.state = .active
let tint = TintView()
tint.autoresizingMask = [.width, .height]
container.addSubview(tint)
addChild(content)
content.view.autoresizingMask = [.width, .height]
// Ohne das bleibt die Vibrancy unsichtbar: `NSHostingView` legt einen
// deckenden Grund unter den SwiftUI-Inhalt, und der steht genau
// dazwischen. Nur der Titelbalken kam vorbei — er wird von macOS
// gezeichnet, nicht von SwiftUI.
content.view.wantsLayer = true
content.view.layer?.backgroundColor = NSColor.clear.cgColor
container.addSubview(content.view)
self.view = container
}
override func viewDidLayout() {
super.viewDidLayout()
for subview in view.subviews { subview.frame = view.bounds }
}
/// Der Titelbalken bekommt keinen eigenen Grund — er liegt auf derselben
/// Fläche wie alles andere.
override func viewDidAppear() {
super.viewDidAppear()
view.window?.styleMask.insert(.fullSizeContentView)
}
}
/// Der getönte Grund über der Vibrancy.
///
/// Eine eigene Klasse, weil die Farbe erscheinungsabhängig ist: eine einmal
/// gelesene `CGColor` bliebe beim Wechsel auf Hell dunkel stehen.
/// `viewDidChangeEffectiveAppearance` gibt es nur an der View, nicht am
/// Controller — daran ist der erste Versuch gescheitert.
private final class TintView: NSView {
override var wantsUpdateLayer: Bool { true }
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
}
required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") }
override func updateLayer() {
effectiveAppearance.performAsCurrentDrawingAppearance {
layer?.backgroundColor = NSColor.windowBackgroundColor
.withAlphaComponent(OnyxBackdropController.tintOpacity).cgColor
}
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
}
+73 -76
View File
@@ -17,89 +17,77 @@ struct WidgetOptionsView: View {
let shelfStore: ShelfStore? let shelfStore: ShelfStore?
var body: some View { var body: some View {
Form { SettingsPage {
Section("options.calendar") {
CalendarOptions(model: calendarModel) CalendarOptions(model: calendarModel)
}
Section("options.weather") {
WeatherOptions(model: weatherModel) WeatherOptions(model: weatherModel)
}
if let shelfStore { if let shelfStore {
Section("options.shelf") {
ShelfOptions(store: shelfStore) ShelfOptions(store: shelfStore)
} }
} }
} }
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
}
} }
// MARK: - Kalender // MARK: - Kalender
private struct CalendarOptions: View { private struct CalendarOptions: View {
@Bindable var model: CalendarModel @Bindable var model: CalendarModel
@State private var calendarrState: CalendarSourceState?
var body: some View { var body: some View {
Picker(selection: $model.kind) { OnyxFormCard(String(localized: "options.calendar")) {
OnyxFormRow(String(localized: "options.calendar.source")) {
Picker("", selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind) Text(kind.localizedName).tag(kind)
} }
} label: {
Text("options.calendar.source")
} }
.pickerStyle(.menu) .labelsHidden()
.frame(width: 180)
}
Picker(selection: $model.showsMonthGrid) { OnyxFormRow(String(localized: "options.calendar.display")) {
Picker("", selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false) Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true) Text("options.calendar.month").tag(true)
} label: {
Text("options.calendar.display")
} }
.pickerStyle(.menu) .labelsHidden()
.frame(width: 180)
}
}
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**: OnyxFormCard(String(localized: "options.calendar.calendars"),
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn hint: String(localized: "options.calendar.calendars.hint")) {
// suchen muss, ohne zu wissen, dass es ihn gibt.
if model.availableCalendars.isEmpty { if model.availableCalendars.isEmpty {
Text("options.calendar.noneFound") OnyxFormRow(String(localized: "options.calendar.noneFound"))
.font(.callout).foregroundStyle(.secondary)
} else { } else {
ForEach(model.availableCalendars) { calendar in ForEach(model.availableCalendars) { calendar in
Toggle(isOn: Binding( OnyxFormRow(calendar.title) {
Circle()
.fill(Color(hex: calendar.colorHex) ?? .secondary)
.frame(width: 9, height: 9)
Toggle("", isOn: Binding(
get: { !model.hiddenCalendarIDs.contains(calendar.id) }, get: { !model.hiddenCalendarIDs.contains(calendar.id) },
set: { shown in set: { shown in
if shown { model.hiddenCalendarIDs.remove(calendar.id) } if shown { model.hiddenCalendarIDs.remove(calendar.id) }
else { model.hiddenCalendarIDs.insert(calendar.id) } else { model.hiddenCalendarIDs.insert(calendar.id) }
})) { }))
Label { .labelsHidden()
Text(calendar.title)
} icon: {
Circle()
.fill(Color(hex: calendar.colorHex) ?? .secondary)
.frame(width: 9, height: 9)
} }
} }
} }
} }
if model.kind != .appleCalendar { if model.kind != .appleCalendar {
OnyxFormCard(String(localized: "options.calendar.serverGroup")) {
CalendarrServerSettings(model: model) CalendarrServerSettings(model: model)
CalendarrStatus(state: calendarrState,
// Der Zustand kommt aus dem Modell, nicht aus einem zweiten
// eigenen Aufruf. Der las immer den Schnappschuss — auch wenn
// längst der Server gefragt wurde — und der Knopf daneben
// aktualisierte ihn nie.
CalendarrStatus(state: model.calendarrState,
usesServer: model.usesCalendarrServer,
isLoading: model.isLoading, isLoading: model.isLoading,
onRefresh: { model.refresh() }) onRefresh: { model.refresh() })
.task(id: model.kind) {
guard model.kind != .appleCalendar else { return }
let now = Date()
calendarrState = await CalendarrSource().load(
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
end: now.addingTimeInterval(60 * 86_400)))
} }
} }
} }
@@ -107,19 +95,29 @@ private struct CalendarOptions: View {
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum. /// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
/// ///
/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den /// Mit einem Knopf zum Nachsehen. Ohne Zugangsdaten liest Onyx den
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine /// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat, /// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. /// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. Mit
/// Zugangsdaten fragt Onyx den Server selbst — dann sagt diese Zeile, wann das
/// zuletzt gelang.
private struct CalendarrStatus: View { private struct CalendarrStatus: View {
let state: CalendarSourceState? let state: CalendarSourceState?
/// Ob gerade direkt vom Server gelesen wird oder aus dem Schnappschuss.
///
/// Ohne diese Auskunft ist der Zeitstempel daneben nicht zu deuten: ein
/// altes Datum heißt beim Schnappschuss „Calendarr lief lange nicht" und
/// beim Server „etwas stimmt nicht".
let usesServer: Bool
let isLoading: Bool let isLoading: Bool
let onRefresh: () -> Void let onRefresh: () -> Void
var body: some View { var body: some View {
HStack(spacing: 8) { OnyxFormRow(String(localized: usesServer ? "options.calendar.viaServer"
: "options.calendar.viaSnapshot"),
hint: usesServer ? nil
: String(localized: "options.calendar.viaSnapshot.hint")) {
message message
Spacer(minLength: 8)
if case .ready(let window) = state { if case .ready(let window) = state {
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–") Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -180,32 +178,31 @@ private struct WeatherOptions: View {
private var usesCurrentLocation: Bool { model.place.isCurrent } private var usesCurrentLocation: Bool { model.place.isCurrent }
var body: some View { var body: some View {
Toggle(isOn: Binding( OnyxFormCard(String(localized: "options.weather")) {
OnyxFormRow(String(localized: "options.weather.automatic"),
// Ohne Ortungsberechtigung nützt die Automatik nichts —
// und das steht hier, statt dass das Widget stumm leer
// bleibt.
hint: usesCurrentLocation && model.locationIsDenied
? String(localized: "options.weather.needsLocation") : nil) {
Toggle("", isOn: Binding(
get: { usesCurrentLocation }, get: { usesCurrentLocation },
set: { model.place = $0 ? .current : model.place })) { set: { model.place = $0 ? .current : model.place }))
Text("options.weather.automatic") .labelsHidden()
}
.disabled(usesCurrentLocation) .disabled(usesCurrentLocation)
if usesCurrentLocation {
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
// steht hier, statt dass das Widget stumm leer bleibt.
if model.locationIsDenied {
Text("options.weather.needsLocation")
.font(.callout).foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
} }
} else if case .fixed(let name, _, _) = model.place {
LabeledContent { if case .fixed(let name, _, _) = model.place {
OnyxFormRow(String(localized: "options.weather.place")) {
Text(name).foregroundStyle(.secondary) Text(name).foregroundStyle(.secondary)
} label: {
Text("options.weather.place")
} }
} }
HStack { OnyxFormRow(String(localized: "options.weather.search"),
TextField("options.weather.search", text: $query) hint: notFound ? String(localized: "options.weather.notFound") : nil) {
TextField("", text: $query)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 160)
.onSubmit { Task { await search() } } .onSubmit { Task { await search() } }
if searching { if searching {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
@@ -214,9 +211,6 @@ private struct WeatherOptions: View {
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
} }
} }
if notFound {
Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning)
} }
} }
@@ -241,7 +235,9 @@ private struct ShelfOptions: View {
private static let choices: [Int?] = [nil, 1, 7, 30, 90] private static let choices: [Int?] = [nil, 1, 7, 30, 90]
var body: some View { var body: some View {
Picker(selection: $store.autoCleanupDays) { OnyxFormCard(String(localized: "options.shelf")) {
OnyxFormRow(String(localized: "settings.shelf.cleanup")) {
Picker("", selection: $store.autoCleanupDays) {
ForEach(Self.choices, id: \.self) { days in ForEach(Self.choices, id: \.self) { days in
if let days { if let days {
Text("settings.shelf.days \(days)").tag(Optional(days)) Text("settings.shelf.days \(days)").tag(Optional(days))
@@ -249,15 +245,16 @@ private struct ShelfOptions: View {
Text("settings.shelf.never").tag(Int?.none) Text("settings.shelf.never").tag(Int?.none)
} }
} }
} label: { }
Text("settings.shelf.cleanup") .labelsHidden()
.frame(width: 180)
} }
HStack { OnyxFormRow(String(localized: "options.shelf")) {
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary) Text("settings.shelf.count \(store.items.count)")
Spacer() .foregroundStyle(.secondary)
Button("settings.shelf.reveal") { store.revealInFinder() } Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless) }
} }
} }
} }
@@ -298,7 +295,7 @@ private struct CalendarrServerSettings: View {
var body: some View { var body: some View {
if let account { if let account {
LabeledContent { LabeledContent {
HStack(spacing: 10) { HStack(spacing: Onyx.Space.row) {
Text(account.username).foregroundStyle(.secondary) Text(account.username).foregroundStyle(.secondary)
Button { Button {
credentials.clear() credentials.clear()
+26
View File
@@ -0,0 +1,26 @@
import AppKit
/// Wohin ein Fenster von Onyx gehört.
///
/// `NSWindow.center()` setzt etwas **oberhalb** der Bildschirmmitte. Bei einem
/// nicht sehr hohen Fenster landet der Schließknopf damit knapp unter der
/// Menüleiste — wer ihn treffen will, fährt durch die Auslösefläche der Notch
/// und hat plötzlich das Panel vor sich.
///
/// Das gilt für jedes Fenster, das Onyx aufmacht, nicht nur für die
/// Einstellungen. Deshalb steht es hier und nicht in einem der Controller.
enum OnyxWindowPlacement {
/// Wirklich mittig, nicht „etwas unterhalb der Notch".
///
/// Ein erster Versuch mit 60 Punkten Abstand zur Oberkante war zu zaghaft:
/// der Titelbalken lag weiter so hoch, dass man auf dem Weg zu seinen
/// Knöpfen durch die Auslösefläche fuhr.
static func center(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
}
@@ -31,10 +31,12 @@ public struct MixerView: View {
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und // Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür // keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
// nichts einschalten müssen. // nichts einschalten müssen.
SectionTitle("mixer.section.system", compact: compact) OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module))
.padding(.top, compact ? 0 : Onyx.Space.xxs)
SystemSection(system: system, compact: compact) SystemSection(system: system, compact: compact)
SectionTitle("mixer.section.apps", compact: compact) OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module))
.padding(.top, compact ? 0 : Onyx.Space.xxs)
if mixer.isEnabled { if mixer.isEnabled {
if mixer.groups.isEmpty { if mixer.groups.isEmpty {
@@ -42,24 +44,20 @@ public struct MixerView: View {
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8) .padding(.vertical, Onyx.Space.md)
} else { } else {
// Eine Karte um die Zeilen: sie fasst zusammen, was // Eine Karte um die Zeilen: sie fasst zusammen, was
// zusammengehört, und trennt es vom Ausschalter darunter. // zusammengehört, und trennt es vom Ausschalter darunter.
OnyxCard {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()), ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
id: \.element.id) { index, group in id: \.element.id) { index, group in
if index > 0 { Divider().overlay(Onyx.Color.hairline) } if index > 0 { OnyxHairline() }
ProcessRow(mixer: mixer, group: group, compact: compact) ProcessRow(mixer: mixer, group: group, compact: compact)
.padding(.horizontal, 10) .padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? 6 : 8) .padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
} }
} }
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
} }
} }
@@ -78,7 +76,7 @@ public struct MixerView: View {
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.top, 2) .padding(.top, Onyx.Space.xxs)
} else { } else {
OffState(mixer: mixer, compact: compact) OffState(mixer: mixer, compact: compact)
autostartToggle autostartToggle
@@ -128,10 +126,10 @@ private struct OffState: View {
let compact: Bool let compact: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: Onyx.Space.row) {
HStack(spacing: 8) { HStack(spacing: Onyx.Space.md) {
Image(systemName: "slider.horizontal.3") Image(systemName: "slider.horizontal.3")
.font(.system(size: 15, weight: .light)) .font(.system(size: Onyx.Symbol.large, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
Text("mixer.off.explain", bundle: .module) Text("mixer.off.explain", bundle: .module)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
@@ -146,15 +144,7 @@ private struct OffState: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Button { mixer.enable() } label: { OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
Text("mixer.on", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
} }
} }
} }
@@ -174,7 +164,7 @@ private struct ProcessRow: View {
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und // ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht, // die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
// hält den Zeiger auf das Symbol. // hält den Zeiger auf das Symbol.
HStack(spacing: 10) { HStack(spacing: Onyx.Space.row) {
icon icon
Button { Button {
@@ -182,7 +172,7 @@ private struct ProcessRow: View {
mixer.setMuted(!muted, for: group.bundleID) mixer.setMuted(!muted, for: group.bundleID)
} label: { } label: {
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol) Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
.font(.system(size: 11)) .font(.system(size: Onyx.Symbol.small))
.frame(width: 15, alignment: .leading) .frame(width: 15, alignment: .leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -190,8 +180,8 @@ private struct ProcessRow: View {
: (managed ? Onyx.Color.textSecondary : (managed ? Onyx.Color.textSecondary
: Onyx.Color.textTertiary)) : Onyx.Color.textTertiary))
VolumeBar( OnyxSlider(
volume: volume, value: volume,
maximum: Gain.maximum(overdrive: overdriven), maximum: Gain.maximum(overdrive: overdriven),
level: mixer.level(for: group.bundleID), level: mixer.level(for: group.bundleID),
isActive: managed && !muted, isActive: managed && !muted,
@@ -238,7 +228,7 @@ private struct ProcessRow: View {
mixer.setOverdrive(!overdriven, for: group.bundleID) mixer.setOverdrive(!overdriven, for: group.bundleID)
} label: { } label: {
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.font(.system(size: 9, weight: .medium)) .font(.system(size: Onyx.Symbol.caption, weight: .medium))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55)) .foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
@@ -265,7 +255,7 @@ private struct ProcessRow: View {
mixer.setVolume(1, for: group.bundleID) mixer.setVolume(1, for: group.bundleID)
} label: { } label: {
Image(systemName: "arrow.counterclockwise") Image(systemName: "arrow.counterclockwise")
.font(.system(size: 9, weight: .medium)) .font(.system(size: Onyx.Symbol.caption, weight: .medium))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
@@ -304,7 +294,7 @@ private struct ProcessRow: View {
.opacity(managed ? 1 : 0.65) .opacity(managed ? 1 : 0.65)
} else { } else {
Image(systemName: "app.dashed") Image(systemName: "app.dashed")
.font(.system(size: 15)) .font(.system(size: Onyx.Symbol.large))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
} }
} }
@@ -326,95 +316,6 @@ private struct ProcessRow: View {
return "\(group.name) (\(group.processes.count))" return "\(group.name) (\(group.processes.count))"
} }
} }
/// Der Lautstärkeregler.
///
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und
/// die Grenze bei 100 %.
private struct VolumeBar: View {
let volume: Double
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
let maximum: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
private let trackHeight: CGFloat = 6
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let fraction = min(max(volume / maximum, 0), 1)
let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen.
let travel = max(width - knob, 1)
let fillWidth = travel * fraction + knob / 2
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.surface)
.frame(height: trackHeight)
// Markierung bei 100 %: darüber wird verstärkt, und man soll
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
if maximum > 1 {
Rectangle()
.fill(Onyx.Color.hairline)
.frame(width: 1, height: trackHeight + 4)
.offset(x: knob / 2 + travel / maximum)
}
Capsule()
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
.frame(width: fillWidth, height: trackHeight)
.opacity(isActive ? 1 : 0.3)
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
// dieselbe Auskunft.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: trackHeight)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
onChange(position * maximum)
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
// MARK: - Widget // MARK: - Widget
public struct AudioMixerWidget: OnyxWidget { public struct AudioMixerWidget: OnyxWidget {
@@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget {
// MARK: - Systemzeilen // MARK: - Systemzeilen
private struct SectionTitle: View {
let key: LocalizedStringKey
let compact: Bool
init(_ key: LocalizedStringKey, compact: Bool) {
self.key = key
self.compact = compact
}
var body: some View {
Text(key, bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.textCase(.uppercase)
.padding(.top, compact ? 0 : 2)
}
}
/// Ausgabe, Eingabe und Gerätewahl. /// Ausgabe, Eingabe und Gerätewahl.
/// ///
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter, /// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
@@ -471,6 +354,7 @@ private struct SystemSection: View {
let compact: Bool let compact: Bool
var body: some View { var body: some View {
OnyxCard {
VStack(spacing: 0) { VStack(spacing: 0) {
SystemRow( SystemRow(
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
@@ -486,7 +370,7 @@ private struct SystemSection: View {
onMute: { system.setOutputMuted(!system.outputMuted) }, onMute: { system.setOutputMuted(!system.outputMuted) },
onSelect: { system.selectOutput($0) }) onSelect: { system.selectOutput($0) })
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
SystemRow( SystemRow(
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill", symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
@@ -505,11 +389,6 @@ private struct SystemSection: View {
onMute: { system.setInputMuted(!system.inputMuted) }, onMute: { system.setInputMuted(!system.inputMuted) },
onSelect: { system.selectInput($0) }) onSelect: { system.selectInput($0) })
} }
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
} }
} }
@@ -555,14 +434,14 @@ private struct SystemRow: View {
} }
} }
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: Onyx.Space.xs) {
Text(title) Text(title)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
Image(systemName: "chevron.up.chevron.down") Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 7)) .font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -571,17 +450,17 @@ private struct SystemRow: View {
.menuIndicator(.hidden) .menuIndicator(.hidden)
.fixedSize() .fixedSize()
HStack(spacing: 9) { HStack(spacing: Onyx.Space.row) {
Button(action: onMute) { Button(action: onMute) {
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 11)) .font(.system(size: Onyx.Symbol.small))
.frame(width: 15, alignment: .leading) .frame(width: 15, alignment: .leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary) .foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
if adjustable { if adjustable {
SystemVolumeBar(volume: volume, level: level, OnyxSlider(value: volume, level: level,
isActive: !muted, onChange: onVolume) isActive: !muted, onChange: onVolume)
if let metering { metering } if let metering { metering }
Text("\(Int(volume * 100)) %") Text("\(Int(volume * 100)) %")
@@ -599,7 +478,7 @@ private struct SystemRow: View {
} }
} }
} }
.padding(.horizontal, 10) .padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? 7 : 9) .padding(.vertical, compact ? 7 : 9)
} }
} }
@@ -617,7 +496,7 @@ struct MeteringButton: View {
var body: some View { var body: some View {
Button(action: toggle) { Button(action: toggle) {
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle") Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
.font(.system(size: 12)) .font(.system(size: Onyx.Symbol.medium))
.frame(width: 14, height: 14) .frame(width: 14, height: 14)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -625,68 +504,3 @@ struct MeteringButton: View {
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module)) .help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
} }
} }
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
private struct SystemVolumeBar: View {
let volume: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
var body: some View {
GeometryReader { geometry in
let knob: CGFloat = isDragging ? 13 : 11
let travel = max(geometry.size.width - knob, 1)
let fraction = min(max(volume, 0), 1)
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.surface).frame(height: 6)
let fillWidth = travel * fraction + knob / 2
Capsule()
.fill(Onyx.Color.accent)
.frame(width: fillWidth, height: 6)
.opacity(isActive ? 1 : 0.3)
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
// so viel Grafik für dieselbe Auskunft wäre.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: 6)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
@@ -56,6 +56,15 @@ final class ProcessTap {
private var procID: AudioDeviceIOProcID? private var procID: AudioDeviceIOProcID?
private let tapUUID = UUID() private let tapUUID = UUID()
/// Der erste Eingangspuffer, der dem Tap gehört.
///
/// **Nicht immer null.** Bringt das Ausgabegerät selbst einen Eingang mit —
/// jedes Headset tut das, jedes USB-Interface auch —, dann reiht das
/// Aggregate-Gerät dessen Puffer vor die des Taps. Wird in `start()`
/// bestimmt und im Echtzeitpfad nur gelesen; geschrieben wird ausschließlich
/// vor `AudioDeviceStart` und nach `AudioDeviceStop`, also nie nebenläufig.
private var tapBufferOffset = 0
/// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund. /// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund.
private(set) var isRunning = false private(set) var isRunning = false
@@ -131,6 +140,24 @@ final class ProcessTap {
} }
aggregateID = device aggregateID = device
// Wo der Tap im Eingang liegt, steht erst fest, wenn das Gerät gebaut
// ist. Danach fragen, vorher nichts starten: liegt er nicht dort, wo
// wir ihn erwarten, wäre die Folge Stille bei stummgeschaltetem
// Original — genau der Fehler, der am Headset auftrat.
guard let subDeviceID = Self.device(forUID: outputDeviceUID),
let subDeviceInputs = Self.inputChannelCounts(subDeviceID),
let aggregateInputs = Self.inputChannelCounts(aggregateID),
let tapChannels = Self.tapChannels(tapID),
let offset = TapBufferLayout.offset(
subDeviceInputBuffers: subDeviceInputs.count,
aggregateInputChannels: aggregateInputs,
tapChannels: tapChannels)
else {
cleanUp()
throw AudioMixerError.unexpectedLayout
}
tapBufferOffset = offset
var proc: AudioDeviceIOProcID? var proc: AudioDeviceIOProcID?
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) { let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
[weak self] _, inputData, _, outputData, _ in [weak self] _, inputData, _, outputData, _ in
@@ -149,7 +176,7 @@ final class ProcessTap {
isRunning = true isRunning = true
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed) lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
log.notice("Tap aktiv für \(self.process.name, privacy: .public)") log.notice("Tap aktiv für \(self.process.name, privacy: .public), Eingangspuffer \(self.tapBufferOffset, privacy: .public)")
} }
// MARK: - Echtzeitpfad // MARK: - Echtzeitpfad
@@ -166,11 +193,14 @@ final class ProcessTap {
var peak: Float = 0 var peak: Float = 0
for (index, outputBuffer) in outputList.enumerated() { for (index, outputBuffer) in outputList.enumerated() {
guard index < inputList.count, // Der Ausgang zählt ab null, der Eingang ab dem Puffer des Taps.
let source = inputList[index].mData, // Ohne den Versatz stünde hier bei einem Headset das Mikrofon.
let sourceIndex = tapBufferOffset + index
guard sourceIndex < inputList.count,
let source = inputList[sourceIndex].mData,
let destination = outputBuffer.mData else { continue } let destination = outputBuffer.mData else { continue }
let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize)) let count = Int(min(inputList[sourceIndex].mDataByteSize, outputBuffer.mDataByteSize))
/ MemoryLayout<Float>.size / MemoryLayout<Float>.size
let inputSamples = source.assumingMemoryBound(to: Float.self) let inputSamples = source.assumingMemoryBound(to: Float.self)
let outputSamples = destination.assumingMemoryBound(to: Float.self) let outputSamples = destination.assumingMemoryBound(to: Float.self)
@@ -224,6 +254,89 @@ final class ProcessTap {
AudioHardwareDestroyProcessTap(tapID) AudioHardwareDestroyProcessTap(tapID)
tapID = AudioObjectID(kAudioObjectUnknown) tapID = AudioObjectID(kAudioObjectUnknown)
} }
tapBufferOffset = 0
}
// MARK: - Core Audio
private static func device(forUID uid: String) -> AudioObjectID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var qualifier = uid as CFString
var device = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = withUnsafeMutablePointer(to: &qualifier) {
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address,
UInt32(MemoryLayout<CFString>.size), $0, &size, &device)
}
guard status == noErr, device != AudioObjectID(kAudioObjectUnknown) else { return nil }
return device
}
/// Die Kanäle je Eingangspuffer eines Geräts. Leer heißt: kein Eingang —
/// das ist ein gültiges Ergebnis, `nil` heißt, es ließ sich nicht lesen.
private static func inputChannelCounts(_ device: AudioObjectID) -> [UInt32]? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr
else { return nil }
guard size > 0 else { return [] }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, raw) == noErr
else { return nil }
let list = UnsafeMutableAudioBufferListPointer(
raw.assumingMemoryBound(to: AudioBufferList.self))
return list.map(\.mNumberChannels)
}
private static func tapChannels(_ tap: AudioObjectID) -> UInt32? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioTapPropertyFormat,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var format = AudioStreamBasicDescription()
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
guard AudioObjectGetPropertyData(tap, &address, 0, nil, &size, &format) == noErr
else { return nil }
return format.mChannelsPerFrame
}
}
/// Wo im Eingang eines Aggregate-Geräts die Puffer des Taps liegen.
///
/// Ein Aggregate-Gerät reiht seine Eingangspuffer in fester Ordnung: erst die
/// des Untergeräts, dann die des Taps. Bei den eingebauten Lautsprechern gibt
/// es keine eigenen — dort liegt der Tap an null, und genau daran hat sich der
/// erste Entwurf gewöhnt. Ein Headset bringt sein Mikrofon mit und schiebt den
/// Tap eine Stelle weiter.
///
/// Als reine Funktion, weil sie sonst nur mit angestecktem Headset prüfbar wäre.
enum TapBufferLayout {
/// - Returns: der Index des ersten Tap-Puffers, oder `nil`, wenn die
/// Aufteilung nicht der erwarteten entspricht. Dann wird **nicht geraten**:
/// ein falsch geratener Puffer heißt Stille bei stummgeschaltetem
/// Original, ein abgewiesener Tap heißt nur, dass dieses Programm
/// ungeregelt bleibt — hörbar.
static func offset(subDeviceInputBuffers: Int,
aggregateInputChannels: [UInt32],
tapChannels: UInt32) -> Int? {
guard subDeviceInputBuffers >= 0, tapChannels > 0 else { return nil }
guard subDeviceInputBuffers < aggregateInputChannels.count else { return nil }
// Die Gegenprobe: an der erwarteten Stelle muss das Format des Taps
// stehen. Läge die Ordnung anders, fiele das hier auf, statt still zu
// bleiben.
guard aggregateInputChannels[subDeviceInputBuffers] == tapChannels else { return nil }
return subDeviceInputBuffers
} }
} }
@@ -233,6 +346,7 @@ public enum AudioMixerError: Error, LocalizedError {
case ioProcFailed(OSStatus) case ioProcFailed(OSStatus)
case startFailed case startFailed
case noOutputDevice case noOutputDevice
case unexpectedLayout
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@@ -246,6 +360,8 @@ public enum AudioMixerError: Error, LocalizedError {
"Das Mischgerät startet nicht." "Das Mischgerät startet nicht."
case .noOutputDevice: case .noOutputDevice:
"Kein Ausgabegerät gefunden." "Kein Ausgabegerät gefunden."
case .unexpectedLayout:
"Das Mischgerät reicht den Ton an unerwarteter Stelle durch."
} }
} }
} }
@@ -44,6 +44,9 @@ public final class SystemAudio {
public private(set) var inputIsAdjustable = false public private(set) var inputIsAdjustable = false
private var listeners: [PropertyListener] = [] private var listeners: [PropertyListener] = []
/// Zuhörer, die am gewählten Gerät hängen und beim Wechsel neu gesetzt
/// werden müssen.
private var deviceListeners: [PropertyListener] = []
/// Die Pegel von Ausgabe und Eingabe. /// Die Pegel von Ausgabe und Eingabe.
/// ///
@@ -59,6 +62,9 @@ public final class SystemAudio {
// MARK: - Lesen // MARK: - Lesen
public func refresh() { public func refresh() {
let previousOutput = outputDeviceID
let previousInput = inputDeviceID
outputDevices = Self.devices().filter(\.isOutput) outputDevices = Self.devices().filter(\.isOutput)
inputDevices = Self.devices().filter(\.isInput) inputDevices = Self.devices().filter(\.isInput)
@@ -79,6 +85,13 @@ public final class SystemAudio {
alertVolume = alertDevice == outputDeviceID alertVolume = alertDevice == outputDeviceID
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür. ? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput) : Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
// Nur bei einem echten Wechsel neu anmelden. Ein Abmelden mitten im
// Rückruf, der gerade läuft, ist eine Einladung zum Absturz.
if outputDeviceID != previousOutput || inputDeviceID != previousInput
|| deviceListeners.isEmpty {
observeCurrentDevices()
}
} }
// MARK: - Setzen // MARK: - Setzen
@@ -135,6 +148,48 @@ public final class SystemAudio {
} }
} }
/// Zuhörer an Lautstärke und Stummschaltung der **gerade gewählten** Geräte.
///
/// **Das fehlte.** Beobachtet wurden nur Gerätewechsel; wer die Lautstärke
/// per Tastatur änderte, bekam davon nichts mit — der Regler im Mixer stand
/// dann auf einem Wert, den es nicht mehr gab, bis irgendetwas anderes ein
/// `refresh()` auslöste.
///
/// Die Zuhörer hängen am Gerät, nicht am Systemobjekt, und müssen deshalb
/// bei jedem Gerätewechsel neu gesetzt werden.
private func observeCurrentDevices() {
deviceListeners = []
for (device, scope) in [(outputDeviceID, kAudioDevicePropertyScopeOutput),
(inputDeviceID, kAudioDevicePropertyScopeInput)] {
guard device != AudioObjectID(kAudioObjectUnknown) else { continue }
for selector in [kAudioDevicePropertyVolumeScalar, kAudioDevicePropertyMute] {
// Manche Geräte führen keine Hauptlautstärke, sondern nur
// Kanäle. Dort scheitert das Anmelden — das ist kein Fehler,
// dort gibt es schlicht nichts zu hören.
if let listener = PropertyListener(object: device,
selector: selector,
scope: scope,
handler: { [weak self] in
Task { @MainActor in self?.readLevels() }
}) {
deviceListeners.append(listener)
}
}
}
}
/// Nur die Pegel und Schalter neu lesen — ohne Geräteliste.
///
/// Getrennt von `refresh()`, weil dieser Weg aus einem Rückruf kommt, der
/// **während** einer Änderung feuert: dort auch noch die Geräte neu
/// aufzuzählen wäre Arbeit für nichts.
private func readLevels() {
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
}
private static func devices() -> [AudioDevice] { private static func devices() -> [AudioDevice] {
var address = AudioObjectPropertyAddress( var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices, mSelector: kAudioHardwarePropertyDevices,
@@ -325,14 +380,23 @@ public final class SystemAudio {
/// geschrieben. /// geschrieben.
private final class PropertyListener: @unchecked Sendable { private final class PropertyListener: @unchecked Sendable {
private let object = AudioObjectID(kAudioObjectSystemObject) private let object: AudioObjectID
private var address: AudioObjectPropertyAddress private var address: AudioObjectPropertyAddress
private let block: AudioObjectPropertyListenerBlock private let block: AudioObjectPropertyListenerBlock
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) { /// - Parameters:
/// - object: Standardmäßig das Systemobjekt. Für Lautstärke und
/// Stummschaltung muss es das **Gerät** sein — die sind keine
/// Eigenschaften der Hardware im Allgemeinen, sondern dieser Kiste.
/// - scope: Ein- oder Ausgabe. Global genügt nur bei Geräteeigenschaften.
init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
selector: AudioObjectPropertySelector,
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
handler: @escaping @Sendable () -> Void) {
self.object = object
address = AudioObjectPropertyAddress( address = AudioObjectPropertyAddress(
mSelector: selector, mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal, mScope: scope,
mElement: kAudioObjectPropertyElementMain) mElement: kAudioObjectPropertyElementMain)
block = { _, _ in handler() } block = { _, _ in handler() }
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
@@ -32,7 +32,7 @@ public struct CalendarSourceSettings: View {
if model.kind != .appleCalendar { if model.kind != .appleCalendar {
Section { Section {
LabeledContent { LabeledContent {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
statusText statusText
Text("calendar.source.readOnly", bundle: .module) Text("calendar.source.readOnly", bundle: .module)
.font(.caption) .font(.caption)
@@ -53,7 +53,7 @@ public struct CalendarSourceSettings: View {
private var statusText: some View { private var statusText: some View {
switch calendarrState { switch calendarrState {
case .ready(let window): case .ready(let window):
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))", Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
bundle: .module) bundle: .module)
if let coverage = window.coverage { if let coverage = window.coverage {
@@ -45,6 +45,18 @@ public final class CalendarModel {
/// Die Kalender, die zur Auswahl stehen. /// Die Kalender, die zur Auswahl stehen.
public private(set) var availableCalendars: [OnyxCalendar] = [] public private(set) var availableCalendars: [OnyxCalendar] = []
/// Der Zustand der Calendarr-Quelle **allein**, für die Einstellungen.
///
/// Getrennt von `state`, weil das bei „beide" auch Apple-Termine enthält —
/// eine Zahl daraus sagt über Calendarr nichts.
///
/// Und getrennt geführt statt in den Einstellungen nachgeladen: dort stand
/// bisher ein zweiter, eigener Aufruf von `CalendarrSource`, also immer der
/// **Schnappschuss** — auch wenn längst der Server gefragt wurde. Der
/// Zeitstempel daneben war deshalb das Alter einer Datei, die niemand mehr
/// liest, und sah aus wie ein Kalender, der seit Wochen stillsteht.
public private(set) var calendarrState: CalendarSourceState?
private static let kindKey = "calendar.source" private static let kindKey = "calendar.source"
private static let hiddenKey = "calendar.hiddenCalendars" private static let hiddenKey = "calendar.hiddenCalendars"
private static let gridKey = "calendar.showsMonthGrid" private static let gridKey = "calendar.showsMonthGrid"
@@ -194,6 +206,7 @@ public final class CalendarModel {
let interval = DateInterval(start: start, end: end) let interval = DateInterval(start: start, end: end)
var states: [CalendarSourceState] = [] var states: [CalendarSourceState] = []
let calendarr = kind == .appleCalendar ? nil : calendarrChoice as AnyObject
for source in activeSources { for source in activeSources {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer // Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
@@ -201,7 +214,9 @@ public final class CalendarModel {
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen. // passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht // Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
// eine leere Terminliste, die "nichts mehr heute" behaupten würde. // eine leere Terminliste, die "nichts mehr heute" behaupten würde.
states.append(source.isAuthorized ? await source.load(interval) : .denied) let loaded = source.isAuthorized ? await source.load(interval) : .denied
if let calendarr, source as AnyObject === calendarr { calendarrState = loaded }
states.append(loaded)
} }
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs) state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
@@ -266,41 +281,26 @@ private struct CalendarWidgetView: View {
AgendaList(events: model.upcoming, coverage: window.coverage) AgendaList(events: model.upcoming, coverage: window.coverage)
} }
case .denied: case .denied:
CalendarNotice(symbol: "lock", text: "widget.calendar.denied") OnyxNotice(symbol: "lock",
text: String(localized: "widget.calendar.denied", bundle: .module))
case .neverWritten: case .neverWritten:
CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten") OnyxNotice(symbol: "clock.arrow.circlepath",
text: String(localized: "widget.calendar.neverWritten", bundle: .module))
case .loggedOut: case .loggedOut:
CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut") OnyxNotice(symbol: "person.crop.circle.badge.xmark",
text: String(localized: "widget.calendar.loggedOut", bundle: .module))
case .incompatible: case .incompatible:
CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible") OnyxNotice(symbol: "exclamationmark.triangle",
text: String(localized: "widget.calendar.incompatible", bundle: .module))
case .unreadable: case .unreadable:
CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable") OnyxNotice(symbol: "questionmark.folder",
text: String(localized: "widget.calendar.unreadable", bundle: .module))
} }
} }
.task { model.refresh() } .task { model.refresh() }
.onDisappear { model.stop() } .onDisappear { model.stop() }
} }
} }
/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist.
private struct CalendarNotice: View {
let symbol: String
let text: LocalizedStringKey
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 16))
.foregroundStyle(Onyx.Color.textTertiary)
Text(text, bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Mini-Monat // MARK: - Mini-Monat
private struct LargeCalendar: View { private struct LargeCalendar: View {
@@ -310,9 +310,9 @@ private struct LargeCalendar: View {
private var calendar: Calendar { .autoupdatingCurrent } private var calendar: Calendar { .autoupdatingCurrent }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: Onyx.Space.md) {
MiniMonth(window: window) MiniMonth(window: window)
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage) AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage)
} }
} }
@@ -328,11 +328,11 @@ private struct MiniMonth: View {
let busy = window.events.busyDays(calendar: calendar) let busy = window.events.busyDays(calendar: calendar)
let today = calendar.startOfDay(for: Date()) let today = calendar.startOfDay(for: Date())
VStack(spacing: 3) { VStack(spacing: Onyx.Space.xs) {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(weekdaySymbols, id: \.self) { symbol in ForEach(weekdaySymbols, id: \.self) { symbol in
Text(symbol) Text(symbol)
.font(.system(size: 8, weight: .medium)) .font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
@@ -370,7 +370,7 @@ private struct DayCell: View {
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15) Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
} }
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))") Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
.font(.system(size: 9, weight: isToday ? .semibold : .regular)) .font(Onyx.Font.micro.weight(isToday ? .semibold : .regular))
.foregroundStyle(textColor) .foregroundStyle(textColor)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -413,10 +413,10 @@ private struct AgendaList: View {
} else { } else {
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem. // Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
let days = AgendaGrouping.group(events) let days = AgendaGrouping.group(events)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
Text(AgendaGrouping.label(for: day.date)) Text(AgendaGrouping.label(for: day.date))
.font(.system(size: 9, weight: .semibold)) .font(Onyx.Font.micro.weight(.semibold))
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
.textCase(.uppercase) .textCase(.uppercase)
// Luft nur zwischen den Tagen, nicht über dem ersten. // Luft nur zwischen den Tagen, nicht über dem ersten.
@@ -435,18 +435,18 @@ private struct EventRow: View {
let event: OnyxEvent let event: OnyxEvent
var body: some View { var body: some View {
HStack(spacing: 6) { HStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous) RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent) .fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
.frame(width: 3) .frame(width: 3)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(event.title) Text(event.title)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1) .lineLimit(1)
Text(timeText) Text(timeText)
.font(.system(size: 9)) .font(Onyx.Font.micro)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1) .lineLimit(1)
@@ -41,7 +41,7 @@ private struct Idle: View {
let model: MediaModel let model: MediaModel
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: Onyx.Space.md) {
Text("widget.media.nothing", bundle: .module) Text("widget.media.nothing", bundle: .module)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
@@ -59,12 +59,12 @@ private struct Playing: View {
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben // Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann // einem Cover blieben für den Titel noch gut hundert Punkte, und dann
// steht dort „Bernhard H…" statt eines Titels. // steht dort „Bernhard H…" statt eines Titels.
VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: Onyx.Space.md) {
Artwork(data: state.artwork) Artwork(data: state.artwork)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 88) .frame(height: 88)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(state.title) Text(state.title)
.font(Onyx.Font.caption.weight(.medium)) .font(Onyx.Font.caption.weight(.medium))
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
@@ -91,7 +91,7 @@ private struct Playing: View {
// greift: dann fehlen Cover und Browser-Wiedergaben, // greift: dann fehlen Cover und Browser-Wiedergaben,
// und das soll erklärbar sein statt rätselhaft. // und das soll erklärbar sein statt rätselhaft.
Image(systemName: "applescript") Image(systemName: "applescript")
.font(.system(size: 9)) .font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
.help(Text("widget.media.viaAppleScript", bundle: .module)) .help(Text("widget.media.viaAppleScript", bundle: .module))
} }
@@ -107,7 +107,7 @@ private struct Artwork: View {
let data: Data? let data: Data?
var body: some View { var body: some View {
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous) let shape = RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous)
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum // Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
// beschneidet man das Bild auf seine eigene, viel größere Größe — und es // beschneidet man das Bild auf seine eigene, viel größere Größe — und es
@@ -122,7 +122,7 @@ private struct Artwork: View {
shape.fill(Onyx.Color.elevated) shape.fill(Onyx.Color.elevated)
.overlay { .overlay {
Image(systemName: "music.note") Image(systemName: "music.note")
.font(.system(size: 18)) .font(.system(size: Onyx.Symbol.xlarge))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
} }
} }
@@ -143,7 +143,7 @@ private struct Scrubber: View {
let now = model.tick let now = model.tick
let progress = state.progress(at: now) let progress = state.progress(at: now)
VStack(spacing: 2) { VStack(spacing: Onyx.Space.xxs) {
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.hairline) Capsule().fill(Onyx.Color.hairline)
@@ -166,7 +166,7 @@ private struct Scrubber: View {
Spacer(minLength: 0) Spacer(minLength: 0)
Text(format(state.duration ?? 0)) Text(format(state.duration ?? 0))
} }
.font(.system(size: 8)) .font(Onyx.Font.micro)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
} }
@@ -185,7 +185,7 @@ private struct TransportControls: View {
let size: CGFloat let size: CGFloat
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: Onyx.Space.lg) {
button("backward.fill") { model.send(.previous) } button("backward.fill") { model.send(.previous) }
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") { button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
model.send(.playPause) model.send(.playPause)
@@ -1,4 +1,5 @@
import AppKit import AppKit
import OnyxDesign
import OnyxMenuBar import OnyxMenuBar
/// Der Akku, gezeichnet wie der von macOS. /// Der Akku, gezeichnet wie der von macOS.
@@ -259,9 +260,9 @@ public struct BatteryGlyphView: View {
private var width: CGFloat { BatteryGlyph.size.width * scale } private var width: CGFloat { BatteryGlyph.size.width * scale }
public var body: some View { public var body: some View {
HStack(spacing: 1 * scale) { HStack(spacing: Onyx.Space.xxs * scale) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous) RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
.strokeBorder(.primary.opacity(0.45), lineWidth: 1) .strokeBorder(.primary.opacity(0.45), lineWidth: 1)
GeometryReader { geometry in GeometryReader { geometry in
@@ -296,7 +297,7 @@ public struct BatteryGlyphView: View {
} }
.compositingGroup() .compositingGroup()
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous) RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
.fill(.primary.opacity(0.45)) .fill(.primary.opacity(0.45))
.frame(width: 2 * scale, height: 5 * scale) .frame(width: 2 * scale, height: 5 * scale)
} }
@@ -34,7 +34,7 @@ public struct ChargeLimitControl: View {
} }
public var body: some View { public var body: some View {
HStack(spacing: 6) { HStack(spacing: Onyx.Space.sm) {
Text("charge.limit", bundle: .module) Text("charge.limit", bundle: .module)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
@@ -62,7 +62,7 @@ public struct ChargeLimitControl: View {
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!) URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
} label: { } label: {
Image(systemName: "arrow.up.forward.app") Image(systemName: "arrow.up.forward.app")
.font(.system(size: 10)) .font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -12,7 +12,7 @@ struct MetricPopoverContent: View {
let metric: MetricKind let metric: MetricKind
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: Onyx.Space.lg) {
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man // Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht. // sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
Text(String(localized: .init(metric.localizationKey), bundle: .module)) Text(String(localized: .init(metric.localizationKey), bundle: .module))
@@ -25,7 +25,7 @@ struct MetricPopoverContent: View {
case .temperature: SensorDetail(snapshot: model.snapshot) case .temperature: SensorDetail(snapshot: model.snapshot)
} }
} }
.padding(14) .padding(Onyx.Space.xl)
.frame(width: 280) .frame(width: 280)
} }
} }
@@ -34,17 +34,6 @@ struct MetricPopoverContent: View {
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine /// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
/// lange Werteliste. /// 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 { private struct Row: View {
let label: String let label: String
let value: String let value: String
@@ -88,7 +77,7 @@ private struct Dial: View {
var body: some View { var body: some View {
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und // Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
// sah aus, als gehörte sie noch hinein. // sah aus, als gehörte sie noch hinein.
VStack(spacing: 6) { VStack(spacing: Onyx.Space.sm) {
ZStack { ZStack {
Circle().stroke(.quaternary, lineWidth: 5) Circle().stroke(.quaternary, lineWidth: 5)
Circle() Circle()
@@ -96,13 +85,13 @@ private struct Dial: View {
.stroke(tint ?? Onyx.Color.accent, .stroke(tint ?? Onyx.Color.accent,
style: .init(lineWidth: 5, lineCap: .round)) style: .init(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit()) Text(text).font(Onyx.Font.headline.monospacedDigit())
} }
.frame(width: 62, height: 62) .frame(width: 62, height: 62)
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] } .alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
Text(caption).font(.caption2).foregroundStyle(.secondary) Text(caption).font(.caption2).foregroundStyle(.secondary)
} }
.padding(.bottom, 2) .padding(.bottom, Onyx.Space.xxs)
} }
} }
@@ -115,7 +104,7 @@ private struct ProcessorDetail: View {
var body: some View { var body: some View {
let snapshot = model.snapshot let snapshot = model.snapshot
HStack(spacing: 14) { HStack(spacing: Onyx.Space.xl) {
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0), Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
caption: metric == .cpu ? "CPU" : "GPU", caption: metric == .cpu ? "CPU" : "GPU",
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
@@ -135,11 +124,11 @@ private struct ProcessorDetail: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty { if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
SectionTitle(text: "popover.perCore") OnyxPopoverTitle(String(localized: "popover.perCore", bundle: .module))
CoreGrid(values: model.snapshot.cpu.perCore) CoreGrid(values: model.snapshot.cpu.perCore)
} }
SectionTitle(text: "popover.power") OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
if let cpu = snapshot.power.cpu { if let cpu = snapshot.power.cpu {
Row(label: String(localized: "popover.power.cpu", bundle: .module), Row(label: String(localized: "popover.power.cpu", bundle: .module),
value: MetricFormat.watts(cpu)) value: MetricFormat.watts(cpu))
@@ -154,7 +143,7 @@ private struct ProcessorDetail: View {
} }
if !snapshot.fans.isEmpty { if !snapshot.fans.isEmpty {
SectionTitle(text: "popover.fans") OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
ForEach(snapshot.fans) { fan in ForEach(snapshot.fans) { fan in
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min") Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
} }
@@ -181,13 +170,13 @@ private struct CoreGrid: View {
let values: [Double] let values: [Double]
var body: some View { var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8), LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: Onyx.Space.xs), count: 8),
spacing: 3) { spacing: Onyx.Space.xs) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in ForEach(Array(values.enumerated()), id: \.offset) { _, value in
RoundedRectangle(cornerRadius: 2, style: .continuous) RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.accent.opacity(0.18)) .fill(Onyx.Color.accent.opacity(0.18))
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
RoundedRectangle(cornerRadius: 2, style: .continuous) RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.accent) .fill(Onyx.Color.accent)
.frame(height: max(14 * value, 1.5)) .frame(height: max(14 * value, 1.5))
} }
@@ -205,12 +194,12 @@ private struct MemoryDetail: View {
var body: some View { var body: some View {
let memory = snapshot.memory let memory = snapshot.memory
HStack(spacing: 14) { HStack(spacing: Onyx.Space.xl) {
Dial(value: memory.usedFraction, Dial(value: memory.usedFraction,
caption: String(localized: "popover.memory.used", bundle: .module), caption: String(localized: "popover.memory.used", bundle: .module),
text: MetricFormat.percent(memory.usedFraction), text: MetricFormat.percent(memory.usedFraction),
tint: pressureColor) tint: pressureColor)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
legend(Onyx.Color.accent, "popover.memory.app", memory.app) legend(Onyx.Color.accent, "popover.memory.app", memory.app)
legend(.purple, "popover.memory.wired", memory.wired) legend(.purple, "popover.memory.wired", memory.wired)
legend(.teal, "popover.memory.compressed", memory.compressed) legend(.teal, "popover.memory.compressed", memory.compressed)
@@ -218,7 +207,7 @@ private struct MemoryDetail: View {
} }
} }
SectionTitle(text: "popover.memory.swap") OnyxPopoverTitle(String(localized: "popover.memory.swap", bundle: .module))
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird, // Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich. // ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module), Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
@@ -226,7 +215,7 @@ private struct MemoryDetail: View {
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil) emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
if !snapshot.topMemoryProcesses.isEmpty { if !snapshot.topMemoryProcesses.isEmpty {
SectionTitle(text: "popover.processes") OnyxPopoverTitle(String(localized: "popover.processes", bundle: .module))
ForEach(snapshot.topMemoryProcesses) { process in ForEach(snapshot.topMemoryProcesses) { process in
Row(label: process.name, value: bytes(process.bytes)) Row(label: process.name, value: bytes(process.bytes))
} }
@@ -242,7 +231,7 @@ private struct MemoryDetail: View {
} }
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View { private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
HStack(spacing: 5) { HStack(spacing: Onyx.Space.sm) {
Circle().fill(color).frame(width: 7, height: 7) Circle().fill(color).frame(width: 7, height: 7)
Text(key, bundle: .module).font(.caption) Text(key, bundle: .module).font(.caption)
Spacer(minLength: 8) Spacer(minLength: 8)
@@ -263,7 +252,7 @@ private struct BatteryDetail: View {
var body: some View { var body: some View {
if let battery = snapshot.battery { if let battery = snapshot.battery {
HStack(alignment: .dialCenter, spacing: 14) { HStack(alignment: .dialCenter, spacing: Onyx.Space.xl) {
Dial(value: battery.charge, Dial(value: battery.charge,
caption: String(localized: "metric.battery", bundle: .module), caption: String(localized: "metric.battery", bundle: .module),
text: MetricFormat.percent(battery.charge), text: MetricFormat.percent(battery.charge),
@@ -275,7 +264,7 @@ private struct BatteryDetail: View {
// darunter waren zweimal dieselbe Auskunft an zwei Stellen — // darunter waren zweimal dieselbe Auskunft an zwei Stellen —
// und die zweite stand allein unter dem Diagramm, wo sie // und die zweite stand allein unter dem Diagramm, wo sie
// aussah, als gehörte sie zu dessen Beschriftung. // aussah, als gehörte sie zu dessen Beschriftung.
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
if let remaining = battery.timeRemaining { if let remaining = battery.timeRemaining {
Text(duration(remaining)).font(.title3.monospacedDigit()) Text(duration(remaining)).font(.title3.monospacedDigit())
Text(battery.isCharging ? "popover.battery.untilFull" Text(battery.isCharging ? "popover.battery.untilFull"
@@ -302,11 +291,11 @@ private struct BatteryDetail: View {
// eine Akkusache, und man greift danach, während man auf den // eine Akkusache, und man greift danach, während man auf den
// Ladestand schaut. // Ladestand schaut.
if let control { if let control {
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
ChargeLimitControl(control: control, roomy: true) ChargeLimitControl(control: control, roomy: true)
} }
SectionTitle(text: "popover.battery.condition") OnyxPopoverTitle(String(localized: "popover.battery.condition", bundle: .module))
if let health = battery.health { if let health = battery.health {
Row(label: String(localized: "popover.battery.health", bundle: .module), Row(label: String(localized: "popover.battery.health", bundle: .module),
value: MetricFormat.percent(health)) value: MetricFormat.percent(health))
@@ -320,7 +309,7 @@ private struct BatteryDetail: View {
value: MetricFormat.temperature(sensor.celsius)) value: MetricFormat.temperature(sensor.celsius))
} }
SectionTitle(text: "popover.power") OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
if let watts = battery.watts { if let watts = battery.watts {
Row(label: String(localized: battery.isCharging ? "popover.battery.charging" Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
: "popover.battery.draining", : "popover.battery.draining",
@@ -354,7 +343,7 @@ private struct BatteryDetail: View {
isPluggedIn: battery.isPluggedIn, isPluggedIn: battery.isPluggedIn,
charge: battery.charge, charge: battery.charge,
limit: control?.chargeLimit) limit: control?.chargeLimit)
HStack(spacing: 6) { HStack(spacing: Onyx.Space.sm) {
Image(systemName: symbol(for: activity)) Image(systemName: symbol(for: activity))
.font(.caption) .font(.caption)
.foregroundStyle(tint(for: activity)) .foregroundStyle(tint(for: activity))
@@ -401,7 +390,7 @@ private struct SensorDetail: View {
let snapshot: MetricsSnapshot let snapshot: MetricsSnapshot
var body: some View { var body: some View {
SectionTitle(text: "popover.temperature") OnyxPopoverTitle(String(localized: "popover.temperature", bundle: .module))
ForEach(snapshot.sensors) { sensor in ForEach(snapshot.sensors) { sensor in
HStack { HStack {
Text(sensor.name).font(.callout) Text(sensor.name).font(.callout)
@@ -421,9 +410,9 @@ private struct SensorDetail: View {
} }
if !snapshot.fans.isEmpty { if !snapshot.fans.isEmpty {
SectionTitle(text: "popover.fans") OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
ForEach(snapshot.fans) { fan in ForEach(snapshot.fans) { fan in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
HStack { HStack {
Text(fan.name).font(.callout) Text(fan.name).font(.callout)
Spacer(minLength: 12) Spacer(minLength: 12)
@@ -8,7 +8,7 @@ struct NetworkPopoverContent: View {
let model: NetworkModel let model: NetworkModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: Onyx.Space.lg) {
HStack(alignment: .top) { HStack(alignment: .top) {
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download) rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
Spacer() Spacer()
@@ -43,17 +43,17 @@ struct NetworkPopoverContent: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
section("network.addresses") OnyxPopoverTitle(String(localized: "network.addresses", bundle: .module))
field("network.field.interface", model.snapshot.interfaceName) field("network.field.interface", model.snapshot.interfaceName)
field("network.field.ipv4", model.snapshot.ipv4) field("network.field.ipv4", model.snapshot.ipv4)
field("network.field.ipv6", model.snapshot.ipv6) field("network.field.ipv6", model.snapshot.ipv6)
field("network.field.router", model.snapshot.router) field("network.field.router", model.snapshot.router)
section("network.transferred") OnyxPopoverTitle(String(localized: "network.transferred", bundle: .module))
field("network.total.received", bytes(model.snapshot.totalReceived)) field("network.total.received", bytes(model.snapshot.totalReceived))
field("network.total.sent", bytes(model.snapshot.totalSent)) field("network.total.sent", bytes(model.snapshot.totalSent))
section("network.public") OnyxPopoverTitle(String(localized: "network.public", bundle: .module))
if model.showsPublicAddress { if model.showsPublicAddress {
HStack { HStack {
if model.publicAddress.isLoading { if model.publicAddress.isLoading {
@@ -86,7 +86,7 @@ struct NetworkPopoverContent: View {
} }
if !model.processes.isEmpty { if !model.processes.isEmpty {
section("network.processes") OnyxPopoverTitle(String(localized: "network.processes", bundle: .module))
ForEach(model.processes) { process in ForEach(model.processes) { process in
HStack { HStack {
Text(process.name).font(.callout).lineLimit(1) Text(process.name).font(.callout).lineLimit(1)
@@ -101,7 +101,7 @@ struct NetworkPopoverContent: View {
} }
} }
} }
.padding(14) .padding(Onyx.Space.xl)
.frame(width: 300) .frame(width: 300)
.onAppear { model.addDetailConsumer() } .onAppear { model.addDetailConsumer() }
.onDisappear { model.removeDetailConsumer() } .onDisappear { model.removeDetailConsumer() }
@@ -117,10 +117,10 @@ struct NetworkPopoverContent: View {
} }
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View { private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(Throughput.formatted(value)) Text(Throughput.formatted(value))
.font(.system(size: 18, weight: .medium).monospacedDigit()) .font(Onyx.Font.display.monospacedDigit())
HStack(spacing: 4) { HStack(spacing: Onyx.Space.xs) {
Circle().fill(tint).frame(width: 6, height: 6) Circle().fill(tint).frame(width: 6, height: 6)
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary) Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
} }
@@ -128,19 +128,12 @@ struct NetworkPopoverContent: View {
} }
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View { private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
HStack(spacing: 4) { HStack(spacing: Onyx.Space.xs) {
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary) Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit()) Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
} }
} }
private func section(_ key: LocalizedStringKey) -> some View {
Text(key, bundle: .module)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
@ViewBuilder @ViewBuilder
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View { private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
if let value { if let value {
@@ -175,16 +168,16 @@ private struct TrafficChart: View {
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler. // Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag. // Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength, let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
totalWidth: geometry.size.width, spacing: 1) totalWidth: geometry.size.width, spacing: Onyx.Space.xxs)
let half = geometry.size.height / 2 let half = geometry.size.height / 2
HStack(alignment: .center, spacing: 1) { HStack(alignment: .center, spacing: Onyx.Space.xxs) {
// Fehlende Messwerte bleiben als freie Fläche links stehen; der // Fehlende Messwerte bleiben als freie Fläche links stehen; der
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm. // Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
Spacer(minLength: 0) Spacer(minLength: 0)
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
VStack(spacing: 1) { VStack(spacing: Onyx.Space.xxs) {
Rectangle() Rectangle()
.fill(Onyx.Color.upload) .fill(Onyx.Color.upload)
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5)) .frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
@@ -141,10 +141,10 @@ private struct NetworkWidgetView: View {
@State private var token: UUID? @State private var token: UUID?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: Onyx.Space.sm) {
HStack(spacing: 6) { HStack(spacing: Onyx.Space.sm) {
Image(systemName: model.snapshot.interfaceKind.symbolName) Image(systemName: model.snapshot.interfaceKind.symbolName)
.font(.system(size: 11)) .font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.download) .foregroundStyle(Onyx.Color.download)
Text(connectionName) Text(connectionName)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
@@ -158,7 +158,7 @@ private struct NetworkWidgetView: View {
capacity: NetworkModel.historyLength) capacity: NetworkModel.historyLength)
.frame(height: 18) .frame(height: 18)
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
Details(snapshot: model.snapshot) Details(snapshot: model.snapshot)
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -189,16 +189,16 @@ private struct Rates: View {
let compact: Bool let compact: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download) row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload) row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
} }
} }
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View { private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
HStack(spacing: 3) { HStack(spacing: Onyx.Space.xs) {
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 8, weight: .bold)) .font(.system(size: Onyx.Symbol.caption, weight: .bold))
.foregroundStyle(tint) .foregroundStyle(tint)
Text(text) Text(text)
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall) .font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
@@ -212,7 +212,7 @@ private struct Details: View {
let snapshot: NetworkSnapshot let snapshot: NetworkSnapshot
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
field("network.field.interface", snapshot.interfaceName) field("network.field.interface", snapshot.interfaceName)
field("network.field.ipv4", snapshot.ipv4) field("network.field.ipv4", snapshot.ipv4)
field("network.field.router", snapshot.router) field("network.field.router", snapshot.router)
@@ -222,12 +222,12 @@ private struct Details: View {
@ViewBuilder @ViewBuilder
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View { private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
if let value { if let value {
HStack(spacing: 4) { HStack(spacing: Onyx.Space.xs) {
Text(label, bundle: .module) Text(label, bundle: .module)
.font(.system(size: 9)) .font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
Text(value) Text(value)
.font(.system(size: 9)) .font(Onyx.Font.micro)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
.textSelection(.enabled) .textSelection(.enabled)
@@ -41,7 +41,7 @@ public struct WidgetGrid: View {
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch, // Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall. // ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
OnyxTile { OnyxTile {
VStack(spacing: 4) { VStack(spacing: Onyx.Space.xs) {
Image(systemName: "questionmark.square.dashed") Image(systemName: "questionmark.square.dashed")
Text(item.placement.widgetID) Text(item.placement.widgetID)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
@@ -45,9 +45,9 @@ public struct AirDropView: View {
if compact { if compact {
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für // Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
// Symbol über Text kein Platz. // Symbol über Text kein Platz.
HStack(spacing: 8) { HStack(spacing: Onyx.Space.md) {
Image(systemName: "shareplay") Image(systemName: "shareplay")
.font(.system(size: 15, weight: .light)) .font(.system(size: Onyx.Symbol.large, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent .foregroundStyle(isTargeted ? Onyx.Color.accent
: Onyx.Color.textTertiary) : Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module) Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
@@ -57,11 +57,11 @@ public struct AirDropView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.padding(.horizontal, 8) .padding(.horizontal, Onyx.Space.md)
} else { } else {
VStack(spacing: 6) { VStack(spacing: Onyx.Space.sm) {
Image(systemName: "shareplay") Image(systemName: "shareplay")
.font(.system(size: 20, weight: .light)) .font(.system(size: Onyx.Symbol.xlarge, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent .foregroundStyle(isTargeted ? Onyx.Color.accent
: Onyx.Color.textTertiary) : Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module) Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
@@ -74,15 +74,12 @@ public struct AirDropView: View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background { // Diese Fläche ist **nur** zum Ablegen da. Ihre Kante steht deshalb
RoundedRectangle(cornerRadius: 10, style: .continuous) // immer — ohne sie wüsste niemand, dass sie eine ist.
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline, .onyxDropZone(isTargeted: isTargeted, style: .always)
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als // Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
// Versprechen an, nicht als fertige Datei. // Versprechen an, nicht als fertige Datei.
.contentShape(.rect) .contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) } .onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
.animation(Onyx.Motion.value, value: isTargeted)
} }
} }
@@ -50,12 +50,12 @@ public struct ShelfView: View {
} }
public var body: some View { public var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: Onyx.Space.sm) {
if store.items.isEmpty { if store.items.isEmpty {
EmptyShelf() EmptyShelf()
} else { } else {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 4) { LazyVStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(store.items.prefix(compact ? 4 : 20)) { item in ForEach(store.items.prefix(compact ? 4 : 20)) { item in
ShelfRow(item: item, store: store, thumbnails: thumbnails) ShelfRow(item: item, store: store, thumbnails: thumbnails)
} }
@@ -63,7 +63,7 @@ public struct ShelfView: View {
} }
.scrollIndicators(.never) .scrollIndicators(.never)
HStack(spacing: 10) { HStack(spacing: Onyx.Space.row) {
Button { store.removeAll() } label: { Button { store.removeAll() } label: {
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption) Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
} }
@@ -82,24 +82,17 @@ public struct ShelfView: View {
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin. // Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
.contentShape(.rect) .contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) } .onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
.overlay { // Die Kachel nimmt nebenbei Dateien an — die Kante erscheint deshalb
if isTargeted { // erst, wenn wirklich etwas darüber schwebt.
RoundedRectangle(cornerRadius: 10, style: .continuous) .onyxDropZone(isTargeted: isTargeted, style: .whenTargeted)
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08),
in: .rect(cornerRadius: 10, style: .continuous))
.allowsHitTesting(false)
}
}
.animation(Onyx.Motion.value, value: isTargeted)
} }
} }
private struct EmptyShelf: View { private struct EmptyShelf: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Onyx.Space.xs) {
Image(systemName: "tray") Image(systemName: "tray")
.font(.system(size: 16)) .font(.system(size: Onyx.Symbol.large))
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
Text("shelf.empty", bundle: .module) Text("shelf.empty", bundle: .module)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
@@ -117,9 +110,9 @@ private struct ShelfRow: View {
@State private var isHovered = false @State private var isHovered = false
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: Onyx.Space.md) {
thumbnail thumbnail
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(item.name) Text(item.name)
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
@@ -133,19 +126,19 @@ private struct ShelfRow: View {
if isHovered { if isHovered {
Button { AirDrop.send([item.url]) } label: { Button { AirDrop.send([item.url]) } label: {
Image(systemName: "shareplay").font(.system(size: 10)) Image(systemName: "shareplay").font(.system(size: Onyx.Symbol.small))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
Button { store.remove(item) } label: { Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11)) Image(systemName: "xmark.circle.fill").font(.system(size: Onyx.Symbol.small))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
} }
} }
.padding(.vertical, 2) .padding(.vertical, Onyx.Space.xxs)
.contentShape(.rect) .contentShape(.rect)
.onHover { isHovered = $0 } .onHover { isHovered = $0 }
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres // `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
@@ -176,9 +169,9 @@ private struct ShelfRow: View {
Image(nsImage: image) Image(nsImage: image)
.resizable().aspectRatio(contentMode: .fit) .resizable().aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22) .frame(width: 22, height: 22)
.clipShape(.rect(cornerRadius: 3, style: .continuous)) .clipShape(.rect(cornerRadius: Onyx.Radius.bar, style: .continuous))
} else { } else {
RoundedRectangle(cornerRadius: 3, style: .continuous) RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.elevated) .fill(Onyx.Color.elevated)
.frame(width: 22, height: 22) .frame(width: 22, height: 22)
} }
@@ -204,13 +197,13 @@ public struct ShelfWidget: OnyxWidget {
public func makeView() -> AnyView { public func makeView() -> AnyView {
AnyView( AnyView(
VStack(spacing: 8) { VStack(spacing: Onyx.Space.md) {
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne // AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
// Zustand, die Ablage darunter braucht den Platz für ihre Liste. // Zustand, die Ablage darunter braucht den Platz für ihre Liste.
AirDropView(compact: true) AirDropView(compact: true)
.frame(height: 52) .frame(height: 52)
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
ShelfView(store: store) ShelfView(store: store)
} }
@@ -195,12 +195,15 @@ private struct WeatherWidgetView: View {
if let lastKnown { if let lastKnown {
content(lastKnown, isStale: true) content(lastKnown, isStale: true)
} else { } else {
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable") OnyxNotice(symbol: "wifi.slash",
text: String(localized: "widget.weather.unavailable", bundle: .module))
} }
case .locationDenied: case .locationDenied:
Notice(symbol: "location.slash", text: "widget.weather.locationDenied") OnyxNotice(symbol: "location.slash",
text: String(localized: "widget.weather.locationDenied", bundle: .module))
case .locationUndetermined: case .locationUndetermined:
Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined") OnyxNotice(symbol: "location.circle",
text: String(localized: "widget.weather.locationUndetermined", bundle: .module))
case .loading, .idle: case .loading, .idle:
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
@@ -217,25 +220,6 @@ private struct WeatherWidgetView: View {
WideWeather(snapshot: snapshot, isStale: isStale) WideWeather(snapshot: snapshot, isStale: isStale)
} }
} }
private struct Notice: View {
let symbol: String
let text: LocalizedStringKey
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 15))
.foregroundStyle(Onyx.Color.textTertiary)
Text(text, bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct WideWeather: View { private struct WideWeather: View {
let snapshot: WeatherSnapshot let snapshot: WeatherSnapshot
let isStale: Bool let isStale: Bool
@@ -246,15 +230,15 @@ private struct WideWeather: View {
// die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die // die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die
// man auf Armlänge nicht entziffert. // man auf Armlänge nicht entziffert.
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 10) { HStack(alignment: .center, spacing: Onyx.Space.row) {
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
.font(.system(size: 34)) .font(.system(size: Onyx.Symbol.hero))
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(snapshot.temperature.onyxFormatted) Text(snapshot.temperature.onyxFormatted)
.font(.system(size: 30, weight: .medium)) .font(Onyx.Font.displayLarge)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(Onyx.Color.textPrimary) .foregroundStyle(Onyx.Color.textPrimary)
if let high = snapshot.high, let low = snapshot.low { if let high = snapshot.high, let low = snapshot.low {
@@ -271,7 +255,7 @@ private struct WideWeather: View {
.font(Onyx.Font.caption) .font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1) .lineLimit(1)
.padding(.top, 2) .padding(.top, Onyx.Space.xxs)
if snapshot.apparentTemperature != snapshot.temperature { if snapshot.apparentTemperature != snapshot.temperature {
Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)", Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)",
@@ -283,19 +267,19 @@ private struct WideWeather: View {
Spacer(minLength: 6) Spacer(minLength: 6)
if !snapshot.hourly.isEmpty { if !snapshot.hourly.isEmpty {
Divider().overlay(Onyx.Color.hairline) OnyxHairline()
// Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so // Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so
// gut vierzig je Spalte, und die Zahlen lassen sich in einer // gut vierzig je Spalte, und die Zahlen lassen sich in einer
// lesbaren Größe setzen. // lesbaren Größe setzen.
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(snapshot.hourly.prefix(4)) { point in ForEach(snapshot.hourly.prefix(4)) { point in
VStack(spacing: 3) { VStack(spacing: Onyx.Space.xs) {
Text(point.date.formatted(.dateTime.hour())) Text(point.date.formatted(.dateTime.hour()))
.font(Onyx.Font.metricSmall) .font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary) .foregroundStyle(Onyx.Color.textTertiary)
Image(systemName: point.condition.symbolName(isDaylight: true)) Image(systemName: point.condition.symbolName(isDaylight: true))
.font(.system(size: 13)) .font(.system(size: Onyx.Symbol.medium))
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textSecondary) .foregroundStyle(Onyx.Color.textSecondary)
Text(point.temperature.onyxFormatted) Text(point.temperature.onyxFormatted)
@@ -306,15 +290,15 @@ private struct WideWeather: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
.padding(.vertical, 8) .padding(.vertical, Onyx.Space.md)
} }
Spacer(minLength: 0) Spacer(minLength: 0)
HStack(spacing: 4) { HStack(spacing: Onyx.Space.xs) {
if isStale { if isStale {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9)) .font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.warning) .foregroundStyle(Onyx.Color.warning)
} }
Text(stampText) Text(stampText)
@@ -338,10 +322,13 @@ private struct WideWeather: View {
} }
} }
extension Measurement where UnitType == UnitTemperature { public extension Measurement where UnitType == UnitTemperature {
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt. /// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor, /// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
/// die die Vorhersage nicht hat. /// die die Vorhersage nicht hat.
///
/// Öffentlich, seit die Temperatur auch neben der Notch steht: dieselbe
/// Zahl zweimal verschieden zu runden wäre der Anfang zweier Wahrheiten.
var onyxFormatted: String { var onyxFormatted: String {
formatted(.measurement(width: .narrow, formatted(.measurement(width: .narrow,
usage: .weather, usage: .weather,
@@ -0,0 +1,71 @@
import Testing
@testable import AudioProvider
// Der Mixer war an einem Headset völlig still. Nachgemessen am Aggregate-Gerät
// über der „PRO X Wireless Gaming Headset":
//
// MacBook Pro-Lautsprecher Eingang 1 Puffer: 2 Kan. → nur der Tap
// PRO X Headset Eingang 2 Puffer: 1 Kan. | 2 Kan. → Mikrofon, dann Tap
//
// Ein Ausgabegerät, das selbst einen Eingang mitbringt — jedes Headset, jedes
// USB-Interface — schiebt seine eigenen Eingangspuffer vor die des Taps. Wer
// blind Puffer null nimmt, greift das Mikrofon ab statt des Programms: der
// Programmton wird nie ausgegeben, während der Tap das Original stumm hält.
// Die eingebauten Lautsprecher haben keinen Eingang, deshalb fiel es dort nie auf.
@Suite("Wo der Tap im Aggregate-Gerät liegt")
struct TapBufferLayoutTests {
@Test("Reines Ausgabegerät: der Tap liegt vorn")
func outputOnlyDeviceKeepsTapFirst() {
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 0,
aggregateInputChannels: [2],
tapChannels: 2) == 0)
}
@Test("Headset: der eigene Mikrofonpuffer schiebt den Tap nach hinten")
func headsetMicrophoneShiftsTap() {
// Der gemessene Fall. Vorher wurde hier Puffer null genommen — das
// Mikrofon — und der Programmton blieb liegen.
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 1,
aggregateInputChannels: [1, 2],
tapChannels: 2) == 1)
}
@Test("Mehrere Eingangspuffer des Geräts zählen alle")
func severalDeviceInputsCount() {
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 2,
aggregateInputChannels: [1, 1, 2],
tapChannels: 2) == 2)
}
@Test("Passt die Kanalzahl an der erwarteten Stelle nicht, wird nicht geraten")
func mismatchedChannelsAreRefused() {
// Läge der Tap wider Erwarten woanders, wäre die Folge wieder Stille
// bei stummgeschaltetem Original. Dann lieber gar kein Tap: der Aufbau
// fliegt auf, das Programm bleibt hörbar.
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 0,
aggregateInputChannels: [1, 2],
tapChannels: 2) == nil)
}
@Test("Fehlt der Puffer ganz, wird nicht geraten")
func missingBufferIsRefused() {
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 2,
aggregateInputChannels: [1, 2],
tapChannels: 2) == nil)
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 0,
aggregateInputChannels: [],
tapChannels: 2) == nil)
}
@Test("Unsinnige Angaben werden abgewiesen, nicht verrechnet")
func nonsenseIsRefused() {
#expect(TapBufferLayout.offset(subDeviceInputBuffers: -1,
aggregateInputChannels: [2],
tapChannels: 2) == nil)
#expect(TapBufferLayout.offset(subDeviceInputBuffers: 0,
aggregateInputChannels: [2],
tapChannels: 0) == nil)
}
}