Compare commits

...
6 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 3da673662f Zwischenablage, Notizen und der Gesichtsvergleich
Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom
Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` —
was dort steht, liest jedes Programm mit `defaults read` mit.

Die Zwischenablage achtet `ConcealedType`, `TransientType` und
`AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und
kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein.

Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein
Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber
`VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und
ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig.

`FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine
Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches
Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort,
das man ohnehin kennt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:06:06 +02:00
ScarriffleandClaude Opus 5 f289d02c37 Der Akku bekommt ein Gedächtnis
Verlauf über 24 Stunden, sieben Tage und dreißig Tage, aufgezeichnet alle fünf
Minuten. Als Säulen und nicht als Linie: eine Linie verbindet auch dort, wo
nichts gemessen wurde, und ein zugeklappter Rechner hatte keinen Ladestand —
eine Linie darüber behauptet einen.

Dazu Segelmodus und Hitzeschutz als reine Regeln im Helfer. Beide haben zwei
Schwellen, eine zum Anhalten und eine zum Weitermachen. Ohne diesen Abstand
hinge der Akku am Limit und würde bei jedem verlorenen Prozent nachgeladen —
hunderte winzige Ladevorgänge am Tag, und genau die altern ihn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:06:06 +02:00
ScarriffleandClaude Opus 5 edb327cabe 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>
2026-09-05 21:05:51 +02:00
ScarriffleandClaude Opus 5 9b600de9a9 Live Activities unter der Notch, nicht daneben
Neben ihr wären die Arme zwangsläufig gleich breit — die Silhouette zeichnet den
Steg mittig um die Hardware. Ein Symbol links und ein Balken rechts hieße dann
132 Punkte Schwarz für ein Symbol. Darunter hängt die Fläche frei und wird so
breit, wie ihr Inhalt es braucht.

Lautstärke, Strom, Titelwechsel und Helligkeit. Gemeldet wird nur, was sich
**geändert** hat: `PowerWatcher` schweigt beim ersten gelesenen Zustand, sonst
begrüßte Onyx jeden Start mit „am Netz".

Die Helligkeit kostete einen Spike. Von drei Wegen trägt nur `DisplayServices`,
und nur für den eingebauten Bildschirm. `CoreDisplay` — der Weg, den jede
Anleitung empfiehlt — gibt für alle Bildschirme 1.0 zurück, auch für den, der
auf 62 % stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:13 +02:00
ScarriffleandClaude Opus 5 c05ccb8ae1 Die Notch wird zur Insel, wo es keine Hardware gibt
Die Silhouette bestand aus zwei überlappenden Teilpfaden. Gefüllt stimmte die
Vereinigung, aber die Kontur zeichnete jeden Teilpfad vollständig nach — auch
dort, wo er innen lag. Dazu kippte die Form ab einer Breite in einen anderen
Zweig, und die beiden Zweige waren nicht dieselbe Geometrie. Zusammen war das
das Flackern an den Rundungen. Jetzt ein durchgehender Umriss, dessen Zweige
ineinander übergehen statt zu springen.

Neu ist die Wahl zwischen verschmolzen und schwebend. Eine echte Notch **muss**
an der Kante kleben und oben eckig sein, sonst klafft ein Spalt zum Gehäuse;
eine Insel **muss** Abstand halten und rundum gerundet sein, sonst sieht sie aus
wie ein abgeschnittenes Rechteck. Beides zu mischen geht nicht, deshalb eine
Wahl und keine zwei Schalter.

Ihre Höhe rät Onyx nicht mehr, sondern misst die Menüleiste: 24 Punkte minus
Luft oben und unten. Vorher hing ein festes Maß unten heraus.

Dazu „nur der Hauptbildschirm" als dritte Anzeigewahl — gemeint ist der mit der
Menüleiste, nicht `NSScreen.main`, das mit dem aktiven Fenster mitwandert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:13 +02:00
ScarriffleandClaude Opus 5 ecb39fbe5f Eine Designschicht statt hundertfach gesetzter Zahlen
Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben
Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel,
Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie.

Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein
9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in
eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten.

Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das
Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form,
damit auch die animierende Notch-Silhouette es benutzen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:04:51 +02:00
92 changed files with 10039 additions and 1950 deletions
+49 -4
View File
@@ -7,17 +7,22 @@
objects = {
/* Begin PBXBuildFile section */
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */; };
08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EDDADF00C8A57744A63191 /* PowerActivity.swift */; };
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
29DEDC6D874DB6FF77DC4338 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */; };
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 913CFE7A8E299E11C2BF23CF /* ClipboardProvider */; };
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */ = {isa = PBXBuildFile; productRef = B9524DE253CBAF4344096790 /* ActivityProvider */; };
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; };
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
@@ -26,6 +31,7 @@
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; };
98AE792A2A346EDAB358728A /* WindowPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2585137DB7F67F91768E0EF /* WindowPlacement.swift */; };
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
@@ -40,8 +46,11 @@
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */; };
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C758A5ABC2F748BE7306038 /* GalleryView.swift */; };
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */; };
E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4472730A92A48657BDCEB9 /* SettingsSection.swift */; };
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */ = {isa = PBXBuildFile; productRef = F8AC40480E05171EB8091269 /* NotesProvider */; };
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; };
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
/* End PBXBuildFile section */
@@ -60,7 +69,9 @@
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = "<group>"; };
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchAccessories.swift; sourceTree = "<group>"; };
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = "<group>"; };
1C758A5ABC2F748BE7306038 /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = "<group>"; };
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
@@ -73,15 +84,19 @@
63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
7A866E72010907DF4BEF025C /* bolt-dark.svg */ = {isa = PBXFileReference; path = "bolt-dark.svg"; sourceTree = "<group>"; };
821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; };
85EDDADF00C8A57744A63191 /* PowerActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerActivity.swift; sourceTree = "<group>"; };
87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = "<group>"; };
89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = "<group>"; };
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; };
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = "<group>"; };
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryWindowController.swift; sourceTree = "<group>"; };
AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettingsView.swift; sourceTree = "<group>"; };
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowPlacement.swift; sourceTree = "<group>"; };
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
@@ -105,6 +120,9 @@
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */,
39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */,
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -166,29 +184,35 @@
isa = PBXGroup;
children = (
0FBA13B97C53F1D0D740A633 /* Resources */,
AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */,
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
B8214C5599D1857E832447EC /* bolt-light.svg */,
085D860E654E09E4CD6E535A /* chip.svg */,
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */,
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
F12041E330231599C6540C21 /* FanSettingsView.swift */,
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
1C758A5ABC2F748BE7306038 /* GalleryView.swift */,
8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */,
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */,
63A5510538846C8427AEB831 /* OnboardingView.swift */,
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
85EDDADF00C8A57744A63191 /* PowerActivity.swift */,
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */,
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */,
);
path = Onyx;
sourceTree = "<group>";
@@ -226,6 +250,9 @@
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
5D8203CC3A09D838A4AC385D /* AudioProvider */,
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
B9524DE253CBAF4344096790 /* ActivityProvider */,
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */,
F8AC40480E05171EB8091269 /* NotesProvider */,
);
productName = Onyx;
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
@@ -358,18 +385,24 @@
buildActionMask = 2147483647;
files = (
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */,
29DEDC6D874DB6FF77DC4338 /* AppearanceSettingsView.swift in Sources */,
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */,
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */,
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */,
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */,
E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */,
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
98AE792A2A346EDAB358728A /* WindowPlacement.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -658,6 +691,10 @@
isa = XCSwiftPackageProductDependency;
productName = MetricsProvider;
};
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = ClipboardProvider;
};
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
isa = XCSwiftPackageProductDependency;
productName = OnyxHelperProtocol;
@@ -670,6 +707,10 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxMenuBar;
};
B9524DE253CBAF4344096790 /* ActivityProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = ActivityProvider;
};
C64A111F449D0F9D36ED46FB /* OnyxCore */ = {
isa = XCSwiftPackageProductDependency;
productName = OnyxCore;
@@ -682,6 +723,10 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxWidgetKit;
};
F8AC40480E05171EB8091269 /* NotesProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = NotesProvider;
};
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = ShelfProvider;
+85
View File
@@ -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
+198
View File
@@ -0,0 +1,198 @@
import SwiftUI
import OnyxDesign
/// Was am Aussehen einstellbar ist: die Farben und die Bewegung.
///
/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet,
/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst
/// nicht zur Wahl — Gelb heißt Warnung, Rot heißt kritisch, und wer das
/// umstellen kann, kann die Bedeutung zerstören.
///
/// Die Bewegung kommt als **Profil**, nicht als Federkonstanten. Ausfahren und
/// Einfahren müssen zueinander passen; diese Beziehung wählt man, nicht vier
/// Zahlen.
struct AppearanceSettingsView: View {
@Bindable var theme: OnyxTheme
/// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss.
///
/// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung
/// darauf sticht, statt zu leiten.
private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [
("color.suggestion.onyx", "#7FA8FF"),
("color.suggestion.mint", "#6FCF97"),
("color.suggestion.amber", "#E8B44A"),
("color.suggestion.coral", "#E5675C"),
("color.suggestion.lilac", "#B79CFF"),
("color.suggestion.slate", "#9AA6B2"),
]
var body: some View {
SettingsPage {
accent
network
material
motion
fixed
}
}
private var accent: some View {
OnyxFormCard(String(localized: "color.section.accent")) {
OnyxFormRow(String(localized: "color.accent"),
hint: String(localized: "color.accent.hint")) {
ColorPicker("", selection: $theme.accent, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.suggestions")) {
HStack(spacing: Onyx.Space.row) {
ForEach(Self.suggestions, id: \.hex) { suggestion in
Swatch(hex: suggestion.hex,
name: suggestion.name,
isSelected: theme.accent.onyxHex == suggestion.hex) {
theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent
}
}
}
}
// Mit dem Farbfleck daneben: welche Farbe das System gerade führt,
// weiß man nicht auswendig — und dann wirkt das Ergebnis wie ein
// Fehler statt wie eine Übernahme.
OnyxFormRow(String(localized: "color.adoptSystem")) {
Button { theme.adoptSystemAccent() } label: {
HStack(spacing: Onyx.Space.sm) {
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
Text("settings.action.adopt")
}
}
}
}
}
private var network: some View {
OnyxFormCard(String(localized: "color.section.network")) {
OnyxFormRow(String(localized: "color.download"),
hint: String(localized: "color.network.hint")) {
ColorPicker("", selection: $theme.download, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.upload")) {
ColorPicker("", selection: $theme.upload, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.swap")) {
Button("settings.action.swap") {
let previous = theme.download
theme.download = theme.upload
theme.upload = previous
}
}
}
}
private var material: some View {
OnyxFormCard(String(localized: "settings.appearance.material"),
hint: String(localized: "settings.appearance.material.hint")) {
OnyxFormRow(String(localized: "settings.appearance.surface")) {
Picker("", selection: $theme.material) {
ForEach(NotchMaterial.allCases, id: \.self) { material in
Text(material.localizedName).tag(material)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 240)
}
}
}
private var motion: some View {
OnyxFormCard(String(localized: "settings.appearance.motion"),
hint: String(localized: "settings.appearance.motion.hint")) {
OnyxFormRow(String(localized: "settings.appearance.profile")) {
Picker("", selection: $theme.motion) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.localizedName).tag(profile)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 240)
}
}
}
private var fixed: some View {
OnyxFormCard(String(localized: "color.section.fixed"),
hint: String(localized: "color.fixed.hint")) {
// Sichtbar, aber nicht bedienbar: sonst sucht man die Warnfarbe
// hier und findet sie nirgends.
OnyxFormRow(String(localized: "color.fixed.positive")) {
Circle().fill(Onyx.Color.positive).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.fixed.warning")) {
Circle().fill(Onyx.Color.warning).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.fixed.critical")) {
Circle().fill(Onyx.Color.critical).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.reset")) {
Button("settings.action.reset") { theme.reset() }
.disabled(theme.isDefault)
}
}
}
}
extension NotchMaterial {
var localizedName: LocalizedStringKey {
switch self {
case .stone: "settings.material.stone"
case .blur: "settings.material.blur"
case .glass: "settings.material.glass"
}
}
}
extension MotionProfile {
var localizedName: LocalizedStringKey {
switch self {
case .snappy: "settings.motion.snappy"
case .bouncy: "settings.motion.bouncy"
case .calm: "settings.motion.calm"
}
}
}
/// Ein Farbfeld zum Anklicken.
private struct Swatch: View {
let hex: String
let name: LocalizedStringKey
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(Color(onyxHex: hex) ?? .gray)
.frame(width: 20, height: 20)
.overlay {
// Der Ring statt eines Hakens: ein Haken auf einer dunklen
// Farbe ist nicht zu sehen, auf einer hellen nicht auf
// einer dunklen Fläche.
Circle()
.strokeBorder(.primary, lineWidth: isSelected ? 2 : 0)
.padding(-3)
}
}
.buttonStyle(.plain)
.help(Text(name))
}
}
-142
View File
@@ -1,142 +0,0 @@
import SwiftUI
import OnyxDesign
/// Die Farbwahl.
///
/// 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 hier — Gelb heißt Warnung, Rot heißt kritisch, und wer das umstellen
/// kann, kann die Bedeutung zerstören.
struct ColorSettingsView: 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 {
Form {
Section("color.section.accent") {
ColorPicker(selection: $theme.accent, supportsOpacity: false) {
Text("color.accent")
}
HStack(spacing: 8) {
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
}
}
Spacer(minLength: 0)
}
// 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.
Button { theme.adoptSystemAccent() } label: {
HStack(spacing: 6) {
Text("color.adoptSystem")
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
}
}
Text("color.accent.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("color.section.network") {
ColorPicker(selection: $theme.download, supportsOpacity: false) {
Text("color.download")
}
ColorPicker(selection: $theme.upload, supportsOpacity: false) {
Text("color.upload")
}
Button {
let previous = theme.download
theme.download = theme.upload
theme.upload = previous
} label: {
Text("color.swap")
}
Text("color.network.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("color.section.fixed") {
// Sichtbar, aber nicht bedienbar: sonst sucht man die
// Warnfarbe hier und findet sie nirgends.
FixedRow(color: Onyx.Color.positive, label: "color.fixed.positive")
FixedRow(color: Onyx.Color.warning, label: "color.fixed.warning")
FixedRow(color: Onyx.Color.critical, label: "color.fixed.critical")
Text("color.fixed.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section {
Button { theme.reset() } label: {
Text("color.reset")
}
.disabled(theme.isDefault)
}
}
.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)
}
}
/// 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))
}
}
private struct FixedRow: View {
let color: Color
let label: LocalizedStringKey
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
Circle().fill(color).frame(width: 14, height: 14)
}
}
}
+39 -45
View File
@@ -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)
}
Button("fans.install") { control.install() }
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)
Button("fans.approval.open") { control.openApprovalSettings() }
Button("fans.approval.recheck") { control.refreshInstallState() }
OnyxFormCard(String(localized: "fans.group.setup")) {
OnyxFormRow(String(localized: "fans.approval.needed"),
symbol: "hand.raised") {
Button("fans.approval.open") { control.openApprovalSettings() }
}
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) {
Task { await control.uninstall() }
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 } }
}
+26 -52
View File
@@ -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") {
control.openApprovalSettings()
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))
}
+568
View File
@@ -0,0 +1,568 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die
/// Notch anzufahren.
///
/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt
/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene
/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf
/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese
/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also
/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher
/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle
/// statt erfundener.
///
/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug,
/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten
/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu
/// übersetzen.
struct GalleryView: View {
let model: AppModel
/// Bleibt stehen, wo man zuletzt war.
///
/// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt
/// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den
/// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne
/// Zwischenklick.
@AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles
var body: some View {
VStack(spacing: 0) {
Picker("", selection: $section) {
ForEach(GallerySection.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, Onyx.Space.window)
.padding(.top, Onyx.Space.row)
.padding(.bottom, Onyx.Space.xl)
Divider()
ScrollView {
switch section {
case .tiles: TileGallery()
case .panel: PanelGallery(model: model)
case .components: ComponentGallery()
case .tokens: TokenGallery()
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
.frame(minWidth: 760, minHeight: 520)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
}
}
enum GallerySection: String, CaseIterable, Identifiable {
case tiles, panel, components, tokens
var id: Self { self }
var title: String {
switch self {
case .tiles: "Kacheln"
case .panel: "Panel"
case .components: "Bauteile"
case .tokens: "Token"
}
}
}
// MARK: - Kacheln
/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche.
///
/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in
/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200
/// auf schwarzem Stein ist der Ort, an dem sie später steht.
private struct TileGallery: View {
private struct Entry: Identifiable {
let id: String
let widget: any OnyxWidget
}
var body: some View {
let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) }
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
if entries.isEmpty {
Text("Keine Kacheln angemeldet.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width,
maximum: LayoutEngine.tileSize.width),
spacing: Onyx.Metric.tileSpacing)],
alignment: .leading,
spacing: Onyx.Space.xxl
) {
ForEach(entries) { entry in
VStack(spacing: Onyx.Space.sm) {
// Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel,
// dann Rahmen, dann beschneiden. Wer erst
// beschneidet, sieht Überlauf nicht, den das Panel
// später zeigt.
OnyxTile { entry.widget.makeView() }
.frame(width: LayoutEngine.tileSize.width,
height: LayoutEngine.tileSize.height)
.clipped()
Text(entry.id)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Panel
/// Das ganze Panel, so breit, wie man es einstellt.
///
/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine`
/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen,
/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen,
/// ohne dafür einen Bildschirm anzustecken.
private struct PanelGallery: View {
let model: AppModel
@State private var availableWidth: Double = 1680
var body: some View {
let width = CGFloat(availableWidth)
let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width)
VStack(alignment: .leading, spacing: Onyx.Space.xl) {
HStack(spacing: Onyx.Space.lg) {
Text("Verfügbare Breite")
.font(.callout)
Slider(value: $availableWidth, in: 400...3000)
.frame(maxWidth: 320)
Text("\(Int(availableWidth)) pt")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · "
+ "\(LayoutEngine.columns(availableWidth: width)) Spalten · "
+ "\(model.layout.count) Kacheln")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
WidgetGrid(placements: model.layout, availableWidth: width)
.background(OnyxSurface())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Bauteile
/// Jedes Bauteil in jedem Zustand.
///
/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle
/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von
/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier
/// kann man ihn also einfach hinschreiben.
private struct ComponentGallery: View {
@State private var volume = 0.62
@State private var overdrive = 1.8
@State private var targeted = true
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
group("OnyxCard mit OnyxHairline und OnyxSectionTitle") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
OnyxSectionTitle("System")
OnyxCard {
VStack(spacing: 0) {
row("MacBook Pro-Lautsprecher", "41 %")
OnyxHairline()
row("MacBook Pro-Mikrofon", "27 %")
}
}
}
.frame(width: 260)
}
group("OnyxNotice") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") }
tile {
OnyxNotice(symbol: "hand.raised",
text: "Der Helfer wartet auf deine Freigabe.") {
OnyxPillButton("Einstellungen") {}
}
}
tile {
OnyxNotice(symbol: "fan",
text: "Noch keine Lüfterdaten.",
placement: .leading)
}
tile {
OnyxNotice(symbol: "fan",
text: "Der Helfer ist nicht installiert.",
placement: .leading) {
OnyxPillButton("Installieren") {}
}
}
}
}
group("OnyxPillButton") {
HStack(spacing: Onyx.Space.lg) {
OnyxPillButton("Einschalten") {}
OnyxPillButton("Automatik", isFilled: false) {}
OnyxPillButton("Auto", size: .compact) {}
OnyxPillButton("Auto", isFilled: false, size: .compact) {}
}
}
group("OnyxSlider") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
sliderRow("normal") {
OnyxSlider(value: volume) { volume = $0 }
}
sliderRow("mit Pegel") {
OnyxSlider(value: volume, level: 0.35) { volume = $0 }
}
sliderRow("stumm") {
OnyxSlider(value: volume, isActive: false) { volume = $0 }
}
sliderRow("Overdrive") {
OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 }
}
}
.frame(width: 320)
}
group("OnyxDropZone") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Toggle("Zeiger schwebt darüber", isOn: $targeted)
.font(Onyx.Font.body)
.foregroundStyle(Onyx.Color.textSecondary)
HStack(spacing: Onyx.Space.lg) {
tile {
OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen")
.onyxDropZone(isTargeted: targeted, style: .always)
}
tile {
OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an")
.onyxDropZone(isTargeted: targeted, style: .whenTargeted)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private func row(_ name: String, _ value: String) -> some View {
HStack {
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
Spacer(minLength: Onyx.Space.md)
Text(value)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.md)
}
/// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein
/// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun
/// kann.
private func tile(@ViewBuilder content: () -> some View) -> some View {
OnyxTile { content() }
.frame(width: LayoutEngine.tileSize.width, height: 130)
}
private func sliderRow(_ name: String,
@ViewBuilder content: () -> some View) -> some View {
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 72, alignment: .leading)
content()
}
}
private func group(_ title: String,
@ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
// MARK: - Token
/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind.
///
/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline`
/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da.
private struct TokenGallery: View {
@State private var moved = false
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
colors
spaces
radii
fonts
metrics
motion
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private var colors: some View {
let swatches: [(String, Color)] = [
("surface", Onyx.Color.surface),
("elevated", Onyx.Color.elevated),
("hairline", Onyx.Color.hairline),
("textPrimary", Onyx.Color.textPrimary),
("textSecondary", Onyx.Color.textSecondary),
("textTertiary", Onyx.Color.textTertiary),
("accent", Onyx.Color.accent),
("download", Onyx.Color.download),
("upload", Onyx.Color.upload),
("positive", Onyx.Color.positive),
("warning", Onyx.Color.warning),
("critical", Onyx.Color.critical),
]
return section("Farben") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)],
alignment: .leading, spacing: Onyx.Space.row) {
ForEach(swatches, id: \.0) { name, color in
HStack(spacing: Onyx.Space.md) {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(color)
.overlay {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 28, height: 28)
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var spaces: some View {
let steps: [(String, CGFloat)] = [
("xxs", Onyx.Space.xxs),
("xs", Onyx.Space.xs),
("sm", Onyx.Space.sm),
("md", Onyx.Space.md),
("lg", Onyx.Space.lg),
("xl", Onyx.Space.xl),
("xxl", Onyx.Space.xxl),
]
return section("Abstände") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(steps, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 40, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: value, height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var radii: some View {
let steps: [(String, CGFloat)] = [
("chip", Onyx.Radius.chip),
("control", Onyx.Radius.control),
("card", Onyx.Radius.card),
("tile", Onyx.Radius.tile),
("notch", Onyx.Radius.notch),
("panel", Onyx.Radius.panel),
]
return section("Rundungen") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
ForEach(steps, id: \.0) { name, value in
VStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: value, style: .continuous)
.fill(Onyx.Color.elevated)
.overlay {
RoundedRectangle(cornerRadius: value, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 64, height: 64)
Text("\(name) \(Int(value))")
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
}
}
private var fonts: some View {
let specimens: [(String, Font)] = [
("micro", Onyx.Font.micro),
("caption", Onyx.Font.caption),
("footnote", Onyx.Font.footnote),
("body", Onyx.Font.body),
("title", Onyx.Font.title),
("headline", Onyx.Font.headline),
("display", Onyx.Font.display),
("displayLarge", Onyx.Font.displayLarge),
("metricMicro", Onyx.Font.metricMicro),
("metricSmall", Onyx.Font.metricSmall),
("metric", Onyx.Font.metric),
("metricLarge", Onyx.Font.metricLarge),
("symbolHero", Onyx.Font.symbolHero),
]
return section("Schrift") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(specimens, id: \.0) { name, font in
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Text("Onyx 0123456789")
.font(font)
.foregroundStyle(Onyx.Color.textPrimary)
}
}
}
}
}
private var metrics: some View {
let values: [(String, CGFloat)] = [
("panelPadding", Onyx.Metric.panelPadding),
("tileSpacing", Onyx.Metric.tileSpacing),
("hairlineWidth", Onyx.Metric.hairlineWidth),
]
return section("Panelgeometrie") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(values, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: max(value, 1), height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
/// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf
/// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten.
private var motion: some View {
let curves: [(String, Animation)] = [
("expand", Onyx.Motion.expand),
("collapse", Onyx.Motion.collapse),
("value", Onyx.Motion.value),
]
return section("Bewegung") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Picker("Profil", selection: Binding(
get: { OnyxTheme.shared.motion },
set: { OnyxTheme.shared.motion = $0 })) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.rawValue).tag(profile)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 280)
ForEach(curves, id: \.0) { name, animation in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.hairline)
.frame(height: 4)
Circle()
.fill(Onyx.Color.accent)
.frame(width: 14, height: 14)
.offset(x: moved ? 246 : 0)
.animation(animation, value: moved)
}
.frame(width: 260, height: 14)
}
}
Button(moved ? "Zurück" : "Los") { moved.toggle() }
}
}
}
@ViewBuilder
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import AppKit
import SwiftUI
/// Führt das Schaufenster.
///
/// Aufgebaut wie `SettingsWindowController` — aus demselben Grund: SwiftUIs
/// eigene Fensterszenen kommen bei einer App ohne Menüleiste unzuverlässig an.
@MainActor
final class GalleryWindowController: NSObject, NSWindowDelegate {
private var window: NSWindow?
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
/// zurückgeschaltet wird.
private var keepsDockIcon = false
/// Ob das Schaufenster überhaupt erreichbar ist.
///
/// Im Debug immer, im Release nur auf ausdrücklichen Wunsch. Es ist
/// Werkzeug, kein Programmteil — und ein Menüeintrag, den niemand braucht,
/// ist selbst schon Unordnung.
///
/// ```
/// defaults write com.scarriffleservices.onyx onyx.gallery -bool YES
/// ```
static var isAvailable: Bool {
#if DEBUG
true
#else
UserDefaults.standard.bool(forKey: "onyx.gallery")
#endif
}
/// Ob es beim Start gleich aufgehen soll.
///
/// Der Werkstatt-Kreislauf ist bauen, starten, hinsehen. Ohne das ist der
/// dritte Schritt ein Griff zur Maus und zwei Klicks ins Statusmenü — bei
/// jedem Durchlauf. `UserDefaults` liest Startargumente von selbst mit:
///
/// ```
/// open Onyx.app --args -onyx.gallery.open YES
/// ```
static var opensAtLaunch: Bool {
isAvailable && UserDefaults.standard.bool(forKey: "onyx.gallery.open")
}
func show(model: AppModel) {
keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock — sonst ist es
// weder über den Dock noch über ⌘-Tab zurückzuholen.
NSApp.setActivationPolicy(.regular)
if window == nil {
let hosting = NSHostingController(rootView: GalleryView(model: model))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx — Schaufenster"
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 1040, height: 720))
window.contentMinSize = NSSize(width: 780, height: 540)
window.isReleasedWhenClosed = false
window.delegate = self
OnyxWindowPlacement.center(window)
window.setFrameAutosaveName("onyx.gallery")
self.window = window
}
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}
/// Der rote Knopf blendet aus, statt zu schließen — wie bei den
/// Einstellungen, und aus demselben Grund: Onyx hat kein Hauptfenster, und
/// manche Hilfsprogramme beenden eine App, sobald ihr letztes Fenster
/// verschwindet.
func windowShouldClose(_ sender: NSWindow) -> Bool {
hide()
return false
}
private func hide() {
window?.orderOut(nil)
// Nur zurück in den Hintergrund, wenn wirklich nichts mehr offen ist.
// Das Einstellungsfenster kann noch stehen — es hier mit auszublenden
// sähe aus wie ein Absturz.
let otherVisible = NSApp.windows.contains {
$0 !== window && $0.isVisible && $0.styleMask.contains(.titled)
}
guard !otherVisible else { return }
if !keepsDockIcon {
NSApp.setActivationPolicy(.accessory)
}
NSApp.hide(nil)
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -46,7 +46,7 @@ final class MixerMenuBarModule: MenuBarModule {
AnyView(
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.
+315
View File
@@ -0,0 +1,315 @@
import SwiftUI
import OnyxDesign
import OnyxNotch
import ActivityProvider
import MetricsProvider
import WeatherProvider
/// Was links und rechts der Notch steht, solange das Panel offen ist.
///
/// **Warum dort und nicht in einer Kachel.** Der Streifen auf Menüleistenhöhe
/// war bisher durchsichtiges, ungenutztes Fenster — bei fünf Kacheln je 376
/// Punkte. Eine Kachel dafür zu opfern hieße, ein Zwölftel des Panels für zwei
/// Zahlen herzugeben. Hier kosten sie nichts.
///
/// **Zwei Werte, nicht zehn.** Der Streifen ist so hoch wie die Notch, also
/// rund 38 Punkte. Was hier steht, muss im Vorbeisehen lesbar sein; eine Reihe
/// aus sechs Kürzeln wäre eine zweite Menüleiste, und eine hat man schon.
struct WeatherAccessory: View {
let model: WeatherModel
var body: some View {
if case .ready(let snapshot) = model.state {
HStack(spacing: Onyx.Space.sm) {
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textSecondary)
Text(snapshot.temperature.onyxFormatted)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textPrimary)
}
// Abstand zur Bildschirmkante links, und Luft zur Notch rechts:
// ein Wert, der die schwarze Hardware berührt, sieht aus, als wäre
// er abgeschnitten.
.padding(.leading, Onyx.Space.xl)
.padding(.trailing, Onyx.Space.lg)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct BatteryAccessory: View {
let model: MetricsModel
var body: some View {
if let battery = model.snapshot.battery {
HStack(spacing: Onyx.Space.sm) {
Text("\(Int((battery.charge * 100).rounded())) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textPrimary)
BatteryGlyphView(charge: battery.charge,
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
height: 13)
}
.padding(.leading, Onyx.Space.lg)
.padding(.trailing, Onyx.Space.xl)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
// MARK: - Live Activities
/// Was beim Regeln der Lautstärke unter der Notch steht.
///
/// Kurz und ohne Anspruch: 1,6 Sekunden. Länger stünde sie noch da, wenn man
/// längst weiterarbeitet — und eine Anzeige, die nach der Handlung übrig
/// bleibt, liest man als Fehler.
struct VolumeActivity: NotchActivity {
let id = "volume"
let rank = ActivityRank.response
let duration: TimeInterval? = 1.6
let level: Double
let isMuted: Bool
/// Symbol, Schiene, Zahl — in einer Zeile unter der Notch.
var size: CGSize { CGSize(width: 196, height: 34) }
private var symbol: String {
if isMuted { return "speaker.slash.fill" }
switch level {
case ..<0.001: return "speaker.fill"
case ..<0.34: return "speaker.wave.1.fill"
case ..<0.67: return "speaker.wave.2.fill"
default: return "speaker.wave.3.fill"
}
}
func makeContent() -> AnyView {
let track: CGFloat = 96
let fraction = isMuted ? 0 : min(max(level, 0), 1)
return AnyView(
HStack(spacing: Onyx.Space.lg) {
Image(systemName: symbol)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.frame(width: 16)
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.track)
.frame(width: track, height: 5)
Capsule().fill(Onyx.Color.textPrimary)
.frame(width: track * fraction, height: 5)
}
Text("\(Int((min(max(level, 0), 1) * 100).rounded()))")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textSecondary)
// Feste Breite: sonst wandert die Schiene, wenn aus 9 %
// eine 10 wird.
.frame(width: 24, alignment: .trailing)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
/// Ein Arm des Bandes — nur ausgefahren, und nur das Zubehör.
///
/// Die Live Activities hängen unter der Notch und nicht hier: neben ihr müssten
/// beide Arme gleich breit sein, und ein Symbol links neben einem Balken rechts
/// hieße über hundert Punkte Schwarz für das Symbol.
struct NotchBandSlot<Content: View>: View {
let presentation: NotchPresentation
@ViewBuilder let content: () -> Content
var body: some View {
if presentation.isExpanded { content() }
}
}
/// Die Live Activity unter der Notch.
///
/// Als eigene View und nicht als Verzweigung im Aufrufer: der Presenter ist
/// beobachtbar, und nur innerhalb eines `body` merkt SwiftUI die Abhängigkeit.
struct NotchAmbientSlot: View {
let presenter: ActivityPresenter
var body: some View {
if let activity = presenter.current {
activity.makeContent()
}
}
}
/// Die Helligkeit des eingebauten Bildschirms.
///
/// Dieselbe Form wie die Lautstärke — Symbol, Schiene, Zahl. Das ist kein
/// Sparen an Gestaltung, sondern die Aussage: es ist dasselbe Ding. Zwei
/// verschiedene Darstellungen für „ein Regler hat sich bewegt" wären eine
/// Behauptung, dass es zwei verschiedene Vorgänge sind.
struct BrightnessActivity: NotchActivity {
let id = "brightness"
let rank = ActivityRank.response
let duration: TimeInterval? = 1.6
let level: Double
var size: CGSize { CGSize(width: 196, height: 34) }
private var symbol: String {
// Die Sonne füllt sich mit — dasselbe Symbol, das macOS benutzt.
level < 0.5 ? "sun.min.fill" : "sun.max.fill"
}
func makeContent() -> AnyView {
let track: CGFloat = 96
let fraction = min(max(level, 0), 1)
return AnyView(
HStack(spacing: Onyx.Space.lg) {
Image(systemName: symbol)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.frame(width: 16)
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.track)
.frame(width: track, height: 5)
Capsule().fill(Onyx.Color.textPrimary)
.frame(width: track * fraction, height: 5)
}
Text("\(Int((fraction * 100).rounded()))")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textSecondary)
.frame(width: 24, alignment: .trailing)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
// MARK: - Was im Band stehen darf
/// Die Elemente, die links und rechts der Notch sitzen können.
///
/// **Eine Wahl je Seite, keine Liste.** Der Arm ist bei fünf Kacheln rund 200
/// Punkte breit und liegt auf Menüleistenhöhe — dort passt ein Wert, nicht drei.
/// Eine Mehrfachauswahl anzubieten hieße, ein Gedränge zu ermöglichen, das man
/// hinterher wieder wegräumen muss.
enum BandAccessory: String, Codable, CaseIterable, Sendable {
case none
case clock
case weather
case battery
case cpu
case memory
case temperature
var localizedName: LocalizedStringKey {
switch self {
case .none: "band.none"
case .clock: "band.clock"
case .weather: "band.weather"
case .battery: "band.battery"
case .cpu: "band.cpu"
case .memory: "band.memory"
case .temperature: "band.temperature"
}
}
}
/// Ein Bandplatz, der zeigt, was dafür eingestellt ist.
///
/// Liest die Einstellung **im Rumpf** und nicht beim Erzeugen: der Panelinhalt
/// wird je Bildschirm einmal gebaut, und ein beim Bauen gelesener Wert bliebe
/// für immer der, der beim Start galt.
struct BandSlotView: View {
let model: AppModel
let edge: HorizontalAlignment
let weather: WeatherModel?
let metrics: MetricsModel?
private var kind: BandAccessory {
edge == .leading ? model.bandLeading : model.bandTrailing
}
var body: some View {
content
.padding(.leading, edge == .leading ? Onyx.Space.xl : Onyx.Space.lg)
.padding(.trailing, edge == .leading ? Onyx.Space.lg : Onyx.Space.xl)
.frame(maxWidth: .infinity, alignment: edge == .leading ? .leading : .trailing)
}
@ViewBuilder
private var content: some View {
switch kind {
case .none:
EmptyView()
case .clock:
// Die eine Angabe, die keine Quelle braucht.
TimelineView(.everyMinute) { context in
value(context.date.formatted(date: .omitted, time: .shortened),
symbol: nil)
}
case .weather:
if let weather, case .ready(let snapshot) = weather.state {
value(snapshot.temperature.onyxFormatted,
symbol: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
}
case .battery:
if let battery = metrics?.snapshot.battery {
HStack(spacing: Onyx.Space.sm) {
Text("\(Int((battery.charge * 100).rounded())) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textPrimary)
BatteryGlyphView(charge: battery.charge,
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
height: 13)
}
}
case .cpu:
if let metrics {
value(MetricFormat.percent(metrics.snapshot.cpu.total), symbol: "cpu")
}
case .memory:
if let metrics {
value(MetricFormat.percent(metrics.snapshot.memory.usedFraction),
symbol: "memorychip")
}
case .temperature:
// Der wärmste Fühler: einer von zwanzig auszuwählen wäre eine
// Einstellung in einer Einstellung.
if let hottest = metrics?.snapshot.sensors.map(\.celsius).max() {
value("\(Int(hottest.rounded())) °C", symbol: "thermometer.medium")
}
}
}
/// Symbol und Wert — dieselbe Form für alles, was hier stehen kann.
///
/// Damit ist die Seite austauschbar: was links passt, passt auch rechts.
private func value(_ text: String, symbol: String?) -> some View {
HStack(spacing: Onyx.Space.sm) {
if let symbol {
Image(systemName: symbol)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textSecondary)
}
Text(text)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textPrimary)
}
}
}
+18 -27
View File
@@ -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() }
}
}
+4 -1
View File
@@ -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
+264 -7
View File
@@ -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) }
+127
View File
@@ -0,0 +1,127 @@
import SwiftUI
import ActivityProvider
import MetricsProvider
import OnyxDesign
/// Was am Strom geschieht — Netzteil dran, ab, Limit erreicht, Akku fast leer.
///
/// **Nicht bei jedem Messwert, sondern bei jedem Wechsel.** Der Ladestand
/// ändert sich ständig; eine Notch, die das begleitet, wäre eine Leuchtreklame.
/// Angemeldet wird nur, wenn sich der *Zustand* ändert.
struct PowerActivity: NotchActivity {
/// Was gemeldet wird.
enum Kind: Equatable {
case charging
case plugged
case unplugged
case atLimit(Int)
case low
var text: String {
switch self {
case .charging: String(localized: "activity.power.charging")
case .plugged: String(localized: "activity.power.plugged")
case .unplugged: String(localized: "activity.power.unplugged")
case .atLimit: String(localized: "activity.power.atLimit")
case .low: String(localized: "activity.power.low")
}
}
/// „Fast leer" ist das eine, das man nicht verpassen darf.
var rank: Int { self == .low ? ActivityRank.alert : ActivityRank.notice }
/// Eine Warnung darf länger stehen als eine Beiläufigkeit.
var duration: TimeInterval { self == .low ? 4 : 2.2 }
}
let id = "power"
let kind: Kind
let charge: Double
let isCharging: Bool
let isPluggedIn: Bool
var rank: Int { kind.rank }
var duration: TimeInterval? { kind.duration }
var size: CGSize { CGSize(width: 232, height: 34) }
func makeContent() -> AnyView {
AnyView(
HStack(spacing: Onyx.Space.lg) {
BatteryGlyphView(charge: charge,
isCharging: isCharging,
isPluggedIn: isPluggedIn,
height: 13)
Text(kind.text)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Spacer(minLength: Onyx.Space.sm)
Text("\(Int((min(max(charge, 0), 1) * 100).rounded())) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(kind == .low ? Onyx.Color.critical
: Onyx.Color.textSecondary)
}
.padding(.horizontal, Onyx.Space.xl)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
/// Was gerade spielt — beim Wechsel des Titels.
///
/// Ohne Fortschritt und ohne Knöpfe: das steht im Panel. Hier geht es um die
/// eine Frage, die man beim Wechsel hat — was ist das jetzt?
struct MediaActivity: NotchActivity {
let id = "media"
let rank = ActivityRank.notice
let duration: TimeInterval? = 3
var size: CGSize { CGSize(width: 264, height: 40) }
let title: String
let artist: String
let artwork: Data?
func makeContent() -> AnyView {
AnyView(
HStack(spacing: Onyx.Space.lg) {
Group {
if let artwork, let image = NSImage(data: artwork) {
Image(nsImage: image).resizable()
} else {
// Kein Cover ist kein Fehler — Radiostreams haben keins.
Onyx.Color.elevated
.overlay {
Image(systemName: "music.note")
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
.frame(width: 26, height: 26)
.clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.chip, style: .continuous))
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(title)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
if !artist.isEmpty {
Text(artist)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, Onyx.Space.xl)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
/// Die Bereiche des Einstellungsfensters.
///
/// **Seitenleiste statt Reiterband.** Sieben deutsche Reiterbeschriftungen
/// brauchten rund 880 Punkte nebeneinander — das Fenster hatte deshalb eine
/// Mindestbreite, die nichts mit seinem Inhalt zu tun hatte, sondern nur damit,
/// dass sonst der erste und der letzte Reiter über die Kante hinausragten. Eine
/// Seitenleiste wächst nach unten, und dort ist Platz.
///
/// **Keine leeren Einträge auf Vorrat.** Sperrbildschirm, Zwischenablage und
/// Notizen kommen mit ihren Phasen. Ein Eintrag, der sich öffnen lässt und
/// nichts enthält, ist ein Versprechen, das die App nicht hält.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case general
case widgets
case widgetOptions
case notch
case appearance
case menubar
case fans
case permissions
var id: Self { self }
var title: String {
switch self {
case .general: String(localized: "settings.general")
case .widgets: String(localized: "settings.tab.widgets")
case .widgetOptions: String(localized: "settings.tab.options")
case .notch: String(localized: "settings.section.notch")
case .appearance: String(localized: "settings.section.appearance")
case .menubar: String(localized: "settings.tab.menubar")
case .fans: String(localized: "settings.tab.fans")
case .permissions: String(localized: "settings.tab.permissions")
}
}
var symbol: String {
switch self {
case .general: "gearshape.fill"
case .widgets: "square.grid.2x2.fill"
case .widgetOptions: "wrench.adjustable.fill"
case .notch: "macbook"
case .appearance: "paintpalette.fill"
case .menubar: "menubar.rectangle"
case .fans: "fan.fill"
case .permissions: "lock.fill"
}
}
/// Die Farbe des Plättchens.
///
/// Fest und **nicht** die Akzentfarbe: acht Einträge in derselben Farbe
/// wären wieder acht gleich aussehende Zeilen. Die Farbe unterscheidet
/// hier, sie schmückt nicht.
var tint: Color {
switch self {
case .general: Color(red: 0.44, green: 0.47, blue: 0.51)
case .widgets: Color(red: 0.31, green: 0.53, blue: 0.93)
case .widgetOptions: Color(red: 0.29, green: 0.68, blue: 0.66)
case .notch: Color(red: 0.55, green: 0.44, blue: 0.85)
case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28)
case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66)
case .fans: Color(red: 0.33, green: 0.68, blue: 0.85)
case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35)
}
}
}
+404 -252
View File
@@ -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,69 +420,68 @@ 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)
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 ?? "" },
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
Text("settings.menubar.sensor.hottest").tag("")
ForEach(metricsModel.snapshot.sensors) { sensor in
Text(sensor.name).tag(sensor.key)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
Spacer()
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das
// stille Maximum über alle Sensoren — eine Zahl, von der niemand
// weiß, woher sie kommt.
if id == "metric.temperature", let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.selectedSensorKey ?? "" },
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
Text("settings.menubar.sensor.hottest").tag("")
ForEach(metricsModel.snapshot.sensors) { sensor in
Text(sensor.name).tag(sensor.key)
// 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) },
set: { metricsModel.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
.labelsHidden()
.frame(width: 110)
.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.
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.displayMode(for: metric) },
set: { metricsModel.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
}
.labelsHidden()
.frame(width: 110)
.disabled(!settings.isEnabled)
}
// 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.
if !Self.presentations(for: id).isEmpty {
Picker("", selection: Binding(
get: { settings.presentation },
set: { presentation in
var updated = settings
updated.presentation = presentation
model.setMenuBarSettings(updated, for: id)
})) {
ForEach(Self.presentations(for: id)) { presentation in
Text(presentation.localizedName).tag(presentation)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
// 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.
if !Self.presentations(for: id).isEmpty {
Picker("", selection: Binding(
get: { settings.presentation },
set: { presentation in
Toggle("", isOn: Binding(
get: { settings.isEnabled },
set: { enabled in
var updated = settings
updated.presentation = presentation
updated.isEnabled = enabled
model.setMenuBarSettings(updated, for: id)
})) {
ForEach(Self.presentations(for: id)) { presentation in
Text(presentation.localizedName).tag(presentation)
}
}
}))
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
}
}
@@ -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))
} else {
Text("settings.shelf.never").tag(Int?.none)
}
}
} label: {
Text("settings.shelf.cleanup")
}
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)
HStack {
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
VStack(spacing: Onyx.Space.xxs) {
Text(widget.displayName)
.font(.callout)
Text("settings.widgets.preview.hint")
.font(.caption)
.foregroundStyle(.secondary)
}
.multilineTextAlignment(.center)
} else {
Text("settings.widgets.preview.none")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
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)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
// niemandem etwas, ein nachgebildeter Bildschirm schon.
TriggerAreaSettings(trigger: $model.triggerArea)
Divider()
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
Text("settings.display.menuBarIcon.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
Text("settings.display.dockIcon.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
}
@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)
} else {
HStack(spacing: Onyx.Space.lg) {
Slider(value: binding(for: id),
in: NotchCoordinator.widthRange, step: 2)
.frame(width: 180)
Text(verbatim: "\(Int(model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width))")
.font(.body.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .trailing)
Button {
model.setNotchWidth(nil, for: id)
} label: {
Image(systemName: "arrow.counterclockwise")
}
.buttonStyle(.borderless)
.disabled(model.notchWidth(for: id) == nil)
.help(Text("settings.notch.width.reset"))
}
}
}
}
private func binding(for id: CGDirectDisplayID) -> Binding<CGFloat> {
Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width },
set: { model.setNotchWidth($0, for: id) })
}
}
+149 -47
View File
@@ -27,8 +27,9 @@ import ShelfProvider
final class SettingsWindowController: NSObject, NSWindowDelegate {
@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
}
}
+115 -118
View File
@@ -17,26 +17,13 @@ struct WidgetOptionsView: View {
let shelfStore: ShelfStore?
var body: some View {
Form {
Section("options.calendar") {
CalendarOptions(model: calendarModel)
}
Section("options.weather") {
WeatherOptions(model: weatherModel)
}
SettingsPage {
CalendarOptions(model: calendarModel)
WeatherOptions(model: weatherModel)
if let shelfStore {
Section("options.shelf") {
ShelfOptions(store: shelfStore)
}
ShelfOptions(store: shelfStore)
}
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
}
}
@@ -44,82 +31,93 @@ struct WidgetOptionsView: View {
private struct CalendarOptions: View {
@Bindable var model: CalendarModel
@State private var calendarrState: CalendarSourceState?
var body: some View {
Picker(selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
} label: {
Text("options.calendar.source")
}
.pickerStyle(.menu)
Picker(selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true)
} label: {
Text("options.calendar.display")
}
.pickerStyle(.menu)
// 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.
if model.availableCalendars.isEmpty {
Text("options.calendar.noneFound")
.font(.callout).foregroundStyle(.secondary)
} else {
ForEach(model.availableCalendars) { calendar in
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)
}
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)
}
}
.labelsHidden()
.frame(width: 180)
}
OnyxFormRow(String(localized: "options.calendar.display")) {
Picker("", selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true)
}
.labelsHidden()
.frame(width: 180)
}
}
OnyxFormCard(String(localized: "options.calendar.calendars"),
hint: String(localized: "options.calendar.calendars.hint")) {
if model.availableCalendars.isEmpty {
OnyxFormRow(String(localized: "options.calendar.noneFound"))
} else {
ForEach(model.availableCalendars) { calendar in
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) }
}))
.labelsHidden()
}
}
}
}
if model.kind != .appleCalendar {
CalendarrServerSettings(model: model)
CalendarrStatus(state: calendarrState,
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)))
}
OnyxFormCard(String(localized: "options.calendar.serverGroup")) {
CalendarrServerSettings(model: model)
// 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() })
}
}
}
}
/// 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,44 +178,40 @@ private struct WeatherOptions: View {
private var usesCurrentLocation: Bool { model.place.isCurrent }
var body: some View {
Toggle(isOn: Binding(
get: { usesCurrentLocation },
set: { model.place = $0 ? .current : model.place })) {
Text("options.weather.automatic")
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 }))
.labelsHidden()
.disabled(usesCurrentLocation)
}
.disabled(usesCurrentLocation)
if usesCurrentLocation {
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
// steht hier, statt dass das Widget stumm leer bleibt.
if model.locationIsDenied {
Text("options.weather.needsLocation")
.font(.callout).foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
if case .fixed(let name, _, _) = model.place {
OnyxFormRow(String(localized: "options.weather.place")) {
Text(name).foregroundStyle(.secondary)
}
}
} else if case .fixed(let name, _, _) = model.place {
LabeledContent {
Text(name).foregroundStyle(.secondary)
} label: {
Text("options.weather.place")
}
}
HStack {
TextField("options.weather.search", text: $query)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await search() } }
if searching {
ProgressView().controlSize(.small)
} else {
Button("options.weather.set") { Task { await search() } }
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
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)
} else {
Button("options.weather.set") { Task { await search() } }
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
if notFound {
Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning)
}
}
private func search() async {
@@ -241,23 +235,26 @@ private struct ShelfOptions: View {
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
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))
} else {
Text("settings.shelf.never").tag(Int?.none)
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))
} else {
Text("settings.shelf.never").tag(Int?.none)
}
}
}
.labelsHidden()
.frame(width: 180)
}
} label: {
Text("settings.shelf.cleanup")
}
HStack {
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
OnyxFormRow(String(localized: "options.shelf")) {
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Button("settings.shelf.reveal") { store.revealInFinder() }
}
}
}
}
@@ -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()
+26
View File
@@ -0,0 +1,26 @@
import AppKit
/// Wohin ein Fenster von Onyx gehört.
///
/// `NSWindow.center()` setzt etwas **oberhalb** der Bildschirmmitte. Bei einem
/// nicht sehr hohen Fenster landet der Schließknopf damit knapp unter der
/// Menüleiste — wer ihn treffen will, fährt durch die Auslösefläche der Notch
/// und hat plötzlich das Panel vor sich.
///
/// Das gilt für jedes Fenster, das Onyx aufmacht, nicht nur für die
/// Einstellungen. Deshalb steht es hier und nicht in einem der Controller.
enum OnyxWindowPlacement {
/// Wirklich mittig, nicht „etwas unterhalb der Notch".
///
/// Ein erster Versuch mit 60 Punkten Abstand zur Oberkante war zu zaghaft:
/// der Titelbalken lag weiter so hoch, dass man auf dem Weg zu seinen
/// Knöpfen durch die Auslösefläche fuhr.
static func center(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
}
+19
View File
@@ -21,6 +21,10 @@ let package = Package(
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
.library(name: "AudioProvider", targets: ["AudioProvider"]),
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
.library(name: "ActivityProvider", targets: ["ActivityProvider"]),
.library(name: "ClipboardProvider", targets: ["ClipboardProvider"]),
.library(name: "NotesProvider", targets: ["NotesProvider"]),
.library(name: "FaceProvider", targets: ["FaceProvider"]),
],
targets: [
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
@@ -73,8 +77,23 @@ let package = Package(
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
.target(name: "FaceProvider"),
.testTarget(name: "FaceProviderTests", dependencies: ["FaceProvider"]),
.target(name: "NotesProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "NotesProviderTests", dependencies: ["NotesProvider"]),
.target(name: "ClipboardProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "ClipboardProviderTests", dependencies: ["ClipboardProvider"]),
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
// Live Activities: die Warteschlange ist reine Rechnung ohne AppKit,
// die Darstellung hängt am Notch-Panel.
.target(name: "ActivityProvider", dependencies: ["OnyxDesign", "OnyxNotch"]),
.testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]),
.testTarget(name: "ActivityProviderTests", dependencies: ["ActivityProvider"]),
]
)
@@ -0,0 +1,124 @@
import SwiftUI
import OnyxDesign
import OnyxNotch
/// Führt die Live Activities und sagt der Notch, was gerade gilt.
///
/// Die Entscheidung selbst trifft `ActivityQueue` — hier steht nur, was mit
/// Zeit und Bildschirm zu tun hat: den Wecker für die nächste ablaufende
/// stellen und den Controllern melden, ob überhaupt etwas anliegt.
@MainActor
@Observable
public final class ActivityPresenter {
/// Die Activity, die gerade gezeichnet wird.
///
/// Bleibt nach dem Ende noch stehen, solange die Fläche einfährt — sonst
/// wäre die View im selben Bild weg, in dem sich die Notch zurückziehen
/// soll, und es gäbe nichts mehr zu sehen. Was **gilt**, sagt
/// `isPresenting`.
public private(set) var current: (any NotchActivity)?
/// Ob die Activity noch gilt oder nur noch ausblendet.
public private(set) var isPresenting = false
/// Wird nach jeder Änderung gerufen — mit dem, was jetzt gilt.
///
/// **Nicht nur bei „etwas / nichts".** Löst ein zweiter Lautstärkeschritt
/// die erste Activity ab, bleibt „etwas liegt an" gleich, aber die Arme
/// werden vielleicht anders breit. Wer nur auf den Wechsel von nichts zu
/// etwas hört, verpasst das.
@ObservationIgnored public var onChange: (((any NotchActivity)?) -> Void)?
@ObservationIgnored private var queue = ActivityQueue()
@ObservationIgnored private var activities: [String: any NotchActivity] = [:]
@ObservationIgnored private var alarm: DispatchWorkItem?
@ObservationIgnored private var retirement: DispatchWorkItem?
@ObservationIgnored private let clock: () -> Date
public init(clock: @escaping () -> Date = Date.init) {
self.clock = clock
}
/// Meldet eine Activity an. Eine bereits stehende mit derselben Kennung
/// wird ersetzt — samt neuer Standzeit.
public func raise(_ activity: any NotchActivity) {
activities[activity.id] = activity
queue.raise(ActivityTicket(id: activity.id,
rank: activity.rank,
raised: clock(),
duration: activity.duration))
settle()
}
public func withdraw(id: String) {
activities[id] = nil
queue.withdraw(id: id)
settle()
}
/// Die Fläche, die die eingeklappte Notch dafür unter sich aufspannt.
///
/// `nil`, sobald die Activity nicht mehr gilt — auch wenn sie noch
/// gezeichnet wird. Genau daran fährt die Fläche ein und schneidet den
/// Inhalt dabei weg.
public var contentSize: CGSize? { isPresenting ? current?.size : nil }
private func settle() {
let now = clock()
queue.prune(now: now)
let next = queue.current(now: now).flatMap { activities[$0.id] }
if let next {
retirement?.cancel()
retirement = nil
current = next
isPresenting = true
} else if isPresenting {
isPresenting = false
scheduleRetirement()
}
onChange?(isPresenting ? current : nil)
scheduleAlarm(after: now)
}
/// Weckt genau dann, wenn die nächste abläuft.
///
/// Nicht abtasten: eine Notch, die zehnmal je Sekunde nachfragt, ob etwas
/// vorbei ist, hält den Rechner wach — und zwar den ganzen Tag, für ein
/// Ereignis alle paar Minuten.
private func scheduleAlarm(after now: Date) {
alarm?.cancel()
alarm = nil
guard let next = queue.nextExpiry(after: now) else { return }
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated { self?.settle() }
}
alarm = work
DispatchQueue.main.asyncAfter(deadline: .now() + max(next.timeIntervalSince(now), 0),
execute: work)
}
}
private extension ActivityPresenter {
/// Räumt die ausgeblendete Activity weg, wenn die Fläche eingefahren ist.
///
/// Nicht sofort: solange die Notch schrumpft, muss noch etwas darin stehen.
/// Die Dauer kommt aus dem Bewegungsprofil, damit sie bei „ruhig" mitwächst
/// — eine feste Zahl schnitte den Inhalt dort mittendrin ab.
func scheduleRetirement() {
retirement?.cancel()
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
guard let self, !self.isPresenting else { return }
self.current = nil
}
}
retirement = work
DispatchQueue.main.asyncAfter(deadline: .now() + OnyxTheme.shared.motion.collapseDuration,
execute: work)
}
}
@@ -0,0 +1,96 @@
import Foundation
/// Ein Anspruch auf die eingeklappte Notch.
///
/// Bewusst ohne Darstellung: was eine Live Activity *zeigt*, ist eine View und
/// gehört nicht in die Entscheidung, **welche** gerade gilt. So lässt sich die
/// Regel ohne Bildschirm prüfen — wie `NotchStateMachine` und `LayoutEngine`.
public struct ActivityTicket: Equatable, Sendable, Identifiable {
public let id: String
/// Höher verdrängt niedriger.
public let rank: Int
/// Wann angemeldet.
public let raised: Date
/// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird.
public let duration: TimeInterval?
public init(id: String, rank: Int, raised: Date, duration: TimeInterval?) {
self.id = id
self.rank = rank
self.raised = raised
self.duration = duration
}
/// Wann sie von selbst geht. `nil` bei dauerhaften.
public var expiry: Date? {
duration.map { raised.addingTimeInterval($0) }
}
public func hasExpired(at now: Date) -> Bool {
guard let expiry else { return false }
return now >= expiry
}
}
/// Wer die eingeklappte Notch gerade bekommt.
///
/// **Warum überhaupt eine Warteschlange.** Die Anlässe kommen unabhängig
/// voneinander: die Lautstärke ändert sich, während ein Titel wechselt, während
/// das Netzteil angesteckt wird. Ohne Regel gewinnt, wer zuletzt kam — und dann
/// verschluckt ein beiläufiger Lautstärkeschritt die Meldung, dass der Akku
/// fast leer ist.
///
/// **Die Regel ist Rang vor Aktualität.** Bei gleichem Rang gilt die jüngere:
/// zwei Lautstärkeschritte hintereinander sollen den zweiten zeigen, nicht den
/// ersten stehen lassen.
public struct ActivityQueue: Equatable, Sendable {
private var tickets: [ActivityTicket] = []
public init() {}
/// Alles, was gerade anliegt — ungefiltert, in Anmeldereihenfolge.
public var all: [ActivityTicket] { tickets }
/// Meldet einen Anspruch an. Ein bereits vorhandener mit derselben Kennung
/// wird **ersetzt**, nicht ergänzt.
///
/// Das ist der Normalfall und keine Ausnahme: wer die Lautstärke dreimal
/// verstellt, meldet dreimal dieselbe Activity an und will dreimal die
/// volle Standzeit — nicht drei Einträge in einer Schlange.
public mutating func raise(_ ticket: ActivityTicket) {
tickets.removeAll { $0.id == ticket.id }
tickets.append(ticket)
}
public mutating func withdraw(id: String) {
tickets.removeAll { $0.id == id }
}
/// Wirft weg, was abgelaufen ist.
public mutating func prune(now: Date) {
tickets.removeAll { $0.hasExpired(at: now) }
}
/// Welche gerade gilt.
public func current(now: Date) -> ActivityTicket? {
tickets
.filter { !$0.hasExpired(at: now) }
// `last` statt `max(by:)`: bei gleichem Rang gewinnt die zuletzt
// angemeldete, und `tickets` steht in Anmeldereihenfolge.
.reduce(into: ActivityTicket?.none) { best, candidate in
guard let current = best else { best = candidate; return }
if candidate.rank >= current.rank { best = candidate }
}
}
/// Wann das nächste Mal etwas von selbst verschwindet.
///
/// Der Presenter stellt darauf seinen Wecker. Ohne diese Auskunft müsste er
/// abtasten — und eine Notch, die zehnmal je Sekunde nachfragt, ob etwas
/// abgelaufen ist, hält den Rechner wach.
public func nextExpiry(after now: Date) -> Date? {
tickets.compactMap(\.expiry).filter { $0 > now }.min()
}
}
@@ -0,0 +1,50 @@
import SwiftUI
/// Was die eingeklappte Notch zeigt.
///
/// **Unter der Notch, nicht daneben.** Neben ihr wären die Arme zwangsläufig
/// gleich breit — die Silhouette zeichnet den Steg mittig um die Hardware, und
/// verschieden breite Arme schöben die Lücke aus ihr heraus. Ein Symbol links
/// und ein Balken rechts hieße dann 132 Punkte Schwarz für ein Symbol. Darunter
/// hängt die Fläche frei und wird genau so breit, wie ihr Inhalt es braucht.
///
/// **Die Größe steht im Protokoll, sie wird nicht gemessen.** Eine Notch, die
/// sich an ihren Inhalt anpasst, müsste ihn erst zeichnen, seine Größe
/// zurückmelden und sich dann bewegen — das ist ein Bild zu spät und sieht als
/// Ruckeln aus. Wer eine Activity schreibt, weiß, wie groß sie wird.
@MainActor
public protocol NotchActivity: Sendable {
/// Stabil: eine erneute Anmeldung mit derselben Kennung ersetzt die alte,
/// statt sich anzureihen.
var id: String { get }
/// Höher verdrängt niedriger.
var rank: Int { get }
/// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird.
var duration: TimeInterval? { get }
/// Die Fläche **unter** der Notch. Ist sie schmaler als die Notch, gibt
/// diese das Maß vor — sonst hinge unter dem Steg ein schmalerer Zipfel.
var size: CGSize { get }
func makeContent() -> AnyView
}
/// Die Ränge, damit sie an einer Stelle stehen und nicht als Zahlen im Code.
///
/// Die Ordnung ist keine Geschmacksfrage: was der Nutzer **selbst gerade
/// ausgelöst** hat, muss er sehen — sonst wirkt der Regler kaputt. Was ihn
/// warnt, steht darüber, weil er es sonst verpasst. Was nur begleitet, steht
/// ganz unten.
public enum ActivityRank {
/// Läuft im Hintergrund mit, wenn sonst nichts ansteht.
public static let ambient = 10
/// Ein Zustand, der sich von selbst geändert hat — Titelwechsel, Kopplung.
public static let notice = 30
/// Eine Reaktion auf einen Handgriff — Lautstärke, Helligkeit.
public static let response = 50
/// Etwas, das man nicht verpassen darf — Akku fast leer.
public static let alert = 90
}
@@ -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.
VStack(spacing: 0) {
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,7 +354,8 @@ private struct SystemSection: View {
let compact: Bool
var body: some View {
VStack(spacing: 0) {
OnyxCard {
VStack(spacing: 0) {
SystemRow(
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: outputName,
@@ -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",
@@ -504,12 +388,7 @@ private struct SystemSection: View {
onVolume: { system.setInputVolume($0) },
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,18 +450,18 @@ 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,
isActive: !muted, onChange: onVolume)
OnyxSlider(value: volume, level: level,
isActive: !muted, onChange: onVolume)
if let metering { metering }
Text("\(Int(volume * 100)) %")
.font(Onyx.Font.metricSmall).monospacedDigit()
@@ -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)
@@ -0,0 +1,132 @@
import Foundation
/// Ein gemerkter Eintrag der Zwischenablage.
///
/// Der **Inhalt steht nicht hier drin**, sondern in einer Datei daneben. Was
/// hier steht, ist genug für die Liste — und wenig genug, dass die Indexdatei
/// klein bleibt, auch bei fünfhundert Einträgen.
public struct ClipboardItem: Identifiable, Equatable, Sendable, Codable {
public enum Kind: String, Codable, Sendable {
case text
case image
}
public let id: UUID
public let kind: Kind
/// Was in der Kachel zu lesen ist. Bei Bildern der Dateiname oder das Maß.
public let preview: String
public let addedAt: Date
/// Aus welchem Programm es kam — für die Ausschlussliste und die Anzeige.
public let sourceBundleID: String?
/// Erkennt Wiederholungen, ohne den ganzen Inhalt im Speicher zu halten.
///
/// Dieselbe Zeile zweimal zu kopieren ist der Normalfall, nicht die
/// Ausnahme — sie soll nach oben rutschen und nicht ein zweites Mal
/// dastehen.
public let fingerprint: String
public init(id: UUID = UUID(), kind: Kind, preview: String, addedAt: Date,
sourceBundleID: String?, fingerprint: String) {
self.id = id
self.kind = kind
self.preview = preview
self.addedAt = addedAt
self.sourceBundleID = sourceBundleID
self.fingerprint = fingerprint
}
}
/// Die Regeln der Zwischenablage — ohne Pasteboard, ohne Dateisystem, ohne Uhr.
///
/// Alles hier ist reine Rechnung und damit prüfbar. Was mit dem System zu tun
/// hat, steht in ``ClipboardStore``.
public enum ClipboardRules {
/// Wie viele Einträge höchstens gemerkt werden.
///
/// Eine Grenze muss es geben: ohne sie wächst der Verlauf, bis das Laden
/// spürbar wird — und niemand scrollt durch tausend Einträge.
public static let limit = 200
/// Wie lang eine Vorschauzeile höchstens ist.
public static let previewLength = 120
/// Typen, bei denen ausdrücklich **nicht** gemerkt werden soll.
///
/// Passwortmanager setzen `ConcealedType`, wenn sie ein Passwort ablegen;
/// `TransientType` heißt „nur zum Durchreichen"; `AutoGeneratedType` setzen
/// Werkzeuge, die etwas ohne Zutun des Menschen kopieren. Diese drei Marken
/// sind eine Verabredung zwischen Programmen, kein Systemdienst — wer sie
/// missachtet, sammelt fremde Passwörter ein.
public static let ignoredTypes: Set<String> = [
"org.nspasteboard.TransientType",
"org.nspasteboard.ConcealedType",
"org.nspasteboard.AutoGeneratedType",
]
/// Ob eine Änderung überhaupt gemerkt werden darf.
public static func shouldRecord(types: [String],
sourceBundleID: String?,
excludedApps: Set<String>) -> Bool {
guard types.allSatisfy({ !ignoredTypes.contains($0) }) else { return false }
guard let sourceBundleID else { return true }
return !excludedApps.contains(sourceBundleID)
}
/// Die eine Zeile, die in der Liste steht.
///
/// Zeilenumbrüche werden zu Leerzeichen: ein mehrzeiliger Ausschnitt würde
/// die Kachel sprengen, und die zweite Zeile sagt selten mehr als die
/// erste.
public static func preview(for text: String) -> String {
let flat = text
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\t", with: " ")
let collapsed = flat.split(separator: " ", omittingEmptySubsequences: true).joined(separator: " ")
guard collapsed.count > previewLength else { return collapsed }
return String(collapsed.prefix(previewLength)) + "…"
}
/// Nimmt einen Eintrag auf: Wiederholungen rutschen nach oben, statt sich
/// anzureihen, und über der Grenze fällt das Älteste heraus.
///
/// Gibt zusätzlich zurück, welche Einträge dabei verdrängt wurden — deren
/// Dateien muss der Aufrufer wegräumen.
public static func merge(_ items: [ClipboardItem],
adding item: ClipboardItem,
limit: Int = limit) -> (items: [ClipboardItem],
dropped: [ClipboardItem]) {
var dropped = items.filter { $0.fingerprint == item.fingerprint }
var kept = items.filter { $0.fingerprint != item.fingerprint }
kept.insert(item, at: 0)
if kept.count > limit {
dropped.append(contentsOf: kept[limit...])
kept = Array(kept.prefix(limit))
}
return (kept, dropped)
}
/// Was zu alt geworden ist. `nil` als Frist heißt: nichts verfällt.
public static func expired(_ items: [ClipboardItem],
olderThanDays days: Int?,
now: Date) -> [ClipboardItem] {
guard let days, days > 0 else { return [] }
let cutoff = now.addingTimeInterval(-Double(days) * 86_400)
return items.filter { $0.addedAt < cutoff }
}
/// Neuestes zuerst.
///
/// Die Kennung als Zweitschlüssel, damit zwei im selben Augenblick
/// gemerkte Einträge nicht bei jeder Anzeige die Plätze tauschen.
public static func sorted(_ items: [ClipboardItem]) -> [ClipboardItem] {
items.sorted {
$0.addedAt == $1.addedAt
? $0.id.uuidString > $1.id.uuidString
: $0.addedAt > $1.addedAt
}
}
}
@@ -0,0 +1,247 @@
import AppKit
import Observation
import CryptoKit
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Clipboard")
/// Der Verlauf der Zwischenablage.
///
/// **Abgetastet, nicht gemeldet.** macOS kennt keine Benachrichtigung über eine
/// geänderte Zwischenablage — es gibt nur `changeCount`, einen Zähler, der
/// hochgeht. Zweimal je Sekunde nachzusehen ist der übliche Preis; dasselbe tut
/// die Lüftersteuerung mit dem SMC.
///
/// **Inhalte gehen in Dateien, nie in `UserDefaults`.** Was dort steht, liest
/// jedes Programm des Benutzers mit `defaults read` mit — für Kopiertes, das
/// auch mal eine Adresse oder eine Kontonummer ist, ist das der falsche Ort.
@MainActor
@Observable
public final class ClipboardStore {
public private(set) var items: [ClipboardItem] = []
/// Nach wie vielen Tagen Einträge verfallen. `nil` heißt: nie.
public var expiryDays: Int? {
didSet {
guard expiryDays != oldValue else { return }
defaults.set(expiryDays ?? 0, forKey: Keys.expiryDays)
purgeExpired()
}
}
/// Programme, aus denen nie gemerkt wird.
///
/// Über die `TransientType`-Marken hinaus: nicht jeder Passwortmanager
/// setzt sie, und manches Programm kopiert Dinge, die einen nichts angehen.
public var excludedApps: Set<String> {
didSet {
guard excludedApps != oldValue else { return }
defaults.set(Array(excludedApps).sorted(), forKey: Keys.excludedApps)
}
}
/// Ob überhaupt gemerkt wird. Aus heißt: der Verlauf bleibt stehen, es
/// kommt nur nichts Neues dazu.
public var isRecording: Bool {
didSet {
guard isRecording != oldValue else { return }
defaults.set(isRecording, forKey: Keys.isRecording)
}
}
@ObservationIgnored private let directory: URL
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let pasteboard: NSPasteboard
@ObservationIgnored private var lastChangeCount: Int
@ObservationIgnored private var timer: Timer?
/// Was Onyx selbst in die Ablage gelegt hat — das soll nicht als neuer
/// Eintrag zurückkommen.
@ObservationIgnored private var ownChangeCount: Int?
private enum Keys {
static let expiryDays = "onyx.clipboard.expiryDays"
static let excludedApps = "onyx.clipboard.excludedApps"
static let isRecording = "onyx.clipboard.isRecording"
}
/// Zweimal je Sekunde. Öfter merkt niemand, seltener verpasst man einen
/// schnellen Doppelgriff.
private static let interval: TimeInterval = 0.5
public init(directory: URL? = nil,
defaults: UserDefaults = .standard,
pasteboard: NSPasteboard = .general) {
self.directory = directory ?? Self.defaultDirectory
self.defaults = defaults
self.pasteboard = pasteboard
self.lastChangeCount = pasteboard.changeCount
let days = defaults.object(forKey: Keys.expiryDays) as? Int
// Null steht für „nie" — ein eigener Wert wäre ein zweiter Weg, dasselbe
// zu sagen.
self.expiryDays = (days == nil || days == 0) ? 7 : days
self.excludedApps = Set(defaults.stringArray(forKey: Keys.excludedApps) ?? [])
self.isRecording = defaults.object(forKey: Keys.isRecording) as? Bool ?? true
try? FileManager.default.createDirectory(at: self.directory,
withIntermediateDirectories: true)
load()
purgeExpired()
start()
}
public static var defaultDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Clipboard", isDirectory: true)
}
// MARK: - Abtasten
private func start() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.poll() }
}
}
public func stop() {
timer?.invalidate()
timer = nil
}
private func poll() {
let count = pasteboard.changeCount
guard count != lastChangeCount else { return }
lastChangeCount = count
// Was Onyx selbst eingesetzt hat, ist kein neuer Fund.
if ownChangeCount == count { ownChangeCount = nil; return }
guard isRecording else { return }
capture()
}
private func capture() {
let types = pasteboard.types?.map(\.rawValue) ?? []
let source = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
guard ClipboardRules.shouldRecord(types: types,
sourceBundleID: source,
excludedApps: excludedApps) else { return }
if let text = pasteboard.string(forType: .string),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
add(kind: .text, preview: ClipboardRules.preview(for: text),
payload: Data(text.utf8), source: source)
} else if let image = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png) {
let size = NSImage(data: image)?.size ?? .zero
add(kind: .image,
preview: "\(Int(size.width)) × \(Int(size.height))",
payload: image, source: source)
}
}
private func add(kind: ClipboardItem.Kind, preview: String, payload: Data, source: String?) {
let fingerprint = SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined()
let item = ClipboardItem(kind: kind, preview: preview, addedAt: Date(),
sourceBundleID: source, fingerprint: fingerprint)
let result = ClipboardRules.merge(items, adding: item)
items = result.items
write(payload, for: item)
result.dropped.forEach(removeFile)
save()
}
// MARK: - Zurücklegen
/// Legt einen Eintrag wieder in die Zwischenablage.
public func copyBack(_ item: ClipboardItem) {
guard let data = try? Data(contentsOf: fileURL(for: item)) else {
log.error("Inhalt von \(item.id, privacy: .public) nicht lesbar")
return
}
pasteboard.clearContents()
switch item.kind {
case .text:
pasteboard.setString(String(decoding: data, as: UTF8.self), forType: .string)
case .image:
pasteboard.setData(data, forType: .tiff)
}
// Der eigene Griff soll nicht als neuer Eintrag zurückkommen.
ownChangeCount = pasteboard.changeCount
lastChangeCount = pasteboard.changeCount
}
public func remove(_ item: ClipboardItem) {
items.removeAll { $0.id == item.id }
removeFile(item)
save()
}
public func removeAll() {
items.forEach(removeFile)
items = []
save()
}
public func purgeExpired() {
let stale = ClipboardRules.expired(items, olderThanDays: expiryDays, now: Date())
guard !stale.isEmpty else { return }
let ids = Set(stale.map(\.id))
items.removeAll { ids.contains($0.id) }
stale.forEach(removeFile)
save()
}
// MARK: - Ablage
private var indexURL: URL { directory.appendingPathComponent("index.json") }
private func fileURL(for item: ClipboardItem) -> URL {
directory.appendingPathComponent("\(item.id.uuidString).\(item.kind == .text ? "txt" : "tiff")")
}
/// Die Indexdatei trägt ihre Fassung mit.
///
/// Ohne Nummer ist eine spätere Änderung am Format nicht von einer kaputten
/// Datei zu unterscheiden — dasselbe Vorgehen wie in `LayoutStore`.
private struct Index: Codable {
var version = 1
var items: [ClipboardItem]
}
private func load() {
guard let data = try? Data(contentsOf: indexURL) else { return }
guard let index = try? JSONDecoder().decode(Index.self, from: data), index.version == 1 else {
log.notice("Unbekannte Fassung der Indexdatei — Verlauf beginnt neu")
return
}
// Nur, was auch wirklich noch daliegt: eine Zeile ohne Datei wäre ein
// Eintrag, der beim Anklicken nichts tut.
items = ClipboardRules.sorted(index.items.filter {
FileManager.default.fileExists(atPath: fileURL(for: $0).path)
})
}
private func save() {
do {
let data = try JSONEncoder().encode(Index(items: items))
try data.write(to: indexURL, options: .atomic)
} catch {
log.error("Index nicht schreibbar: \(error.localizedDescription, privacy: .public)")
}
}
private func write(_ payload: Data, for item: ClipboardItem) {
do {
try payload.write(to: fileURL(for: item), options: .atomic)
} catch {
log.error("Inhalt nicht schreibbar: \(error.localizedDescription, privacy: .public)")
}
}
private func removeFile(_ item: ClipboardItem) {
try? FileManager.default.removeItem(at: fileURL(for: item))
}
}
@@ -0,0 +1,106 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
/// Der Verlauf der Zwischenablage, zum Zurückholen.
public struct ClipboardView: View {
private let store: ClipboardStore
private let compact: Bool
@State private var hovered: UUID?
@State private var justCopied: UUID?
public init(store: ClipboardStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
if store.items.isEmpty {
OnyxNotice(symbol: "doc.on.clipboard",
text: store.isRecording
? String(localized: "clipboard.empty", bundle: .module)
: String(localized: "clipboard.paused", bundle: .module))
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(store.items.prefix(compact ? 5 : 40).enumerated()),
id: \.element.id) { index, item in
if index > 0 { OnyxHairline() }
row(item)
}
}
}
.scrollIndicators(.never)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ item: ClipboardItem) -> some View {
// Die ganze Zeile ist der Knopf. Ein kleines Ziel neben dem Text wäre
// genauer zu treffen zu verlangen, als die Aufgabe es hergibt.
Button { copy(item) } label: {
HStack(spacing: Onyx.Space.row) {
Image(systemName: item.kind == .image ? "photo" : "text.alignleft")
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 16)
Text(item.preview)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Onyx.Space.xs)
if justCopied == item.id {
// Eine Rückmeldung, die nichts verschiebt: ohne sie sieht
// ein Klick, der die Ablage füllt, wie ein Klick aus, der
// nichts getan hat.
Image(systemName: "checkmark")
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.positive)
} else if hovered == item.id {
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.textTertiary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
.contentShape(.rect)
}
.buttonStyle(.plain)
.background(hovered == item.id ? Onyx.Color.elevated : .clear)
.onHover { hovered = $0 ? item.id : (hovered == item.id ? nil : hovered) }
}
private func copy(_ item: ClipboardItem) {
store.copyBack(item)
justCopied = item.id
Task {
try? await Task.sleep(for: .seconds(1.2))
if justCopied == item.id { justCopied = nil }
}
}
}
public struct ClipboardWidget: OnyxWidget {
public let id = "clipboard"
public var displayName: String { String(localized: "clipboard.name", bundle: .module) }
public let symbolName = "doc.on.clipboard"
private let store: ClipboardStore
public init(store: ClipboardStore) { self.store = store }
public func makeView() -> AnyView {
AnyView(ClipboardView(store: store))
}
}
@@ -0,0 +1,54 @@
{
"sourceLanguage": "en",
"strings": {
"clipboard.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zwischenablage"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Clipboard"
}
}
}
},
"clipboard.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Was du kopierst, erscheint hier."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "What you copy shows up here."
}
}
}
},
"clipboard.paused": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Verlauf ist angehalten."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "History is paused."
}
}
}
}
},
"version": "1.0"
}
@@ -0,0 +1,138 @@
import Foundation
/// Ein hinterlegtes Gesicht — ein Merkmalsvektor aus einer Aufnahme.
///
/// Mehrere davon je Person, aus verschiedenen Posen. Ein einzelner Vektor
/// beschreibt eine Kopfhaltung bei einer Beleuchtung, nicht ein Gesicht.
public struct FaceTemplate: Equatable, Sendable, Codable {
public let id: UUID
/// Wofür die Aufnahme gedacht war — nur zur Anzeige beim Anlernen.
public let pose: String
/// Der Merkmalsvektor. 768 Elemente aus
/// `VNGenerateImageFeaturePrintRequest`; siehe `docs/spikes/G-gesichtsmerkmal.md`.
public let vector: [Float]
public init(id: UUID = UUID(), pose: String, vector: [Float]) {
self.id = id
self.pose = pose
self.vector = vector
}
}
/// Wie ein Vergleich ausgegangen ist.
public enum FaceDecision: Equatable, Sendable {
/// Erkannt — genug übereinstimmende Einzelbilder hintereinander.
case accepted
/// Noch nicht genug. Der Aufrufer soll weiter Bilder liefern.
case undecided(confirmations: Int)
/// Abgelehnt und gesperrt: ab hier hilft nur noch das Passwort.
case lockedOut
}
/// Der Vergleich — reine Rechnung, ohne Kamera und ohne Vision.
///
/// **Warum das ein eigener Typ ist und keine Methode am Kameracode.** Genau hier
/// entscheidet sich, ob ein Fremder hereinkommt. Das muss man mit abgelegten
/// Vektoren durchspielen können, ohne sich vor eine Kamera zu setzen — mit
/// Grenzfällen, die man vor einer Kamera gar nicht herstellen könnte.
///
/// **Und warum die Schwelle streng ist.** Ein falsches Ja ist ein Fremder am
/// entsperrten Rechner. Ein falsches Nein ist ein Passwort, das man ohnehin
/// kennt. Die beiden Fehler sind nicht gleich teuer, also ist die Schwelle nicht
/// mittig.
public struct FaceMatcher: Sendable {
/// Ab welchem Abstand zwei Vektoren als dieselbe Person gelten.
///
/// Aus dem Spike: ähnliche Bilder lagen bei 0,13, deutlich verschiedene bei
/// 0,47. Die Schwelle liegt bewusst näher am Ähnlichen — siehe oben.
public static let defaultThreshold: Float = 0.22
/// Wie viele Einzelbilder hintereinander passen müssen.
///
/// Eines genügt nicht: die Kamera liefert dreißig Bilder je Sekunde, und
/// unter dreißig ist irgendwann eines dabei, das zufällig passt.
public static let defaultConfirmations = 3
/// Nach wie vielen Fehlversuchen Schluss ist.
public static let defaultAttempts = 10
public let threshold: Float
public let requiredConfirmations: Int
public let maximumAttempts: Int
private let templates: [FaceTemplate]
public init(templates: [FaceTemplate],
threshold: Float = defaultThreshold,
requiredConfirmations: Int = defaultConfirmations,
maximumAttempts: Int = defaultAttempts) {
self.templates = templates
self.threshold = threshold
self.requiredConfirmations = requiredConfirmations
self.maximumAttempts = maximumAttempts
}
/// Ob überhaupt ein Gesicht hinterlegt ist.
public var isEnrolled: Bool { !templates.isEmpty }
/// Der kleinste Abstand zu allen hinterlegten Vektoren.
///
/// Der kleinste und nicht der mittlere: die Posen sind absichtlich
/// verschieden, und ein Mittelwert über verschiedene Posen bestraft genau
/// die Vielfalt, für die sie aufgenommen wurden.
public func distance(to vector: [Float]) -> Float? {
templates.compactMap { Self.distance($0.vector, vector) }.min()
}
/// Euklidischer Abstand. `nil` bei ungleicher Länge — zwei verschieden
/// lange Vektoren zu vergleichen ergäbe eine Zahl ohne Bedeutung.
public static func distance(_ a: [Float], _ b: [Float]) -> Float? {
guard a.count == b.count, !a.isEmpty else { return nil }
var sum: Float = 0
for i in a.indices {
let d = a[i] - b[i]
sum += d * d
}
return sum.squareRoot()
}
/// Der laufende Versuch: zählt Bestätigungen und Fehlversuche mit.
public struct Session: Sendable {
public private(set) var confirmations = 0
public private(set) var attempts = 0
public private(set) var isLockedOut = false
public init() {}
fileprivate mutating func confirm() { confirmations += 1 }
fileprivate mutating func fail(limit: Int) {
attempts += 1
// Bestätigungen laufen **nicht** über Fehlversuche hinweg weiter:
// sonst summierten sich zufällige Treffer über Minuten zu einem Ja.
confirmations = 0
if attempts >= limit { isLockedOut = true }
}
}
/// Nimmt ein Einzelbild entgegen und sagt, wie es weitergeht.
public func evaluate(_ vector: [Float], in session: inout Session) -> FaceDecision {
guard !session.isLockedOut else { return .lockedOut }
// Ohne hinterlegtes Gesicht gibt es nichts zu vergleichen — dann ist
// jeder Versuch ein Fehlversuch und nicht etwa ein Freifahrtschein.
guard isEnrolled, let distance = distance(to: vector) else {
session.fail(limit: maximumAttempts)
return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0)
}
if distance <= threshold {
session.confirm()
return session.confirmations >= requiredConfirmations
? .accepted
: .undecided(confirmations: session.confirmations)
}
session.fail(limit: maximumAttempts)
return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0)
}
}
@@ -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)
}
@@ -0,0 +1,186 @@
import Foundation
import Observation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "BatteryHistory")
/// Ein Messpunkt des Akkus.
///
/// Vier Zahlen, mehr nicht. Was sich nicht zeichnen lässt, gehört nicht in
/// einen Verlauf — Zyklen und Gesundheit ändern sich in Monaten und stehen
/// besser als aktueller Wert da.
public struct BatterySample: Equatable, Sendable, Codable {
public let at: Date
/// 0 bis 1.
public let charge: Double
/// Zellentemperatur, falls der SMC sie hergibt.
public let celsius: Double?
/// Negativ heißt Entladung.
public let watts: Double?
public init(at: Date, charge: Double, celsius: Double?, watts: Double?) {
self.at = at
self.charge = charge
self.celsius = celsius
self.watts = watts
}
}
/// Über welchen Zeitraum der Verlauf gezeigt wird.
public enum HistoryWindow: String, CaseIterable, Sendable {
case day, week, month
public var duration: TimeInterval {
switch self {
case .day: 86_400
case .week: 7 * 86_400
case .month: 30 * 86_400
}
}
/// Wie viele Säulen die Anzeige bekommt.
///
/// Nicht ein Punkt je Messung: über einen Monat wären das über achttausend,
/// und mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne etwas zu
/// zeigen.
public var buckets: Int {
switch self {
case .day: 48 // halbstündlich
case .week: 56 // dreistündlich
case .month: 60 // halbtägig
}
}
}
/// Die Regeln des Verlaufs — ohne Uhr, ohne Dateisystem.
public enum BatteryHistoryRules {
/// Wie oft gemessen wird.
///
/// Fünf Minuten reichen: der Ladestand eines Akkus ändert sich nicht
/// sprunghaft, und ein Punkt je Minute wäre die zwölffache Datei für
/// dieselbe Kurve.
public static let interval: TimeInterval = 300
/// Wie lange Messpunkte aufgehoben werden.
///
/// Genau so lang wie das längste Fenster. Alles darüber hinaus wäre eine
/// Datei, die wächst, ohne dass es je jemand ansieht.
public static let maximumAge: TimeInterval = HistoryWindow.month.duration
/// Ob jetzt ein neuer Messpunkt fällig ist.
public static func shouldRecord(last: Date?, now: Date,
interval: TimeInterval = interval) -> Bool {
guard let last else { return true }
return now.timeIntervalSince(last) >= interval
}
/// Was zu alt geworden ist, fällt heraus.
public static func pruned(_ samples: [BatterySample], now: Date,
maximumAge: TimeInterval = maximumAge) -> [BatterySample] {
let cutoff = now.addingTimeInterval(-maximumAge)
return samples.filter { $0.at >= cutoff }
}
/// Fasst die Messpunkte eines Fensters zu Säulen zusammen.
///
/// Der Mittelwert je Zeitabschnitt, nicht der erste oder letzte Wert: ein
/// einzelner Ausreißer soll die Säule nicht bestimmen. Abschnitte ohne
/// Messung bleiben `nil` — ein zugeklappter Rechner hat dort keinen
/// Ladestand gehabt, und eine durchgezogene Linie darüber wäre erfunden.
public static func buckets(_ samples: [BatterySample],
window: HistoryWindow,
now: Date) -> [Double?] {
let start = now.addingTimeInterval(-window.duration)
let width = window.duration / Double(window.buckets)
var sums = [Double](repeating: 0, count: window.buckets)
var counts = [Int](repeating: 0, count: window.buckets)
for sample in samples where sample.at >= start && sample.at <= now {
let offset = sample.at.timeIntervalSince(start)
let index = min(Int(offset / width), window.buckets - 1)
sums[index] += sample.charge
counts[index] += 1
}
return (0..<window.buckets).map { counts[$0] == 0 ? nil : sums[$0] / Double(counts[$0]) }
}
/// Höchst-, Tiefst- und Mittelwert eines Fensters. `nil`, wenn nichts vorliegt.
public static func summary(_ samples: [BatterySample], window: HistoryWindow,
now: Date) -> (low: Double, high: Double, average: Double)? {
let start = now.addingTimeInterval(-window.duration)
let inWindow = samples.filter { $0.at >= start && $0.at <= now }
guard !inWindow.isEmpty else { return nil }
let charges = inWindow.map(\.charge)
return (charges.min()!, charges.max()!,
charges.reduce(0, +) / Double(charges.count))
}
}
/// Der Verlauf, aufgezeichnet und abgelegt.
@MainActor
@Observable
public final class BatteryHistoryStore {
public private(set) var samples: [BatterySample] = []
@ObservationIgnored private let fileURL: URL
@ObservationIgnored private var lastRecorded: Date?
private struct Archive: Codable {
var version = 1
var samples: [BatterySample]
}
public init(directory: URL? = nil) {
let folder = directory ?? Self.defaultDirectory
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
fileURL = folder.appendingPathComponent("battery.json")
load()
lastRecorded = samples.last?.at
}
public static var defaultDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Metrics", isDirectory: true)
}
/// Nimmt eine Messung entgegen — und legt sie nur ab, wenn sie fällig ist.
public func record(_ reading: BatteryReading, now: Date = Date()) {
guard BatteryHistoryRules.shouldRecord(last: lastRecorded, now: now) else { return }
lastRecorded = now
samples.append(BatterySample(at: now, charge: reading.charge,
celsius: reading.celsius, watts: reading.watts))
samples = BatteryHistoryRules.pruned(samples, now: now)
save()
}
public func buckets(_ window: HistoryWindow, now: Date = Date()) -> [Double?] {
BatteryHistoryRules.buckets(samples, window: window, now: now)
}
public func summary(_ window: HistoryWindow,
now: Date = Date()) -> (low: Double, high: Double, average: Double)? {
BatteryHistoryRules.summary(samples, window: window, now: now)
}
private func load() {
guard let data = try? Data(contentsOf: fileURL) else { return }
guard let archive = try? JSONDecoder().decode(Archive.self, from: data),
archive.version == 1 else {
log.notice("Unbekannte Fassung des Akkuverlaufs — er beginnt neu")
return
}
samples = archive.samples.sorted { $0.at < $1.at }
}
private func save() {
do {
try JSONEncoder().encode(Archive(samples: samples)).write(to: fileURL, options: .atomic)
} catch {
log.error("Verlauf nicht schreibbar: \(error.localizedDescription, privacy: .public)")
}
}
}
@@ -0,0 +1,90 @@
import SwiftUI
import OnyxDesign
/// Der Ladeverlauf als Säulen.
///
/// **Säulen und keine Linie.** Eine Linie verbindet auch dort, wo nichts
/// gemessen wurde — ein zugeklappter Rechner hat keinen Ladestand gehabt, und
/// eine Linie darüber behauptet einen. Säulen können einfach fehlen.
public struct BatteryHistoryView: View {
private let store: BatteryHistoryStore
@State private var window: HistoryWindow = .day
public init(store: BatteryHistoryStore) { self.store = store }
public var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
header
chart
}
}
private var header: some View {
HStack(spacing: Onyx.Space.sm) {
if let summary = store.summary(window) {
Text(verbatim: "\(percent(summary.low)) – \(percent(summary.high)) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textSecondary)
} else {
Text("battery.history.empty", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: Onyx.Space.xs)
// Drei kurze Wörter statt eines Menüs: die Auswahl hat drei
// Möglichkeiten, und ein Menü dafür verlangt zwei Klicks für das,
// was ein Klick kann.
ForEach(HistoryWindow.allCases, id: \.self) { candidate in
Button { window = candidate } label: {
Text(candidate.localizedName, bundle: .module)
.font(Onyx.Font.micro)
.foregroundStyle(candidate == window
? Onyx.Color.accent : Onyx.Color.textTertiary)
}
.buttonStyle(.plain)
}
}
}
private var chart: some View {
GeometryReader { geometry in
let buckets = store.buckets(window)
let gap: CGFloat = 1
let width = max((geometry.size.width - gap * CGFloat(buckets.count - 1))
/ CGFloat(buckets.count), 1)
HStack(alignment: .bottom, spacing: gap) {
ForEach(Array(buckets.enumerated()), id: \.offset) { _, value in
if let value {
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.accent)
.frame(width: width,
height: max(geometry.size.height * value, 1))
} else {
// Die Lücke bleibt eine Lücke.
Color.clear.frame(width: width, height: 1)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
.frame(height: 44)
}
private func percent(_ value: Double) -> Int {
Int((min(max(value, 0), 1) * 100).rounded())
}
}
extension HistoryWindow {
var localizedName: LocalizedStringKey {
switch self {
case .day: "battery.history.day"
case .week: "battery.history.week"
case .month: "battery.history.month"
}
}
}
@@ -35,3 +35,58 @@ public enum ChargeActivity: Equatable, Sendable {
return .pluggedNotCharging
}
}
/// Was am Strom geschehen ist — und ob es überhaupt etwas war.
///
/// ``ChargeActivity`` sagt, welcher Zustand *gilt*. Das ist nicht dasselbe wie
/// „es ist etwas passiert": der Zustand gilt die ganze Zeit, gemeldet werden
/// soll er nur beim Wechsel.
public enum PowerEvent: Equatable, Sendable {
case charging
case plugged
case unplugged
case atLimit(Int)
/// Am Akku und fast leer. Schlägt `unplugged`: beides trifft zu, aber nur
/// eines davon muss man sehen.
case low
}
/// Merkt sich den letzten Zustand und meldet nur Wechsel.
///
/// **Der erste gelesene Zustand meldet nichts.** Sonst begrüßte Onyx jeden
/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen ist.
///
/// Eigener Typ und nicht zwei Zeilen im Programmstart, weil hier drei Regeln
/// zusammenkommen, die man einzeln prüfen können muss: die Schwelle für „fast
/// leer", der Vorrang vor „am Akku" und das Schweigen beim ersten Mal.
public struct PowerWatcher: Equatable, Sendable {
/// Ab hier heißt „am Akku" nicht mehr nur „am Akku".
public static let lowThreshold = 0.2
private var last: PowerEvent?
public init() {}
/// Der zuletzt gesehene Zustand. `nil`, solange nichts gelesen wurde.
public var current: PowerEvent? { last }
/// Nimmt eine Messung entgegen und gibt zurück, was zu melden ist — oder
/// `nil`, wenn sich nichts geändert hat.
public mutating func observe(isCharging: Bool, isPluggedIn: Bool,
charge: Double, limit: Int?) -> PowerEvent? {
let state = ChargeActivity.resolve(isCharging: isCharging,
isPluggedIn: isPluggedIn,
charge: charge, limit: limit)
let event: PowerEvent = switch state {
case .charging: .charging
case .pausedAtLimit(let value): .atLimit(value)
case .pluggedNotCharging: .plugged
case .onBattery: charge <= Self.lowThreshold ? .low : .unplugged
}
defer { last = event }
guard let previous = last else { return nil }
return event == previous ? nil : event
}
}
@@ -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)
@@ -0,0 +1,101 @@
import AppKit
import Observation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Brightness")
/// Die Helligkeit des eingebauten Bildschirms — gelesen, nicht gesetzt.
///
/// **Nur der eingebaute.** Ein Spike über drei Wege hat ergeben: von
/// `DisplayServices` bekommt man für externe Monitore Fehler 1000, `CoreDisplay`
/// liefert für jeden Bildschirm stur 1,0 und ist damit wertlos, und
/// `IODisplayConnect` gibt es auf Apple Silicon gar nicht mehr. Siehe
/// `Spikes/brightness-probe.swift`.
///
/// **Und nicht abgetastet, sondern gemeldet.** Eine Notch, die zehnmal je
/// Sekunde nach der Helligkeit fragt, hält den Rechner wach — den ganzen Tag,
/// für ein Ereignis alle paar Stunden. `DisplayServices` kann benachrichtigen;
/// gäbe es das nicht, wäre diese Anzeige den Preis nicht wert gewesen.
///
/// **Private API, und deshalb mit Netz.** Fehlt eines der Symbole — bei einem
/// Systemwechsel jederzeit möglich —, bleibt `level` auf `nil` und Onyx zeigt
/// schlicht keine Helligkeit an. Nichts anzuzeigen ist ein hinnehmbarer
/// Ausfall; abzustürzen wäre keiner.
@MainActor
@Observable
public final class DisplayBrightness {
/// Der zuletzt gemeldete Stand, 0 bis 1. `nil` heißt: nicht lesbar.
public private(set) var level: Double?
/// Ob dieser Rechner überhaupt eine lesbare Helligkeit hat.
public var isAvailable: Bool { display != nil }
@ObservationIgnored private let display: CGDirectDisplayID?
@ObservationIgnored private let read: GetBrightness?
private typealias GetBrightness =
@convention(c) (CGDirectDisplayID, UnsafeMutablePointer<Float>) -> Int32
private typealias Callback =
@convention(c) (UnsafeRawPointer?, CGDirectDisplayID, CFString?, Double) -> Void
private typealias Register =
@convention(c) (CGDirectDisplayID, UnsafeRawPointer?, Callback) -> Int32
/// Der Rückruf ist ein reiner C-Zeiger und kann nichts einfangen. Die eine
/// laufende Instanz steht deshalb hier — schwach, damit sie sich beenden
/// lässt.
@ObservationIgnored private nonisolated(unsafe) static weak var current: DisplayBrightness?
private static let frameworkPath =
"/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices"
public init() {
display = NSScreen.screens
.compactMap { $0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")]
as? CGDirectDisplayID }
.first { CGDisplayIsBuiltin($0) != 0 }
guard let handle = dlopen(Self.frameworkPath, RTLD_LAZY) else {
log.notice("DisplayServices nicht ladbar — keine Helligkeitsanzeige")
read = nil
return
}
read = dlsym(handle, "DisplayServicesGetBrightness")
.map { unsafeBitCast($0, to: GetBrightness.self) }
Self.current = self
refresh()
subscribe(handle)
}
/// Liest den aktuellen Stand einmal.
public func refresh() {
guard let display, let read else { level = nil; return }
var value: Float = 0
// Status ungleich null heißt: dieser Bildschirm hat keine Helligkeit,
// die man lesen könnte. Kein Fehler, nur keine Antwort.
guard read(display, &value) == 0 else { level = nil; return }
level = Double(min(max(value, 0), 1))
}
private func subscribe(_ handle: UnsafeMutableRawPointer) {
guard let display,
let symbol = dlsym(handle,
"DisplayServicesRegisterForBrightnessChangeNotifications")
else {
log.notice("Keine Helligkeitsmeldungen — Anzeige bleibt beim Startwert")
return
}
let callback: Callback = { _, _, _, value in
// Der Rückruf kommt aus einem fremden Faden.
Task { @MainActor in
DisplayBrightness.current?.level = min(max(value, 0), 1)
}
}
let status = unsafeBitCast(symbol, to: Register.self)(display, nil, callback)
if status != 0 {
log.notice("Anmeldung für Helligkeitsmeldungen fehlgeschlagen: \(status, privacy: .public)")
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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)
@@ -82,14 +82,14 @@ private struct CompactMetric: View {
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
Label {
Text(String(localized: .init(metric.localizationKey), bundle: .module))
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
} icon: {
Image(systemName: metric.symbolName)
.font(.system(size: 11))
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.accent)
}
@@ -103,7 +103,7 @@ private struct CompactMetric: View {
if let detail = MetricSummary.detail(metric, model.snapshot) {
Text(detail)
.font(.system(size: 9))
.font(Onyx.Font.micro)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
@@ -121,7 +121,7 @@ private struct WideMetric: View {
// Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein
// Verlauf über die volle Breite liest sich besser als einer, der sich
// die Zeile mit der Zahl teilt.
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
CompactMetric(model: model, metric: metric)
Spacer(minLength: 0)
Sparkline(values: model.series(metric),
@@ -200,11 +200,11 @@ private struct SensorList: View {
let detailed: Bool
var body: some View {
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
HStack(spacing: 6) {
HStack(spacing: Onyx.Space.sm) {
Text(sensor.name)
.font(.system(size: 10))
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
Spacer(minLength: 0)
@@ -215,17 +215,17 @@ private struct SensorList: View {
}
if detailed, !snapshot.fans.isEmpty {
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
OnyxHairline().padding(.vertical, Onyx.Space.xxs)
ForEach(snapshot.fans) { fan in
HStack(spacing: 6) {
HStack(spacing: Onyx.Space.sm) {
Image(systemName: "fan")
.font(.system(size: 9))
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.textTertiary)
Text("\(Int(fan.rpm))")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textSecondary)
Text(verbatim: "U/min")
.font(.system(size: 9))
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
@@ -351,8 +351,10 @@ private struct BatteryTile: View {
var body: some View {
let battery = model.snapshot.battery
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 7) {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
// Der Verlauf steht unter den Zahlen, nicht über ihnen: die Frage
// „wie voll ist er jetzt" kommt vor „wie war es gestern".
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.md) {
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
// dasselbe wie bei 95 % und trägt nichts bei.
if let battery {
@@ -362,7 +364,7 @@ private struct BatteryTile: View {
.foregroundStyle(tint(battery))
} else {
Image(systemName: BatterySymbol.unavailable)
.font(.system(size: 13))
.font(.system(size: Onyx.Symbol.medium))
.foregroundStyle(Onyx.Color.textTertiary)
}
Text(MetricFormat.percent(battery?.charge ?? 0))
@@ -386,7 +388,7 @@ private struct BatteryTile: View {
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
if let watts = battery.watts, abs(watts) > 0.1 {
row(watts > 0 ? "battery.charging" : "battery.draining",
value: String(format: "%.1f W", abs(watts)))
@@ -405,19 +407,23 @@ private struct BatteryTile: View {
// Der Energiesparmodus erklärt, warum die Maschine langsamer
// wirkt. Ohne diesen Hinweis sucht man den Grund woanders.
if battery.isLowPower {
HStack(spacing: 4) {
Image(systemName: "leaf.fill").font(.system(size: 9))
HStack(spacing: Onyx.Space.xs) {
Image(systemName: "leaf.fill").font(.system(size: Onyx.Symbol.caption))
Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall)
}
.foregroundStyle(Onyx.Color.warning)
}
// Der Verlauf steht unten: „wie voll ist er jetzt" kommt vor
// „wie war es gestern".
BatteryHistoryView(store: model.batteryHistory)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ key: String.LocalizationValue, value: String) -> some View {
HStack(spacing: 4) {
HStack(spacing: Onyx.Space.xs) {
Text(String(localized: key, bundle: .module))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 4)
@@ -49,6 +49,14 @@ public final class MetricsModel {
}
public private(set) var snapshot = MetricsSnapshot()
/// Der Akkuverlauf.
///
/// Gehört hierher und nicht in den Programmstart: die Messwerte entstehen
/// hier, und ein zweiter Beobachter für dieselben Zahlen wäre ein zweiter
/// Weg zu ihnen. Wie oft wirklich aufgezeichnet wird, entscheidet der
/// Speicher selbst — hier wird nur jede Messung angeboten.
public let batteryHistory = BatteryHistoryStore()
/// Verlauf für die Graphen, jüngster Wert zuletzt.
public private(set) var history: [MetricsSnapshot] = []
@@ -132,6 +140,7 @@ public final class MetricsModel {
private func sample() {
let value = source.sample()
snapshot = value
if let battery = value.battery { batteryHistory.record(battery) }
history.append(value)
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
}
@@ -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)
@@ -0,0 +1,38 @@
{
"sourceLanguage": "en",
"strings": {
"notes.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Notizen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Notes"
}
}
}
},
"notes.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Noch nichts notiert."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing noted yet."
}
}
}
}
},
"version": "1.0"
}
@@ -0,0 +1,71 @@
import Foundation
/// Eine kurze Notiz.
///
/// Kein Titel als eigenes Feld: wer eine Notiz schreibt, will schreiben und
/// nicht erst benennen. Die Überschrift ist die erste Zeile — dieselbe
/// Verabredung, die Apples Notizen seit jeher treffen.
public struct Note: Identifiable, Equatable, Sendable, Codable {
public let id: UUID
public var text: String
public var updatedAt: Date
public init(id: UUID = UUID(), text: String = "", updatedAt: Date = Date()) {
self.id = id
self.text = text
self.updatedAt = updatedAt
}
}
/// Die Regeln der Notizen — ohne Dateisystem und ohne Uhr.
public enum NoteRules {
/// Wie viele Notizen höchstens.
///
/// Die Kachel ist klein und die Liste soll überschaubar bleiben. Wer mehr
/// braucht, braucht ein Notizprogramm und keine Notch.
public static let limit = 50
/// Wie lang eine Überschrift höchstens ist.
public static let titleLength = 60
/// Die erste nichtleere Zeile, gekürzt. Leer bleibt leer — daraus macht die
/// Anzeige „Neue Notiz", und das ist ihre Sache, nicht unsere.
public static func title(of note: Note) -> String {
let line = note.text
.split(whereSeparator: \.isNewline)
.first { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
guard let line else { return "" }
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.count > titleLength else { return trimmed }
return String(trimmed.prefix(titleLength)) + "…"
}
/// Ob eine Notiz überhaupt etwas enthält.
///
/// Eine Notiz aus lauter Leerzeichen ist keine — sie entsteht, wenn man auf
/// „Neu" drückt und es sich anders überlegt, und soll dann von selbst
/// verschwinden statt als leere Zeile stehen zu bleiben.
public static func isEmpty(_ note: Note) -> Bool {
note.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Zuletzt geändert zuerst, mit fester Reihenfolge bei Gleichstand.
public static func sorted(_ notes: [Note]) -> [Note] {
notes.sorted {
$0.updatedAt == $1.updatedAt
? $0.id.uuidString > $1.id.uuidString
: $0.updatedAt > $1.updatedAt
}
}
/// Nimmt eine geänderte Notiz auf: leere fallen weg, über der Grenze fällt
/// die älteste heraus.
public static func merge(_ notes: [Note], updating note: Note,
limit: Int = limit) -> [Note] {
var kept = notes.filter { $0.id != note.id }
if !isEmpty(note) { kept.append(note) }
let sorted = sorted(kept)
return Array(sorted.prefix(limit))
}
}
@@ -0,0 +1,77 @@
import Foundation
import Observation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notes")
/// Die Notizen und ihre Ablage.
///
/// Eine Datei für alles: Notizen sind kurz, und fünfzig davon sind ein paar
/// Kilobyte. Eine Datei je Notiz wäre der Ordnung wegen — nicht der Sache.
///
/// **Nicht in `UserDefaults`.** Was dort steht, liest jedes Programm des
/// Benutzers mit `defaults read` mit, und eine Notiz ist oft genau das, was man
/// sich merkt, weil man es nicht im Kopf behalten will.
@MainActor
@Observable
public final class NotesStore {
public private(set) var notes: [Note] = []
@ObservationIgnored private let fileURL: URL
private struct Archive: Codable {
var version = 1
var notes: [Note]
}
public init(directory: URL? = nil) {
let folder = directory ?? Self.defaultDirectory
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
fileURL = folder.appendingPathComponent("notes.json")
load()
}
public static var defaultDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Notes", isDirectory: true)
}
/// Legt eine leere Notiz an und gibt sie zurück, damit die Anzeige den
/// Schreibcursor hineinsetzen kann.
///
/// Sie steht noch **nicht** in der Liste: eine leere Notiz ist keine, und
/// wer auf „Neu" drückt und es sich anders überlegt, soll keine Leerzeile
/// hinterlassen. Erst der erste Buchstabe macht sie zur Notiz.
public func makeDraft() -> Note { Note() }
public func update(_ note: Note) {
var updated = note
updated.updatedAt = Date()
notes = NoteRules.merge(notes, updating: updated)
save()
}
public func remove(_ note: Note) {
notes.removeAll { $0.id == note.id }
save()
}
private func load() {
guard let data = try? Data(contentsOf: fileURL) else { return }
guard let archive = try? JSONDecoder().decode(Archive.self, from: data),
archive.version == 1 else {
log.notice("Unbekannte Fassung der Notizdatei — sie bleibt unangetastet")
return
}
notes = NoteRules.sorted(archive.notes)
}
private func save() {
do {
try JSONEncoder().encode(Archive(notes: notes)).write(to: fileURL, options: .atomic)
} catch {
log.error("Notizen nicht schreibbar: \(error.localizedDescription, privacy: .public)")
}
}
}
@@ -0,0 +1,132 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Kurze Notizen, direkt in der Kachel zu schreiben.
public struct NotesView: View {
private let store: NotesStore
private let compact: Bool
@State private var editing: Note?
@State private var draft = ""
@FocusState private var isWriting: Bool
public init(store: NotesStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
if let editing {
editor(for: editing)
} else {
list
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var list: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("notes.name", bundle: .module)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
Spacer()
Button { begin(store.makeDraft()) } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.accent)
}
.padding(.bottom, Onyx.Space.sm)
if store.notes.isEmpty {
OnyxNotice(symbol: "note.text",
text: String(localized: "notes.empty", bundle: .module))
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(store.notes.prefix(compact ? 4 : 20).enumerated()),
id: \.element.id) { index, note in
if index > 0 { OnyxHairline() }
Button { begin(note) } label: {
HStack {
Text(NoteRules.title(of: note))
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Spacer(minLength: Onyx.Space.xs)
}
.padding(.vertical, Onyx.Space.sm)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
}
.scrollIndicators(.never)
}
}
}
private func editor(for note: Note) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
HStack {
Button { finish() } label: {
Image(systemName: "chevron.left")
.font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.accent)
Spacer()
Button { store.remove(note); editing = nil } label: {
Image(systemName: "trash")
.font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
// `TextEditor` ohne eigenen Hintergrund: der Standardhintergrund
// ist hell und stünde als weißer Block in der schwarzen Kachel.
TextEditor(text: $draft)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.scrollContentBackground(.hidden)
.focused($isWriting)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear { isWriting = true }
}
private func begin(_ note: Note) {
editing = note
draft = note.text
}
private func finish() {
if var note = editing {
note.text = draft
store.update(note)
}
editing = nil
draft = ""
}
}
public struct NotesWidget: OnyxWidget {
public let id = "notes"
public var displayName: String { String(localized: "notes.name", bundle: .module) }
public let symbolName = "note.text"
private let store: NotesStore
public init(store: NotesStore) { self.store = store }
public func makeView() -> AnyView { AnyView(NotesView(store: store)) }
}
@@ -0,0 +1,68 @@
import SwiftUI
/// Woraus die Fläche gemacht ist.
///
/// **Der Werkstoff, nicht die Form.** Ob Onyx oben als verschmolzene Notch
/// oder als schwebende Insel sitzt, entscheidet `NotchStyle`; woraus diese
/// Fläche besteht, steht hier. Beides zu vermengen hieße, eine Insel aus Glas
/// nicht bauen zu können, ohne die Notch mitzuändern.
///
/// Drei Werkstoffe und keine einzelnen Regler. Deckkraft, Glanz und Kante
/// hängen voneinander ab: viel Glanz auf einer deckenden Fläche sieht nach
/// Fettfilm aus, eine kaum sichtbare Kante auf einer durchscheinenden lässt
/// die Form ausfransen. Was man wählt, ist das Zusammenspiel — nicht drei
/// Zahlen.
public enum NotchMaterial: String, Codable, Sendable, CaseIterable {
/// Deckend schwarz. Die Voreinstellung, und die einzige, die mit der
/// Hardware-Notch verschmilzt: alles andere hätte dort eine sichtbare
/// Kante zum Gehäuse.
case stone
/// Durchscheinend — der Hintergrund ist als Weichzeichnung zu ahnen.
case blur
/// Stark durchscheinend, mit deutlichem Glanz und heller Kante.
case glass
/// Wie deckend der Tint über der Vibrancy-Ebene liegt.
public var fillOpacity: Double {
switch self {
case .stone: 1.0
case .blur: 0.72
case .glass: 0.42
}
}
/// Der Glanz von oben — die Bänderung des Steins.
///
/// Wächst mit der Durchlässigkeit: auf deckendem Schwarz genügt ein Hauch,
/// auf Glas ist er das, was die Fläche überhaupt als Körper lesbar macht.
public var sheen: Double {
switch self {
case .stone: 0.02
case .blur: 0.04
case .glass: 0.10
}
}
/// Die Kante.
///
/// Je durchlässiger die Fläche, desto mehr Arbeit muss die Kante leisten —
/// sie ist dann das Einzige, was die Form von dem trennt, was durch sie
/// hindurch zu sehen ist.
public var edge: Color {
switch self {
case .stone: Onyx.Color.hairline
case .blur: .white.opacity(0.12)
case .glass: .white.opacity(0.20)
}
}
/// Ob die Vibrancy-Ebene überhaupt etwas beiträgt.
///
/// Bei `stone` nicht — dort liegt sie vollständig unter deckendem Schwarz.
/// Das ist keine Verschwendung, sondern der Preis dafür, dass ein Wechsel
/// des Werkstoffs kein Neuaufbau des Fensters ist.
public var showsVibrancy: Bool { self != .stone }
}
@@ -0,0 +1,353 @@
import SwiftUI
/// Die Bauteile, aus denen Onyx besteht.
///
/// **Warum es sie gibt.** Nachgemessen war jedes dieser Stücke zwei- bis
/// fünfmal einzeln nachgebaut — die Karte im Mischpult zweimal wortgleich, der
/// Lautstärkeregler zweimal mit denselben achtzig Zeilen, der Leerzustand
/// fünfmal, der Pillenknopf dreimal. Solange das so ist, ist eine
/// Designänderung keine Änderung an einer Stelle, sondern eine Suche.
///
/// **Alle Beschriftungen kommen fertig übersetzt herein**, als `String`, nicht
/// als `LocalizedStringKey`. Jedes Modul hat seinen eigenen Sprachkatalog, und
/// ein Bauteil in `OnyxDesign` kann `bundle: .module` des Aufrufers nicht
/// auflösen. Der Aufrufer übersetzt, das Bauteil zeichnet.
// MARK: - Karte
/// Eine Karte **innerhalb** einer Kachel.
///
/// Fasst zusammen, was zusammengehört, und trennt es von dem, was daneben
/// steht. Nicht zu verwechseln mit `OnyxTile`: die Kachel ist das Fach im
/// Panel, die Karte ist eine Gruppe darin.
public struct OnyxCard<Content: View>: View {
private let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
content
.background(Onyx.Color.card, in: shape)
.overlay {
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
}
}
}
// MARK: - Abschnittstitel
/// Die Überschrift über einer Gruppe.
///
/// Versal und klein — sie soll die Gruppe benennen, nicht mit ihrem Inhalt um
/// Aufmerksamkeit streiten.
public struct OnyxSectionTitle: View {
private let text: String
public init(_ text: String) { self.text = text }
public var body: some View {
Text(text)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.textCase(.uppercase)
}
}
// MARK: - Trennlinie
/// Die Haarlinie zwischen zwei Zeilen.
///
/// `Divider()` allein zeichnet in der Systemfarbe und ist auf schwarzem Stein
/// entweder zu hell oder gar nicht da.
public struct OnyxHairline: View {
public init() {}
public var body: some View {
Divider().overlay(Onyx.Color.hairline)
}
}
// MARK: - Leerzustand
/// Wenn eine Kachel nichts zu zeigen hat.
///
/// Ein Leerzustand ist kein Fehler und soll auch nicht so aussehen: Symbol
/// gedämpft, Text eine Stufe heller, und wenn es etwas zu tun gibt, genau ein
/// Knopf.
public struct OnyxNotice<Action: View>: View {
/// Mittig in der Kachel oder linksbündig unter dem, was darüber steht.
public enum Placement {
/// Für Kacheln, die sonst leer wären — Wetter, Kalender.
case centered
/// Für Kacheln, die den Hinweis in eine bestehende Spalte einreihen —
/// Lüfter.
case leading
}
private let symbol: String
private let text: String
private let placement: Placement
private let action: Action
public init(symbol: String,
text: String,
placement: Placement = .centered,
@ViewBuilder action: () -> Action) {
self.symbol = symbol
self.text = text
self.placement = placement
self.action = action()
}
public var body: some View {
VStack(alignment: placement == .centered ? .center : .leading, spacing: Onyx.Space.sm) {
Image(systemName: symbol)
.font(Onyx.Font.headline)
.foregroundStyle(Onyx.Color.textTertiary)
Text(text)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(placement == .centered ? .center : .leading)
// Ohne das reißt ein zweizeiliger Hinweis in der schmalen
// Kachel mitten im Wort ab, statt umzubrechen.
.fixedSize(horizontal: false, vertical: true)
action
}
.frame(maxWidth: .infinity,
maxHeight: placement == .centered ? .infinity : nil,
alignment: placement == .centered ? .center : .leading)
}
}
public extension OnyxNotice where Action == EmptyView {
init(symbol: String, text: String, placement: Placement = .centered) {
self.init(symbol: symbol, text: text, placement: placement) { EmptyView() }
}
}
// MARK: - Pillenknopf
/// Der kleine Knopf in einer Kachel.
///
/// Zwei Zustände, und der Unterschied ist Absicht: gefüllt heißt „das gilt
/// gerade", umrandet heißt „das kannst du wählen". Ein blauer Text sähe aus wie
/// ein Verweis und nicht wie ein Zustand.
public struct OnyxPillButton: View {
/// Zwei Dichten, weil zwei Orte gemeint sind: der Knopf unter einem
/// Hinweis hat Platz, der Knopf in einer Lüfterzeile hat ihn nicht.
public enum Size {
case regular
case compact
var font: Font { self == .regular ? Onyx.Font.caption : Onyx.Font.metricSmall }
var horizontal: CGFloat { self == .regular ? Onyx.Space.md : Onyx.Space.sm }
var vertical: CGFloat { self == .regular ? Onyx.Space.xs : Onyx.Space.xxs }
}
private let title: String
private let isFilled: Bool
private let size: Size
private let action: () -> Void
public init(_ title: String,
isFilled: Bool = true,
size: Size = .regular,
action: @escaping () -> Void) {
self.title = title
self.isFilled = isFilled
self.size = size
self.action = action
}
public var body: some View {
Button(action: action) {
Text(title)
.font(size.font)
// Umrandet heißt bedienbar, nicht abgeschaltet — in der
// Lüfterkachel ist gerade die **gefüllte** Pille die
// ausgegraute. `textTertiary` sah hier aus wie „geht nicht";
// das Ausgrauen macht ohnehin `.disabled()`.
.foregroundStyle(isFilled ? Onyx.Color.onAccent : Onyx.Color.textSecondary)
.padding(.horizontal, size.horizontal)
.padding(.vertical, size.vertical)
.background {
Capsule()
.fill(isFilled ? Onyx.Color.accent : Color.clear)
.overlay {
Capsule().strokeBorder(isFilled ? .clear : Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
}
}
.buttonStyle(.plain)
}
}
// MARK: - Regler
/// Der Regler mit mitlaufendem Pegel.
///
/// **Einer, nicht zwei.** Es gab ihn zweimal — für die Programme im Mischpult
/// und für die Systemlautstärke — mit denselben Knopfgrößen, derselben Geste
/// und derselben Pegelrechnung. Der Systemregler war exakt dieser hier mit
/// `maximum: 1`; der 100-%-Strich unterdrückt sich dort von selbst.
///
/// Der Pegel läuft **im** Regler mit, nicht daneben: eine zweite Leiste je
/// Zeile wäre doppelt so viel Grafik für dieselbe Auskunft.
public struct OnyxSlider: View {
private let value: Double
private let maximum: Double
private let level: Double
private let isActive: Bool
private let onChange: (Double) -> Void
@State private var isDragging = false
/// - Parameters:
/// - maximum: Der rechte Anschlag. 1 im Normalfall, mehr bei Overdrive.
/// - level: Der Ausschlag, der im Regler mitläuft. 0 blendet ihn aus.
/// - isActive: Ausgegraut, wenn das Programm stumm ist oder nicht regelbar.
public init(value: Double,
maximum: Double = 1,
level: Double = 0,
isActive: Bool = true,
onChange: @escaping (Double) -> Void) {
self.value = value
self.maximum = maximum
self.level = level
self.isActive = isActive
self.onChange = onChange
}
private static let trackHeight: CGFloat = 6
private static let barHeight: CGFloat = 16
public var body: some View {
GeometryReader { geometry in
let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen.
let travel = max(geometry.size.width - knob, 1)
let fraction = min(max(value / maximum, 0), 1)
let fillWidth = travel * fraction + knob / 2
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.surface)
.frame(height: Self.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: Self.trackHeight + 4)
.offset(x: knob / 2 + travel / maximum)
}
Capsule()
.fill(value > 1 ? Onyx.Color.warning : Onyx.Color.accent)
.frame(width: fillWidth, height: Self.trackHeight)
.opacity(isActive ? 1 : 0.3)
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * Self.meterFraction(level),
height: Self.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: Self.barHeight)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
isDragging = true
let position = min(max((drag.location.x - knob / 2) / travel, 0), 1)
onChange(position * maximum)
}
.onEnded { _ in isDragging = false })
}
.frame(height: Self.barHeight)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Wie weit der Pegel im Regler ausschlägt.
///
/// 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.
public static func meterFraction(_ level: Double) -> Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
// MARK: - Ablegefläche
/// Wohin man Dateien ziehen kann.
///
/// Zwei Fassungen, weil zwei Dinge gemeint sind: eine Fläche, die **nur zum
/// Ablegen da ist**, zeigt ihre gestrichelte Kante immer — sonst weiß niemand,
/// dass sie eine ist. Eine Kachel, die **nebenbei auch** Dateien annimmt, zeigt
/// sie erst, wenn wirklich etwas darüber schwebt.
public enum OnyxDropZoneStyle {
/// Immer sichtbar, beim Anvisieren in der Akzentfarbe.
case always
/// Nur beim Anvisieren, dann mit leichter Füllung.
case whenTargeted
}
private struct OnyxDropZone: ViewModifier {
let isTargeted: Bool
let style: OnyxDropZoneStyle
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
return content
.overlay {
switch style {
case .always:
shape.strokeBorder(
isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
style: StrokeStyle(lineWidth: Onyx.Metric.hairlineWidth, dash: [4, 3]))
.allowsHitTesting(false)
case .whenTargeted:
if isTargeted {
shape.strokeBorder(Onyx.Color.accent,
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08), in: shape)
.allowsHitTesting(false)
}
}
}
.animation(Onyx.Motion.value, value: isTargeted)
}
}
public extension View {
/// Zeichnet die gestrichelte Kante einer Ablegefläche.
///
/// Nimmt selbst keine Dateien an — das macht `onFileDrop`. Getrennt, weil
/// die Kante auch dort gebraucht wird, wo das Ablegen die ganze Kachel
/// betrifft und nicht nur den umrandeten Teil.
func onyxDropZone(isTargeted: Bool, style: OnyxDropZoneStyle) -> some View {
modifier(OnyxDropZone(isTargeted: isTargeted, style: style))
}
}
@@ -0,0 +1,243 @@
import SwiftUI
/// Die Bauteile der **Fenster** — Einstellungen, Onboarding, Popover.
///
/// **Getrennt von den Panel-Bauteilen, und genau das ist der Punkt.** Das Panel
/// ist schwarzer Stein und bleibt es; ein Fenster gehört dem System und folgt
/// dessen Erscheinung. Diese Trennung fehlte bisher: `Onyx.Color.textTertiary`
/// ist Weiß mit 34 % Deckkraft und stand in den Einstellungen — im Hellmodus
/// war das praktisch unsichtbar. Die Bauteile hier benutzen deshalb
/// Systemsemantik (`.secondary`, `.quaternary`, `controlBackgroundColor`), die
/// hell wie dunkel richtig herauskommt. Die Akzentfarbe von Onyx kommt über
/// `.tint` von außen dazu.
// MARK: - Karte
/// Eine Gruppe zusammengehörender Zeilen.
///
/// Zieht die Trennlinien **selbst** zwischen die Zeilen. Von Hand gesetzt
/// fehlen sie irgendwann irgendwo, oder es steht eine zu viel am Ende der
/// Gruppe — das ist keine Frage von Sorgfalt, sondern von Gelegenheit.
public struct OnyxFormCard<Content: View>: View {
private let header: String?
private let headerHint: String?
private let content: Content
/// - Parameters:
/// - header: Die Überschrift der Gruppe. Sie steht **in** der Karte und
/// ohne Trennlinie darunter — sie benennt die Zeilen, sie ist keine
/// davon.
/// - headerHint: Ein Satz, der für die **ganze** Gruppe gilt. Er gehört
/// hierher und nicht an die erste Zeile: dort läse er sich, als
/// erkläre er nur diese eine.
public init(_ header: String? = nil,
hint headerHint: String? = nil,
@ViewBuilder content: () -> Content) {
self.header = header
self.headerHint = headerHint
self.content = content()
}
public var body: some View {
VStack(spacing: 0) {
if header != nil || headerHint != nil {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
if let header {
Text(header).font(.headline)
}
if let headerHint {
Text(headerHint)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.top, OnyxForm.rowPadding)
.padding(.bottom, Onyx.Space.xxs)
}
Group(subviews: content) { rows in
ForEach(rows.indices, id: \.self) { index in
if index > 0 {
// Eingerückt wie in den Systemeinstellungen: eine Linie
// von Kante zu Kante zerschneidet die Karte, statt ihre
// Zeilen zu trennen.
Divider().padding(.leading, OnyxForm.rowPadding)
}
rows[index]
}
}
}
// Ein Material, keine Farbe.
//
// `controlBackgroundColor` ist deckend und macht aus jeder Karte eine
// Platte — das Fenster darunter kann dann so durchlässig sein, wie es
// will, man sieht nur zwischen den Karten etwas davon. Ein Material
// nimmt den Grund auf und bleibt trotzdem lesbar, weil es ihn
// gleichzeitig verwäscht.
.background(.ultraThinMaterial,
in: .rect(cornerRadius: OnyxForm.cardRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: OnyxForm.cardRadius, style: .continuous)
.strokeBorder(.quaternary, lineWidth: 1)
}
}
}
// MARK: - Zeile
/// Eine Einstellung: was sie ist, warum es sie gibt, und ihr Bedienelement.
///
/// Der Hinweis steht **unter** dem Titel, nicht daneben und nicht in einer
/// eigenen Zeile darunter. Daneben drängt er das Bedienelement an den Rand;
/// als eigene Zeile verliert er den Bezug zu dem, was er erklärt.
public struct OnyxFormRow<Trailing: View>: View {
/// Wie ernst die Zeile ist.
///
/// Die Signalfarben stehen fest und bedeuten etwas. Eine Warnung, die
/// aussieht wie eine Einstellung, ist keine.
public enum Tone {
case normal
case warning
case critical
/// `nil` heißt: nimm, was die Umgebung vorgibt — `.tint` beim Symbol,
/// `.primary` beim Titel. Ein fester Ersatzwert stünde beiden im Weg.
var style: AnyShapeStyle? {
switch self {
case .normal: nil
case .warning: AnyShapeStyle(Onyx.Color.warning)
case .critical: AnyShapeStyle(Onyx.Color.critical)
}
}
}
private let title: String
private let hint: String?
private let symbol: String?
private let tone: Tone
private let trailing: Trailing
public init(_ title: String,
hint: String? = nil,
symbol: String? = nil,
tone: Tone = .normal,
@ViewBuilder trailing: () -> Trailing) {
self.title = title
self.hint = hint
self.symbol = symbol
self.tone = tone
self.trailing = trailing()
}
public var body: some View {
HStack(alignment: .center, spacing: OnyxForm.rowPadding) {
if let symbol {
Image(systemName: symbol)
.frame(width: 18)
.foregroundStyle(tone.style ?? AnyShapeStyle(.tint))
}
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(title)
.foregroundStyle(tone.style ?? AnyShapeStyle(.primary))
if let hint {
Text(hint)
.font(.callout)
.foregroundStyle(.secondary)
// Ohne das bricht ein längerer Hinweis nicht um,
// sondern schiebt die ganze Zeile über die Kante.
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
trailing
}
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.vertical, Onyx.Space.row)
}
}
public extension OnyxFormRow where Trailing == EmptyView {
init(_ title: String, hint: String? = nil, symbol: String? = nil, tone: Tone = .normal) {
self.init(title, hint: hint, symbol: symbol, tone: tone) { EmptyView() }
}
}
// MARK: - Seitenleiste
/// Ein Eintrag in der Seitenleiste: farbiges Plättchen, dann der Name.
///
/// Das Plättchen ist keine Verzierung. Eine Seitenleiste aus zwölf Zeilen
/// gleicher Schrift liest man jedes Mal von vorn; mit Farbe und Form findet
/// man den gesuchten Eintrag, ohne ihn zu lesen.
public struct OnyxSidebarLabel: View {
private let title: String
private let symbol: String
private let tint: Color
public init(_ title: String, symbol: String, tint: Color) {
self.title = title
self.symbol = symbol
self.tint = tint
}
public var body: some View {
HStack(spacing: Onyx.Space.md) {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(tint)
.frame(width: 20, height: 20)
.overlay {
Image(systemName: symbol)
.font(.system(size: Onyx.Symbol.small, weight: .semibold))
// Weiß, nicht `.primary`: das Plättchen ist in beiden
// Erscheinungen farbig und damit immer dunkel genug.
.foregroundStyle(.white)
}
Text(title)
}
}
}
/// Die Überschrift eines Abschnitts in einem Popover.
///
/// Sie stand zweimal im Baum, zeichengleich — einmal als Struktur in
/// `MetricPopovers`, einmal als Funktion in `NetworkPopover`. Dass beide gleich
/// aussahen, war Zufall und keine Zusage.
///
/// In der Akzentfarbe und nicht gedämpft: ein Popover ist klein, und die
/// Abschnitte darin sind das einzige Gerüst, an dem man sich festhält.
public struct OnyxPopoverTitle: View {
private let text: String
public init(_ text: String) { self.text = text }
public var body: some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
}
// MARK: - Maße
/// Die wenigen Maße, die Karte, Zeile und Trennlinie miteinander teilen.
///
/// Nicht in `Onyx.Space`: das ist die Staffel für die dichte Kachelfläche.
/// Ein Fenster hat andere Verhältnisse, und beides in eine Staffel zu zwingen
/// hieße, eines von beidem falsch zu machen.
public enum OnyxForm {
public static let cardRadius: CGFloat = 10
public static let rowPadding: CGFloat = 14
/// Abstand zwischen zwei Karten.
public static let cardSpacing: CGFloat = 18
/// Rand um den Inhalt eines Bereichs.
public static let pagePadding: CGFloat = 20
}
@@ -0,0 +1,66 @@
import SwiftUI
/// Wie sich Onyx bewegt.
///
/// Drei Profile statt einzelner Regler. Wer Federkonstanten einzeln einstellen
/// darf, stellt sie kaputt: Ausfahren und Einfahren müssen zueinander passen,
/// und diese Beziehung ist es, die man wählt — nicht vier Zahlen.
///
/// **Einfahren ist in jedem Profil kürzer als Ausfahren und schwingt nicht
/// nach.** Beim Öffnen sieht man zu, beim Schließen ist man schon woanders;
/// dieselbe Kurve rückwärts fühlt sich dort zäh an, und das Nachschwingen liest
/// sich als Ruckler.
public enum MotionProfile: String, CaseIterable, Sendable {
/// Die Voreinstellung: schnell, mit einem Hauch Nachschwingen. Das gibt dem
/// Panel Masse, statt es nur größer werden zu lassen.
case snappy
/// Deutlich mehr Nachschwingen. Verspielter, und bei häufigem Öffnen
/// anstrengender.
case bouncy
/// Länger und ohne jedes Überschwingen. Für Bildschirme, auf denen viel
/// gleichzeitig passiert.
case calm
/// Ausfahren.
public var expand: Animation {
switch self {
case .snappy: .spring(response: 0.34, dampingFraction: 0.86)
case .bouncy: .spring(response: 0.42, dampingFraction: 0.62)
case .calm: .spring(response: 0.52, dampingFraction: 1.0)
}
}
/// Einfahren.
public var collapse: Animation {
switch self {
case .snappy: .spring(response: 0.24, dampingFraction: 1.0)
case .bouncy: .spring(response: 0.28, dampingFraction: 0.85)
case .calm: .spring(response: 0.36, dampingFraction: 1.0)
}
}
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
public var value: Animation {
switch self {
case .snappy: .easeOut(duration: 0.25)
case .bouncy: .easeOut(duration: 0.28)
case .calm: .easeOut(duration: 0.40)
}
}
/// Wie lange das Einfahren dauert.
///
/// Der Fenstercontroller braucht die Zahl, nicht die Kurve: er blendet das
/// Fenster erst danach aus. Wird sie zu klein angesetzt, reißt das Panel
/// mitten in der Bewegung weg.
public var collapseDuration: Double {
switch self {
case .snappy: 0.30
case .bouncy: 0.36
case .calm: 0.46
}
}
}
@@ -20,34 +20,72 @@ public struct OnyxVisualEffect: NSViewRepresentable {
public func updateNSView(_ view: NSVisualEffectView, context: Context) {}
}
/// Der Hintergrund des Panels: Vibrancy, Tint, ein sehr flacher Glanz von oben
/// und eine Haarlinie als Innenkante.
public struct OnyxSurface: View {
private let cornerRadius: CGFloat
/// Der Stein selbst — auf einer beliebigen Form.
///
/// Tint, Bänderung, Kante. Drei Lagen, die zusammen den Werkstoff ergeben, und
/// zwar unabhängig davon, welche Form sie annehmen.
///
/// **Warum generisch und nicht einfach ein abgerundetes Rechteck.** Das Panel
/// zeichnet auf ``NotchShape``, einer Form, die sich beim Aus- und Einfahren
/// verändert. Solange das Rezept an ein Rechteck gebunden war, konnte es dort
/// nicht mitbenutzt werden — also stand es zweimal da, einmal hier und einmal
/// im Panel. Zweimal heißt: eine Änderung an der Bänderung wirkt an einer
/// Stelle und an der anderen nicht, und das fällt erst auf, wenn beide
/// nebeneinander zu sehen sind.
public struct OnyxStone<S: InsettableShape>: View {
private let shape: S
public init(cornerRadius: CGFloat = Onyx.Metric.panelCornerRadius) {
self.cornerRadius = cornerRadius
public init(_ shape: S) {
self.shape = shape
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
// Aus dem Thema gelesen und nicht als Argument gereicht: sonst müsste
// jede Stelle, die eine Fläche zeichnet, den Werkstoff durchschleifen —
// bis hinunter zur Kachel, die ihn gar nicht kennt.
let material = OnyxTheme.shared.material
shape
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
.background(OnyxVisualEffect().clipShape(shape))
.fill(Onyx.Color.surface.opacity(material.fillOpacity))
.overlay {
// Die Bänderung des Steins: oben ein Hauch heller, nach 40 %
// vollständig verlaufen. Mehr wirkt sofort billig.
// vollständig verlaufen. Mehr wirkt sofort billig — wie viel
// „ein Hauch" ist, hängt am Werkstoff.
shape.fill(
LinearGradient(
stops: [
.init(color: .white.opacity(0.02), location: 0),
.init(color: .white.opacity(material.sheen), location: 0),
.init(color: .clear, location: 0.4),
],
startPoint: .top, endPoint: .bottom))
}
.overlay {
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
// `strokeBorder` und nicht `stroke`: die Linie liegt dann
// vollständig **innen**. Mittig gezeichnet ragte sie zur Hälfte
// über die Kante hinaus und wurde an den Rundungen sichtbar
// dünner.
shape.strokeBorder(material.edge, lineWidth: Onyx.Metric.hairlineWidth)
}
}
}
/// Der Hintergrund des Panels: Vibrancy und darauf der Stein.
public struct OnyxSurface: View {
private let cornerRadius: CGFloat
public init(cornerRadius: CGFloat = Onyx.Radius.panel) {
self.cornerRadius = cornerRadius
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
OnyxStone(shape)
.background {
// Bei deckendem Stein trägt die Weichzeichnung nichts bei und
// wird auch nicht berechnet.
if OnyxTheme.shared.material.showsVibrancy {
OnyxVisualEffect().clipShape(shape)
}
}
}
}
@@ -61,10 +99,10 @@ public struct OnyxTile<Content: View>: View {
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: Onyx.Metric.tileCornerRadius, style: .continuous)
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.tile, style: .continuous)
content
.padding(12)
.padding(Onyx.Space.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(shape.fill(Onyx.Color.elevated))
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth))
@@ -1,7 +1,8 @@
import SwiftUI
import AppKit
/// Die einstellbaren Farben.
/// Was am Aussehen von Onyx einstellbar ist: die Farben und das
/// Bewegungsprofil.
///
/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt
/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
@@ -20,8 +21,17 @@ public final class OnyxTheme {
/// Kühl und entsättigt — Farbe ist in Onyx ein Signal, keine Dekoration.
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
/// aber eine verbreitete. Wer sie anders kennt, dreht sie um.
public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592)
/// aber eine verbreitete.
///
/// **Wärmer als das Signalgrün, und das ist der Punkt.** Vorher war dieser
/// Wert Zeichen für Zeichen `Onyx.Color.positive` — derselbe grüne Balken
/// hieß im Netzwerk „lädt herunter" und in den Messwerten „alles in
/// Ordnung". Zwei Bedeutungen in einer Farbe sind eine zu viel.
///
/// Wärmer und nicht kühler, weil das Gegenstück rosa bis cyan sein darf:
/// ein Grün, das ins Türkise zieht, wäre vom Hochladen kaum noch zu
/// unterscheiden. Nach Gelb hin ist von beidem Abstand.
public static let defaultDownload = Color(red: 0.561, green: 0.835, blue: 0.408)
public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
public var accent: Color {
@@ -34,12 +44,31 @@ public final class OnyxTheme {
didSet { store(upload, as: Keys.upload) }
}
/// Wie sich das Panel bewegt.
///
/// Getrennt von den Farben gespeichert und **nicht** von `reset()` erfasst:
/// der Knopf dort heißt „Farben zurücksetzen", und ein Knopf, der nebenbei
/// die Animationen umstellt, ist eine Falle.
public var motion: MotionProfile = .snappy {
didSet { defaults.set(motion.rawValue, forKey: Keys.motion) }
}
/// Woraus die Panelfläche gemacht ist.
///
/// Wie `motion` von den Farben getrennt und **nicht** von `reset()`
/// erfasst: „Farben zurücksetzen" soll Farben zurücksetzen.
public var material: NotchMaterial = .stone {
didSet { defaults.set(material.rawValue, forKey: Keys.material) }
}
private let defaults: UserDefaults
private enum Keys {
static let accent = "onyx.theme.accent"
static let download = "onyx.theme.download"
static let upload = "onyx.theme.upload"
static let motion = "onyx.theme.motion"
static let material = "onyx.theme.material"
}
public init(defaults: UserDefaults = .standard) {
@@ -47,10 +76,17 @@ public final class OnyxTheme {
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
// Unbekannte Profilnamen fallen auf die Voreinstellung zurück, statt die
// Bewegung ganz ausfallen zu lassen — eine App ohne Animation sieht aus,
// als hinge sie.
motion = MotionProfile(rawValue: defaults.string(forKey: Keys.motion) ?? "") ?? .snappy
material = NotchMaterial(rawValue: defaults.string(forKey: Keys.material) ?? "") ?? .stone
}
/// Ob alles auf den Werkseinstellungen steht — für den Zurücksetzen-Knopf,
/// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt.
/// Ob **die Farben** auf den Werkseinstellungen stehen — für den
/// Zurücksetzen-Knopf, der sonst auch dann anböte, etwas zu tun, wenn es
/// nichts zu tun gibt. Das Bewegungsprofil zählt bewusst nicht mit; siehe
/// `motion`.
public var isDefault: Bool {
accent.onyxHex == Self.defaultAccent.onyxHex
&& download.onyxHex == Self.defaultDownload.onyxHex
@@ -1,6 +1,6 @@
import SwiftUI
/// Die einzige Quelle für Farben, Maße und Bewegung in Onyx.
/// Die einzige Quelle für Farben, Maße, Schrift und Bewegung in Onyx.
///
/// Onyx ist ein tiefschwarzer Stein mit feiner Bänderung und mattem Glanz — kein
/// Glas. Das Panel benutzt deshalb bewusst **nicht** die Liquid-Glass-APIs von
@@ -10,8 +10,13 @@ public enum Onyx {
// MARK: - Farben
public enum Color {
/// Grundfläche des Panels.
public static let surface = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
/// Grundfläche des Panels — **wirklich schwarz**.
///
/// Nicht sehr dunkles Grau: die Fläche grenzt oben unmittelbar an die
/// Notch, und die ist schwarzes Gehäuse. Jeder Grauwert daneben ist
/// eine sichtbare Kante genau dort, wo beides als ein Körper wirken
/// soll — vorher #0B0B0D bei 74 % Deckkraft, also grau auf grau.
public static let surface = SwiftUI.Color.black
/// Kacheln auf der Grundfläche.
public static let elevated = SwiftUI.Color(red: 0.078, green: 0.078, blue: 0.090)
/// Innenkante — der halbe „edle" Effekt steckt in dieser einen Linie.
@@ -41,16 +46,107 @@ public enum Onyx {
public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290)
public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361)
/// Deckkraft des Tints über der Vibrancy-Ebene. Darunter liest man den
/// Hintergrund durch, darüber wirkt das Panel wie eine schwarze Platte.
public static let surfaceOpacity: Double = 0.74
/// Eine Karte **innerhalb** einer Kachel — etwas heller als die Kachel,
/// aber nicht so hell, dass sie selbst wie eine Kachel wirkt.
public static let card = SwiftUI.Color.white.opacity(0.035)
/// Die Schiene hinter einem Balken oder Regler.
///
/// **Nicht `hairline`.** Das sind 8 % Weiß und ein Wert für Kanten; auf
/// schwarzem Grund ist er praktisch nicht da. Als Schiene liest sich
/// das, als liefe der gefüllte Teil ins Nichts aus — man sieht dann nur
/// noch eine Länge, ohne zu wissen, wovon.
public static let track = SwiftUI.Color.white.opacity(0.22)
/// Text und Symbole **auf** der Akzentfarbe.
///
/// Bisher stand dafür an drei Stellen `Onyx.Color.surface` — was
/// zufällig richtig aussah und beim ersten hellen Akzent falsch würde.
public static let onAccent = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
}
// MARK: - Abstände
/// Die Staffel für Abstände innerhalb einer Kachel oder eines Fensters.
///
/// **Zwei Punkte Schrittweite, nicht vier.** Nachgemessen standen im Baum
/// sechzehn verschiedene Abstände; die häufigsten waren 6, 4, 8 und 12. Ein
/// Vierer-Raster hätte davon drei verschoben — bei 180 × 200 Punkt großen
/// Kacheln ist das keine Feinheit, sondern eine sichtbare Umordnung ohne
/// Gegenwert. Die Staffel bildet also ab, was der dichten Fläche entspricht,
/// und schneidet stattdessen die Ausreißer weg: aus 3 und 5 wird 4, aus 7
/// und 9 wird 8, aus 10 wird 12, aus 14 wird 16.
public enum Space {
/// Zwischen Zeichen und dem, was direkt daneben klebt.
public static let xxs: CGFloat = 2
public static let xs: CGFloat = 4
public static let sm: CGFloat = 6
public static let md: CGFloat = 8
/// Das Innenpolster einer Zeile — zwischen `md` und `lg`.
///
/// Keine willkürliche Zwischenstufe: eine Zeile mit 8 Punkten wirkt
/// gedrängt, mit 12 fällt sie auseinander. Der Wert kam an zehn Stellen
/// unabhängig voneinander heraus, bevor er hier stand.
public static let row: CGFloat = 10
public static let lg: CGFloat = 12
public static let xl: CGFloat = 16
/// Der Rand eines Fensterinhalts — zwischen `xl` und `xxl`.
///
/// Fenster brauchen mehr Luft als Kacheln: eine Kachel sitzt in einem
/// Panel und teilt sich dessen Rand, ein Fenster hat nur den eigenen.
public static let window: CGFloat = 20
public static let xxl: CGFloat = 24
}
// MARK: - Rundungen
/// Rundungen sind nach dem benannt, was sie umschließen, nicht nach ihrer
/// Größe.
///
/// Eine Größenstaffel („sm", „md") lädt dazu ein, die nächstbeste zu nehmen.
/// Ein Name sagt dagegen, wo der Wert hingehört — und macht sofort sichtbar,
/// wenn eine Karte plötzlich so rund ist wie eine Kachel.
public enum Radius {
/// Vorschaubilder, Farbmarken, kleine Plättchen.
/// Ein Balken — Fortschritt, Pegel, Verlauf.
///
/// So klein, dass es nur darum geht, die Ecke nicht scharf stehen zu
/// lassen. Vorher standen hier 1, 2 und 3 nebeneinander, was an einem
/// 4 Punkte hohen Balken niemand unterscheiden kann.
public static let bar: CGFloat = 2
/// Ein Vorschaubild.
public static let thumbnail: CGFloat = 6
public static let chip: CGFloat = 4
/// Knöpfe, Felder, Regler.
public static let control: CGFloat = 8
/// Eine Karte **innerhalb** einer Kachel.
public static let card: CGFloat = 12
/// Die Kachel selbst.
public static let tile: CGFloat = 16
/// Das ausgefahrene Panel.
public static let panel: CGFloat = 26
/// Die Unterkante der zusammengezogenen Notch.
///
/// Kleiner als `panel`: ein fester Radius lässt die kleine Form
/// aufgeblasen aussehen.
public static let notch: CGFloat = 10
}
// MARK: - Maße
/// Was das Panel als Ganzes ausmisst — keine Rhythmusfrage, sondern
/// Geometrie.
///
/// `panelPadding` und `tileSpacing` gehen in die Größenrechnung von
/// `LayoutEngine` ein und stehen so in dessen Tests. Sie gehören deshalb
/// nicht in `Space`: wer dort die Staffel verschiebt, soll nicht aus
/// Versehen die Panelbreite ändern.
public enum Metric {
public static let panelCornerRadius: CGFloat = 26
public static let tileCornerRadius: CGFloat = 16
public static let panelPadding: CGFloat = 16
public static let tileSpacing: CGFloat = 10
public static let hairlineWidth: CGFloat = 1
@@ -58,31 +154,89 @@ public enum Onyx {
// MARK: - Bewegung
/// Die Kurven — eingestellt über das Bewegungsprofil in `OnyxTheme`.
///
/// Gelesen statt gespeichert, aus demselben Grund wie bei der Akzentfarbe:
/// so kommt ein Profilwechsel an allen Fundstellen an, ohne dass eine
/// einzige davon davon weiß.
public enum Motion {
/// Ausfahren: ein Spring mit einem Hauch Nachschwingen. Das gibt dem
/// Panel Masse, statt es nur größer werden zu lassen.
public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86)
/// Einfahren: kürzer und ohne Nachschwingen.
///
/// Schließen muss schneller sein als Öffnen. Beim Öffnen sieht man zu,
/// beim Schließen ist man schon woanders — dieselbe Kurve rückwärts
/// fühlt sich dort zäh an, und das Nachschwingen liest sich als Ruckler.
public static let collapse = Animation.spring(response: 0.24, dampingFraction: 1.0)
/// Ausfahren.
@MainActor public static var expand: Animation { OnyxTheme.shared.motion.expand }
/// Einfahren.
@MainActor public static var collapse: Animation { OnyxTheme.shared.motion.collapse }
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
public static let value = Animation.easeOut(duration: 0.25)
@MainActor public static var value: Animation { OnyxTheme.shared.motion.value }
}
// MARK: - Typografie
/// Die Schriftstaffel.
///
/// **Warum sie größer geworden ist.** Vorher gab es fünf Stufen, und zwei
/// davon benutzte niemand. Daneben standen rund hundert Aufrufe von
/// `.font(.system(size:))` — allein Größe 9 dreizehnmal. Das war kein
/// Ungehorsam: die Staffel hatte am unteren Ende schlicht nichts anzubieten,
/// und eine Kachel von 180 × 200 Punkt lebt dort unten.
/// Wie groß ein Symbol gezeichnet wird.
///
/// **Eine eigene Staffel, nicht die Schriftstaffel.** Symbole und Text
/// teilen sich zwar `.font(.system(size:))`, aber nicht die Frage: bei Text
/// entscheidet die Größe über Lesbarkeit und Rang, bei einem Symbol über
/// sein Gewicht im Bild. Ein 9-Punkt-Symbol neben 9-Punkt-Text wirkt
/// kleiner als der Text, nicht gleich groß. Sie in eine Staffel zu zwingen
/// hieße, an jeder Stelle gegen sie zu arbeiten.
///
/// Vorher standen hier zwölf verschiedene Werte von 7 bis 34 — 7, 8, 10, 12,
/// 15, 16, 18 waren keine Entscheidungen, sondern das, was gerade passte.
///
/// Nur die Größe, nicht das Gewicht: `.light` an einem großen Symbol und
/// `.bold` an einem winzigen sind Entscheidungen am Ort, keine Stufen.
public enum Symbol {
/// Marke neben kleinem Text — das Blatt am Energiesparmodus.
public static let caption: CGFloat = 9
/// Das Symbol einer Zeile.
public static let small: CGFloat = 11
/// Das Symbol eines Knopfes.
public static let medium: CGFloat = 13
/// Das Symbol eines leeren Zustands.
public static let large: CGFloat = 16
/// Das Symbol, das eine Kachel trägt.
public static let xlarge: CGFloat = 20
/// Das eine große Symbol — Wetterlage.
public static let hero: CGFloat = 34
}
public enum Font {
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
// Text
/// Der Arbeitswert in der Kachel: Beschriftungen an Werten, Wochentage,
/// Einheiten.
public static let micro = SwiftUI.Font.system(size: 9, weight: .medium)
public static let caption = SwiftUI.Font.system(size: 10, weight: .medium)
/// Für alle Messwerte — ohne feste Ziffernbreite zappeln die Zahlen.
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
public static let footnote = SwiftUI.Font.system(size: 11, weight: .regular)
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
/// Kachelüberschriften und Abschnittstitel.
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
public static let headline = SwiftUI.Font.system(size: 15, weight: .medium)
public static let display = SwiftUI.Font.system(size: 20, weight: .medium)
public static let displayLarge = SwiftUI.Font.system(size: 30, weight: .medium)
// Zahlen — ohne feste Ziffernbreite zappeln sie beim Zählen
public static let metricMicro = SwiftUI.Font.system(size: 9, weight: .medium)
.monospacedDigit()
public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium)
.monospacedDigit()
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
.monospacedDigit()
public static let metricLarge = SwiftUI.Font.system(size: 30, weight: .medium)
.monospacedDigit()
/// Das eine große Symbol einer Kachel — Wetterlage, Ladezustand.
///
/// Die einzige Größe, die kein Textgegenstück hat: hier steht ein Bild,
/// keine Zeile.
public static let symbolHero = SwiftUI.Font.system(size: 34, weight: .regular)
}
}
@@ -66,3 +66,81 @@ public enum ChargeSafety {
return percent
}
}
/// Segelmodus und Hitzeschutz — die beiden Regeln, die *über* dem Limit liegen.
///
/// **Warum im Helfer und nicht in der App.** Beide greifen in das Laden ein,
/// und ein falscher Eingriff dort ist die eine Operation in diesem Projekt, die
/// den Akku dauerhaft beschädigen kann. Was der Helfer tut, ist von außen nicht
/// abschaltbar — dieselbe Begründung wie bei `FanSafety`.
public enum ChargePolicy {
/// Wie weit der Ladestand unter das Limit fallen muss, bevor wieder geladen
/// wird.
///
/// **Der eigentliche Zweck des Segelns.** Ohne Abstand hängt der Akku
/// dauerhaft am Limit und wird bei jedem verlorenen Prozent nachgeladen —
/// hunderte winzige Ladevorgänge am Tag, und genau die altern ihn. Mit
/// Abstand lädt er einmal und segelt dann von selbst hinunter.
public static let sailingHysteresis = 5
/// Ab dieser Zellentemperatur wird nicht geladen.
///
/// Hitze schadet einem Lithium-Akku mehr als jeder Ladestand. Der Wert ist
/// bewusst konservativ: macOS selbst drosselt später, aber macOS kennt auch
/// die Ladehistorie des Geräts, und wir kennen sie nicht.
public static let maximumChargingCelsius = 40.0
/// Unter dieser Temperatur wird wieder geladen.
///
/// Auch hier ein Abstand, aus demselben Grund wie beim Segeln: genau an der
/// Schwelle schaltete es sonst im Sekundentakt an und aus.
public static let resumeChargingCelsius = 36.0
/// Ob jetzt geladen werden soll.
///
/// - Parameters:
/// - charge: Ladestand, 0 bis 1.
/// - limit: Das eingestellte Limit in Prozent.
/// - celsius: Zellentemperatur, falls bekannt. Unbekannt heißt: kein
/// Hitzeschutz — eine Temperatur zu erfinden wäre schlimmer als keine.
/// - wasCharging: Ob zuletzt geladen wurde. Daran hängt der Abstand:
/// beide Regeln haben zwei Schwellen, eine zum Anhalten und eine zum
/// Weitermachen.
public static func shouldCharge(charge: Double, limit: Int, celsius: Double?,
wasCharging: Bool) -> Bool {
if let celsius {
if wasCharging, celsius >= maximumChargingCelsius { return false }
if !wasCharging, celsius >= resumeChargingCelsius { return false }
}
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
// Ein Limit von hundert ist keines — dann gilt nur der Hitzeschutz.
guard limit < ChargeSafety.noLimit else { return true }
if wasCharging { return percent < limit }
return percent <= limit - sailingHysteresis
}
/// Warum gerade nicht geladen wird — für die Anzeige.
public enum Reason: Equatable, Sendable {
case charging
case atLimit(Int)
case sailing(Int)
case tooHot(Double)
}
public static func reason(charge: Double, limit: Int, celsius: Double?,
wasCharging: Bool) -> Reason {
if let celsius {
let threshold = wasCharging ? maximumChargingCelsius : resumeChargingCelsius
if celsius >= threshold { return .tooHot(celsius) }
}
guard shouldCharge(charge: charge, limit: limit, celsius: celsius,
wasCharging: wasCharging) else {
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
return percent >= limit ? .atLimit(limit) : .sailing(limit)
}
return .charging
}
}
@@ -12,6 +12,14 @@ public extension NSScreen {
CGDisplayIsBuiltin(displayID) != 0
}
/// Der Bildschirm mit der Menüleiste.
///
/// Das ist der **erste** in `NSScreen.screens`, nicht `NSScreen.main` —
/// letzteres ist der mit dem aktiven Fenster und wandert beim Klicken.
var isMainDisplay: Bool {
NSScreen.screens.first?.displayID == displayID
}
/// Reduziert den Bildschirm auf genau die Werte, die die Geometrie braucht.
/// Alles Weitere bleibt draußen, damit `NotchGeometry` ohne Fenstersystem
/// prüfbar bleibt.
@@ -21,9 +29,21 @@ public extension NSScreen {
auxiliaryTopLeft: auxiliaryTopLeftArea,
auxiliaryTopRight: auxiliaryTopRightArea,
isBuiltIn: isBuiltInDisplay,
menuBarHeight: onyxMenuBarHeight,
isMain: isMainDisplay,
displayID: displayID)
}
/// Die Höhe der Menüleiste auf diesem Bildschirm.
///
/// `visibleFrame` lässt sie oben aus — die Differenz *ist* die Leiste.
/// Bei einem Vollbildfenster ist die Differenz null; dann taugt sie nicht
/// als Maß, und die Systemdicke tritt an ihre Stelle.
var onyxMenuBarHeight: CGFloat {
let measured = frame.maxY - visibleFrame.maxY
return measured > 1 ? measured : NSStatusBar.system.thickness
}
/// Verdeckt gerade ein Vollbildfenster die Menüleiste?
///
/// Es gibt keine öffentliche API, die „hier läuft ein Vollbildvideo" meldet.
@@ -0,0 +1,59 @@
import CoreGraphics
/// Wie viel Platz links und rechts der Notch bleibt — auf Menüleistenhöhe.
///
/// Der oberste Streifen des Panels ist so hoch wie die Notch und so breit wie
/// das Panel. In der Mitte sitzt die Hardware; links und rechts davon lag
/// bisher durchsichtiges, ungenutztes Fenster. Bei fünf Kacheln sind das je
/// 376 Punkte.
public struct NotchBand: Equatable, Sendable {
/// Breite links der Notch.
public let leading: CGFloat
/// Breite rechts der Notch.
public let trailing: CGFloat
public init(leading: CGFloat, trailing: CGFloat) {
self.leading = leading
self.trailing = trailing
}
public static let none = NotchBand(leading: 0, trailing: 0)
public var isEmpty: Bool { leading <= 0 && trailing <= 0 }
/// Die Gesamtbreite des gezeichneten Streifens, Notch eingeschlossen.
public func totalWidth(notchWidth: CGFloat) -> CGFloat {
leading + notchWidth + trailing
}
}
/// Rechnet aus, wie breit die Arme neben der Notch sein dürfen.
///
/// Reine Rechnung ohne AppKit, damit sie ohne Bildschirm prüfbar ist — wie
/// `NotchGeometry` und `TriggerArea`.
public enum NotchBandLayout {
/// Ab wann ein Arm überhaupt etwas tragen kann.
///
/// Ein Symbol, ein zweistelliger Wert und Luft an beiden Seiten. Schmaler
/// wird der Arm zu einem Streifen, in dem nichts steht — und ein leerer
/// Streifen neben der Notch sieht nicht nach Zurückhaltung aus, sondern
/// nach einem Zeichenfehler.
public static let minimumUsableWidth: CGFloat = 56
/// - Parameters:
/// - panelWidth: Die Breite des ausgefahrenen Panels.
/// - notchWidth: Die Breite der Notch bzw. ihres virtuellen Ersatzes.
///
/// **Symmetrisch oder gar nicht.** Reicht es nur auf einer Seite, bleiben
/// beide leer: ein Arm links ohne Gegenstück rechts liest sich als
/// verrutschtes Panel, nicht als Absicht.
public static func resolve(panelWidth: CGFloat,
notchWidth: CGFloat,
minimumUsableWidth: CGFloat = minimumUsableWidth) -> NotchBand {
let side = (panelWidth - notchWidth) / 2
guard side >= minimumUsableWidth else { return .none }
return NotchBand(leading: side, trailing: side)
}
}
@@ -36,6 +36,61 @@ public final class NotchCoordinator {
didSet { guard virtualNotchSize != oldValue else { return }; rebuild() }
}
/// Das Maß einer Insel, wenn nichts anderes eingestellt ist.
///
/// Die **Höhe** ist hier nur ein Rückfall: auf einem Bildschirm mit
/// Menüleiste gibt deren Höhe das Maß vor, damit die Insel darin liegt
/// statt darunter herauszuhängen.
///
/// Die Breite bestimmt, wie rund die Form *wirkt* — der Radius ist mit der
/// halben Höhe ohnehin am Maximum. Bei 150 zu 26 war es ein Balken mit
/// runden Enden; 72 zu rund 18 sind etwa 4:1, nah an der Insel am iPhone.
/// Inhalt braucht die leere Insel keinen: für jede Live Activity wächst sie
/// ohnehin auf deren Maß.
public static let defaultVirtualSize = CGSize(width: 72, height: 26)
/// Was eine Inselbreite mindestens und höchstens sein darf.
///
/// Nach unten, damit die Insel nicht zum Punkt wird, den niemand mehr
/// trifft; nach oben, weil sie sonst die halbe Menüleiste verdeckt.
public static let widthRange: ClosedRange<CGFloat> = 48...320
/// Abweichende Inselbreiten, je Bildschirm.
///
/// **Nur die Breite.** 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 daneben wäre ein Bedienelement, das nichts tut.
///
/// Je Bildschirm, weil ein 27-Zoll-Monitor und ein Laptopdeckel dieselbe
/// Breite verschieden groß aussehen lassen.
public var virtualWidthOverrides: [CGDirectDisplayID: CGFloat] = [:] {
didSet { guard virtualWidthOverrides != oldValue else { return }; rebuild() }
}
/// Die Inselgröße, die für diesen Bildschirm gilt.
func virtualSize(for displayID: CGDirectDisplayID) -> CGSize {
guard let width = virtualWidthOverrides[displayID] else { return virtualNotchSize }
return CGSize(width: width, height: virtualNotchSize.height)
}
/// Notch oder Insel. Siehe ``NotchStyle``.
public var style: NotchStyle = .automatic {
didSet {
guard style != oldValue else { return }
controllers.values.forEach { $0.style = style }
}
}
/// Ob die eingeklappte Insel auf jedem Bildschirm stehen bleibt.
/// Siehe ``NotchScreenController/showsIdleIsland``.
public var showsIdleIsland = true {
didSet {
guard showsIdleIsland != oldValue else { return }
controllers.values.forEach { $0.showsIdleIsland = showsIdleIsland }
}
}
private var controllers: [CGDirectDisplayID: NotchScreenController] = [:]
private var monitors: [Any] = []
private var suppressionTimer: Timer?
@@ -50,7 +105,7 @@ public final class NotchCoordinator {
public init(policy: DisplayPolicy = .builtInOnly,
trigger: TriggerArea = .standard,
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
virtualNotchSize: CGSize = NotchCoordinator.defaultVirtualSize,
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
self.policy = policy
self.trigger = trigger
@@ -108,14 +163,17 @@ public final class NotchCoordinator {
if let existing = controllers[metrics.displayID] {
// Bestehenden Controller aktualisieren statt neu zu bauen:
// ein Auflösungswechsel darf ein offenes Panel nicht wegreißen.
existing.update(metrics: metrics, virtualSize: virtualNotchSize)
existing.update(metrics: metrics,
virtualSize: virtualSize(for: metrics.displayID))
} else {
let id = metrics.displayID
let controller = NotchScreenController(
metrics: metrics,
virtualSize: virtualNotchSize,
virtualSize: virtualSize(for: id),
trigger: trigger,
style: style,
makeContent: { presentation in makeContent(id, presentation) })
controller.showsIdleIsland = showsIdleIsland
controller.onVisibilityChanged = { [weak self] _ in
self?.updatePolling()
}
@@ -8,6 +8,18 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable {
public let auxiliaryTopLeft: CGRect?
public let auxiliaryTopRight: CGRect?
public let isBuiltIn: Bool
/// Wie hoch die Menüleiste auf diesem Bildschirm ist.
///
/// Bestimmt die Höhe einer schwebenden Insel: sie soll **in** der
/// Menüleiste liegen, nicht unter ihr heraushängen. Null heißt „unbekannt",
/// dann bleibt es beim vorgegebenen Maß.
public let menuBarHeight: CGFloat
/// Trägt dieser Bildschirm die Menüleiste?
///
/// Nicht `NSScreen.main`: das ist der Bildschirm mit dem aktiven Fenster
/// und wechselt, sobald man woanders hinklickt. Gemeint ist der, den man
/// in den Systemeinstellungen zum Hauptbildschirm erklärt hat.
public let isMain: Bool
/// `CGDirectDisplayID` — stabil über Anordnungswechsel hinweg, anders als
/// der Index in `NSScreen.screens`.
public let displayID: UInt32
@@ -19,12 +31,16 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable {
auxiliaryTopLeft: CGRect?,
auxiliaryTopRight: CGRect?,
isBuiltIn: Bool = false,
menuBarHeight: CGFloat = 0,
isMain: Bool = false,
displayID: UInt32 = 0) {
self.frame = frame
self.safeAreaTop = safeAreaTop
self.auxiliaryTopLeft = auxiliaryTopLeft
self.auxiliaryTopRight = auxiliaryTopRight
self.isBuiltIn = isBuiltIn
self.menuBarHeight = menuBarHeight
self.isMain = isMain
self.displayID = displayID
}
}
@@ -33,6 +49,14 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable {
public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
/// Nur das eingebaute Display.
case builtInOnly
/// Nur der Bildschirm mit der Menüleiste — egal ob eingebaut oder nicht.
///
/// Die dritte Antwort auf „wo soll das Ding sein": nicht „am Gerät" und
/// nicht „überall", sondern **dort, wo ich ohnehin hinschaue**. Wer den
/// Laptop zugeklappt am Monitor betreibt, meint mit „mein Bildschirm"
/// diesen — und musste bisher zwischen „nur eingebaut" (dann ein
/// Ersatzdisplay) und „alle" (dann vier) wählen.
case mainDisplay
/// Jedes angeschlossene Display, externe mit virtueller Notch.
case allDisplays
@@ -40,6 +64,12 @@ public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
switch self {
case .allDisplays:
return available
case .mainDisplay:
// Meldet niemand die Menüleiste — etwa in Tests —, gilt der erste.
guard let main = available.first(where: \.isMain) else {
return Array(available.prefix(1))
}
return [main]
case .builtInOnly:
let builtIn = available.filter(\.isBuiltIn)
// Am Dock mit geschlossenem Deckel gibt es kein eingebautes Display.
@@ -51,6 +81,43 @@ public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
}
}
/// Welche Form Onyx oben annimmt.
///
/// Die Frage ist nicht kosmetisch: eine verschmolzene Notch **muss** an der
/// Kante kleben und oben eckig sein, sonst klafft ein Spalt zwischen Gehäuse
/// und Fläche. Eine schwebende Insel **muss** Abstand halten und rundum
/// gerundet sein, sonst sieht sie aus wie ein abgeschnittenes Rechteck. Beides
/// zu mischen geht nicht — deshalb eine Wahl und keine zwei Schalter.
public enum NotchStyle: String, Codable, Sendable, CaseIterable {
/// Verschmolzen, wo es Hardware gibt; schwebend, wo nicht.
case automatic
/// Immer an der Kante. Auf Bildschirmen ohne Notch eine nachgemalte.
case notch
/// Immer schwebend — auch auf dem eingebauten Bildschirm, wo die Insel
/// dann unter der echten Notch hängt.
case island
/// Ob die Form mit der Hardware verschmilzt.
public func merges(withHardware hasHardware: Bool) -> Bool {
switch self {
case .automatic, .notch: hasHardware
case .island: false
}
}
/// Ob die Form frei schwebt.
public func isDetached(withHardware hasHardware: Bool) -> Bool {
switch self {
case .automatic: !hasHardware
case .notch: false
case .island: true
}
}
}
/// Wo die Notch sitzt und wo das Panel dazu hingehört.
///
/// Auf dem eingebauten Display wird die echte Notch aus den beiden Hilfsflächen
@@ -70,6 +137,30 @@ public struct NotchGeometry: Equatable, Sendable {
public let rect: CGRect
public let screenFrame: CGRect
/// Wie weit die Fläche unter der Bildschirmkante beginnt.
///
/// Bei einer verschmolzenen Notch null: sie **ist** die Kante, und jeder
/// Abstand wäre ein sichtbarer Spalt zwischen Gehäuse und Fläche. Bei einer
/// schwebenden Insel ist der Abstand genau das, was sie zur Insel macht
/// statt zu einer nachgemalten Notch — und auf einem Gerät mit echter Notch
/// kommt deren Höhe hinzu, damit die Insel unter der Hardware hängt und
/// nicht in ihr verschwindet.
public let topMargin: CGFloat
/// Frei schwebend statt mit der Kante verschmolzen — und damit rundum
/// gerundet.
public let isDetached: Bool
/// Die Luft über und unter einer schwebenden Insel.
///
/// Klein, weil sie zweimal vorkommt: die Insel soll in die Menüleiste
/// passen, und von deren 24 Punkten bleiben bei 3 Punkten oben und unten
/// noch 18 für die Insel selbst. Mehr Luft hieße weniger Insel.
public static let detachedMargin: CGFloat = 3
/// Darunter wird die Insel zum Strich.
public static let minimumIslandHeight: CGFloat = 14
/// Wie weit die Auslösefläche über die Notch hinausreicht — einstellbar,
/// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
public var trigger: TriggerArea = .standard
@@ -87,40 +178,85 @@ public struct NotchGeometry: Equatable, Sendable {
return copy
}
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
public init(kind: Kind, rect: CGRect, screenFrame: CGRect,
isDetached: Bool = false, topMargin: CGFloat = 0) {
self.kind = kind
self.rect = rect
self.screenFrame = screenFrame
self.isDetached = isDetached
self.topMargin = topMargin
}
/// Die echte Notch dieses Bildschirms, falls es eine gibt.
///
/// **Öffentlich, weil die Einstellungen dieselbe Frage stellen.** Dort ist
/// zu entscheiden, ob eine Breite überhaupt einstellbar ist — bei einer
/// echten Notch gibt die Hardware sie vor. Ein zweiter Weg zu dieser
/// Antwort wäre eine zweite Wahrheit, die irgendwann von dieser abweicht.
///
/// Erkannt an den beiden Flächen daneben, nicht am Inset allein: bei
/// Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt, obwohl es nichts
/// gibt, um das man herumlegen könnte.
public static func hardwareRect(_ metrics: ScreenMetrics) -> CGRect? {
guard let left = metrics.auxiliaryTopLeft,
let right = metrics.auxiliaryTopRight,
metrics.safeAreaTop > 0 else { return nil }
let width = metrics.frame.width - left.width - right.width
guard width > 0 else { return nil }
return CGRect(x: left.maxX,
y: metrics.frame.maxY - metrics.safeAreaTop,
width: width,
height: metrics.safeAreaTop)
}
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize,
trigger: TriggerArea = .standard) -> NotchGeometry {
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
// obwohl es nichts gibt, um das man herumlegen könnte.
if let left = metrics.auxiliaryTopLeft,
let right = metrics.auxiliaryTopRight,
metrics.safeAreaTop > 0 {
let width = metrics.frame.width - left.width - right.width
if width > 0 {
return NotchGeometry(
kind: .physical,
rect: CGRect(x: left.maxX,
y: metrics.frame.maxY - metrics.safeAreaTop,
width: width,
height: metrics.safeAreaTop),
screenFrame: metrics.frame).with(trigger: trigger)
}
trigger: TriggerArea = .standard,
style: NotchStyle = .automatic) -> NotchGeometry {
let hardware = hardwareRect(metrics)
// Verschmelzen kann nur, was etwas zum Verschmelzen hat.
if style.merges(withHardware: hardware != nil), let hardware {
return NotchGeometry(kind: .physical, rect: hardware,
screenFrame: metrics.frame).with(trigger: trigger)
}
let width = min(virtualSize.width, metrics.frame.width)
let detached = style.isDetached(withHardware: hardware != nil)
// Wie hoch die Insel wird und wie weit unter der Kante sie beginnt.
//
// Ohne Hardware liegt sie **in** der Menüleiste: deren Höhe minus Luft
// oben und unten. Vorher stand hier ein festes Maß, und das hing dann
// unter der Leiste heraus — die Insel grenzte unten an nichts mehr an.
//
// Mit Hardware (Stil „immer schwebend") hängt sie unter der Notch, denn
// in ihr wäre sie zur Hälfte schwarz auf schwarzem Gehäuse.
let height: CGFloat
let clearance: CGFloat
switch (detached, hardware) {
case (false, _):
height = virtualSize.height
clearance = 0
case (true, .some(let notch)):
height = virtualSize.height
clearance = notch.height + detachedMargin
case (true, .none):
let bar = metrics.menuBarHeight
height = bar > 0 ? max(bar - 2 * detachedMargin, minimumIslandHeight)
: virtualSize.height
// Mittig in der Leiste: der Rest der Höhe, halbiert.
clearance = bar > 0 ? max((bar - height) / 2, 0) : detachedMargin
}
return NotchGeometry(
kind: .virtual,
rect: CGRect(x: metrics.frame.midX - width / 2,
y: metrics.frame.maxY - virtualSize.height,
y: metrics.frame.maxY - clearance - height,
width: width,
height: virtualSize.height),
screenFrame: metrics.frame).with(trigger: trigger)
height: height),
screenFrame: metrics.frame,
isDetached: detached,
topMargin: clearance).with(trigger: trigger)
}
/// Die Fläche, deren Betreten das Panel scharf schaltet.
@@ -151,7 +287,7 @@ public struct NotchGeometry: Equatable, Sendable {
let height = min(size.height, screenFrame.height)
return CGRect(x: rect.midX - width / 2,
y: screenFrame.maxY - height,
y: screenFrame.maxY - topMargin - height,
width: width,
height: height)
.clamped(to: screenFrame)
@@ -28,16 +28,64 @@ public final class NotchPresentation {
self.contentTopInset = contentTopInset
}
/// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste
/// zuzudecken — also die Breite der Notch.
///
/// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr.
/// Die Breite der Notch bzw. ihres virtuellen Ersatzes.
public var notchWidth: CGFloat { collapsedSize.width }
/// Was eine Live Activity **unter** der Notch aufspannt. `nil` = keine.
///
/// Wird vom Presenter gesetzt.
public var ambientContentSize: CGSize?
/// Ob die Fläche frei schwebt statt mit der Bildschirmkante zu verschmelzen.
///
/// Vom Controller aus der Geometrie gesetzt. Eine echte Notch verschmilzt
/// mit dem Gehäuse und ist oben eckig; ohne Hardware gibt es nichts zu
/// verschmelzen, und eine oben eckige Fläche mit Luft darüber sähe aus wie
/// ein abgeschnittenes Rechteck.
public var isDetached = false
/// Die Arme links und rechts der Notch, auf Menüleistenhöhe.
///
/// Nur ausgefahren: eingeklappt hängt die Live Activity **unter** der
/// Notch, nicht neben ihr. Neben ihr müssten beide Arme gleich breit sein —
/// die Silhouette zeichnet den Steg mittig um die Hardware —, und das hieße
/// bei einem Symbol links und einem Balken rechts über hundert Punkte
/// Schwarz für das Symbol.
public var band: NotchBand {
isExpanded
? NotchBandLayout.resolve(panelWidth: expandedSize.width, notchWidth: notchWidth)
: .none
}
/// Die Größe der **eingeklappten** Fläche.
///
/// Ohne Activity die Notch allein. Mit einer wächst sie nach unten — und
/// mindestens auf Notchbreite, sonst hinge unter dem Steg ein schmalerer
/// Zipfel.
public var collapsedDisplaySize: CGSize {
guard let ambientContentSize else { return collapsedSize }
return CGSize(width: max(ambientContentSize.width, collapsedSize.width),
height: collapsedSize.height + ambientContentSize.height)
}
/// Wie breit der oberste Streifen gerade gezeichnet wird.
///
/// **Der Steg wächst, es kommen nicht Arme dazu.** Das ist derselbe Wert,
/// den die Silhouette animiert: zusammengezogen die Notch, ausgefahren die
/// Notch samt Armen. So wächst das Band aus der Hardware heraus, statt
/// daneben aufzublenden — und die Animation bleibt die eine, die es schon
/// gab.
public var topStripWidth: CGFloat {
band.totalWidth(notchWidth: notchWidth)
}
/// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf.
///
/// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel
/// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen.
public static let collapseDuration: TimeInterval = 0.30
/// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen. Seit
/// die Kurve am Bewegungsprofil hängt, hängt die Dauer mit — eine feste Zahl
/// wäre bei „calm" um ein Drittel zu kurz gewesen.
public static var collapseDuration: TimeInterval { OnyxTheme.shared.motion.collapseDuration }
}
/// Die Silhouette des Panels: oben nur so breit wie die Notch, darunter voll.
@@ -48,17 +96,31 @@ public final class NotchPresentation {
/// und rechts davon aber die Menüleiste zu — bei sechs Kacheln die ganze,
/// samt aller Statuselemente.
///
/// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die
/// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei.
/// Also ein T: oben ein Steg, ab Unterkante der Menüleiste die volle Fläche.
/// Die Notch bleibt verschmolzen, die Menüleiste daneben bleibt frei.
///
/// Der Steg ist **nicht** zwingend so breit wie die Notch: er darf bis zur
/// Panelbreite wachsen und trägt dann links und rechts der Hardware das Band.
/// Welche Breite gilt, entscheidet `NotchPresentation.topStripWidth`.
struct NotchShape: Shape, InsettableShape {
var cornerRadius: CGFloat
var topBandHeight: CGFloat
var notchWidth: CGFloat
/// Die Breite des oberen Stegs — Notch, oder Notch samt Band.
var topWidth: CGFloat
/// Frei schwebend: dann ist auch die Oberkante gerundet.
///
/// Kein eigener Radius, sondern derselbe wie unten — sonst hätte dieselbe
/// Form zwei Rundungen, und das liest sich als Fehler. Weil er von
/// `cornerRadius` abgeleitet ist, wächst er beim Ausfahren mit, ohne
/// eigens animiert werden zu müssen.
var isDetached: Bool = false
var inset: CGFloat = 0
private var topRadius: CGFloat { isDetached ? cornerRadius : 0 }
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { .init(cornerRadius, notchWidth) }
set { cornerRadius = newValue.first; notchWidth = newValue.second }
get { .init(cornerRadius, topWidth) }
set { cornerRadius = newValue.first; topWidth = newValue.second }
}
func inset(by amount: CGFloat) -> NotchShape {
@@ -69,30 +131,83 @@ struct NotchShape: Shape, InsettableShape {
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: inset, dy: inset)
// Ist das Panel nicht breiter als die Notch, ist der Steg die ganze
// Form — dann bleibt es die schlichte abgerundete Fläche.
let band = min(max(notchWidth - 2 * inset, 0), rect.width)
let band = min(max(topWidth - 2 * inset, 0), rect.width)
let radius = min(cornerRadius, rect.width / 2, rect.height / 2)
let bandBottom = rect.minY + max(topBandHeight, 0)
// **Ein durchgehender Umriss, nicht zwei überlappende Rechtecke.**
//
// Gefüllt sahen zwei Teilpfade richtig aus — die Vereinigung stimmt.
// Die Kontur aber zeichnet jeden Teilpfad vollständig nach, auch dort,
// wo er *innen* liegt: quer durch die Fläche lief die Oberkante des
// Körpers und die eckige Unterkante des Stegs. Dazu sprang die Form ab
// einer Breite in einen anderen Zweig. Zusammen war das das Flackern.
var path = Path()
// Die Schulter: der Übergang vom Steg in den Körper, nach innen
// gewölbt. Sie ist es, die aus einem T eine Insel macht — ohne sie
// sitzt der Steg wie aufgeklebt auf einer Platte.
let shoulder = min(radius, max((rect.width - band) / 2, 0), max(topBandHeight, 0))
guard topBandHeight > 0, band < rect.width - 0.5 else {
path.addRoundedRect(in: rect,
cornerSize: CGSize(width: radius, height: radius),
style: .continuous)
// Nur der Steg. Verschmolzen ist er oben eckig — dort ist die
// Bildschirmkante, und eine Rundung wäre ein Spalt zum Gehäuse.
// Schwebend ist er rundum gerundet, sonst sähe er aus wie ein
// abgeschnittenes Rechteck.
let top = min(topRadius, rect.width / 2, rect.height / 2)
path.move(to: CGPoint(x: rect.minX, y: rect.minY + top))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - radius))
path.addQuadCurve(to: CGPoint(x: rect.minX + radius, y: rect.maxY),
control: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.maxY))
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - radius),
control: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + top))
path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.minY),
control: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX + top, y: rect.minY))
path.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.minY + top),
control: CGPoint(x: rect.minX, y: rect.minY))
path.closeSubpath()
return path
}
let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY,
width: band, height: topBandHeight + radius)
let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight,
width: rect.width, height: rect.height - topBandHeight)
let bandMinX = rect.midX - band / 2
let bandMaxX = rect.midX + band / 2
let top = min(topRadius, band / 2, max(topBandHeight, 0))
// Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und
// eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen.
path.addRect(bandRect)
path.addRoundedRect(in: body,
cornerSize: CGSize(width: radius, height: radius),
style: .continuous)
path.move(to: CGPoint(x: bandMinX, y: rect.minY + top))
// Linke Stegkante hinunter, dann nach außen in den Körper.
path.addLine(to: CGPoint(x: bandMinX, y: bandBottom - shoulder))
path.addQuadCurve(to: CGPoint(x: bandMinX - shoulder, y: bandBottom),
control: CGPoint(x: bandMinX, y: bandBottom))
// Oberkante des Körpers nach links, obere linke Ecke.
path.addLine(to: CGPoint(x: rect.minX + radius, y: bandBottom))
path.addQuadCurve(to: CGPoint(x: rect.minX, y: bandBottom + radius),
control: CGPoint(x: rect.minX, y: bandBottom))
// Linke Flanke, untere Ecken, rechte Flanke.
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - radius))
path.addQuadCurve(to: CGPoint(x: rect.minX + radius, y: rect.maxY),
control: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.maxY))
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - radius),
control: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: bandBottom + radius))
path.addQuadCurve(to: CGPoint(x: rect.maxX - radius, y: bandBottom),
control: CGPoint(x: rect.maxX, y: bandBottom))
// Zurück zur rechten Stegkante, wieder über die Schulter.
path.addLine(to: CGPoint(x: bandMaxX + shoulder, y: bandBottom))
path.addQuadCurve(to: CGPoint(x: bandMaxX, y: bandBottom - shoulder),
control: CGPoint(x: bandMaxX, y: bandBottom))
// Rechte Stegkante hinauf und über die Oberkante zurück. Verschmolzen
// ist `top` null, dann sind es schlicht zwei Geraden.
path.addLine(to: CGPoint(x: bandMaxX, y: rect.minY + top))
path.addQuadCurve(to: CGPoint(x: bandMaxX - top, y: rect.minY),
control: CGPoint(x: bandMaxX, y: rect.minY))
path.addLine(to: CGPoint(x: bandMinX + top, y: rect.minY))
path.addQuadCurve(to: CGPoint(x: bandMinX, y: rect.minY + top),
control: CGPoint(x: bandMinX, y: rect.minY))
path.closeSubpath()
return path
}
}
@@ -104,34 +219,73 @@ struct NotchShape: Shape, InsettableShape {
/// Hardware darüber, sodass beides als ein Körper wirkt. Eingeblendet wird nur der
/// Inhalt, und zwar erst, wenn die Fläche fast ihre Endgröße hat: Text, der sich
/// mitdehnt, verrät die Animation als Skalierung und sieht billig aus.
public struct NotchContainer<Content: View>: View {
public struct NotchContainer<Leading: View, Trailing: View,
Ambient: View, Content: View>: View {
private let presentation: NotchPresentation
private let leading: Leading
private let trailing: Trailing
private let ambient: Ambient
private let content: Content
public init(presentation: NotchPresentation, @ViewBuilder content: () -> Content) {
/// - Parameters:
/// - leading: Was links neben der Notch steht, wenn ausgefahren.
/// - trailing: Was rechts daneben steht.
/// - ambient: Die Live Activity, eingeklappt unter der Notch.
/// - content: Die Kacheln, unterhalb der Menüleiste.
public init(presentation: NotchPresentation,
@ViewBuilder leading: () -> Leading,
@ViewBuilder trailing: () -> Trailing,
@ViewBuilder ambient: () -> Ambient,
@ViewBuilder content: () -> Content) {
self.presentation = presentation
self.leading = leading()
self.trailing = trailing()
self.ambient = ambient()
self.content = content()
}
private var size: CGSize {
presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize
presentation.isExpanded ? presentation.expandedSize : presentation.collapsedDisplaySize
}
private var cornerRadius: CGFloat {
// Zusammengezogen die Rundung der Notch-Unterkante, ausgefahren die des
// Panels. Ein fester Radius lässt die kleine Form aufgeblasen aussehen.
presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10
// Ausgefahren die Rundung des Panels. Ein fester Radius ließe die
// kleine Form aufgeblasen aussehen.
if presentation.isExpanded { return Onyx.Radius.panel }
// Eingeklappt und verschmolzen: die Rundung der Notch-Unterkante.
guard presentation.isDetached else { return Onyx.Radius.notch }
// Eingeklappt und schwebend: die **halbe Höhe**, also eine Pille.
//
// Eine Insel mit dem Radius einer Notch-Unterkante ist keine Insel,
// sondern eine abgerundete Kachel — die Rundung muss aus der Form
// selbst kommen und nicht aus einer geerbten Zahl. Bei wachsender
// Live Activity bleibt es bei der halben *Insel*höhe, sonst würde die
// Fläche mit dem Inhalt immer runder.
return presentation.collapsedSize.height / 2
}
private var shapeAnimation: Animation {
presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse
/// Die Kurve für **jede** Größenänderung der Fläche.
///
/// Eine, nicht zwei. Zwei gestapelte `.animation(_:value:)` sehen aus wie
/// zwei Zuständigkeiten, sind aber keine: der innere Modifikator
/// beansprucht den Teilbaum, und der äußere kommt nicht mehr an die Form
/// heran. Weil eine Live Activity `isExpanded` unberührt lässt, hatte der
/// innere nichts zu animieren — und die Fläche sprang auf, während die
/// zweite Kurve daneben wirkungslos stand.
private var sizeAnimation: Animation {
// Ausfahren und Wachsen sind dieselbe Richtung; nur das Einfahren
// ist kürzer und schwingt nicht nach.
presentation.isExpanded || presentation.ambientContentSize != nil
? Onyx.Motion.expand
: Onyx.Motion.collapse
}
public var body: some View {
let shape = NotchShape(cornerRadius: cornerRadius,
topBandHeight: presentation.contentTopInset,
notchWidth: presentation.notchWidth)
topWidth: presentation.topStripWidth,
isDetached: presentation.isDetached)
ZStack(alignment: .top) {
// Das Weichzeichner-Material behält **feste** Größe; animiert wird
@@ -141,31 +295,68 @@ public struct NotchContainer<Content: View>: View {
// Weichzeichner in jedem Einzelbild neu in anderer Größe berechnen —
// das ruckelt sichtbar, und zwar genau beim Schließen, wo die Fläche
// am schnellsten schrumpft. Eine Maske kostet dagegen nichts.
OnyxVisualEffect()
.frame(width: presentation.expandedSize.width,
height: presentation.expandedSize.height)
.mask(alignment: .top) {
shape.frame(width: size.width, height: size.height)
}
if OnyxTheme.shared.material.showsVibrancy {
OnyxVisualEffect()
.frame(width: presentation.expandedSize.width,
height: presentation.expandedSize.height)
.mask(alignment: .top) {
shape.frame(width: size.width, height: size.height)
}
}
// Tint, Bänderung und Kante sind reine SwiftUI-Formen. Die dürfen
// in der Größe animieren, das kostet nichts.
shape
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
.overlay {
shape.fill(
LinearGradient(
stops: [.init(color: .white.opacity(0.02), location: 0),
.init(color: .clear, location: 0.4)],
startPoint: .top, endPoint: .bottom))
}
.overlay { shape.stroke(Onyx.Color.hairline, lineWidth: 1) }
//
// Dasselbe Rezept wie in ``OnyxSurface`` — und zwar buchstäblich
// dasselbe, nicht eine zweite Fassung davon.
OnyxStone(shape)
.frame(width: size.width, height: size.height)
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
// ohnehin mit der Form mit.
.shadow(color: .black.opacity(0.45), radius: 22, y: 10)
// Die Live Activity, unter der Notch.
ambient
.frame(width: presentation.collapsedDisplaySize.width,
height: presentation.collapsedDisplaySize.height
- presentation.contentTopInset)
.padding(.top, presentation.contentTopInset)
// Beim Einfahren schneidet die schrumpfende Fläche den Inhalt
// weg — das ist die Bewegung. Das Ausblenden kommt nur dazu,
// damit die letzte Zeile nicht als Strich stehen bleibt.
.opacity(presentation.isExpanded || presentation.ambientContentSize == nil
? 0 : 1)
.allowsHitTesting(false)
.clipped()
// Das Band: links und rechts der Notch, auf Menüleistenhöhe.
//
// Die Notchbreite in der Mitte bleibt leer und **muss** es: dort
// sitzt undurchsichtige Hardware, alles darunter ist schlicht nicht
// zu sehen.
HStack(spacing: 0) {
leading
.frame(width: presentation.band.leading, alignment: .leading)
Color.clear
.frame(width: presentation.notchWidth)
trailing
.frame(width: presentation.band.trailing, alignment: .trailing)
}
.frame(height: presentation.contentTopInset)
.clipped()
// Eingeklappt ist das Band die Live Activity und muss stehen;
// ausgefahren blendet es mit dem übrigen Inhalt ein.
.opacity(presentation.band.isEmpty ? 0 : 1)
.allowsHitTesting(false)
// Dieselbe Kurve wie beim Kachelinhalt: die Fläche läuft vorweg,
// der Inhalt kommt nach. Ein Band, das mit der Fläche mitwächst,
// verrät die Animation als Skalierung.
.animation(presentation.isExpanded
? .easeOut(duration: 0.20).delay(0.08)
: .easeIn(duration: 0.08),
value: presentation.isExpanded)
content
.frame(width: presentation.expandedSize.width,
height: presentation.expandedSize.height
@@ -191,7 +382,9 @@ public struct NotchContainer<Content: View>: View {
.frame(width: presentation.expandedSize.width,
height: presentation.expandedSize.height,
alignment: .top)
.animation(shapeAnimation, value: presentation.isExpanded)
// Der Anlass ist die gezeichnete Größe selbst — nicht der Umweg über
// `isExpanded`, der die Live Activity nicht mitbekommt.
.animation(sizeAnimation, value: size)
}
}
@@ -216,7 +409,8 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
required init?(coder: NSCoder) { fatalError() }
public override func hitTest(_ point: NSPoint) -> NSView? {
let size = presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize
let size = presentation.isExpanded
? presentation.expandedSize : presentation.collapsedDisplaySize
// Bildschirmkoordinaten wachsen nach oben, View-Koordinaten nach unten:
// die sichtbare Fläche hängt oben mittig im Fenster.
let visible = CGRect(x: (bounds.width - size.width) / 2,
@@ -225,16 +419,34 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
height: size.height)
guard visible.contains(point) else { return nil }
// Links und rechts neben der Notch gehört der oberste Streifen der
// Menüleiste. Dort ist das Panel durchsichtig — und muss auch
// durchlässig sein, sonst schluckt es Klicks auf fremde
// Statuselemente, ohne dass man sieht, warum.
// Außerhalb des Stegs gehört der oberste Streifen der Menüleiste. Dort
// ist das Panel durchsichtig — und muss auch durchlässig sein, sonst
// schluckt es Klicks auf fremde Statuselemente, ohne dass man sieht,
// warum.
//
// **Die Prüfung folgt dem, was gezeichnet wird**, nicht der Notchbreite:
// sonst wäre das Band zwar zu sehen, aber tot für Klicks — und ein
// Knopf, der aussieht wie einer und keiner ist, ist schlimmer als
// keiner.
if presentation.isExpanded, point.y < presentation.contentTopInset {
let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0,
width: presentation.notchWidth,
let strip = presentation.topStripWidth
let band = CGRect(x: (bounds.width - strip) / 2, y: 0,
width: strip,
height: presentation.contentTopInset)
guard band.contains(point) else { return nil }
}
return super.hitTest(point)
}
}
public extension NotchContainer
where Leading == EmptyView, Trailing == EmptyView, Ambient == EmptyView {
/// Ohne Band und ohne Live Activity — die Fassung, die es vorher gab.
init(presentation: NotchPresentation, @ViewBuilder content: () -> Content) {
self.init(presentation: presentation,
leading: { EmptyView() },
trailing: { EmptyView() },
ambient: { EmptyView() },
content: content)
}
}
@@ -46,15 +46,33 @@ public final class NotchScreenController {
}
}
/// Notch oder Insel. Ändern baut die Geometrie neu — die Form hängt daran,
/// aber auch die Lage des Fensters.
public var style: NotchStyle {
didSet {
guard style != oldValue, let metrics = lastMetrics else { return }
update(metrics: metrics, virtualSize: lastVirtualSize)
}
}
/// Was zuletzt gemessen wurde — damit ein Stilwechsel die Geometrie ohne
/// Umweg über den Koordinator neu rechnen kann.
private var lastMetrics: ScreenMetrics?
private var lastVirtualSize: CGSize
public init(metrics: ScreenMetrics,
virtualSize: CGSize,
trigger: TriggerArea = .standard,
style: NotchStyle = .automatic,
contentSize: CGSize = CGSize(width: 640, height: 320),
makeContent: (NotchPresentation) -> NSView) {
self.displayID = metrics.displayID
self.trigger = trigger
self.style = style
self.lastMetrics = metrics
self.lastVirtualSize = virtualSize
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger)
trigger: trigger, style: style)
self.geometry = geometry
self.contentSize = contentSize
self.presentation = NotchPresentation(
@@ -62,13 +80,18 @@ public final class NotchScreenController {
expandedSize: CGSize(width: contentSize.width,
height: contentSize.height + geometry.rect.height),
contentTopInset: geometry.rect.height)
presentation.isDetached = geometry.isDetached
self.panel = NotchPanel(contentRect: .zero)
panel.contentView = makeContent(presentation)
layoutPanel()
applyIdleIsland()
}
public var phase: NotchPhase { machine.phase }
public var isVisible: Bool { machine.isVisible }
/// Ausgefahren. **Nicht** „auf dem Bildschirm": eine eingeklappte Live
/// Activity soll den Koordinator nicht dazu bringen, den Zeiger zu
/// verfolgen, als stünde ein Panel offen.
public var isVisible: Bool { machine.isExpanded }
/// Meldet, wenn das Panel sichtbar wird oder verschwindet. Der Koordinator
/// schaltet daran die Zeigerabfrage an und aus.
@@ -99,29 +122,99 @@ public final class NotchScreenController {
/// und ist links und rechts der Notch dort durchsichtig — dieser Streifen
/// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick
/// auf ein fremdes Statuselement für einen Klick ins Panel.
///
/// Der oberste Streifen zählt genau so weit, wie er auch **gezeichnet**
/// wird — sonst gilt der Zeiger auf dem eigenen Band als „außerhalb", und
/// das Panel fährt unter dem Finger ein, während man auf sein Akkusymbol
/// zeigt.
public func containsVisiblePoint(_ point: CGPoint) -> Bool {
guard presentation.isExpanded, panel.frame.contains(point) else { return false }
let menuBarBand = panel.frame.maxY - geometry.rect.height
guard point.y > menuBarBand else { return true }
return abs(point.x - panel.frame.midX) <= geometry.rect.width / 2
return abs(point.x - panel.frame.midX) <= presentation.topStripWidth / 2
}
public func escape() { send(.escape) }
/// Was eine Live Activity gerade unter der Notch beansprucht. `nil` = keine.
///
/// Meldet der Presenter, wenn sich seine Warteschlange ändert. Das Panel
/// fährt davon nicht aus — es bleibt oder kommt nur eingeklappt auf den
/// Schirm.
///
/// **Die Reihenfolge ist der ganze Trick.** Erst das Fenster zeigen, dann
/// im nächsten Durchlauf wachsen lassen: steht die Endgröße schon im ersten
/// Bild, gibt es nichts zu animieren, und die Fläche springt auf. Beim
/// Ausfahren macht `show()` seit jeher genau das — für die Activity fehlte
/// es.
public func setAmbient(_ size: CGSize?) {
guard let size else {
// Umgekehrt beim Verschwinden: erst schrumpfen, dann fortnehmen.
// Das Fenster geht ohnehin erst nach der Einfahrfrist.
presentation.ambientContentSize = nil
send(.ambient(false))
return
}
send(.ambient(true))
DispatchQueue.main.async { [weak self] in
self?.presentation.ambientContentSize = size
}
}
/// Ob die eingeklappte Fläche stehen bleibt, wenn nichts anliegt.
///
/// **Damit das Panel aus etwas herauswächst.** Auf dem eingebauten Display
/// wächst es aus der Notch — die ist immer da. Auf einem externen gab es
/// bisher nichts: das Fenster kam erst mit dem Inhalt auf den Schirm, und
/// eine schwarze Fläche, die im Nichts aufgeht, liest sich als Fehler und
/// nicht als Bewegung. Bleibt die eingeklappte Insel stehen, ist der
/// Ausgangspunkt sichtbar — und die Bewegung hat einen Ursprung.
///
/// Auf dem eingebauten Display kostet das nichts: dort liegt die Fläche
/// deckungsgleich unter der schwarzen Hardware.
public var showsIdleIsland = true {
didSet {
guard showsIdleIsland != oldValue else { return }
applyIdleIsland()
}
}
/// Unterdrückung schlägt die Insel: bei einem Vollbildvideo hat auch sie
/// zu schweigen.
private var mayShowIdleIsland: Bool { showsIdleIsland && !machine.isSuppressed }
/// Bringt das Fenster in den Zustand, den die Insel gerade verlangt.
///
/// Rührt nichts an, solange etwas anderes das Fenster braucht — ein
/// ausgefahrenes Panel oder eine Live Activity haben Vorrang.
private func applyIdleIsland() {
guard !presentation.isExpanded, !machine.hasAmbient else { return }
if mayShowIdleIsland {
panel.orderFrontRegardless()
} else {
panel.orderOut(nil)
}
}
public func setSuppressed(_ suppressed: Bool) {
guard suppressed != machine.isSuppressed else { return }
log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)")
send(.suppressed(suppressed))
applyIdleIsland()
}
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
lastMetrics = metrics
lastVirtualSize = virtualSize
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger)
trigger: trigger, style: style)
presentation.isDetached = geometry.isDetached
presentation.collapsedSize = geometry.rect.size
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
// (virtueller Balken) ändert sich der Freiraum mit.
presentation.contentTopInset = geometry.rect.height
presentation.expandedSize = expandedSize
layoutPanel()
applyIdleIsland()
}
public func tearDown() {
@@ -138,7 +231,7 @@ public final class NotchScreenController {
/// Sichtbarkeit wird hier verglichen und nicht in `apply`: `handle` hat den
/// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück.
private func send(_ input: NotchInput) {
let wasVisible = machine.isVisible
let wasVisible = machine.isExpanded
let before = machine.phase
apply(machine.handle(input))
// Jeden Zustandswechsel protokollieren — außer den beiden, die
@@ -155,7 +248,7 @@ public final class NotchScreenController {
log.notice(" Zeiger \(Int(self.lastPoint.x), privacy: .public)/\(Int(self.lastPoint.y), privacy: .public), Auslösefläche \(String(describing: self.geometry.triggerRect), privacy: .public), Fenster \(String(describing: self.panel.frame), privacy: .public), ausgefahren \(self.presentation.isExpanded, privacy: .public)")
}
}
if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) }
if machine.isExpanded != wasVisible { onVisibilityChanged?(machine.isExpanded) }
}
private func apply(_ effects: [NotchEffect]) {
@@ -186,6 +279,12 @@ public final class NotchScreenController {
case .hide:
hide()
case .showAmbient:
showAmbient()
case .hideAmbient:
hideAmbient()
}
}
}
@@ -213,6 +312,10 @@ public final class NotchScreenController {
teardownTimer?.cancel()
teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in
guard let self, !presentation.isExpanded else { return }
// Liegt eine Live Activity an, bleibt das Fenster stehen — nur
// eingeklappt. Es fortzunehmen hieße, sie mit dem Panel zu
// beerdigen, obwohl sie mit ihm nichts zu tun hat.
guard !machine.hasAmbient, !mayShowIdleIsland else { return }
panel.orderOut(nil)
}
}
@@ -231,3 +334,27 @@ public final class NotchScreenController {
panel.setFrame(geometry.panelRect(for: expandedSize), display: presentation.isExpanded)
}
}
// MARK: - Live Activities
private extension NotchScreenController {
/// Fenster auf den Schirm, ohne auszufahren.
func showAmbient() {
teardownTimer?.cancel()
teardownTimer = nil
panel.orderFrontRegardless()
presentation.isExpanded = false
}
/// Nichts mehr zu zeigen — Fenster fort, sofern es nicht ausgefahren ist.
func hideAmbient() {
guard !presentation.isExpanded else { return }
teardownTimer?.cancel()
teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in
guard let self, !presentation.isExpanded,
!machine.hasAmbient, !mayShowIdleIsland else { return }
panel.orderOut(nil)
}
}
}
@@ -31,6 +31,12 @@ public enum NotchInput: Equatable, Sendable {
case closeTimerFired
/// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten.
case suppressed(Bool)
/// Ob gerade eine Live Activity zu zeigen ist.
///
/// Sie hält das Panel **eingeklappt sichtbar**: Fenster auf dem Schirm,
/// Fläche in Notchgröße, Band daneben belegt. Ausgefahren wird davon
/// nichts — das bleibt dem Zeiger vorbehalten.
case ambient(Bool)
}
/// Was der Aufrufer nach einem Übergang zu tun hat. Die Maschine selbst
@@ -42,6 +48,10 @@ public enum NotchEffect: Equatable, Sendable {
case cancelCloseTimer
case show
case hide
/// Fenster einblenden, ohne auszufahren.
case showAmbient
/// Den eingeklappten Inhalt wieder wegnehmen.
case hideAmbient
}
/// Das Öffnungs- und Schließverhalten des Notch-Panels, frei von AppKit.
@@ -75,16 +85,29 @@ public struct NotchStateMachine: Equatable, Sendable {
/// Kommt er zurück, wird ohne weiteres Warten geöffnet.
private var debounceElapsed = false
/// Ob eine Live Activity anliegt.
public private(set) var hasAmbient = false
public init() {}
/// Sichtbar, egal aus welchem Grund.
public var isVisible: Bool {
/// Ausgefahren — die Fläche zeigt Kacheln.
public var isExpanded: Bool {
switch phase {
case .open, .pinned, .closing: true
case .idle, .arming: false
}
}
/// Auf dem Bildschirm, aus welchem Grund auch immer.
///
/// **Getrennt von `isExpanded`, und das ist der ganze Punkt dieser
/// Erweiterung.** Bis hierher hieß „nicht ausgefahren" auch „nicht da":
/// `idle` war unsichtbar. Eine Live Activity ist aber eingeklappt *und*
/// sichtbar — sonst gäbe es keinen Ort, an dem sie stehen könnte.
public var isOnScreen: Bool {
!isSuppressed && (isExpanded || hasAmbient)
}
@discardableResult
public mutating func handle(_ input: NotchInput) -> [NotchEffect] {
// Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung.
@@ -94,12 +117,28 @@ public struct NotchStateMachine: Equatable, Sendable {
guard active != isSuppressed else { return [] }
isSuppressed = active
guard active else { return [] }
let wasVisible = isVisible
guard active else {
// Unterdrückung vorbei: was eingeklappt anliegt, darf zurück.
return hasAmbient ? [.showAmbient] : []
}
let wasExpanded = isExpanded
phase = .idle
return wasVisible
? [.cancelArmTimer, .cancelCloseTimer, .hide]
: [.cancelArmTimer, .cancelCloseTimer]
var effects: [NotchEffect] = [.cancelArmTimer, .cancelCloseTimer]
if wasExpanded { effects.append(.hide) }
if hasAmbient { effects.append(.hideAmbient) }
return effects
}
// Live Activities kommen und gehen unabhängig vom Zeiger. Sie ändern
// die Phase **nicht**: ein eingeblendeter Titel darf nicht dazu führen,
// dass ein Klick plötzlich fixiert oder eine Frist läuft.
if case .ambient(let present) = input {
guard present != hasAmbient else { return [] }
hasAmbient = present
// Ausgefahren ist ohnehin alles zu sehen, und unterdrückt gar
// nichts. In beiden Fällen bleibt es beim Merken.
guard !isSuppressed, !isExpanded else { return [] }
return present ? [.showAmbient] : [.hideAmbient]
}
guard !isSuppressed else { return [] }
@@ -17,7 +17,7 @@ public struct TriggerAreaSettings: View {
}
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text("trigger.hint", bundle: .module)
.font(.callout)
.foregroundStyle(.secondary)
@@ -120,9 +120,9 @@ private struct TriggerPreview: View {
.frame(width: width, height: height)
// Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und
// Fläche gemeinsam ab, sonst ragt etwas über die Ecken hinaus.
.clipShape(.rect(cornerRadius: 8, style: .continuous))
.clipShape(.rect(cornerRadius: Onyx.Radius.control, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -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,150 @@
import Testing
import Foundation
import CoreGraphics
import SwiftUI
import OnyxNotch
@testable import ActivityProvider
@Suite("Warteschlange der Live Activities")
struct ActivityQueueTests {
private let now = Date(timeIntervalSince1970: 1_780_000_000)
private func ticket(_ id: String, rank: Int, at offset: TimeInterval = 0,
for duration: TimeInterval? = 2) -> ActivityTicket {
ActivityTicket(id: id, rank: rank,
raised: now.addingTimeInterval(offset), duration: duration)
}
@Test("Leer heißt: nichts zu zeigen")
func emptyShowsNothing() {
#expect(ActivityQueue().current(now: now) == nil)
}
/// Ohne diese Regel verschluckt ein beiläufiger Lautstärkeschritt die
/// Meldung, dass der Akku fast leer ist.
@Test("Rang schlägt Aktualität")
func rankBeatsRecency() {
var queue = ActivityQueue()
queue.raise(ticket("battery", rank: 90))
queue.raise(ticket("volume", rank: 10, at: 1))
#expect(queue.current(now: now.addingTimeInterval(1))?.id == "battery")
}
@Test("Bei gleichem Rang gilt die jüngere")
func sameRankPrefersNewest() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10))
queue.raise(ticket("brightness", rank: 10, at: 1))
#expect(queue.current(now: now.addingTimeInterval(1))?.id == "brightness")
}
/// Wer die Lautstärke dreimal verstellt, will dreimal die volle Standzeit —
/// nicht drei Einträge in einer Schlange.
@Test("Dieselbe Kennung ersetzt, statt sich anzureihen")
func raisingAgainReplaces() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10))
queue.raise(ticket("volume", rank: 10, at: 1))
#expect(queue.all.count == 1)
#expect(queue.current(now: now.addingTimeInterval(1))?.raised
== now.addingTimeInterval(1))
}
@Test("Abgelaufene zählen nicht mehr")
func expiredIsGone() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10, for: 2))
#expect(queue.current(now: now.addingTimeInterval(1)) != nil)
#expect(queue.current(now: now.addingTimeInterval(2)) == nil)
}
/// Genau dafür gibt es dauerhafte: etwas, das steht, wenn sonst nichts
/// ansteht — das Wetter etwa.
@Test("Eine dauerhafte läuft nie ab")
func persistentNeverExpires() {
var queue = ActivityQueue()
queue.raise(ticket("weather", rank: 1, for: nil))
#expect(queue.current(now: now.addingTimeInterval(86_400))?.id == "weather")
}
@Test("Nach dem Ablauf der kurzen kommt die dauerhafte zurück")
func persistentReturns() {
var queue = ActivityQueue()
queue.raise(ticket("weather", rank: 1, for: nil))
queue.raise(ticket("volume", rank: 10, for: 2))
#expect(queue.current(now: now)?.id == "volume")
#expect(queue.current(now: now.addingTimeInterval(3))?.id == "weather")
}
@Test("Zurückziehen wirkt sofort")
func withdrawWorks() {
var queue = ActivityQueue()
queue.raise(ticket("media", rank: 20, for: nil))
queue.withdraw(id: "media")
#expect(queue.current(now: now) == nil)
}
/// Eine Notch, die zehnmal je Sekunde nachfragt, ob etwas abgelaufen ist,
/// hält den Rechner wach. Deshalb rechnet die Schlange den Weckzeitpunkt aus.
@Test("Der nächste Ablauf ist der früheste noch offene")
func nextExpiryIsEarliest() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10, for: 5))
queue.raise(ticket("charge", rank: 50, for: 2))
queue.raise(ticket("weather", rank: 1, for: nil))
#expect(queue.nextExpiry(after: now) == now.addingTimeInterval(2))
}
@Test("Ohne offene Fristen gibt es keinen Weckzeitpunkt")
func noExpiryWithoutDurations() {
var queue = ActivityQueue()
queue.raise(ticket("weather", rank: 1, for: nil))
#expect(queue.nextExpiry(after: now) == nil)
}
@Test("Aufräumen entfernt genau die abgelaufenen")
func pruneRemovesExpiredOnly() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10, for: 1))
queue.raise(ticket("weather", rank: 1, for: nil))
queue.prune(now: now.addingTimeInterval(2))
#expect(queue.all.map(\.id) == ["weather"])
}
}
@Suite("Fläche unter der Notch")
@MainActor
struct AmbientSizeTests {
private struct Row: NotchActivity {
let id = "volume"
let rank = ActivityRank.response
let duration: TimeInterval? = 2
var size: CGSize { CGSize(width: 196, height: 34) }
func makeContent() -> AnyView { AnyView(EmptyView()) }
}
@Test("Die angemeldete Activity gibt die Fläche vor")
func sizeComesFromActivity() {
let presenter = ActivityPresenter()
presenter.raise(Row())
#expect(presenter.contentSize == CGSize(width: 196, height: 34))
}
@Test("Ohne Activity gibt es keine Fläche")
func noActivityNoSize() {
#expect(ActivityPresenter().contentSize == nil)
}
}
@@ -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)
}
}
@@ -0,0 +1,143 @@
import Testing
import Foundation
@testable import ClipboardProvider
@Suite("Zwischenablage: die Regeln")
struct ClipboardRulesTests {
private func item(_ fingerprint: String, at seconds: TimeInterval = 0,
preview: String = "x") -> ClipboardItem {
ClipboardItem(kind: .text, preview: preview,
addedAt: Date(timeIntervalSince1970: seconds),
sourceBundleID: nil, fingerprint: fingerprint)
}
// MARK: - Was nie gemerkt werden darf
@Test("Ein als verborgen markierter Inhalt wird nicht gemerkt")
func concealedIsIgnored() {
// Passwortmanager setzen diese Marke. Sie zu missachten hieße, fremde
// Passwörter einzusammeln.
#expect(!ClipboardRules.shouldRecord(
types: ["public.utf8-plain-text", "org.nspasteboard.ConcealedType"],
sourceBundleID: nil, excludedApps: []))
}
@Test("Durchreichen und Selbsterzeugtes ebenso wenig")
func transientAndGeneratedAreIgnored() {
#expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.TransientType"],
sourceBundleID: nil, excludedApps: []))
#expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.AutoGeneratedType"],
sourceBundleID: nil, excludedApps: []))
}
@Test("Gewöhnlicher Text schon")
func plainTextIsRecorded() {
#expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: "com.apple.TextEdit",
excludedApps: []))
}
@Test("Aus einem ausgeschlossenen Programm nicht")
func excludedAppIsIgnored() {
#expect(!ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: "com.agilebits.onepassword7",
excludedApps: ["com.agilebits.onepassword7"]))
}
@Test("Ohne bekanntes Herkunftsprogramm wird gemerkt")
func unknownSourceIsRecorded() {
// Sonst ginge alles verloren, was aus einem Hintergrunddienst kommt.
#expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: nil,
excludedApps: ["com.agilebits.onepassword7"]))
}
// MARK: - Vorschau
@Test("Zeilenumbrüche werden zu Leerzeichen")
func previewIsOneLine() {
#expect(ClipboardRules.preview(for: "erste\nzweite") == "erste zweite")
#expect(ClipboardRules.preview(for: "a\t\tb") == "a b")
}
@Test("Zu langer Text wird gekürzt und bekommt Auslassungspunkte")
func previewIsTruncated() {
let long = String(repeating: "a", count: 500)
let preview = ClipboardRules.preview(for: long)
#expect(preview.count == ClipboardRules.previewLength + 1)
#expect(preview.hasSuffix("…"))
}
@Test("Kurzer Text bleibt unangetastet")
func shortPreviewIsUnchanged() {
#expect(ClipboardRules.preview(for: "kurz") == "kurz")
}
// MARK: - Aufnehmen
@Test("Dasselbe noch einmal kopiert rutscht nach oben, statt sich anzureihen")
func duplicateMovesToFront() {
let existing = [item("a", at: 10), item("b", at: 20)]
let again = item("a", at: 30)
let result = ClipboardRules.merge(existing, adding: again)
#expect(result.items.map(\.fingerprint) == ["a", "b"])
#expect(result.items.first?.addedAt == Date(timeIntervalSince1970: 30))
// Der alte Eintrag ist verdrängt — seine Datei muss weg.
#expect(result.dropped.count == 1)
}
@Test("Über der Grenze fällt das Älteste heraus")
func limitDropsOldest() {
let existing = (0..<5).map { item("f\($0)", at: Double(5 - $0)) }
let result = ClipboardRules.merge(existing, adding: item("neu", at: 99), limit: 3)
#expect(result.items.count == 3)
#expect(result.items.first?.fingerprint == "neu")
// Verdrängtes wird gemeldet, damit die Dateien nicht liegen bleiben.
#expect(result.dropped.count == 3)
}
@Test("Verdrängtes wird vollständig gemeldet, auch die Wiederholung")
func droppedIncludesDuplicate() {
let existing = [item("a", at: 1), item("b", at: 2), item("c", at: 3)]
let result = ClipboardRules.merge(existing, adding: item("a", at: 4), limit: 2)
// „a" einmal als Wiederholung, „c" als das Älteste über der Grenze.
#expect(Set(result.dropped.map(\.fingerprint)) == ["a", "c"])
}
// MARK: - Verfall
@Test("Ohne Frist verfällt nichts")
func noExpiryKeepsEverything() {
let old = [item("a", at: 0)]
#expect(ClipboardRules.expired(old, olderThanDays: nil,
now: Date(timeIntervalSince1970: 9_000_000)).isEmpty)
#expect(ClipboardRules.expired(old, olderThanDays: 0,
now: Date(timeIntervalSince1970: 9_000_000)).isEmpty)
}
@Test("Was älter ist als die Frist, verfällt")
func oldItemsExpire() {
let now = Date(timeIntervalSince1970: 10 * 86_400)
let items = [item("alt", at: 0), item("neu", at: 9 * 86_400)]
let stale = ClipboardRules.expired(items, olderThanDays: 7, now: now)
#expect(stale.map(\.fingerprint) == ["alt"])
}
// MARK: - Reihenfolge
@Test("Neuestes zuerst, und gleich alte in fester Reihenfolge")
func sortingIsStable() {
let a = item("a", at: 5)
let b = item("b", at: 5)
let c = item("c", at: 9)
let once = ClipboardRules.sorted([a, b, c])
let twice = ClipboardRules.sorted([c, b, a])
#expect(once.first?.fingerprint == "c")
// Ohne festen Zweitschlüssel tauschten gleich alte Einträge bei jeder
// Anzeige die Plätze.
#expect(once.map(\.id) == twice.map(\.id))
}
}
@@ -0,0 +1,126 @@
import Testing
import Foundation
@testable import FaceProvider
@Suite("Gesichtsvergleich")
struct FaceMatcherTests {
/// Ein Vektor, der sich um `offset` von der Grundform unterscheidet.
private func vector(_ offset: Float, count: Int = 768) -> [Float] {
(0..<count).map { Float($0 % 7) * 0.01 + offset }
}
private func matcher(offsets: [Float] = [0],
confirmations: Int = 3,
attempts: Int = 10) -> FaceMatcher {
FaceMatcher(templates: offsets.enumerated().map {
FaceTemplate(pose: "p\($0.offset)", vector: vector($0.element))
},
requiredConfirmations: confirmations,
maximumAttempts: attempts)
}
// MARK: - Abstand
@Test("Derselbe Vektor hat Abstand null")
func identicalIsZero() {
#expect(FaceMatcher.distance(vector(0), vector(0)) == 0)
}
@Test("Verschieden lange Vektoren ergeben keinen Abstand")
func lengthMismatchIsNil() {
// Zwei verschieden lange Vektoren zu vergleichen ergäbe eine Zahl ohne
// Bedeutung — und die sähe aus wie ein sehr guter Treffer.
#expect(FaceMatcher.distance([1, 2, 3], [1, 2]) == nil)
#expect(FaceMatcher.distance([], []) == nil)
}
@Test("Der kleinste Abstand über alle Posen zählt")
func closestPoseWins() {
// Die Posen sind absichtlich verschieden; ein Mittelwert bestrafte
// genau die Vielfalt, für die sie aufgenommen wurden.
let m = matcher(offsets: [0, 0.5])
let d = m.distance(to: vector(0.5))
#expect(d == 0)
}
// MARK: - Entscheidung
@Test("Erst nach genug Bestätigungen wird geöffnet")
func needsSeveralConfirmations() {
// Die Kamera liefert dreißig Bilder je Sekunde — unter dreißig ist
// irgendwann eines dabei, das zufällig passt.
let m = matcher(confirmations: 3)
var session = FaceMatcher.Session()
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 1))
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 2))
#expect(m.evaluate(vector(0), in: &session) == .accepted)
}
@Test("Ein fremdes Gesicht wird abgelehnt")
func strangerIsRejected() {
let m = matcher()
var session = FaceMatcher.Session()
#expect(m.evaluate(vector(5), in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Ein Fehlversuch setzt die Bestätigungen zurück")
func failureResetsConfirmations() {
// Sonst summierten sich zufällige Treffer über Minuten zu einem Ja.
let m = matcher(confirmations: 3)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(0), in: &session)
_ = m.evaluate(vector(0), in: &session)
#expect(session.confirmations == 2)
_ = m.evaluate(vector(5), in: &session)
#expect(session.confirmations == 0)
}
@Test("Nach genug Fehlversuchen ist gesperrt")
func locksOutAfterAttempts() {
let m = matcher(attempts: 3)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(5), in: &session)
_ = m.evaluate(vector(5), in: &session)
#expect(m.evaluate(vector(5), in: &session) == .lockedOut)
}
@Test("Nach der Sperre hilft auch das richtige Gesicht nicht mehr")
func lockoutIsFinal() {
// Ab hier bleibt nur das Passwort — sonst wäre die Sperre eine
// Verzögerung und keine Grenze.
let m = matcher(attempts: 2)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(5), in: &session)
_ = m.evaluate(vector(5), in: &session)
#expect(m.evaluate(vector(0), in: &session) == .lockedOut)
}
@Test("Ohne hinterlegtes Gesicht öffnet nichts")
func noEnrollmentNeverOpens() {
// Ein leerer Vergleich darf kein Freifahrtschein sein.
let m = FaceMatcher(templates: [])
var session = FaceMatcher.Session()
#expect(!m.isEnrolled)
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Ein Vektor falscher Länge gilt als Fehlversuch, nicht als Treffer")
func wrongLengthIsAFailure() {
let m = matcher()
var session = FaceMatcher.Session()
#expect(m.evaluate([1, 2, 3], in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Die Schwelle liegt näher am Ähnlichen als am Verschiedenen")
func thresholdLeansStrict() {
// Aus dem Spike: ähnlich 0,13 — verschieden 0,47. Ein falsches Ja ist
// ein Fremder am entsperrten Rechner, ein falsches Nein nur ein
// Passwort, das man ohnehin kennt.
#expect(FaceMatcher.defaultThreshold < (0.13 + 0.47) / 2)
#expect(FaceMatcher.defaultThreshold > 0.13)
}
}
@@ -0,0 +1,92 @@
import Testing
import Foundation
@testable import MetricsProvider
@Suite("Akkuverlauf")
struct BatteryHistoryTests {
private func sample(_ seconds: TimeInterval, charge: Double) -> BatterySample {
BatterySample(at: Date(timeIntervalSince1970: seconds), charge: charge,
celsius: nil, watts: nil)
}
private let now = Date(timeIntervalSince1970: 100 * 86_400)
@Test("Ohne vorherige Messung wird sofort aufgezeichnet")
func firstSampleIsRecorded() {
#expect(BatteryHistoryRules.shouldRecord(last: nil, now: now))
}
@Test("Vor Ablauf des Abstands nicht noch einmal")
func waitsForTheInterval() {
let recent = now.addingTimeInterval(-60)
#expect(!BatteryHistoryRules.shouldRecord(last: recent, now: now))
}
@Test("Nach dem Abstand wieder")
func recordsAfterTheInterval() {
let old = now.addingTimeInterval(-BatteryHistoryRules.interval)
#expect(BatteryHistoryRules.shouldRecord(last: old, now: now))
}
@Test("Zu alte Messpunkte fallen heraus")
func oldSamplesArePruned() {
let samples = [
sample(now.timeIntervalSince1970 - 40 * 86_400, charge: 0.5),
sample(now.timeIntervalSince1970 - 1 * 86_400, charge: 0.6),
]
let kept = BatteryHistoryRules.pruned(samples, now: now)
#expect(kept.count == 1)
#expect(kept.first?.charge == 0.6)
}
@Test("Die Säulen bekommen den Mittelwert ihres Abschnitts")
func bucketsAverage() {
// Zwei Messungen in derselben halben Stunde ergeben eine Säule mit
// ihrem Mittel — nicht die erste und nicht die letzte.
let base = now.timeIntervalSince1970 - 600
let samples = [sample(base, charge: 0.4), sample(base + 60, charge: 0.6)]
let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now)
let filled = buckets.compactMap { $0 }
#expect(filled.count == 1)
#expect(abs(filled[0] - 0.5) < 0.0001)
}
@Test("Abschnitte ohne Messung bleiben leer")
func gapsStayEmpty() {
// Ein zugeklappter Rechner hat dort keinen Ladestand gehabt. Eine
// durchgezogene Linie darüber wäre erfunden.
let samples = [sample(now.timeIntervalSince1970 - 600, charge: 0.5)]
let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now)
#expect(buckets.count == HistoryWindow.day.buckets)
#expect(buckets.filter { $0 == nil }.count == HistoryWindow.day.buckets - 1)
}
@Test("Messpunkte außerhalb des Fensters zählen nicht mit")
func outOfWindowIsIgnored() {
let samples = [sample(now.timeIntervalSince1970 - 10 * 86_400, charge: 0.9)]
#expect(BatteryHistoryRules.buckets(samples, window: .day, now: now).allSatisfy { $0 == nil })
#expect(BatteryHistoryRules.summary(samples, window: .day, now: now) == nil)
}
@Test("Die Zusammenfassung nennt Tief, Hoch und Mittel")
func summaryReportsRange() {
let base = now.timeIntervalSince1970 - 3600
let samples = [sample(base, charge: 0.2), sample(base + 60, charge: 0.8)]
let summary = BatteryHistoryRules.summary(samples, window: .day, now: now)
#expect(summary?.low == 0.2)
#expect(summary?.high == 0.8)
#expect(abs((summary?.average ?? 0) - 0.5) < 0.0001)
}
@Test("Jedes Fenster hat weniger Säulen als Messpunkte")
func bucketsAreFewerThanSamples() {
// Über einen Monat wären es bei fünf Minuten Abstand über achttausend
// Punkte — mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne
// etwas zu zeigen.
for window in HistoryWindow.allCases {
let possible = Int(window.duration / BatteryHistoryRules.interval)
#expect(window.buckets < possible)
}
}
}
@@ -0,0 +1,71 @@
import Testing
@testable import MetricsProvider
@Suite("Strom: was gemeldet wird")
struct PowerWatcherTests {
@Test("Der erste gelesene Zustand meldet nichts")
func firstReadingIsSilent() {
var watcher = PowerWatcher()
// Beim Start ist „am Netz" kein Ereignis, sondern die Lage.
#expect(watcher.observe(isCharging: false, isPluggedIn: true,
charge: 0.8, limit: nil) == nil)
#expect(watcher.current == .plugged)
}
@Test("Derselbe Zustand meldet sich nicht noch einmal")
func repeatedStateIsSilent() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil)
#expect(watcher.observe(isCharging: true, isPluggedIn: true,
charge: 0.51, limit: nil) == nil)
#expect(watcher.observe(isCharging: true, isPluggedIn: true,
charge: 0.99, limit: nil) == nil)
}
@Test("Netzteil abgezogen: das ist ein Ereignis")
func unplugging() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil)
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
charge: 0.5, limit: nil) == .unplugged)
}
@Test("Fast leer schlägt „am Akku\"")
func lowBeatsUnplugged() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
// Beides trifft zu — gemeldet wird das, was man nicht verpassen darf.
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
charge: 0.15, limit: nil) == .low)
}
@Test("Von fast leer zurück auf normal ist wieder ein Wechsel")
func recoveringFromLow() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: false, isPluggedIn: false, charge: 0.1, limit: nil)
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
charge: 0.5, limit: nil) == .unplugged)
}
@Test("Die Schwelle liegt bei zwanzig Prozent, einschließlich")
func thresholdIsInclusive() {
var atThreshold = PowerWatcher()
_ = atThreshold.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
#expect(atThreshold.observe(isCharging: false, isPluggedIn: false,
charge: 0.2, limit: nil) == .low)
var above = PowerWatcher()
_ = above.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
#expect(above.observe(isCharging: false, isPluggedIn: false,
charge: 0.21, limit: nil) == .unplugged)
}
@Test("Am Ladelimit ist ein eigener Zustand, nicht „am Netz\"")
func limitIsItsOwnState() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: 80)
#expect(watcher.observe(isCharging: false, isPluggedIn: true,
charge: 0.80, limit: 80) == .atLimit(80))
}
}
@@ -0,0 +1,83 @@
import Testing
import Foundation
@testable import NotesProvider
@Suite("Notizen: die Regeln")
struct NoteRulesTests {
@Test("Die Überschrift ist die erste nichtleere Zeile")
func titleIsFirstLine() {
let note = Note(text: "\n\n Einkaufen \nMilch\nBrot")
#expect(NoteRules.title(of: note) == "Einkaufen")
}
@Test("Eine zu lange erste Zeile wird gekürzt")
func longTitleIsTruncated() {
let note = Note(text: String(repeating: "a", count: 200))
let title = NoteRules.title(of: note)
#expect(title.count == NoteRules.titleLength + 1)
#expect(title.hasSuffix("…"))
}
@Test("Eine leere Notiz hat keine Überschrift")
func emptyHasNoTitle() {
#expect(NoteRules.title(of: Note(text: " \n\n ")) == "")
}
@Test("Eine Notiz aus lauter Leerzeichen zählt als leer")
func whitespaceIsEmpty() {
// Sie entsteht, wenn man auf „Neu" drückt und es sich anders überlegt.
#expect(NoteRules.isEmpty(Note(text: " \n\t ")))
#expect(!NoteRules.isEmpty(Note(text: "x")))
}
@Test("Eine leer gewordene Notiz verschwindet beim Speichern")
func emptyNoteIsDropped() {
let note = Note(text: "war mal etwas")
let kept = NoteRules.merge([], updating: note)
#expect(kept.count == 1)
var emptied = note
emptied.text = " "
#expect(NoteRules.merge(kept, updating: emptied).isEmpty)
}
@Test("Zuletzt geändert steht oben")
func newestFirst() {
let old = Note(text: "alt", updatedAt: Date(timeIntervalSince1970: 1))
let new = Note(text: "neu", updatedAt: Date(timeIntervalSince1970: 2))
#expect(NoteRules.sorted([old, new]).first?.text == "neu")
}
@Test("Gleich alte Notizen behalten ihre Reihenfolge")
func sortingIsStable() {
let when = Date(timeIntervalSince1970: 5)
let a = Note(text: "a", updatedAt: when)
let b = Note(text: "b", updatedAt: when)
#expect(NoteRules.sorted([a, b]).map(\.id) == NoteRules.sorted([b, a]).map(\.id))
}
@Test("Eine bestehende Notiz wird ersetzt, nicht verdoppelt")
func updateReplaces() {
let note = Note(text: "erst")
var changed = note
changed.text = "dann"
let result = NoteRules.merge([note], updating: changed)
#expect(result.count == 1)
#expect(result.first?.text == "dann")
}
@Test("Über der Grenze fällt die älteste heraus")
func limitDropsOldest() {
let existing = (0..<3).map {
Note(text: "n\($0)", updatedAt: Date(timeIntervalSince1970: Double($0)))
}
let result = NoteRules.merge(existing,
updating: Note(text: "neu",
updatedAt: Date(timeIntervalSince1970: 99)),
limit: 3)
#expect(result.count == 3)
#expect(result.first?.text == "neu")
#expect(!result.contains { $0.text == "n0" })
}
}
@@ -0,0 +1,109 @@
import Testing
import Foundation
import SwiftUI
@testable import OnyxDesign
@Suite("Bewegungsprofil")
@MainActor
struct MotionProfileTests {
private func freshDefaults(_ name: String) -> UserDefaults {
let suite = "onyx.motion.test.\(name)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
@Test("Ohne Einstellung ist es schnell")
func defaultsToSnappy() {
let theme = OnyxTheme(defaults: freshDefaults(#function))
#expect(theme.motion == .snappy)
}
@Test("Das gewählte Profil übersteht den Neustart")
func choiceSurvivesRestart() {
let defaults = freshDefaults(#function)
let theme = OnyxTheme(defaults: defaults)
theme.motion = .calm
let restarted = OnyxTheme(defaults: defaults)
#expect(restarted.motion == .calm)
}
/// Ein Profilname, den diese Fassung nicht kennt, darf die Bewegung nicht
/// ausfallen lassen — eine App ohne Animation sieht aus, als hinge sie.
@Test("Ein unbekannter Name fällt auf die Voreinstellung zurück")
func unknownNameFallsBack() {
let defaults = freshDefaults(#function)
defaults.set("hyperspeed", forKey: "onyx.theme.motion")
#expect(OnyxTheme(defaults: defaults).motion == .snappy)
}
/// Der Zurücksetzen-Knopf steht bei den Farben. Nähme er das
/// Bewegungsprofil mit, stellte er etwas um, wovon in seiner Beschriftung
/// nichts steht.
@Test("Farben zurücksetzen lässt das Bewegungsprofil in Ruhe")
func resetLeavesMotionAlone() {
let theme = OnyxTheme(defaults: freshDefaults(#function))
theme.motion = .bouncy
theme.reset()
#expect(theme.motion == .bouncy)
#expect(theme.isDefault)
}
/// Beim Öffnen sieht man zu, beim Schließen ist man schon woanders.
@Test("Einfahren ist in jedem Profil kürzer als Ausfahren")
func collapseIsShorterThanExpand() {
// Die Federantwort steckt in der Animation und ist von außen nicht
// lesbar. Prüfbar ist die Dauer, die der Fenstercontroller benutzt —
// und die ist genau der Wert, an dem es sichtbar schiefgeht.
for profile in MotionProfile.allCases {
#expect(profile.collapseDuration > 0)
#expect(profile.collapseDuration < 1)
}
#expect(MotionProfile.snappy.collapseDuration < MotionProfile.calm.collapseDuration)
}
/// Verschwindet das Fenster vor dem Ende der Bewegung, reißt das Panel
/// sichtbar weg. Genau dafür hängt die Dauer am Profil und ist keine feste
/// Zahl mehr.
@Test("Jedes Profil bringt seine eigene Einfahrdauer mit")
func everyProfileCarriesItsOwnDuration() {
let durations = Set(MotionProfile.allCases.map(\.collapseDuration))
#expect(durations.count == MotionProfile.allCases.count)
}
}
@Suite("Pegel im Regler")
struct MeterFractionTests {
/// 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, und genau das ist hier zu prüfen — sonst ist der Regler ein
/// Balken, der die meiste Zeit leer aussieht.
@Test("Leiser Ton schlägt sichtbar aus")
func quietSignalIsVisible() {
// Ein Viertel der Amplitude sähe linear nach 25 % aus. Die Wurzel macht
// daraus die Hälfte.
#expect(abs(OnyxSlider.meterFraction(0.25) - 0.5) < 0.0001)
}
@Test("Die Enden bleiben, wo sie sind")
func endsAreUntouched() {
#expect(OnyxSlider.meterFraction(0) == 0)
#expect(OnyxSlider.meterFraction(1) == 1)
}
/// Der Ausschlag kommt aus einem Tap und ist nicht garantiert im Bereich.
/// Ein Wert über 1 machte den Pegelbalken breiter als den Regler, ein
/// negativer ließe `sqrt` NaN liefern — und NaN in einer Breitenangabe
/// beendet SwiftUI.
@Test("Werte außerhalb des Bereichs werden gekappt")
func outOfRangeIsClamped() {
#expect(OnyxSlider.meterFraction(4) == 1)
#expect(OnyxSlider.meterFraction(-1) == 0)
#expect(!OnyxSlider.meterFraction(-1).isNaN)
}
}
@@ -0,0 +1,59 @@
import Testing
import SwiftUI
@testable import OnyxDesign
@Suite("Werkstoffe")
@MainActor
struct NotchMaterialTests {
@Test("Stein ist deckend — sonst hätte er an der Hardware-Notch eine Kante")
func stoneIsOpaque() {
#expect(NotchMaterial.stone.fillOpacity == 1.0)
// Und braucht deshalb keine Weichzeichnung darunter.
#expect(!NotchMaterial.stone.showsVibrancy)
}
@Test("Je durchlässiger, desto weniger Deckkraft")
func opacityDecreasesWithTranslucency() {
#expect(NotchMaterial.stone.fillOpacity > NotchMaterial.blur.fillOpacity)
#expect(NotchMaterial.blur.fillOpacity > NotchMaterial.glass.fillOpacity)
}
@Test("Je durchlässiger, desto mehr Glanz")
func sheenGrowsWithTranslucency() {
// Auf deckendem Schwarz genügt ein Hauch; auf Glas ist der Glanz das,
// was die Fläche überhaupt als Körper lesbar macht.
#expect(NotchMaterial.stone.sheen < NotchMaterial.blur.sheen)
#expect(NotchMaterial.blur.sheen < NotchMaterial.glass.sheen)
}
@Test("Alle durchscheinenden Werkstoffe brauchen die Vibrancy-Ebene")
func translucentMaterialsNeedVibrancy() {
#expect(NotchMaterial.blur.showsVibrancy)
#expect(NotchMaterial.glass.showsVibrancy)
}
@Test("Kein Werkstoff ist unsichtbar")
func nothingIsFullyTransparent() {
// Eine Fläche mit Deckkraft null wäre kein Werkstoff, sondern ein
// fehlendes Panel — und niemand fände die Einstellung zurück.
for material in NotchMaterial.allCases {
#expect(material.fillOpacity > 0.3)
}
}
@Test("Jeder Werkstoff überlebt das Speichern")
func roundTripsThroughDefaults() {
for material in NotchMaterial.allCases {
#expect(NotchMaterial(rawValue: material.rawValue) == material)
}
}
@Test("Ein unbekannter Name fällt auf Stein zurück, nicht ins Nichts")
func unknownNameFallsBack() {
let defaults = UserDefaults(suiteName: "onyx.test.material")!
defaults.removePersistentDomain(forName: "onyx.test.material")
defaults.set("liquidUnobtainium", forKey: "onyx.theme.material")
#expect(OnyxTheme(defaults: defaults).material == .stone)
}
}
@@ -74,3 +74,30 @@ struct ColourHexTests {
#expect(Color(onyxHex: "#GGGGGG") == nil)
}
}
@Suite("Farben, die sich nicht gleichen dürfen")
@MainActor
struct ColourCollisionTests {
/// Beide waren zeichengleich `#6FCF97`. Damit hieß derselbe grüne Balken im
/// Netzwerk „lädt herunter" und in den Messwerten „alles in Ordnung" — und
/// wer beides nebeneinander sieht, liest eine Aussage, die niemand gemeint
/// hat.
@Test("Herunterladen ist nicht die Signalfarbe für „in Ordnung“")
func downloadIsNotThePositiveSignal() {
#expect(OnyxTheme.defaultDownload.onyxHex != Onyx.Color.positive.onyxHex)
}
@Test("Herunter und Hoch sind auch untereinander unterscheidbar")
func directionsDifferFromEachOther() {
#expect(OnyxTheme.defaultDownload.onyxHex != OnyxTheme.defaultUpload.onyxHex)
}
/// Die drei Signalfarben tragen die Bedeutung. Fielen zwei davon zusammen,
/// wäre eine Warnung von einem Fehler nicht mehr zu unterscheiden.
@Test("Die Signalfarben sind paarweise verschieden")
func signalColoursAreDistinct() {
let signals = [Onyx.Color.positive, Onyx.Color.warning, Onyx.Color.critical]
#expect(Set(signals.map(\.onyxHex)).count == signals.count)
}
}
@@ -0,0 +1,89 @@
import Testing
@testable import OnyxHelperProtocol
@Suite("Segelmodus und Hitzeschutz")
struct ChargePolicyTests {
// MARK: - Segeln
@Test("Unter dem Limit wird geladen")
func chargesBelowLimit() {
#expect(ChargePolicy.shouldCharge(charge: 0.5, limit: 80, celsius: nil,
wasCharging: true))
}
@Test("Am Limit hört es auf")
func stopsAtLimit() {
#expect(!ChargePolicy.shouldCharge(charge: 0.80, limit: 80, celsius: nil,
wasCharging: true))
}
@Test("Nach dem Anhalten wird nicht sofort wieder geladen")
func doesNotResumeImmediately() {
// Der Kern des Segelns: ohne Abstand hinge der Akku am Limit und würde
// bei jedem verlorenen Prozent nachgeladen — hunderte winzige
// Ladevorgänge am Tag, und genau die altern ihn.
#expect(!ChargePolicy.shouldCharge(charge: 0.79, limit: 80, celsius: nil,
wasCharging: false))
#expect(!ChargePolicy.shouldCharge(charge: 0.76, limit: 80, celsius: nil,
wasCharging: false))
}
@Test("Erst unter dem Abstand geht es wieder los")
func resumesBelowHysteresis() {
#expect(ChargePolicy.shouldCharge(charge: 0.75, limit: 80, celsius: nil,
wasCharging: false))
}
@Test("Ohne Limit gilt nur der Hitzeschutz")
func noLimitMeansAlwaysCharge() {
#expect(ChargePolicy.shouldCharge(charge: 0.99, limit: 100, celsius: nil,
wasCharging: false))
}
// MARK: - Hitze
@Test("Zu heiß heißt: nicht laden")
func heatStopsCharging() {
#expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 41,
wasCharging: true))
}
@Test("Nach dem Abkühlen nicht gleich an der Schwelle wieder los")
func heatHasHysteresisToo() {
// Genau an der Schwelle schaltete es sonst im Sekundentakt an und aus.
#expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 37,
wasCharging: false))
#expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 35,
wasCharging: false))
}
@Test("Ohne Temperaturmessung kein Hitzeschutz")
func unknownTemperatureIsNotAnExcuse() {
// Eine Temperatur zu erfinden wäre schlimmer als keine zu haben.
#expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: nil,
wasCharging: true))
}
// MARK: - Der Grund, den die Anzeige nennt
@Test("Der Grund unterscheidet Limit, Segeln und Hitze")
func reasonsAreDistinct() {
#expect(ChargePolicy.reason(charge: 0.5, limit: 80, celsius: nil,
wasCharging: true) == .charging)
#expect(ChargePolicy.reason(charge: 0.80, limit: 80, celsius: nil,
wasCharging: true) == .atLimit(80))
#expect(ChargePolicy.reason(charge: 0.78, limit: 80, celsius: nil,
wasCharging: false) == .sailing(80))
#expect(ChargePolicy.reason(charge: 0.3, limit: 100, celsius: 42,
wasCharging: true) == .tooHot(42))
}
@Test("Hitze schlägt jeden anderen Grund")
func heatWinsOverLimit() {
// Wer bei 42 Grad „am Limit" liest, sucht den Fehler an der falschen
// Stelle.
#expect(ChargePolicy.reason(charge: 0.95, limit: 80, celsius: 42,
wasCharging: true) == .tooHot(42))
}
}
@@ -0,0 +1,107 @@
import Testing
@testable import OnyxNotch
@Suite("Eingeklappt und trotzdem da")
struct AmbientStateTests {
/// Öffnet das Panel auf dem üblichen Weg: hinein, Entprellzeit ab.
private func opened() -> NotchStateMachine {
var machine = NotchStateMachine()
machine.handle(.pointerEntered)
machine.handle(.armTimerFired)
return machine
}
@Test("Ohne Live Activity ist eingeklappt gleich unsichtbar")
func idleIsOffScreen() {
let machine = NotchStateMachine()
#expect(!machine.isExpanded)
#expect(!machine.isOnScreen)
}
/// Der ganze Zweck: Fenster auf dem Schirm, Fläche in Notchgröße.
@Test("Eine Live Activity holt das Fenster, ohne auszufahren")
func ambientShowsWithoutExpanding() {
var machine = NotchStateMachine()
let effects = machine.handle(.ambient(true))
#expect(effects == [.showAmbient])
#expect(machine.isOnScreen)
#expect(!machine.isExpanded)
}
@Test("Fällt sie weg, geht das Fenster wieder fort")
func ambientVanishing() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
#expect(machine.handle(.ambient(false)) == [.hideAmbient])
#expect(!machine.isOnScreen)
}
/// Zweimal dieselbe Meldung ist keine Änderung. Ohne diese Sperre blendet
/// jeder Lautstärkeschritt das Fenster neu ein.
@Test("Dieselbe Meldung zweimal bewirkt nichts")
func repeatedIsQuiet() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
#expect(machine.handle(.ambient(true)).isEmpty)
}
/// Ausgefahren ist ohnehin alles zu sehen — dort darf eine Activity das
/// Fenster nicht ein zweites Mal einblenden.
@Test("Ausgefahren bleibt eine Live Activity ohne Wirkung")
func ambientWhileExpanded() {
var machine = opened()
#expect(machine.handle(.ambient(true)).isEmpty)
#expect(machine.isExpanded)
#expect(machine.isOnScreen)
}
/// Der eigentliche Grund für die Trennung: nach dem Einfahren darf das
/// Fenster nicht fortgeräumt werden, wenn noch etwas anliegt.
@Test("Nach dem Einfahren bleibt das Fenster, solange etwas anliegt")
func staysAfterCollapse() {
var machine = opened()
machine.handle(.ambient(true))
machine.handle(.pointerExited)
machine.handle(.closeTimerFired)
#expect(!machine.isExpanded)
#expect(machine.isOnScreen)
#expect(machine.hasAmbient)
}
@Test("Unterdrückung nimmt auch die Live Activity weg")
func suppressionHidesAmbient() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
let effects = machine.handle(.suppressed(true))
#expect(effects.contains(.hideAmbient))
#expect(!machine.isOnScreen)
// Gemerkt bleibt sie trotzdem — sonst käme sie nach dem Vollbildvideo
// nicht von selbst zurück.
#expect(machine.hasAmbient)
}
@Test("Nach der Unterdrückung kommt sie von selbst zurück")
func ambientReturnsAfterSuppression() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
machine.handle(.suppressed(true))
#expect(machine.handle(.suppressed(false)) == [.showAmbient])
#expect(machine.isOnScreen)
}
/// Eine Activity darf die Phase nicht anfassen: sonst gälte ein Klick
/// plötzlich als Fixieren, oder eine Frist liefe, die niemand gestartet hat.
@Test("Eine Live Activity ändert die Phase nicht")
func ambientLeavesPhaseAlone() {
var machine = NotchStateMachine()
machine.handle(.pointerEntered)
let before = machine.phase
machine.handle(.ambient(true))
#expect(machine.phase == before)
}
}
@@ -53,10 +53,44 @@ struct DisplayPolicyTests {
#expect(chosen.count == 1)
}
// MARK: - Hauptbildschirm
/// Ein externer Bildschirm, der die Menüleiste trägt — der übliche Fall am
/// Schreibtisch, wenn der Laptop danebensteht.
static let externalMain = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, isMain: true, displayID: 2
)
@Test("Hauptbildschirm: der mit der Menüleiste, auch wenn er extern ist")
func mainDisplayPicksTheMenuBarScreen() {
let chosen = DisplayPolicy.mainDisplay
.screens(from: [Self.builtIn, Self.externalMain, Self.secondExternal])
#expect(chosen.map(\.displayID) == [2])
}
@Test("Hauptbildschirm: genau einer, nie mehrere")
func mainDisplayPicksExactlyOne() {
let chosen = DisplayPolicy.mainDisplay
.screens(from: [Self.builtIn, Self.externalMain, Self.secondExternal])
#expect(chosen.count == 1)
}
@Test("Hauptbildschirm: meldet ihn niemand, bleibt Onyx trotzdem erreichbar")
func mainDisplayFallsBackWhenNobodyClaimsIt() {
// Kann im Betrieb nicht vorkommen — die Menüleiste ist immer irgendwo.
// Ein leeres Ergebnis hieße aber: keine Notch, kein Zugang, kein Weg
// zurück in die Einstellungen.
let chosen = DisplayPolicy.mainDisplay.screens(from: [Self.external, Self.secondExternal])
#expect(chosen.count == 1)
}
@Test("Ohne Displays kein Panel — und kein Absturz")
func noScreensIsSafe() {
#expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty)
#expect(DisplayPolicy.allDisplays.screens(from: []).isEmpty)
#expect(DisplayPolicy.mainDisplay.screens(from: []).isEmpty)
}
@Test("Mehrere eingebaute Displays gibt es nicht, aber der Code darf daran nicht scheitern")
@@ -0,0 +1,65 @@
import Testing
import CoreGraphics
@testable import OnyxNotch
@Suite("Band neben der Notch")
struct NotchBandTests {
// Die nachgemessenen Werte des Zielgeräts: 1800 Punkte breiter Bildschirm,
// Notch 220 breit, bei fünf Kacheln ein 972 Punkte breites Panel.
private let notchWidth: CGFloat = 220
private let panelWidth: CGFloat = 972
@Test("Bei fünf Kacheln bleiben je 376 Punkte")
func referenceMachine() {
let band = NotchBandLayout.resolve(panelWidth: panelWidth, notchWidth: notchWidth)
#expect(band.leading == 376)
#expect(band.trailing == 376)
#expect(!band.isEmpty)
}
@Test("Der gezeichnete Streifen ist wieder so breit wie das Panel")
func totalMatchesPanel() {
let band = NotchBandLayout.resolve(panelWidth: panelWidth, notchWidth: notchWidth)
#expect(band.totalWidth(notchWidth: notchWidth) == panelWidth)
}
/// Zusammengezogen ist das Panel genau die Notch. Dann gibt es keine Arme —
/// und erst recht keine negativen.
@Test("Ist das Panel so breit wie die Notch, gibt es kein Band")
func collapsedHasNoBand() {
let band = NotchBandLayout.resolve(panelWidth: notchWidth, notchWidth: notchWidth)
#expect(band == .none)
#expect(band.isEmpty)
}
/// Kommt auf sehr schmalen Bildschirmen vor, wenn das Panel geklemmt wird.
/// Eine negative Breite wäre in einer `frame`-Angabe ein Absturz.
@Test("Ein Panel schmaler als die Notch ergibt keine negative Breite")
func narrowerThanNotchStaysZero() {
let band = NotchBandLayout.resolve(panelWidth: 120, notchWidth: notchWidth)
#expect(band == .none)
}
@Test("Knapp unter dem Mindestmaß bleibt der Streifen leer")
func belowMinimumIsEmpty() {
let width = notchWidth + 2 * (NotchBandLayout.minimumUsableWidth - 1)
#expect(NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth) == .none)
}
@Test("Genau am Mindestmaß trägt er")
func atMinimumIsUsed() {
let width = notchWidth + 2 * NotchBandLayout.minimumUsableWidth
let band = NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth)
#expect(band.leading == NotchBandLayout.minimumUsableWidth)
}
/// Ein Arm links ohne Gegenstück rechts liest sich als verrutschtes Panel.
@Test("Beide Seiten sind immer gleich breit")
func bothSidesMatch() {
for width in stride(from: CGFloat(200), through: 2000, by: 37) {
let band = NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth)
#expect(band.leading == band.trailing)
}
}
}
@@ -43,8 +43,71 @@ struct NotchGeometryTests {
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize)
#expect(g.kind == .virtual)
// Mittig: 1800 + (2560 − 220)/2 = 2970
#expect(g.rect == CGRect(x: 2970, y: 1408, width: 220, height: 32))
#expect(g.isDetached)
// Mittig: 1800 + (2560 − 220)/2 = 2970.
// Und **nicht** an der Oberkante: ohne Hardware gibt es nichts zum
// Verschmelzen, also schwebt die Insel. Dieser Bildschirm meldet keine
// Menüleistenhöhe, deshalb bleibt es beim vorgegebenen Maß und der
// kleinen Luft: 1440 − 3 − 32 = 1405.
#expect(g.rect == CGRect(x: 2970, y: 1405, width: 220, height: 32))
}
@Test("Die Insel liegt in der Menüleiste, nicht darunter")
func islandFitsIntoTheMenuBar() {
// Ein Bildschirm, der seine Menüleiste meldet — der Normalfall.
let screen = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, menuBarHeight: 24, displayID: 2)
let g = NotchGeometry.resolve(screen, virtualSize: CGSize(width: 72, height: 26))
#expect(g.isDetached)
// 24 minus je 3 Punkt Luft: die vorgegebenen 26 wären zu hoch gewesen
// und hätten unten aus der Leiste herausgeragt.
#expect(g.rect.height == 18)
// Oben und unten gleich viel Luft.
#expect(screen.frame.maxY - g.rect.maxY == 3)
#expect(g.rect.minY - (screen.frame.maxY - screen.menuBarHeight) == 3)
}
@Test("Eine sehr flache Menüleiste drückt die Insel nicht zum Strich")
func islandKeepsAMinimumHeight() {
let screen = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, menuBarHeight: 12, displayID: 9)
let g = NotchGeometry.resolve(screen, virtualSize: CGSize(width: 72, height: 26))
#expect(g.rect.height == NotchGeometry.minimumIslandHeight)
}
@Test("Echte Notch verschmilzt und schwebt nicht")
func physicalNotchStaysMerged() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
#expect(g.kind == .physical)
#expect(!g.isDetached)
// Kein Spalt: die Fläche *ist* die Kante.
#expect(g.topMargin == 0)
}
@Test("Stil „immer schwebend\": auch mit Notch hängt die Insel darunter")
func islandStyleDetachesEvenWithHardware() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize,
style: .island)
#expect(g.isDetached)
// Unter der Hardware, nicht in ihr: sonst wäre die Insel zur Hälfte
// schwarz auf schwarzem Gehäuse.
#expect(g.topMargin > Self.builtIn.safeAreaTop)
}
@Test("Stil „immer verschmolzen\": ohne Hardware klebt die Fläche an der Kante")
func notchStyleStaysAttachedWithoutHardware() {
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize,
style: .notch)
#expect(!g.isDetached)
#expect(g.topMargin == 0)
#expect(g.rect.maxY == Self.external.frame.maxY)
}
@Test("safeAreaTop ohne Hilfsflächen gilt nicht als Notch")
@@ -128,7 +191,10 @@ struct NotchGeometryTests {
let panel = g.panelRect(for: CGSize(width: 640, height: 320))
#expect(panel.minX >= 1800)
#expect(panel.maxY == 1440)
// Die Oberkante des Fensters folgt der schwebenden Insel, nicht der
// Bildschirmkante — sonst klebte das Panel oben fest, während die
// Insel darunter hängt.
#expect(panel.maxY == 1440 - NotchGeometry.detachedMargin)
}
// MARK: - Sichtbarer Inhaltsbereich
@@ -181,3 +247,57 @@ struct NotchGeometryTests {
#expect(panel.minY >= Self.builtIn.frame.minY)
}
}
@Suite("Hardware-Erkennung")
struct HardwareNotchTests {
@Test("Ein Bildschirm mit Hilfsflächen hat eine echte Notch")
func builtInHasHardware() {
let metrics = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38),
auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38),
isBuiltIn: true, displayID: 1)
#expect(NotchGeometry.hardwareRect(metrics) != nil)
}
@Test("Ein externer Bildschirm hat keine")
func externalHasNone() {
let metrics = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, displayID: 2)
#expect(NotchGeometry.hardwareRect(metrics) == nil)
}
@Test("safeAreaTop allein genügt nicht")
func insetAloneIsNotANotch() {
// Bei Spiegelung und Fernanzeige ist das Inset gesetzt, obwohl es
// nichts gibt, um das man herumlegen könnte.
let metrics = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: true, displayID: 3)
#expect(NotchGeometry.hardwareRect(metrics) == nil)
}
@Test("Die Einstellungen und die Geometrie kommen zum selben Schluss")
func settingsAgreeWithGeometry() {
// Genau dafür ist `hardwareRect` öffentlich: würden beide die Frage
// getrennt beantworten, zeigten die Einstellungen irgendwann einen
// Regler für eine Breite, die gar nicht gilt.
let builtIn = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38),
auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38),
isBuiltIn: true, displayID: 1)
let hasHardware = NotchGeometry.hardwareRect(builtIn) != nil
let geometry = NotchGeometry.resolve(builtIn, virtualSize: CGSize(width: 72, height: 26))
#expect(NotchStyle.automatic.merges(withHardware: hasHardware))
#expect(geometry.kind == .physical)
}
}
+41
View File
@@ -0,0 +1,41 @@
import AppKit
// Lässt sich auf Helligkeitsänderungen *hören*, statt sie abzufragen?
//
// Eine Notch, die zehnmal je Sekunde nachfragt, hält den Rechner wach. Wenn es
// keine Benachrichtigung gibt, ist die Helligkeits-Activity nicht zu haben —
// dann lieber gar nicht als mit einem Dauerwecker.
//
// Eigener Prozess, weil die Signatur geraten ist: stimmt sie nicht, stürzt hier
// etwas ab und sonst nichts.
typealias BrightnessCallback = @convention(c) (UnsafeRawPointer?, CGDirectDisplayID,
CFString?, Double) -> Void
typealias Register = @convention(c) (CGDirectDisplayID, UnsafeRawPointer?,
BrightnessCallback) -> Int32
let path = "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices"
guard let handle = dlopen(path, RTLD_LAZY),
let sym = dlsym(handle, "DisplayServicesRegisterForBrightnessChangeNotifications") else {
print("nicht vorhanden"); exit(1)
}
let register = unsafeBitCast(sym, to: Register.self)
let callback: BrightnessCallback = { _, display, key, value in
print(" → Bildschirm \(display), Schlüssel \(key.map { $0 as String } ?? "—"), Wert \(value)")
}
// Nur das eingebaute Display kann Helligkeit; die externen melden Fehler.
let builtIn = NSScreen.screens.compactMap {
$0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
}.first { CGDisplayIsBuiltin($0) != 0 }
guard let builtIn else { print("kein eingebautes Display"); exit(1) }
let status = register(builtIn, nil, callback)
print("Anmeldung für Bildschirm \(builtIn): status \(status) \(status == 0 ? "(erfolgreich)" : "(FEHLER)")")
print("Lausche 12 Sekunden — jede Helligkeitsänderung erscheint hier.")
RunLoop.current.run(until: Date().addingTimeInterval(12))
print("fertig")
+81
View File
@@ -0,0 +1,81 @@
import AppKit
import IOKit
// Gibt es einen benutzbaren Weg, die Bildschirmhelligkeit zu *lesen* — und
// besser noch: auf ihre Änderung zu *hören*?
//
// Öffentlich gibt es keinen. Geprüft werden deshalb drei private bzw.
// halböffentliche Wege, jeder einzeln, damit hinterher klar ist, welcher trägt
// und welcher nur auf manchen Geräten funktioniert.
let displays: [CGDirectDisplayID] = NSScreen.screens.compactMap {
$0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
}
print("Bildschirme: \(displays)\n")
// MARK: - Weg 1: DisplayServices
typealias GetBrightness = @convention(c) (CGDirectDisplayID, UnsafeMutablePointer<Float>) -> Int32
typealias RegisterNotify = @convention(c) (CGDirectDisplayID, UnsafeRawPointer?, UnsafeRawPointer?) -> Int32
let dsPath = "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices"
if let handle = dlopen(dsPath, RTLD_LAZY) {
print("DisplayServices: geladen")
if let sym = dlsym(handle, "DisplayServicesGetBrightness") {
let fn = unsafeBitCast(sym, to: GetBrightness.self)
for id in displays {
var value: Float = -1
let status = fn(id, &value)
print(" GetBrightness(\(id)) → status \(status), Wert \(value)")
}
} else {
print(" DisplayServicesGetBrightness: nicht vorhanden")
}
print(" RegisterForBrightnessChangeNotifications: " +
(dlsym(handle, "DisplayServicesRegisterForBrightnessChangeNotifications") != nil
? "vorhanden" : "FEHLT"))
} else {
print("DisplayServices: nicht ladbar — \(String(cString: dlerror()))")
}
// MARK: - Weg 2: CoreDisplay
typealias UserBrightness = @convention(c) (CGDirectDisplayID) -> Double
let cdPath = "/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay"
if let handle = dlopen(cdPath, RTLD_LAZY) {
print("\nCoreDisplay: geladen")
if let sym = dlsym(handle, "CoreDisplay_Display_GetUserBrightness") {
let fn = unsafeBitCast(sym, to: UserBrightness.self)
for id in displays {
print(" GetUserBrightness(\(id)) → \(fn(id))")
}
} else {
print(" CoreDisplay_Display_GetUserBrightness: nicht vorhanden")
}
} else {
print("\nCoreDisplay: nicht ladbar")
}
// MARK: - Weg 3: IOKit
print("\nIOKit IODisplayGetFloatParameter:")
var iterator = io_iterator_t()
let matching = IOServiceMatching("IODisplayConnect")
if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS {
var found = false
var service = IOIteratorNext(iterator)
while service != 0 {
found = true
var brightness: Float = -1
let status = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString,
&brightness)
print(" Dienst \(service) → status \(status), Wert \(brightness)")
IOObjectRelease(service)
service = IOIteratorNext(iterator)
}
if !found { print(" kein IODisplayConnect gefunden (auf Apple Silicon üblich)") }
IOObjectRelease(iterator)
} else {
print(" IOServiceGetMatchingServices fehlgeschlagen")
}
+67
View File
@@ -0,0 +1,67 @@
import Vision
import AppKit
// Gibt Vision ein Merkmal her, mit dem sich ein Gesicht *wiedererkennen* lässt?
//
// Öffentlich liefert Vision Rechtecke und Landmarken — beides sagt, wo ein
// Gesicht ist, nicht wessen. Für den Vergleich braucht es einen Vektor mit
// Abstandsmaß. Geprüft werden zwei Wege.
print("=== Weg 1: VNGenerateFaceprintRequest (privat) ===")
for name in ["VNGenerateFaceprintRequest", "VNFaceprintObservation",
"VNFaceObservation", "VNGenerateImageFeaturePrintRequest"] {
print(" \(name): \(NSClassFromString(name) != nil ? "vorhanden" : "FEHLT")")
}
print("\n=== Weg 2: VNGenerateImageFeaturePrintRequest (öffentlich) ===")
/// Ein Testbild: eine Fläche mit einem Muster, damit die Merkmale nicht leer sind.
func testImage(hue: CGFloat, shift: CGFloat) -> CGImage {
let size = 224
let space = CGColorSpaceCreateDeviceRGB()
let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8,
bytesPerRow: 0, space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
ctx.setFillColor(NSColor(hue: hue, saturation: 0.6, brightness: 0.9, alpha: 1).cgColor)
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
ctx.setFillColor(NSColor.black.cgColor)
for i in 0..<6 {
ctx.fillEllipse(in: CGRect(x: 20 + CGFloat(i) * 30 + shift, y: 60 + shift,
width: 24, height: 34))
}
return ctx.makeImage()!
}
func featurePrint(_ image: CGImage) -> VNFeaturePrintObservation? {
let request = VNGenerateImageFeaturePrintRequest()
let handler = VNImageRequestHandler(cgImage: image, options: [:])
do {
try handler.perform([request])
return request.results?.first as? VNFeaturePrintObservation
} catch {
print(" Fehler: \(error.localizedDescription)")
return nil
}
}
let a = featurePrint(testImage(hue: 0.1, shift: 0))
let aAgain = featurePrint(testImage(hue: 0.1, shift: 2)) // fast dasselbe
let b = featurePrint(testImage(hue: 0.6, shift: 40)) // deutlich anders
if let a, let aAgain, let b {
print(" Merkmalslänge: \(a.elementCount) Elemente, Typ \(a.elementType.rawValue)")
var near: Float = 0, far: Float = 0
try? a.computeDistance(&near, to: aAgain)
try? a.computeDistance(&far, to: b)
print(" Abstand ähnlich: \(near)")
print(" Abstand verschieden: \(far)")
print(" Trennschärfe: \(far > near ? "gegeben" : "NICHT gegeben") (Verhältnis \(far / max(near, 0.0001)))")
} else {
print(" Merkmale nicht berechenbar")
}
print("\n=== Gesichtserkennung selbst ===")
let request = VNDetectFaceLandmarksRequest()
let handler = VNImageRequestHandler(cgImage: testImage(hue: 0.1, shift: 0), options: [:])
try? handler.perform([request])
print(" Landmarken-Anfrage lief durch, \(request.results?.count ?? 0) Gesichter im Testmuster (0 erwartet)")
+48
View File
@@ -0,0 +1,48 @@
# Spike G — Gesichtswiedererkennung
> Im Plan heißt dieser Spike „F". Der Buchstabe war in `docs/spikes/` schon an die
> Frequenzmessung vergeben; hier läuft die vorhandene Reihe weiter.
**Frage.** Vision liefert öffentlich Rechtecke und Landmarken, aber sagt damit nur,
*wo* ein Gesicht ist — nicht *wessen*. Für einen Vergleich braucht es einen Vektor
mit Abstandsmaß. Gibt es den, oder braucht es ein eigenes CoreML-Modell?
**Gemessen am 05.09.2026**, `Spikes/faceprint-probe.swift`.
## Ergebnis
| Weg | Befund |
|---|---|
| `VNGenerateFaceprintRequest` | **existiert nicht** — auch nicht über `NSClassFromString` erreichbar |
| `VNFaceprintObservation` | **existiert nicht** |
| `VNGenerateImageFeaturePrintRequest` | **öffentlich und benutzbar** |
Der öffentliche Merkmalsvektor hat **768 Elemente** und bringt `computeDistance`
mit. An zwei synthetischen Mustern:
- ähnliche Bilder: Abstand **0,134**
- deutlich verschiedene: Abstand **0,473**
Also Faktor 3,5 Trennschärfe. Ein eigenes CoreML-Modell ist **nicht** nötig.
## Was das für die Bauform heißt
1. `VNDetectFaceLandmarksRequest` findet das Gesicht und seine Landmarken.
2. Daraus wird ausgerichtet und zugeschnitten — ohne Ausrichtung misst man die
Kopfhaltung mit, nicht die Person.
3. `VNGenerateImageFeaturePrintRequest` auf dem Ausschnitt gibt den Vektor.
4. Verglichen wird über `computeDistance` gegen die hinterlegten Vektoren.
## Die Einschränkung, die bleibt
Ein **allgemeiner Bildmerkmalsvektor ist kein Gesichtsmerkmal.** Er reagiert auf
Beleuchtung, Kopfhaltung und Hintergrund mit, nicht nur auf die Person. Deshalb:
- mehrere Posen beim Anlernen, nicht eine,
- Bestätigung über mehrere Einzelbilder, nicht eines,
- eine Schwelle, die lieber ablehnt als falsch zulässt,
- Sperre nach mehreren Fehlversuchen, Rückfall aufs Passwort.
Und die Aussage aus dem Plan bleibt in voller Schärfe: **das ist Bequemlichkeit,
keine Sicherheitsgrenze.** Ein Foto vor die Kamera zu halten genügt — Face ID hat
dafür einen Tiefensensor, die Mac-Webcam hat keinen.
+37
View File
@@ -0,0 +1,37 @@
# Spike H — Helligkeit mitlesen
**Frage.** Wie liest man die Bildschirmhelligkeit, ohne Bedienungshilfen-Berechtigung?
Was kostet Abtasten?
**Gemessen am 05.09.2026**, `Spikes/brightness-probe.swift` und
`Spikes/brightness-notify-probe.swift`. Vier Bildschirme: ein eingebauter, drei externe.
## Ergebnis
| Weg | Befund |
|---|---|
| `DisplayServices` (privat) | **trägt — aber nur für den eingebauten Bildschirm.** Extern: Status 1000, kein Wert |
| `CoreDisplay_Display_GetUserBrightness` | liefert für **jeden** Bildschirm stur `1.0` — wertlos |
| IOKit `IODisplayGetFloatParameter` | **kein `IODisplayConnect` vorhanden** — auf Apple Silicon nicht mehr da |
`DisplayServicesRegisterForBrightnessChangeNotifications` ist vorhanden und die
Anmeldung gibt Status 0 zurück. Damit ist **kein Abtasten nötig** — was die Frage
nach den Kosten erledigt.
## Was das für die Bauform heißt
- Nur der eingebaute Bildschirm bekommt eine Helligkeitsanzeige. Auf einem Mini am
Monitor gibt es keine, und dann erscheint auch nichts.
- Private API, also mit Netz: fehlt ein Symbol nach einem Systemwechsel, bleibt der
Wert leer und Onyx zeigt schlicht nichts an. Nichts anzuzeigen ist ein hinnehmbarer
Ausfall, abzustürzen wäre keiner.
- Kleine Änderungen werden verworfen: die automatische Anpassung ans Umgebungslicht
kriecht in winzigen Schritten, und jede davon als Ereignis zu zeigen wäre eine
Leuchtreklame.
## Sackgasse
`CoreDisplay` sieht in jeder Anleitung im Netz wie der richtige Weg aus. Auf diesem
Gerät gibt es für alle vier Bildschirme `1.0` zurück — auch für den eingebauten, der
tatsächlich auf 62 % stand. Wer den Wert ungeprüft übernimmt, baut eine Anzeige, die
immer „100 %" sagt.
+6
View File
@@ -99,6 +99,12 @@ targets:
product: AudioProvider
- package: OnyxKit
product: ShelfProvider
- package: OnyxKit
product: ActivityProvider
- package: OnyxKit
product: ClipboardProvider
- package: OnyxKit
product: NotesProvider
postBuildScripts:
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort