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:
co-authored by
Claude Opus 5
parent
9b600de9a9
commit
edb327cabe
@@ -20,6 +20,9 @@ final class AppModel {
|
||||
var onLayoutChanged: (([WidgetPlacement]) -> Void)?
|
||||
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
|
||||
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
|
||||
var onShowsIdleIslandChanged: ((Bool) -> Void)?
|
||||
var onNotchStyleChanged: ((NotchStyle) -> Void)?
|
||||
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
|
||||
|
||||
var layout: [WidgetPlacement] {
|
||||
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 {
|
||||
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
|
||||
}
|
||||
@@ -86,6 +151,11 @@ final class AppModel {
|
||||
private enum Keys {
|
||||
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
|
||||
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 showsDockIcon = "onyx.showsDockIcon"
|
||||
static let triggerArea = "onyx.triggerArea"
|
||||
@@ -101,6 +171,21 @@ final class AppModel {
|
||||
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
|
||||
.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
|
||||
// zurück, wenn die Notch einmal nicht reagiert.
|
||||
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
|
||||
|
||||
@@ -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
@@ -12,7 +12,7 @@ struct FanSettingsView: View {
|
||||
@Bindable var control: FanControl
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
SettingsPage {
|
||||
switch control.installState {
|
||||
case .notInstalled, .failed:
|
||||
installSection
|
||||
@@ -23,16 +23,13 @@ struct FanSettingsView: View {
|
||||
// Das Ladelimit hängt am selben Helfer wie die Lüfter — beides
|
||||
// ist Hardwaresteuerung über den SMC, und beides ist erst
|
||||
// erreichbar, wenn der Helfer läuft.
|
||||
Section("charge.section") {
|
||||
OnyxFormCard(String(localized: "charge.section")) {
|
||||
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 {
|
||||
control.refreshInstallState()
|
||||
control.start()
|
||||
@@ -43,37 +40,33 @@ struct FanSettingsView: View {
|
||||
// MARK: - Einrichtung
|
||||
|
||||
private var installSection: some View {
|
||||
Section {
|
||||
Text("fans.intro")
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
OnyxFormCard(String(localized: "fans.group.setup"),
|
||||
hint: String(localized: "fans.intro")) {
|
||||
// Das Risiko benennen, bevor jemand zustimmt — nicht danach.
|
||||
Label("fans.warning", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(Onyx.Color.warning)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("fans.safety")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
OnyxFormRow(String(localized: "fans.warning"),
|
||||
hint: String(localized: "fans.safety"),
|
||||
symbol: "exclamationmark.triangle.fill",
|
||||
tone: .warning)
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var approvalSection: some View {
|
||||
Section {
|
||||
Label("fans.approval.needed", systemImage: "hand.raised")
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
OnyxFormCard(String(localized: "fans.group.setup")) {
|
||||
OnyxFormRow(String(localized: "fans.approval.needed"),
|
||||
symbol: "hand.raised") {
|
||||
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
|
||||
private var controlSection: some View {
|
||||
if let report = control.report {
|
||||
Section {
|
||||
OnyxFormCard(String(localized: "fans.group.control")) {
|
||||
if let hottest = report.hottestCelsius {
|
||||
HStack {
|
||||
Label("fans.hottest", systemImage: "thermometer")
|
||||
Spacer()
|
||||
OnyxFormRow(String(localized: "fans.hottest"), symbol: "thermometer") {
|
||||
Text("\(Int(hottest))°").monospacedDigit()
|
||||
.foregroundStyle(hottest >= FanSafety.criticalCelsius
|
||||
? Onyx.Color.critical : .secondary)
|
||||
? AnyShapeStyle(Onyx.Color.critical)
|
||||
: AnyShapeStyle(.secondary))
|
||||
}
|
||||
}
|
||||
ForEach(report.fans, id: \.index) { fan in
|
||||
@@ -97,22 +89,21 @@ struct FanSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("fans.safety.active")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("fans.uninstall", role: .destructive) {
|
||||
OnyxFormCard(String(localized: "fans.group.danger"),
|
||||
hint: String(localized: "fans.safety.active")) {
|
||||
OnyxFormRow(String(localized: "fans.uninstall")) {
|
||||
Button("settings.action.remove", role: .destructive) {
|
||||
Task { await control.uninstall() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
HStack {
|
||||
OnyxFormCard(String(localized: "fans.group.control")) {
|
||||
OnyxFormRow(String(localized: "fans.connecting")) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("fans.connecting").foregroundStyle(.secondary)
|
||||
}
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||
HStack {
|
||||
Label(name, systemImage: "fan")
|
||||
Spacer()
|
||||
@@ -173,6 +164,9 @@ private struct FanRow: View {
|
||||
.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) } }
|
||||
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
|
||||
}
|
||||
|
||||
+25
-51
@@ -16,7 +16,7 @@ struct FanView: View {
|
||||
let roomy: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.row) {
|
||||
switch control.installState {
|
||||
case .installed:
|
||||
if let report = control.report, !report.fans.isEmpty {
|
||||
@@ -24,10 +24,10 @@ struct FanView: View {
|
||||
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
||||
}
|
||||
if let hottest = report.hottestCelsius {
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
HStack(spacing: 5) {
|
||||
OnyxHairline()
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: "thermometer.medium")
|
||||
.font(.system(size: 10))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
Text("\(Int(hottest.rounded())) °C")
|
||||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||
Spacer(minLength: 0)
|
||||
@@ -35,14 +35,24 @@ struct FanView: View {
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
} else {
|
||||
notice("fans.noData", symbol: "fan")
|
||||
OnyxNotice(symbol: "fan",
|
||||
text: String(localized: "fans.noData"),
|
||||
placement: .leading)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
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):
|
||||
Text(reason)
|
||||
.font(Onyx.Font.caption)
|
||||
@@ -54,29 +64,6 @@ struct FanView: View {
|
||||
.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 {
|
||||
@@ -86,8 +73,8 @@ private struct FanRow: View {
|
||||
let roomy: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Text("fans.fan \(fan.index + 1)")
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
@@ -98,29 +85,16 @@ private struct FanRow: View {
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
|
||||
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
|
||||
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
|
||||
// aus wie ein Link und nicht wie ein Zustand.
|
||||
Button { control.setAutomatic(index: index) } label: {
|
||||
Text("fans.auto")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
|
||||
: 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)
|
||||
OnyxPillButton(String(localized: "fans.auto"),
|
||||
isFilled: !fan.isManual,
|
||||
size: .compact) {
|
||||
control.setAutomatic(index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!fan.isManual)
|
||||
|
||||
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
|
||||
@@ -184,7 +158,7 @@ final class FanMenuBarModule: MenuBarModule {
|
||||
|
||||
func makePopoverView() -> AnyView {
|
||||
AnyView(FanView(control: control, roomy: true)
|
||||
.padding(14)
|
||||
.padding(Onyx.Space.xl)
|
||||
.frame(width: 260))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -46,7 +46,7 @@ final class MixerMenuBarModule: MenuBarModule {
|
||||
AnyView(
|
||||
ScrollView {
|
||||
MixerView(mixer: mixer, system: system)
|
||||
.padding(16)
|
||||
.padding(Onyx.Space.xl)
|
||||
}
|
||||
.scrollIndicators(.automatic)
|
||||
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
|
||||
|
||||
+18
-27
@@ -33,7 +33,7 @@ struct OnboardingView: View {
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
OnyxFormCard {
|
||||
OnboardingRow(
|
||||
symbol: "calendar",
|
||||
title: "onboarding.calendar",
|
||||
@@ -74,9 +74,10 @@ struct OnboardingView: View {
|
||||
fanControl.openApprovalSettings()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, Onyx.Space.xxl)
|
||||
.padding(.vertical, Onyx.Space.lg)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
Divider()
|
||||
footer
|
||||
@@ -95,10 +96,10 @@ struct OnboardingView: View {
|
||||
// MARK: - Kopf und Fuß
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: Onyx.Space.xl) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.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.subtitle")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
@@ -106,7 +107,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(22)
|
||||
.padding(Onyx.Space.xxl)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
@@ -128,7 +129,7 @@ struct OnboardingView: View {
|
||||
Button("onboarding.done", action: onFinish)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(22)
|
||||
.padding(Onyx.Space.xxl)
|
||||
}
|
||||
|
||||
// MARK: - Zustände
|
||||
@@ -169,33 +170,25 @@ private struct OnboardingRow: View {
|
||||
enum State { case granted, denied, undetermined }
|
||||
|
||||
let symbol: String
|
||||
let title: LocalizedStringKey
|
||||
let detail: LocalizedStringKey
|
||||
let title: String.LocalizationValue
|
||||
let detail: String.LocalizationValue
|
||||
let state: State
|
||||
let request: () async -> 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 {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary)
|
||||
.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)
|
||||
|
||||
OnyxFormRow(String(localized: title),
|
||||
hint: String(localized: detail),
|
||||
symbol: symbol,
|
||||
tone: state == .granted ? .normal : .normal) {
|
||||
switch state {
|
||||
case .granted:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Onyx.Color.positive)
|
||||
.font(.system(size: 16))
|
||||
.font(.system(size: Onyx.Symbol.large))
|
||||
case .undetermined:
|
||||
Button("onboarding.allow") { Task { await request() } }
|
||||
case .denied:
|
||||
@@ -204,7 +197,5 @@ private struct OnboardingRow: View {
|
||||
Button("onboarding.openSettings", action: openSettings)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
||||
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
|
||||
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.styleMask = [.titled, .closable]
|
||||
window.isReleasedWhenClosed = false
|
||||
|
||||
+263
-6
@@ -12,6 +12,9 @@ import MetricsProvider
|
||||
import NetworkProvider
|
||||
import AudioProvider
|
||||
import ShelfProvider
|
||||
import ClipboardProvider
|
||||
import NotesProvider
|
||||
import ActivityProvider
|
||||
import OSLog
|
||||
|
||||
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 mediaModel: MediaModel?
|
||||
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 shelfStore: ShelfStore?
|
||||
private var clipboardStore: ClipboardStore?
|
||||
private var notesStore: NotesStore?
|
||||
private let settingsWindow = SettingsWindowController()
|
||||
private let fanControl = FanControl()
|
||||
private let audioMixer = AudioMixer()
|
||||
private let systemAudio = SystemAudio()
|
||||
private let launchAtLogin = LaunchAtLogin()
|
||||
private let onboarding = OnboardingWindowController()
|
||||
private let galleryWindow = GalleryWindowController()
|
||||
private let activities = ActivityPresenter()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
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
|
||||
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
|
||||
// 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,
|
||||
trigger: model.triggerArea) { displayID, presentation in
|
||||
NotchHostingView(
|
||||
rootView: NotchContainer(presentation: presentation) {
|
||||
NotchPanelContent(model: model, displayID: displayID)
|
||||
rootView: NotchContainer(
|
||||
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)
|
||||
}
|
||||
coordinator.showsIdleIsland = model.showsIdleIsland
|
||||
coordinator.style = model.notchStyle
|
||||
coordinator.virtualWidthOverrides = model.notchWidths
|
||||
coordinator.start()
|
||||
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(
|
||||
self, selector: #selector(screensChanged),
|
||||
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||
@@ -97,6 +174,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self?.coordinator?.policy = policy
|
||||
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()
|
||||
if let metricsModel {
|
||||
@@ -133,6 +219,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
installOnyxStatusItem()
|
||||
observeMenuBarPreference()
|
||||
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
|
||||
@@ -178,7 +278,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
|
||||
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
|
||||
// genügt der Berechtigungsbereich der Einstellungen.
|
||||
showSettings(tab: .permissions)
|
||||
showSettings(section: .permissions)
|
||||
}
|
||||
|
||||
/// 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
|
||||
// eine leere Ablegefläche waren ein Drittel des Panels.
|
||||
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.
|
||||
@@ -292,6 +400,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
action: #selector(openSettings), keyEquivalent: ",").target = self
|
||||
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
|
||||
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(withTitle: String(localized: "menu.quit"),
|
||||
action: #selector(quit), keyEquivalent: "q").target = self
|
||||
@@ -302,6 +417,148 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@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.
|
||||
///
|
||||
/// 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 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 }
|
||||
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||
weatherModel: weatherModel, metricsModel: metricsModel,
|
||||
shelfStore: shelfStore, fanControl: fanControl,
|
||||
launchAtLogin: launchAtLogin,
|
||||
showOnboarding: { [weak self] in self?.showOnboarding() },
|
||||
tab: tab)
|
||||
section: section)
|
||||
}
|
||||
|
||||
@objc private func quit() { NSApp.terminate(nil) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+365
-213
@@ -9,10 +9,6 @@ import MetricsProvider
|
||||
import OnyxHelperProtocol
|
||||
import WeatherProvider
|
||||
|
||||
enum SettingsTab: Hashable {
|
||||
case widgets, display, colors, sources, menubar, fans, permissions
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var model: AppModel
|
||||
let calendarModel: CalendarModel
|
||||
@@ -22,87 +18,215 @@ struct SettingsView: View {
|
||||
let fanControl: FanControl
|
||||
let launchAtLogin: LaunchAtLogin
|
||||
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.
|
||||
@Binding var selectedTab: SettingsTab
|
||||
|
||||
/// 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"),
|
||||
]
|
||||
@Binding var section: SettingsSection
|
||||
|
||||
var body: some View {
|
||||
// Eigene Reiterleiste statt `TabView`.
|
||||
// **Zweiteilung von Hand, kein `NavigationSplitView`.**
|
||||
//
|
||||
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
|
||||
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf.
|
||||
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
|
||||
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
ForEach(Self.tabs, id: \.tab) { entry in
|
||||
Text(entry.label).tag(entry.tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund
|
||||
// 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)
|
||||
// Der malt Seitenleiste und Inhaltsfläche selbst deckend zu. Die
|
||||
// Vibrancy-Ebene des Fensters kam deshalb nur unter dem Titelbalken
|
||||
// durch — oben durchsichtig, darunter eine Platte.
|
||||
// `.scrollContentBackground(.hidden)` erreicht sie nicht: es gilt für
|
||||
// Listen und Scrollflächen, nicht für den Rahmen darum.
|
||||
//
|
||||
// Gebraucht wird hier ohnehin keine Navigation, sondern eine Auswahl
|
||||
// und zwei Spalten.
|
||||
HStack(spacing: 0) {
|
||||
sidebar
|
||||
.frame(width: 200)
|
||||
|
||||
Divider()
|
||||
|
||||
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)
|
||||
.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
|
||||
// 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
|
||||
private var content: some View {
|
||||
switch selectedTab {
|
||||
switch section {
|
||||
case .general:
|
||||
GeneralSettings(model: model, launchAtLogin: launchAtLogin,
|
||||
showOnboarding: showOnboarding)
|
||||
case .widgets:
|
||||
WidgetSettings(model: model, shelfStore: shelfStore)
|
||||
case .display:
|
||||
DisplaySettings(model: model)
|
||||
case .colors:
|
||||
ColorSettingsView(theme: OnyxTheme.shared)
|
||||
case .sources:
|
||||
WidgetSettings(model: model)
|
||||
case .widgetOptions:
|
||||
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||
shelfStore: shelfStore)
|
||||
case .notch:
|
||||
NotchSettings(model: model)
|
||||
case .appearance:
|
||||
AppearanceSettingsView(theme: OnyxTheme.shared)
|
||||
case .menubar:
|
||||
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||
case .fans:
|
||||
FanSettingsView(control: fanControl)
|
||||
case .permissions:
|
||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
|
||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let calendarModel: CalendarModel
|
||||
let weatherModel: WeatherModel
|
||||
let launchAtLogin: LaunchAtLogin
|
||||
let showOnboarding: () -> Void
|
||||
@State private var calendarGranted = false
|
||||
|
||||
private var locationState: PermissionRow.State {
|
||||
@@ -131,67 +253,29 @@ private struct PermissionSettings: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("settings.general") {
|
||||
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 {
|
||||
SettingsPage {
|
||||
OnyxFormCard(String(localized: "settings.permissions.group")) {
|
||||
PermissionRow(
|
||||
title: "settings.permissions.calendar",
|
||||
title: String(localized: "settings.permissions.calendar"),
|
||||
symbol: "calendar",
|
||||
state: calendarGranted ? .granted
|
||||
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
||||
explanation: "settings.permissions.calendar.why",
|
||||
explanation: String(localized: "settings.permissions.calendar.why"),
|
||||
pane: .calendar,
|
||||
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
||||
}
|
||||
|
||||
Section {
|
||||
PermissionRow(
|
||||
title: "settings.permissions.location",
|
||||
title: String(localized: "settings.permissions.location"),
|
||||
symbol: "location",
|
||||
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
||||
// sich, während dieses Fenster offen steht.
|
||||
state: locationState,
|
||||
explanation: "settings.permissions.location.why",
|
||||
explanation: String(localized: "settings.permissions.location.why"),
|
||||
pane: .location,
|
||||
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 {
|
||||
launchAtLogin.refresh()
|
||||
calendarGranted = calendarModel.hasAccess
|
||||
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
|
||||
// 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 state: State
|
||||
let explanation: LocalizedStringKey
|
||||
let explanation: String
|
||||
let pane: Pane
|
||||
let request: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Label(title, systemImage: symbol)
|
||||
Spacer()
|
||||
OnyxFormRow(title, hint: explanation, symbol: symbol) {
|
||||
switch state {
|
||||
case .granted:
|
||||
// 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
|
||||
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.gpu", "metric.gpu", "cpu.fill"),
|
||||
("metric.memory", "metric.memory", "memorychip"),
|
||||
@@ -290,22 +368,20 @@ private struct MenuBarSettings: View {
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsPage {
|
||||
Text("settings.menubar.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
List {
|
||||
OnyxFormCard {
|
||||
ForEach(Self.modules, id: \.id) { module in
|
||||
ModuleRow(model: model, id: module.id,
|
||||
label: module.label, symbol: module.symbol,
|
||||
metricsModel: metricsModel)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.padding()
|
||||
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
|
||||
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
|
||||
.id(model.menuBarRevision)
|
||||
@@ -315,7 +391,7 @@ private struct MenuBarSettings: View {
|
||||
private struct ModuleRow: View {
|
||||
@Bindable var model: AppModel
|
||||
let id: String
|
||||
let label: LocalizedStringKey
|
||||
let label: String.LocalizationValue
|
||||
let symbol: String
|
||||
let metricsModel: MetricsModel?
|
||||
|
||||
@@ -344,22 +420,11 @@ private struct ModuleRow: View {
|
||||
var body: some View {
|
||||
let settings = model.menuBarSettings(for: id)
|
||||
|
||||
HStack {
|
||||
Toggle(isOn: Binding(
|
||||
get: { settings.isEnabled },
|
||||
set: { enabled in
|
||||
var updated = settings
|
||||
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.
|
||||
OnyxFormRow(String(localized: label), symbol: symbol) {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
// 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 {
|
||||
Picker("", selection: Binding(
|
||||
get: { metricsModel.selectedSensorKey ?? "" },
|
||||
@@ -374,9 +439,9 @@ private struct ModuleRow: View {
|
||||
.disabled(!settings.isEnabled)
|
||||
}
|
||||
|
||||
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
|
||||
// stammen aus derselben Messung — ein zweites Element in der
|
||||
// ohnehin knappen Menüleiste wäre Verschwendung.
|
||||
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide
|
||||
// Werte stammen aus derselben Messung — ein zweites Element in
|
||||
// der ohnehin knappen Menüleiste wäre Verschwendung.
|
||||
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
|
||||
Picker("", selection: Binding(
|
||||
get: { metricsModel.displayMode(for: metric) },
|
||||
@@ -390,8 +455,8 @@ private struct ModuleRow: View {
|
||||
}
|
||||
|
||||
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
|
||||
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen
|
||||
// könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
||||
// keinen einzelnen Messwert, den man als Zahl oder Verlauf
|
||||
// zeigen könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
||||
if !Self.presentations(for: id).isEmpty {
|
||||
Picker("", selection: Binding(
|
||||
get: { settings.presentation },
|
||||
@@ -408,6 +473,16 @@ private struct ModuleRow: View {
|
||||
.frame(width: 150)
|
||||
.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 {
|
||||
@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 {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("settings.widgets.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
List {
|
||||
Section("settings.widgets.inPanel") {
|
||||
HStack(spacing: 0) {
|
||||
// Eine Liste, keine Karte: das Ziehen zum Umsortieren kommt von
|
||||
// `List.onMove`. Und **nur** die Liste, ohne Rahmen darum — eine
|
||||
// Liste in einer Spalte mit eigenem Rand lief unter den
|
||||
// Fensterknöpfen hindurch, weil sie ihre eigene Scrollfläche
|
||||
// mitbringt und die dann zwei Ränder gegeneinander schieben.
|
||||
List(selection: $previewID) {
|
||||
Section {
|
||||
// Die Reihenfolge hier ist die Reihenfolge im Panel: die
|
||||
// Layout-Engine setzt die Widgets von oben links der Reihe
|
||||
// nach ein.
|
||||
ForEach(model.layout) { placement in
|
||||
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
|
||||
PlacementRow(model: model, placement: placement, widget: widget)
|
||||
.tag(placement.widgetID)
|
||||
}
|
||||
}
|
||||
.onMove { model.move(fromOffsets: $0, toOffset: $1) }
|
||||
@@ -454,6 +537,13 @@ private struct WidgetSettings: View {
|
||||
Text("settings.widgets.empty")
|
||||
.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) }
|
||||
@@ -466,47 +556,69 @@ private struct WidgetSettings: View {
|
||||
Button("settings.widgets.add") { model.add(widget) }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.tag(widget.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.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:
|
||||
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
|
||||
/// Papierkorb mit Zeitschaltuhr.
|
||||
private struct ShelfSettingsRows: View {
|
||||
@Bindable var store: ShelfStore
|
||||
|
||||
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
||||
/// Echte Größe (180 × 200) auf echter Paneloberfläche und mit den echten
|
||||
/// Modellen — eine verkleinerte oder erfundene Vorschau wäre eine Behauptung.
|
||||
/// Dieselben Schritte wie in `WidgetGrid`: Kachel, Rahmen, beschneiden.
|
||||
private struct WidgetPreview: View {
|
||||
let widgetID: String?
|
||||
|
||||
var body: some View {
|
||||
Picker(selection: $store.autoCleanupDays) {
|
||||
ForEach(Self.choices, id: \.self) { days in
|
||||
if let days {
|
||||
Text("settings.shelf.days \(days)").tag(Optional(days))
|
||||
VStack(spacing: Onyx.Space.row) {
|
||||
if let widgetID, let widget = WidgetRegistry.shared.widget(id: widgetID) {
|
||||
OnyxTile { widget.makeView() }
|
||||
.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 {
|
||||
Text("settings.shelf.never").tag(Int?.none)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("settings.shelf.cleanup")
|
||||
Text("settings.widgets.preview.none")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("settings.shelf.count \(store.items.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||||
.buttonStyle(.borderless)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.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
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker("settings.display.policy", selection: $model.displayPolicy) {
|
||||
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
|
||||
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
|
||||
OnyxFormCard(String(localized: "settings.notch.width"),
|
||||
hint: String(localized: "settings.notch.width.hint")) {
|
||||
ForEach(NSScreen.screens, id: \.displayID) { screen in
|
||||
row(for: screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
Text("settings.display.hint")
|
||||
.font(.callout)
|
||||
@ViewBuilder
|
||||
private func row(for screen: NSScreen) -> some View {
|
||||
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)
|
||||
.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)
|
||||
} 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)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding()
|
||||
private func binding(for id: CGDirectDisplayID) -> Binding<CGFloat> {
|
||||
Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width },
|
||||
set: { model.setNotchWidth($0, for: id) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ import ShelfProvider
|
||||
final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
@ObservationIgnored private var window: NSWindow?
|
||||
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
|
||||
private var selectedTab: SettingsTab = .widgets
|
||||
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Bereich
|
||||
/// setzen kann.
|
||||
private var section: SettingsSection = .general
|
||||
|
||||
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
|
||||
/// zurückgeschaltet wird.
|
||||
@@ -37,8 +38,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
||||
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
|
||||
tab: SettingsTab = .widgets) {
|
||||
selectedTab = tab
|
||||
section: SettingsSection = .general) {
|
||||
self.section = section
|
||||
keepsDockIcon = model.showsDockIcon
|
||||
|
||||
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
|
||||
@@ -59,39 +60,48 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
fanControl: fanControl,
|
||||
launchAtLogin: launchAtLogin,
|
||||
showOnboarding: showOnboarding,
|
||||
selectedTab: Binding(
|
||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||
set: { [weak self] in self?.selectedTab = $0 })))
|
||||
section: Binding(
|
||||
get: { [weak self] in self?.section ?? .general },
|
||||
set: { [weak self] in self?.section = $0 })))
|
||||
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting))
|
||||
window.title = "Onyx"
|
||||
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
|
||||
// verschwindet.
|
||||
// **Kein durchsichtiger Titelbalken mehr.**
|
||||
//
|
||||
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
|
||||
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
|
||||
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
|
||||
// Titel auf eine Trennlinie.
|
||||
// Er war für die selbstgebaute Reiterleiste da: macOS zieht unter
|
||||
// dem Titelbalken einen Trenner quer durchs Fenster, und die
|
||||
// Leiste saß mittig darauf. Die Leiste gibt es nicht mehr — und
|
||||
// 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.titlebarSeparatorStyle = .none
|
||||
// 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
|
||||
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
|
||||
// Bildschirm frei ist, ist eine Zumutung.
|
||||
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
window.setContentSize(NSSize(width: 920, height: 680))
|
||||
// Breit genug für die Reiterleiste.
|
||||
//
|
||||
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880
|
||||
// Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten
|
||||
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner
|
||||
// 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.setContentSize(NSSize(width: 940, height: 700))
|
||||
// Seit die Bereiche in einer Seitenleiste stehen, hängt die
|
||||
// Mindestbreite am Inhalt statt an der Beschriftung: das
|
||||
// Reiterband brauchte 900 Punkte, nur damit der erste und der
|
||||
// letzte Reiter nicht über die Kante hinausragten.
|
||||
window.contentMinSize = NSSize(width: 780, height: 480)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
Self.place(window)
|
||||
OnyxWindowPlacement.center(window)
|
||||
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
||||
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
||||
window.setFrameAutosaveName("onyx.settings")
|
||||
@@ -104,27 +114,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
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.
|
||||
///
|
||||
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
||||
@@ -147,3 +136,116 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,89 +17,77 @@ struct WidgetOptionsView: View {
|
||||
let shelfStore: ShelfStore?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("options.calendar") {
|
||||
SettingsPage {
|
||||
CalendarOptions(model: calendarModel)
|
||||
}
|
||||
|
||||
Section("options.weather") {
|
||||
WeatherOptions(model: weatherModel)
|
||||
}
|
||||
|
||||
if let shelfStore {
|
||||
Section("options.shelf") {
|
||||
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
|
||||
|
||||
private struct CalendarOptions: View {
|
||||
@Bindable var model: CalendarModel
|
||||
@State private var calendarrState: CalendarSourceState?
|
||||
|
||||
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
|
||||
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.month").tag(true)
|
||||
} label: {
|
||||
Text("options.calendar.display")
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 180)
|
||||
}
|
||||
}
|
||||
|
||||
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**:
|
||||
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn
|
||||
// suchen muss, ohne zu wissen, dass es ihn gibt.
|
||||
OnyxFormCard(String(localized: "options.calendar.calendars"),
|
||||
hint: String(localized: "options.calendar.calendars.hint")) {
|
||||
if model.availableCalendars.isEmpty {
|
||||
Text("options.calendar.noneFound")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
OnyxFormRow(String(localized: "options.calendar.noneFound"))
|
||||
} else {
|
||||
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) },
|
||||
set: { shown in
|
||||
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
|
||||
else { model.hiddenCalendarIDs.insert(calendar.id) }
|
||||
})) {
|
||||
Label {
|
||||
Text(calendar.title)
|
||||
} icon: {
|
||||
Circle()
|
||||
.fill(Color(hex: calendar.colorHex) ?? .secondary)
|
||||
.frame(width: 9, height: 9)
|
||||
}))
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if model.kind != .appleCalendar {
|
||||
OnyxFormCard(String(localized: "options.calendar.serverGroup")) {
|
||||
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,
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// 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 {
|
||||
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 onRefresh: () -> Void
|
||||
|
||||
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
|
||||
Spacer(minLength: 8)
|
||||
if case .ready(let window) = state {
|
||||
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -180,32 +178,31 @@ private struct WeatherOptions: View {
|
||||
private var usesCurrentLocation: Bool { model.place.isCurrent }
|
||||
|
||||
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 },
|
||||
set: { model.place = $0 ? .current : model.place })) {
|
||||
Text("options.weather.automatic")
|
||||
}
|
||||
set: { model.place = $0 ? .current : model.place }))
|
||||
.labelsHidden()
|
||||
.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)
|
||||
} label: {
|
||||
Text("options.weather.place")
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("options.weather.search", text: $query)
|
||||
OnyxFormRow(String(localized: "options.weather.search"),
|
||||
hint: notFound ? String(localized: "options.weather.notFound") : nil) {
|
||||
TextField("", text: $query)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 160)
|
||||
.onSubmit { Task { await search() } }
|
||||
if searching {
|
||||
ProgressView().controlSize(.small)
|
||||
@@ -214,9 +211,6 @@ private struct WeatherOptions: View {
|
||||
.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]
|
||||
|
||||
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
|
||||
if let days {
|
||||
Text("settings.shelf.days \(days)").tag(Optional(days))
|
||||
@@ -249,15 +245,16 @@ private struct ShelfOptions: View {
|
||||
Text("settings.shelf.never").tag(Int?.none)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("settings.shelf.cleanup")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 180)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
OnyxFormRow(String(localized: "options.shelf")) {
|
||||
Text("settings.shelf.count \(store.items.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,7 +295,7 @@ private struct CalendarrServerSettings: View {
|
||||
var body: some View {
|
||||
if let account {
|
||||
LabeledContent {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: Onyx.Space.row) {
|
||||
Text(account.username).foregroundStyle(.secondary)
|
||||
Button {
|
||||
credentials.clear()
|
||||
|
||||
@@ -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
|
||||
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
|
||||
// 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)
|
||||
|
||||
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.groups.isEmpty {
|
||||
@@ -42,24 +44,20 @@ public struct MixerView: View {
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, Onyx.Space.md)
|
||||
} else {
|
||||
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||||
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||||
OnyxCard {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||||
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)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, compact ? 6 : 8)
|
||||
.padding(.horizontal, Onyx.Space.row)
|
||||
.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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 2)
|
||||
.padding(.top, Onyx.Space.xxs)
|
||||
} else {
|
||||
OffState(mixer: mixer, compact: compact)
|
||||
autostartToggle
|
||||
@@ -128,10 +126,10 @@ private struct OffState: View {
|
||||
let compact: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.row) {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 15, weight: .light))
|
||||
.font(.system(size: Onyx.Symbol.large, weight: .light))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text("mixer.off.explain", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
@@ -146,15 +144,7 @@ private struct OffState: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Button { mixer.enable() } label: {
|
||||
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)
|
||||
OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +164,7 @@ private struct ProcessRow: View {
|
||||
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
|
||||
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
|
||||
// hält den Zeiger auf das Symbol.
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: Onyx.Space.row) {
|
||||
icon
|
||||
|
||||
Button {
|
||||
@@ -182,7 +172,7 @@ private struct ProcessRow: View {
|
||||
mixer.setMuted(!muted, for: group.bundleID)
|
||||
} label: {
|
||||
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
.frame(width: 15, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -190,8 +180,8 @@ private struct ProcessRow: View {
|
||||
: (managed ? Onyx.Color.textSecondary
|
||||
: Onyx.Color.textTertiary))
|
||||
|
||||
VolumeBar(
|
||||
volume: volume,
|
||||
OnyxSlider(
|
||||
value: volume,
|
||||
maximum: Gain.maximum(overdrive: overdriven),
|
||||
level: mixer.level(for: group.bundleID),
|
||||
isActive: managed && !muted,
|
||||
@@ -238,7 +228,7 @@ private struct ProcessRow: View {
|
||||
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
||||
} label: {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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)
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
@@ -304,7 +294,7 @@ private struct ProcessRow: View {
|
||||
.opacity(managed ? 1 : 0.65)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.font(.system(size: 15))
|
||||
.font(.system(size: Onyx.Symbol.large))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
@@ -326,95 +316,6 @@ private struct ProcessRow: View {
|
||||
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
|
||||
|
||||
public struct AudioMixerWidget: OnyxWidget {
|
||||
@@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget {
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
|
||||
@@ -471,6 +354,7 @@ private struct SystemSection: View {
|
||||
let compact: Bool
|
||||
|
||||
var body: some View {
|
||||
OnyxCard {
|
||||
VStack(spacing: 0) {
|
||||
SystemRow(
|
||||
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
@@ -486,7 +370,7 @@ private struct SystemSection: View {
|
||||
onMute: { system.setOutputMuted(!system.outputMuted) },
|
||||
onSelect: { system.selectOutput($0) })
|
||||
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
|
||||
SystemRow(
|
||||
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
||||
@@ -505,11 +389,6 @@ private struct SystemSection: View {
|
||||
onMute: { system.setInputMuted(!system.inputMuted) },
|
||||
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: {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: Onyx.Space.xs) {
|
||||
Text(title)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 7))
|
||||
.font(.system(size: Onyx.Symbol.caption))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -571,17 +450,17 @@ private struct SystemRow: View {
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
|
||||
HStack(spacing: 9) {
|
||||
HStack(spacing: Onyx.Space.row) {
|
||||
Button(action: onMute) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
.frame(width: 15, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||||
|
||||
if adjustable {
|
||||
SystemVolumeBar(volume: volume, level: level,
|
||||
OnyxSlider(value: volume, level: level,
|
||||
isActive: !muted, onChange: onVolume)
|
||||
if let metering { metering }
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -617,7 +496,7 @@ struct MeteringButton: View {
|
||||
var body: some View {
|
||||
Button(action: toggle) {
|
||||
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: Onyx.Symbol.medium))
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -625,68 +504,3 @@ struct MeteringButton: View {
|
||||
.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 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.
|
||||
private(set) var isRunning = false
|
||||
|
||||
@@ -131,6 +140,24 @@ final class ProcessTap {
|
||||
}
|
||||
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?
|
||||
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
|
||||
[weak self] _, inputData, _, outputData, _ in
|
||||
@@ -149,7 +176,7 @@ final class ProcessTap {
|
||||
|
||||
isRunning = true
|
||||
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
|
||||
@@ -166,11 +193,14 @@ final class ProcessTap {
|
||||
var peak: Float = 0
|
||||
|
||||
for (index, outputBuffer) in outputList.enumerated() {
|
||||
guard index < inputList.count,
|
||||
let source = inputList[index].mData,
|
||||
// Der Ausgang zählt ab null, der Eingang ab dem Puffer des Taps.
|
||||
// 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 count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize))
|
||||
let count = Int(min(inputList[sourceIndex].mDataByteSize, outputBuffer.mDataByteSize))
|
||||
/ MemoryLayout<Float>.size
|
||||
let inputSamples = source.assumingMemoryBound(to: Float.self)
|
||||
let outputSamples = destination.assumingMemoryBound(to: Float.self)
|
||||
@@ -224,6 +254,89 @@ final class ProcessTap {
|
||||
AudioHardwareDestroyProcessTap(tapID)
|
||||
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 startFailed
|
||||
case noOutputDevice
|
||||
case unexpectedLayout
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -246,6 +360,8 @@ public enum AudioMixerError: Error, LocalizedError {
|
||||
"Das Mischgerät startet nicht."
|
||||
case .noOutputDevice:
|
||||
"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
|
||||
|
||||
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.
|
||||
///
|
||||
@@ -59,6 +62,9 @@ public final class SystemAudio {
|
||||
// MARK: - Lesen
|
||||
|
||||
public func refresh() {
|
||||
let previousOutput = outputDeviceID
|
||||
let previousInput = inputDeviceID
|
||||
|
||||
outputDevices = Self.devices().filter(\.isOutput)
|
||||
inputDevices = Self.devices().filter(\.isInput)
|
||||
|
||||
@@ -79,6 +85,13 @@ public final class SystemAudio {
|
||||
alertVolume = alertDevice == outputDeviceID
|
||||
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
|
||||
: 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
|
||||
@@ -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] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
@@ -325,14 +380,23 @@ public final class SystemAudio {
|
||||
/// geschrieben.
|
||||
private final class PropertyListener: @unchecked Sendable {
|
||||
|
||||
private let object = AudioObjectID(kAudioObjectSystemObject)
|
||||
private let object: AudioObjectID
|
||||
private var address: AudioObjectPropertyAddress
|
||||
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(
|
||||
mSelector: selector,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mScope: scope,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
block = { _, _ in handler() }
|
||||
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
|
||||
|
||||
@@ -32,7 +32,7 @@ public struct CalendarSourceSettings: View {
|
||||
if model.kind != .appleCalendar {
|
||||
Section {
|
||||
LabeledContent {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
statusText
|
||||
Text("calendar.source.readOnly", bundle: .module)
|
||||
.font(.caption)
|
||||
@@ -53,7 +53,7 @@ public struct CalendarSourceSettings: View {
|
||||
private var statusText: some View {
|
||||
switch calendarrState {
|
||||
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))",
|
||||
bundle: .module)
|
||||
if let coverage = window.coverage {
|
||||
|
||||
@@ -45,6 +45,18 @@ public final class CalendarModel {
|
||||
/// Die Kalender, die zur Auswahl stehen.
|
||||
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 hiddenKey = "calendar.hiddenCalendars"
|
||||
private static let gridKey = "calendar.showsMonthGrid"
|
||||
@@ -194,6 +206,7 @@ public final class CalendarModel {
|
||||
let interval = DateInterval(start: start, end: end)
|
||||
|
||||
var states: [CalendarSourceState] = []
|
||||
let calendarr = kind == .appleCalendar ? nil : calendarrChoice as AnyObject
|
||||
for source in activeSources {
|
||||
guard !Task.isCancelled else { return }
|
||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||
@@ -201,7 +214,9 @@ public final class CalendarModel {
|
||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||
// 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 }
|
||||
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
|
||||
@@ -266,41 +281,26 @@ private struct CalendarWidgetView: View {
|
||||
AgendaList(events: model.upcoming, coverage: window.coverage)
|
||||
}
|
||||
case .denied:
|
||||
CalendarNotice(symbol: "lock", text: "widget.calendar.denied")
|
||||
OnyxNotice(symbol: "lock",
|
||||
text: String(localized: "widget.calendar.denied", bundle: .module))
|
||||
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:
|
||||
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:
|
||||
CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible")
|
||||
OnyxNotice(symbol: "exclamationmark.triangle",
|
||||
text: String(localized: "widget.calendar.incompatible", bundle: .module))
|
||||
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() }
|
||||
.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
|
||||
|
||||
private struct LargeCalendar: View {
|
||||
@@ -310,9 +310,9 @@ private struct LargeCalendar: View {
|
||||
private var calendar: Calendar { .autoupdatingCurrent }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||
MiniMonth(window: window)
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
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 today = calendar.startOfDay(for: Date())
|
||||
|
||||
VStack(spacing: 3) {
|
||||
VStack(spacing: Onyx.Space.xs) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekdaySymbols, id: \.self) { symbol in
|
||||
Text(symbol)
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.font(Onyx.Font.micro)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -370,7 +370,7 @@ private struct DayCell: View {
|
||||
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -413,10 +413,10 @@ private struct AgendaList: View {
|
||||
} else {
|
||||
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
|
||||
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
|
||||
Text(AgendaGrouping.label(for: day.date))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.font(Onyx.Font.micro.weight(.semibold))
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
// Luft nur zwischen den Tagen, nicht über dem ersten.
|
||||
@@ -435,18 +435,18 @@ private struct EventRow: View {
|
||||
let event: OnyxEvent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
||||
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
|
||||
.frame(width: 3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||
Text(event.title)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(timeText)
|
||||
.font(.system(size: 9))
|
||||
.font(Onyx.Font.micro)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -41,7 +41,7 @@ private struct Idle: View {
|
||||
let model: MediaModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: Onyx.Space.md) {
|
||||
Text("widget.media.nothing", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
@@ -59,12 +59,12 @@ private struct Playing: View {
|
||||
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
|
||||
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
|
||||
// steht dort „Bernhard H…" statt eines Titels.
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||
Artwork(data: state.artwork)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 88)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||
Text(state.title)
|
||||
.font(Onyx.Font.caption.weight(.medium))
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
@@ -91,7 +91,7 @@ private struct Playing: View {
|
||||
// greift: dann fehlen Cover und Browser-Wiedergaben,
|
||||
// und das soll erklärbar sein statt rätselhaft.
|
||||
Image(systemName: "applescript")
|
||||
.font(.system(size: 9))
|
||||
.font(.system(size: Onyx.Symbol.caption))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.help(Text("widget.media.viaAppleScript", bundle: .module))
|
||||
}
|
||||
@@ -107,7 +107,7 @@ private struct Artwork: View {
|
||||
let data: Data?
|
||||
|
||||
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
|
||||
// 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)
|
||||
.overlay {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 18))
|
||||
.font(.system(size: Onyx.Symbol.xlarge))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ private struct Scrubber: View {
|
||||
let now = model.tick
|
||||
let progress = state.progress(at: now)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
VStack(spacing: Onyx.Space.xxs) {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.hairline)
|
||||
@@ -166,7 +166,7 @@ private struct Scrubber: View {
|
||||
Spacer(minLength: 0)
|
||||
Text(format(state.duration ?? 0))
|
||||
}
|
||||
.font(.system(size: 8))
|
||||
.font(Onyx.Font.micro)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ private struct TransportControls: View {
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
button("backward.fill") { model.send(.previous) }
|
||||
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
|
||||
model.send(.playPause)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import OnyxDesign
|
||||
import OnyxMenuBar
|
||||
|
||||
/// Der Akku, gezeichnet wie der von macOS.
|
||||
@@ -259,9 +260,9 @@ public struct BatteryGlyphView: View {
|
||||
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 1 * scale) {
|
||||
HStack(spacing: Onyx.Space.xxs * scale) {
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
|
||||
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
||||
|
||||
GeometryReader { geometry in
|
||||
@@ -296,7 +297,7 @@ public struct BatteryGlyphView: View {
|
||||
}
|
||||
.compositingGroup()
|
||||
|
||||
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
|
||||
.fill(.primary.opacity(0.45))
|
||||
.frame(width: 2 * scale, height: 5 * scale)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public struct ChargeLimitControl: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Text("charge.limit", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
@@ -62,7 +62,7 @@ public struct ChargeLimitControl: View {
|
||||
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.forward.app")
|
||||
.font(.system(size: 10))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -12,7 +12,7 @@ struct MetricPopoverContent: View {
|
||||
let metric: MetricKind
|
||||
|
||||
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
|
||||
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
|
||||
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||
@@ -25,7 +25,7 @@ struct MetricPopoverContent: View {
|
||||
case .temperature: SensorDetail(snapshot: model.snapshot)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.padding(Onyx.Space.xl)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,6 @@ struct MetricPopoverContent: View {
|
||||
|
||||
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
|
||||
/// lange Werteliste.
|
||||
private struct SectionTitle: View {
|
||||
let text: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
Text(text, bundle: .module)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Row: View {
|
||||
let label: String
|
||||
let value: String
|
||||
@@ -88,7 +77,7 @@ private struct Dial: View {
|
||||
var body: some View {
|
||||
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
|
||||
// sah aus, als gehörte sie noch hinein.
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: Onyx.Space.sm) {
|
||||
ZStack {
|
||||
Circle().stroke(.quaternary, lineWidth: 5)
|
||||
Circle()
|
||||
@@ -96,13 +85,13 @@ private struct Dial: View {
|
||||
.stroke(tint ?? Onyx.Color.accent,
|
||||
style: .init(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
|
||||
Text(text).font(Onyx.Font.headline.monospacedDigit())
|
||||
}
|
||||
.frame(width: 62, height: 62)
|
||||
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
||||
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 {
|
||||
let snapshot = model.snapshot
|
||||
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: Onyx.Space.xl) {
|
||||
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
|
||||
caption: metric == .cpu ? "CPU" : "GPU",
|
||||
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
|
||||
@@ -135,11 +124,11 @@ private struct ProcessorDetail: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.power")
|
||||
OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
|
||||
if let cpu = snapshot.power.cpu {
|
||||
Row(label: String(localized: "popover.power.cpu", bundle: .module),
|
||||
value: MetricFormat.watts(cpu))
|
||||
@@ -154,7 +143,7 @@ private struct ProcessorDetail: View {
|
||||
}
|
||||
|
||||
if !snapshot.fans.isEmpty {
|
||||
SectionTitle(text: "popover.fans")
|
||||
OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
|
||||
ForEach(snapshot.fans) { fan in
|
||||
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
|
||||
}
|
||||
@@ -181,13 +170,13 @@ private struct CoreGrid: View {
|
||||
let values: [Double]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
|
||||
spacing: 3) {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: Onyx.Space.xs), count: 8),
|
||||
spacing: Onyx.Space.xs) {
|
||||
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))
|
||||
.overlay(alignment: .bottom) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
|
||||
.fill(Onyx.Color.accent)
|
||||
.frame(height: max(14 * value, 1.5))
|
||||
}
|
||||
@@ -205,12 +194,12 @@ private struct MemoryDetail: View {
|
||||
var body: some View {
|
||||
let memory = snapshot.memory
|
||||
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: Onyx.Space.xl) {
|
||||
Dial(value: memory.usedFraction,
|
||||
caption: String(localized: "popover.memory.used", bundle: .module),
|
||||
text: MetricFormat.percent(memory.usedFraction),
|
||||
tint: pressureColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
|
||||
legend(.purple, "popover.memory.wired", memory.wired)
|
||||
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,
|
||||
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
|
||||
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)
|
||||
|
||||
if !snapshot.topMemoryProcesses.isEmpty {
|
||||
SectionTitle(text: "popover.processes")
|
||||
OnyxPopoverTitle(String(localized: "popover.processes", bundle: .module))
|
||||
ForEach(snapshot.topMemoryProcesses) { process in
|
||||
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 {
|
||||
HStack(spacing: 5) {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Circle().fill(color).frame(width: 7, height: 7)
|
||||
Text(key, bundle: .module).font(.caption)
|
||||
Spacer(minLength: 8)
|
||||
@@ -263,7 +252,7 @@ private struct BatteryDetail: View {
|
||||
|
||||
var body: some View {
|
||||
if let battery = snapshot.battery {
|
||||
HStack(alignment: .dialCenter, spacing: 14) {
|
||||
HStack(alignment: .dialCenter, spacing: Onyx.Space.xl) {
|
||||
Dial(value: battery.charge,
|
||||
caption: String(localized: "metric.battery", bundle: .module),
|
||||
text: MetricFormat.percent(battery.charge),
|
||||
@@ -275,7 +264,7 @@ private struct BatteryDetail: View {
|
||||
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
|
||||
// und die zweite stand allein unter dem Diagramm, wo sie
|
||||
// aussah, als gehörte sie zu dessen Beschriftung.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
if let remaining = battery.timeRemaining {
|
||||
Text(duration(remaining)).font(.title3.monospacedDigit())
|
||||
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
|
||||
// Ladestand schaut.
|
||||
if let control {
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
ChargeLimitControl(control: control, roomy: true)
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.battery.condition")
|
||||
OnyxPopoverTitle(String(localized: "popover.battery.condition", bundle: .module))
|
||||
if let health = battery.health {
|
||||
Row(label: String(localized: "popover.battery.health", bundle: .module),
|
||||
value: MetricFormat.percent(health))
|
||||
@@ -320,7 +309,7 @@ private struct BatteryDetail: View {
|
||||
value: MetricFormat.temperature(sensor.celsius))
|
||||
}
|
||||
|
||||
SectionTitle(text: "popover.power")
|
||||
OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
|
||||
if let watts = battery.watts {
|
||||
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
|
||||
: "popover.battery.draining",
|
||||
@@ -354,7 +343,7 @@ private struct BatteryDetail: View {
|
||||
isPluggedIn: battery.isPluggedIn,
|
||||
charge: battery.charge,
|
||||
limit: control?.chargeLimit)
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: symbol(for: activity))
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint(for: activity))
|
||||
@@ -401,7 +390,7 @@ private struct SensorDetail: View {
|
||||
let snapshot: MetricsSnapshot
|
||||
|
||||
var body: some View {
|
||||
SectionTitle(text: "popover.temperature")
|
||||
OnyxPopoverTitle(String(localized: "popover.temperature", bundle: .module))
|
||||
ForEach(snapshot.sensors) { sensor in
|
||||
HStack {
|
||||
Text(sensor.name).font(.callout)
|
||||
@@ -421,9 +410,9 @@ private struct SensorDetail: View {
|
||||
}
|
||||
|
||||
if !snapshot.fans.isEmpty {
|
||||
SectionTitle(text: "popover.fans")
|
||||
OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
|
||||
ForEach(snapshot.fans) { fan in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||
HStack {
|
||||
Text(fan.name).font(.callout)
|
||||
Spacer(minLength: 12)
|
||||
|
||||
@@ -8,7 +8,7 @@ struct NetworkPopoverContent: View {
|
||||
let model: NetworkModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||
HStack(alignment: .top) {
|
||||
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
|
||||
Spacer()
|
||||
@@ -43,17 +43,17 @@ struct NetworkPopoverContent: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
section("network.addresses")
|
||||
OnyxPopoverTitle(String(localized: "network.addresses", bundle: .module))
|
||||
field("network.field.interface", model.snapshot.interfaceName)
|
||||
field("network.field.ipv4", model.snapshot.ipv4)
|
||||
field("network.field.ipv6", model.snapshot.ipv6)
|
||||
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.sent", bytes(model.snapshot.totalSent))
|
||||
|
||||
section("network.public")
|
||||
OnyxPopoverTitle(String(localized: "network.public", bundle: .module))
|
||||
if model.showsPublicAddress {
|
||||
HStack {
|
||||
if model.publicAddress.isLoading {
|
||||
@@ -86,7 +86,7 @@ struct NetworkPopoverContent: View {
|
||||
}
|
||||
|
||||
if !model.processes.isEmpty {
|
||||
section("network.processes")
|
||||
OnyxPopoverTitle(String(localized: "network.processes", bundle: .module))
|
||||
ForEach(model.processes) { process in
|
||||
HStack {
|
||||
Text(process.name).font(.callout).lineLimit(1)
|
||||
@@ -101,7 +101,7 @@ struct NetworkPopoverContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.padding(Onyx.Space.xl)
|
||||
.frame(width: 300)
|
||||
.onAppear { model.addDetailConsumer() }
|
||||
.onDisappear { model.removeDetailConsumer() }
|
||||
@@ -117,10 +117,10 @@ struct NetworkPopoverContent: 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))
|
||||
.font(.system(size: 18, weight: .medium).monospacedDigit())
|
||||
HStack(spacing: 4) {
|
||||
.font(Onyx.Font.display.monospacedDigit())
|
||||
HStack(spacing: Onyx.Space.xs) {
|
||||
Circle().fill(tint).frame(width: 6, height: 6)
|
||||
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -128,19 +128,12 @@ struct NetworkPopoverContent: 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(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
|
||||
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
||||
if let value {
|
||||
@@ -175,16 +168,16 @@ private struct TrafficChart: View {
|
||||
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
|
||||
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
|
||||
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
|
||||
|
||||
HStack(alignment: .center, spacing: 1) {
|
||||
HStack(alignment: .center, spacing: Onyx.Space.xxs) {
|
||||
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
|
||||
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
|
||||
Spacer(minLength: 0)
|
||||
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
||||
VStack(spacing: 1) {
|
||||
VStack(spacing: Onyx.Space.xxs) {
|
||||
Rectangle()
|
||||
.fill(Onyx.Color.upload)
|
||||
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
||||
|
||||
@@ -141,10 +141,10 @@ private struct NetworkWidgetView: View {
|
||||
@State private var token: UUID?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: model.snapshot.interfaceKind.symbolName)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
.foregroundStyle(Onyx.Color.download)
|
||||
Text(connectionName)
|
||||
.font(Onyx.Font.caption)
|
||||
@@ -158,7 +158,7 @@ private struct NetworkWidgetView: View {
|
||||
capacity: NetworkModel.historyLength)
|
||||
.frame(height: 18)
|
||||
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
Details(snapshot: model.snapshot)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -189,16 +189,16 @@ private struct Rates: View {
|
||||
let compact: Bool
|
||||
|
||||
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.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
HStack(spacing: Onyx.Space.xs) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.font(.system(size: Onyx.Symbol.caption, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
Text(text)
|
||||
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
|
||||
@@ -212,7 +212,7 @@ private struct Details: View {
|
||||
let snapshot: NetworkSnapshot
|
||||
|
||||
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.ipv4", snapshot.ipv4)
|
||||
field("network.field.router", snapshot.router)
|
||||
@@ -222,12 +222,12 @@ private struct Details: View {
|
||||
@ViewBuilder
|
||||
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
||||
if let value {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: Onyx.Space.xs) {
|
||||
Text(label, bundle: .module)
|
||||
.font(.system(size: 9))
|
||||
.font(Onyx.Font.micro)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text(value)
|
||||
.font(.system(size: 9))
|
||||
.font(Onyx.Font.micro)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
@@ -41,7 +41,7 @@ public struct WidgetGrid: View {
|
||||
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
|
||||
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
|
||||
OnyxTile {
|
||||
VStack(spacing: 4) {
|
||||
VStack(spacing: Onyx.Space.xs) {
|
||||
Image(systemName: "questionmark.square.dashed")
|
||||
Text(item.placement.widgetID)
|
||||
.font(Onyx.Font.caption)
|
||||
|
||||
@@ -45,9 +45,9 @@ public struct AirDropView: View {
|
||||
if compact {
|
||||
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
|
||||
// Symbol über Text kein Platz.
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
Image(systemName: "shareplay")
|
||||
.font(.system(size: 15, weight: .light))
|
||||
.font(.system(size: Onyx.Symbol.large, weight: .light))
|
||||
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
||||
: Onyx.Color.textTertiary)
|
||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||
@@ -57,11 +57,11 @@ public struct AirDropView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.horizontal, Onyx.Space.md)
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: "shareplay")
|
||||
.font(.system(size: 20, weight: .light))
|
||||
.font(.system(size: Onyx.Symbol.xlarge, weight: .light))
|
||||
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
||||
: Onyx.Color.textTertiary)
|
||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||
@@ -74,15 +74,12 @@ public struct AirDropView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
// Diese Fläche ist **nur** zum Ablegen da. Ihre Kante steht deshalb
|
||||
// immer — ohne sie wüsste niemand, dass sie eine ist.
|
||||
.onyxDropZone(isTargeted: isTargeted, style: .always)
|
||||
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
|
||||
// Versprechen an, nicht als fertige Datei.
|
||||
.contentShape(.rect)
|
||||
.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 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||
if store.items.isEmpty {
|
||||
EmptyShelf()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
LazyVStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
|
||||
ShelfRow(item: item, store: store, thumbnails: thumbnails)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public struct ShelfView: View {
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: Onyx.Space.row) {
|
||||
Button { store.removeAll() } label: {
|
||||
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.
|
||||
.contentShape(.rect)
|
||||
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
|
||||
.overlay {
|
||||
if isTargeted {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.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)
|
||||
// Die Kachel nimmt nebenbei Dateien an — die Kante erscheint deshalb
|
||||
// erst, wenn wirklich etwas darüber schwebt.
|
||||
.onyxDropZone(isTargeted: isTargeted, style: .whenTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyShelf: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 16))
|
||||
.font(.system(size: Onyx.Symbol.large))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text("shelf.empty", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
@@ -117,9 +110,9 @@ private struct ShelfRow: View {
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
thumbnail
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||
Text(item.name)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
@@ -133,19 +126,19 @@ private struct ShelfRow: View {
|
||||
|
||||
if isHovered {
|
||||
Button { AirDrop.send([item.url]) } label: {
|
||||
Image(systemName: "shareplay").font(.system(size: 10))
|
||||
Image(systemName: "shareplay").font(.system(size: Onyx.Symbol.small))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
|
||||
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)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.vertical, Onyx.Space.xxs)
|
||||
.contentShape(.rect)
|
||||
.onHover { isHovered = $0 }
|
||||
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
|
||||
@@ -176,9 +169,9 @@ private struct ShelfRow: View {
|
||||
Image(nsImage: image)
|
||||
.resizable().aspectRatio(contentMode: .fit)
|
||||
.frame(width: 22, height: 22)
|
||||
.clipShape(.rect(cornerRadius: 3, style: .continuous))
|
||||
.clipShape(.rect(cornerRadius: Onyx.Radius.bar, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
|
||||
.fill(Onyx.Color.elevated)
|
||||
.frame(width: 22, height: 22)
|
||||
}
|
||||
@@ -204,13 +197,13 @@ public struct ShelfWidget: OnyxWidget {
|
||||
|
||||
public func makeView() -> AnyView {
|
||||
AnyView(
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: Onyx.Space.md) {
|
||||
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
|
||||
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
|
||||
AirDropView(compact: true)
|
||||
.frame(height: 52)
|
||||
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
|
||||
ShelfView(store: store)
|
||||
}
|
||||
|
||||
@@ -195,12 +195,15 @@ private struct WeatherWidgetView: View {
|
||||
if let lastKnown {
|
||||
content(lastKnown, isStale: true)
|
||||
} else {
|
||||
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable")
|
||||
OnyxNotice(symbol: "wifi.slash",
|
||||
text: String(localized: "widget.weather.unavailable", bundle: .module))
|
||||
}
|
||||
case .locationDenied:
|
||||
Notice(symbol: "location.slash", text: "widget.weather.locationDenied")
|
||||
OnyxNotice(symbol: "location.slash",
|
||||
text: String(localized: "widget.weather.locationDenied", bundle: .module))
|
||||
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:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
@@ -217,25 +220,6 @@ private struct WeatherWidgetView: View {
|
||||
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 {
|
||||
let snapshot: WeatherSnapshot
|
||||
let isStale: Bool
|
||||
@@ -246,15 +230,15 @@ private struct WideWeather: View {
|
||||
// die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die
|
||||
// man auf Armlänge nicht entziffert.
|
||||
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))
|
||||
.font(.system(size: 34))
|
||||
.font(.system(size: Onyx.Symbol.hero))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(snapshot.temperature.onyxFormatted)
|
||||
.font(.system(size: 30, weight: .medium))
|
||||
.font(Onyx.Font.displayLarge)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
if let high = snapshot.high, let low = snapshot.low {
|
||||
@@ -271,7 +255,7 @@ private struct WideWeather: View {
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
.padding(.top, Onyx.Space.xxs)
|
||||
|
||||
if snapshot.apparentTemperature != snapshot.temperature {
|
||||
Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)",
|
||||
@@ -283,19 +267,19 @@ private struct WideWeather: View {
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if !snapshot.hourly.isEmpty {
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
|
||||
// Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so
|
||||
// gut vierzig je Spalte, und die Zahlen lassen sich in einer
|
||||
// lesbaren Größe setzen.
|
||||
HStack(spacing: 0) {
|
||||
ForEach(snapshot.hourly.prefix(4)) { point in
|
||||
VStack(spacing: 3) {
|
||||
VStack(spacing: Onyx.Space.xs) {
|
||||
Text(point.date.formatted(.dateTime.hour()))
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Image(systemName: point.condition.symbolName(isDaylight: true))
|
||||
.font(.system(size: 13))
|
||||
.font(.system(size: Onyx.Symbol.medium))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
Text(point.temperature.onyxFormatted)
|
||||
@@ -306,15 +290,15 @@ private struct WideWeather: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, Onyx.Space.md)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: Onyx.Space.xs) {
|
||||
if isStale {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 9))
|
||||
.font(.system(size: Onyx.Symbol.caption))
|
||||
.foregroundStyle(Onyx.Color.warning)
|
||||
}
|
||||
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.
|
||||
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
|
||||
/// 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 {
|
||||
formatted(.measurement(width: .narrow,
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user