Compare commits
6
Commits
49a3ff0548
...
3da673662f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da673662f | ||
|
|
f289d02c37 | ||
|
|
edb327cabe | ||
|
|
9b600de9a9 | ||
|
|
c05ccb8ae1 | ||
|
|
ecb39fbe5f |
@@ -7,17 +7,22 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
||||||
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
||||||
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
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 */; };
|
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
|
||||||
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
|
||||||
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||||
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
|
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 */; };
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
||||||
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
|
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
|
||||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
|
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 */; };
|
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; };
|
||||||
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||||
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
|
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
|
||||||
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
|
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
|
||||||
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; };
|
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 */; };
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
||||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
||||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
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 */; };
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
|
||||||
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
|
||||||
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
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 */; };
|
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 */; };
|
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; };
|
||||||
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -60,7 +69,9 @@
|
|||||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
|
||||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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; };
|
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
||||||
@@ -105,6 +120,9 @@
|
|||||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
||||||
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
||||||
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
||||||
|
534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */,
|
||||||
|
39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */,
|
||||||
|
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -166,29 +184,35 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0FBA13B97C53F1D0D740A633 /* Resources */,
|
0FBA13B97C53F1D0D740A633 /* Resources */,
|
||||||
|
AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */,
|
||||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
||||||
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
|
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
|
||||||
B8214C5599D1857E832447EC /* bolt-light.svg */,
|
B8214C5599D1857E832447EC /* bolt-light.svg */,
|
||||||
085D860E654E09E4CD6E535A /* chip.svg */,
|
085D860E654E09E4CD6E535A /* chip.svg */,
|
||||||
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */,
|
|
||||||
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
||||||
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
||||||
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
||||||
|
1C758A5ABC2F748BE7306038 /* GalleryView.swift */,
|
||||||
|
8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */,
|
||||||
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
|
||||||
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
||||||
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
||||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
||||||
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
||||||
|
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */,
|
||||||
63A5510538846C8427AEB831 /* OnboardingView.swift */,
|
63A5510538846C8427AEB831 /* OnboardingView.swift */,
|
||||||
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
|
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
|
||||||
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
||||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
||||||
|
85EDDADF00C8A57744A63191 /* PowerActivity.swift */,
|
||||||
|
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */,
|
||||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
||||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
||||||
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
||||||
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
||||||
|
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */,
|
||||||
);
|
);
|
||||||
path = Onyx;
|
path = Onyx;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -226,6 +250,9 @@
|
|||||||
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
||||||
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
||||||
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
||||||
|
B9524DE253CBAF4344096790 /* ActivityProvider */,
|
||||||
|
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */,
|
||||||
|
F8AC40480E05171EB8091269 /* NotesProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
@@ -358,18 +385,24 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
||||||
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */,
|
29DEDC6D874DB6FF77DC4338 /* AppearanceSettingsView.swift in Sources */,
|
||||||
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
||||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
||||||
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
||||||
|
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */,
|
||||||
|
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */,
|
||||||
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
||||||
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
||||||
|
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */,
|
||||||
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
||||||
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
|
||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
||||||
|
08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */,
|
||||||
|
E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */,
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
||||||
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
|
||||||
|
98AE792A2A346EDAB358728A /* WindowPlacement.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -658,6 +691,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = MetricsProvider;
|
productName = MetricsProvider;
|
||||||
};
|
};
|
||||||
|
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ClipboardProvider;
|
||||||
|
};
|
||||||
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
|
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxHelperProtocol;
|
productName = OnyxHelperProtocol;
|
||||||
@@ -670,6 +707,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxMenuBar;
|
productName = OnyxMenuBar;
|
||||||
};
|
};
|
||||||
|
B9524DE253CBAF4344096790 /* ActivityProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ActivityProvider;
|
||||||
|
};
|
||||||
C64A111F449D0F9D36ED46FB /* OnyxCore */ = {
|
C64A111F449D0F9D36ED46FB /* OnyxCore */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxCore;
|
productName = OnyxCore;
|
||||||
@@ -682,6 +723,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxWidgetKit;
|
productName = OnyxWidgetKit;
|
||||||
};
|
};
|
||||||
|
F8AC40480E05171EB8091269 /* NotesProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = NotesProvider;
|
||||||
|
};
|
||||||
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = ShelfProvider;
|
productName = ShelfProvider;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ final class AppModel {
|
|||||||
var onLayoutChanged: (([WidgetPlacement]) -> Void)?
|
var onLayoutChanged: (([WidgetPlacement]) -> Void)?
|
||||||
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
|
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
|
||||||
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
|
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
|
||||||
|
var onShowsIdleIslandChanged: ((Bool) -> Void)?
|
||||||
|
var onNotchStyleChanged: ((NotchStyle) -> Void)?
|
||||||
|
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
|
||||||
|
|
||||||
var layout: [WidgetPlacement] {
|
var layout: [WidgetPlacement] {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -37,6 +40,68 @@ final class AppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Abweichende Inselbreiten, je Bildschirm — leer heißt „Standardbreite".
|
||||||
|
///
|
||||||
|
/// Als Wörterbuch über die `CGDirectDisplayID` und nicht als Liste über den
|
||||||
|
/// Index in `NSScreen.screens`: der Index ändert sich beim Umstecken, die
|
||||||
|
/// Kennung nicht. Sonst gälte die Einstellung des einen Monitors plötzlich
|
||||||
|
/// für den anderen.
|
||||||
|
private(set) var notchWidths: [CGDirectDisplayID: CGFloat] = [:]
|
||||||
|
|
||||||
|
/// Die eingestellte Breite dieses Bildschirms, falls es eine gibt.
|
||||||
|
func notchWidth(for displayID: CGDirectDisplayID) -> CGFloat? {
|
||||||
|
notchWidths[displayID]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt eine abweichende Breite — oder nimmt sie mit `nil` zurück.
|
||||||
|
func setNotchWidth(_ width: CGFloat?, for displayID: CGDirectDisplayID) {
|
||||||
|
if let width { notchWidths[displayID] = width } else { notchWidths[displayID] = nil }
|
||||||
|
// Als Zeichenkette gespeichert, weil `UserDefaults` keine Wörterbücher
|
||||||
|
// mit Zahlenschlüsseln kann.
|
||||||
|
let raw = notchWidths.reduce(into: [String: Double]()) { $0[String($1.key)] = Double($1.value) }
|
||||||
|
defaults.set(raw, forKey: Keys.notchWidths)
|
||||||
|
onNotchWidthsChanged?(notchWidths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was links neben der Notch steht.
|
||||||
|
var bandLeading: BandAccessory {
|
||||||
|
didSet {
|
||||||
|
guard bandLeading != oldValue else { return }
|
||||||
|
defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was rechts daneben steht.
|
||||||
|
var bandTrailing: BandAccessory {
|
||||||
|
didSet {
|
||||||
|
guard bandTrailing != oldValue else { return }
|
||||||
|
defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notch oder Insel — die Form, die Onyx oben annimmt.
|
||||||
|
var notchStyle: NotchStyle {
|
||||||
|
didSet {
|
||||||
|
guard notchStyle != oldValue else { return }
|
||||||
|
defaults.set(notchStyle.rawValue, forKey: Keys.notchStyle)
|
||||||
|
onNotchStyleChanged?(notchStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob die eingeklappte Insel sichtbar stehen bleibt.
|
||||||
|
///
|
||||||
|
/// Standardmäßig an: ohne sie wächst das Panel auf externen Bildschirmen
|
||||||
|
/// aus dem Nichts. Abschaltbar, weil ein schwarzer Balken auf vier
|
||||||
|
/// Monitoren eine Geschmacksfrage ist und keine Voreinstellung sein darf,
|
||||||
|
/// von der man nicht wegkommt.
|
||||||
|
var showsIdleIsland: Bool {
|
||||||
|
didSet {
|
||||||
|
guard showsIdleIsland != oldValue else { return }
|
||||||
|
defaults.set(showsIdleIsland, forKey: Keys.showsIdleIsland)
|
||||||
|
onShowsIdleIslandChanged?(showsIdleIsland)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var showsMenuBarIcon: Bool {
|
var showsMenuBarIcon: Bool {
|
||||||
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
|
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
|
||||||
}
|
}
|
||||||
@@ -86,6 +151,11 @@ final class AppModel {
|
|||||||
private enum Keys {
|
private enum Keys {
|
||||||
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
|
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
|
||||||
static let displayPolicy = "onyx.displayPolicy"
|
static let displayPolicy = "onyx.displayPolicy"
|
||||||
|
static let showsIdleIsland = "onyx.showsIdleIsland"
|
||||||
|
static let notchStyle = "onyx.notchStyle"
|
||||||
|
static let notchWidths = "onyx.notchWidths"
|
||||||
|
static let bandLeading = "onyx.band.leading"
|
||||||
|
static let bandTrailing = "onyx.band.trailing"
|
||||||
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
||||||
static let showsDockIcon = "onyx.showsDockIcon"
|
static let showsDockIcon = "onyx.showsDockIcon"
|
||||||
static let triggerArea = "onyx.triggerArea"
|
static let triggerArea = "onyx.triggerArea"
|
||||||
@@ -101,6 +171,21 @@ final class AppModel {
|
|||||||
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
|
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
|
||||||
.flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly
|
.flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly
|
||||||
|
|
||||||
|
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
|
||||||
|
self.notchStyle = defaults.string(forKey: Keys.notchStyle)
|
||||||
|
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic
|
||||||
|
// Die bisherige feste Verdrahtung als Voreinstellung: wer nichts
|
||||||
|
// einstellt, sieht, was vorher auch dastand.
|
||||||
|
self.bandLeading = defaults.string(forKey: Keys.bandLeading)
|
||||||
|
.flatMap(BandAccessory.init(rawValue:)) ?? .weather
|
||||||
|
self.bandTrailing = defaults.string(forKey: Keys.bandTrailing)
|
||||||
|
.flatMap(BandAccessory.init(rawValue:)) ?? .battery
|
||||||
|
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
|
||||||
|
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
|
||||||
|
guard let id = CGDirectDisplayID(entry.key) else { return }
|
||||||
|
result[id] = CGFloat(entry.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
|
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
|
||||||
// zurück, wenn die Notch einmal nicht reagiert.
|
// zurück, wenn die Notch einmal nicht reagiert.
|
||||||
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
|
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -12,7 +12,7 @@ struct FanSettingsView: View {
|
|||||||
@Bindable var control: FanControl
|
@Bindable var control: FanControl
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
SettingsPage {
|
||||||
switch control.installState {
|
switch control.installState {
|
||||||
case .notInstalled, .failed:
|
case .notInstalled, .failed:
|
||||||
installSection
|
installSection
|
||||||
@@ -23,16 +23,13 @@ struct FanSettingsView: View {
|
|||||||
// Das Ladelimit hängt am selben Helfer wie die Lüfter — beides
|
// Das Ladelimit hängt am selben Helfer wie die Lüfter — beides
|
||||||
// ist Hardwaresteuerung über den SMC, und beides ist erst
|
// ist Hardwaresteuerung über den SMC, und beides ist erst
|
||||||
// erreichbar, wenn der Helfer läuft.
|
// erreichbar, wenn der Helfer läuft.
|
||||||
Section("charge.section") {
|
OnyxFormCard(String(localized: "charge.section")) {
|
||||||
ChargeLimitControl(control: control, roomy: true)
|
ChargeLimitControl(control: control, roomy: true)
|
||||||
|
.padding(.horizontal, OnyxForm.rowPadding)
|
||||||
|
.padding(.bottom, OnyxForm.rowPadding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
|
||||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.padding()
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
control.refreshInstallState()
|
control.refreshInstallState()
|
||||||
control.start()
|
control.start()
|
||||||
@@ -43,37 +40,33 @@ struct FanSettingsView: View {
|
|||||||
// MARK: - Einrichtung
|
// MARK: - Einrichtung
|
||||||
|
|
||||||
private var installSection: some View {
|
private var installSection: some View {
|
||||||
Section {
|
OnyxFormCard(String(localized: "fans.group.setup"),
|
||||||
Text("fans.intro")
|
hint: String(localized: "fans.intro")) {
|
||||||
.font(.callout)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
// Das Risiko benennen, bevor jemand zustimmt — nicht danach.
|
// Das Risiko benennen, bevor jemand zustimmt — nicht danach.
|
||||||
Label("fans.warning", systemImage: "exclamationmark.triangle.fill")
|
OnyxFormRow(String(localized: "fans.warning"),
|
||||||
.font(.callout)
|
hint: String(localized: "fans.safety"),
|
||||||
.foregroundStyle(Onyx.Color.warning)
|
symbol: "exclamationmark.triangle.fill",
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
tone: .warning)
|
||||||
|
|
||||||
Text("fans.safety")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
if case .failed(let message) = control.installState {
|
if case .failed(let message) = control.installState {
|
||||||
Text(message).font(.callout).foregroundStyle(Onyx.Color.critical)
|
OnyxFormRow(message, symbol: "xmark.octagon", tone: .critical)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("fans.install") { control.install() }
|
OnyxFormRow(String(localized: "fans.install")) {
|
||||||
|
Button("fans.install") { control.install() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var approvalSection: some View {
|
private var approvalSection: some View {
|
||||||
Section {
|
OnyxFormCard(String(localized: "fans.group.setup")) {
|
||||||
Label("fans.approval.needed", systemImage: "hand.raised")
|
OnyxFormRow(String(localized: "fans.approval.needed"),
|
||||||
.font(.callout)
|
symbol: "hand.raised") {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Button("fans.approval.open") { control.openApprovalSettings() }
|
||||||
Button("fans.approval.open") { control.openApprovalSettings() }
|
}
|
||||||
Button("fans.approval.recheck") { control.refreshInstallState() }
|
OnyxFormRow(String(localized: "fans.action.recheck")) {
|
||||||
|
Button("fans.action.recheck") { control.refreshInstallState() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +75,13 @@ struct FanSettingsView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var controlSection: some View {
|
private var controlSection: some View {
|
||||||
if let report = control.report {
|
if let report = control.report {
|
||||||
Section {
|
OnyxFormCard(String(localized: "fans.group.control")) {
|
||||||
if let hottest = report.hottestCelsius {
|
if let hottest = report.hottestCelsius {
|
||||||
HStack {
|
OnyxFormRow(String(localized: "fans.hottest"), symbol: "thermometer") {
|
||||||
Label("fans.hottest", systemImage: "thermometer")
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(hottest))°").monospacedDigit()
|
Text("\(Int(hottest))°").monospacedDigit()
|
||||||
.foregroundStyle(hottest >= FanSafety.criticalCelsius
|
.foregroundStyle(hottest >= FanSafety.criticalCelsius
|
||||||
? Onyx.Color.critical : .secondary)
|
? AnyShapeStyle(Onyx.Color.critical)
|
||||||
|
: AnyShapeStyle(.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ForEach(report.fans, id: \.index) { fan in
|
ForEach(report.fans, id: \.index) { fan in
|
||||||
@@ -97,22 +89,21 @@ struct FanSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
OnyxFormCard(String(localized: "fans.group.danger"),
|
||||||
Text("fans.safety.active")
|
hint: String(localized: "fans.safety.active")) {
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
OnyxFormRow(String(localized: "fans.uninstall")) {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Button("settings.action.remove", role: .destructive) {
|
||||||
Button("fans.uninstall", role: .destructive) {
|
Task { await control.uninstall() }
|
||||||
Task { await control.uninstall() }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Section {
|
OnyxFormCard(String(localized: "fans.group.control")) {
|
||||||
HStack {
|
OnyxFormRow(String(localized: "fans.connecting")) {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
Text("fans.connecting").foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
if let error = control.lastError {
|
if let error = control.lastError {
|
||||||
Text(error).font(.callout).foregroundStyle(Onyx.Color.critical)
|
OnyxFormRow(error, symbol: "xmark.octagon", tone: .critical)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +118,7 @@ private struct FanRow: View {
|
|||||||
@State private var editing = false
|
@State private var editing = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||||
HStack {
|
HStack {
|
||||||
Label(name, systemImage: "fan")
|
Label(name, systemImage: "fan")
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -173,6 +164,9 @@ private struct FanRow: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Die Karte polstert nur `OnyxFormRow`; dieser Block ist ein eigener.
|
||||||
|
.padding(.horizontal, OnyxForm.rowPadding)
|
||||||
|
.padding(.vertical, Onyx.Space.row)
|
||||||
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
|
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
|
||||||
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
|
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-52
@@ -16,7 +16,7 @@ struct FanView: View {
|
|||||||
let roomy: Bool
|
let roomy: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: Onyx.Space.row) {
|
||||||
switch control.installState {
|
switch control.installState {
|
||||||
case .installed:
|
case .installed:
|
||||||
if let report = control.report, !report.fans.isEmpty {
|
if let report = control.report, !report.fans.isEmpty {
|
||||||
@@ -24,10 +24,10 @@ struct FanView: View {
|
|||||||
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
||||||
}
|
}
|
||||||
if let hottest = report.hottestCelsius {
|
if let hottest = report.hottestCelsius {
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Image(systemName: "thermometer.medium")
|
Image(systemName: "thermometer.medium")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: Onyx.Symbol.small))
|
||||||
Text("\(Int(hottest.rounded())) °C")
|
Text("\(Int(hottest.rounded())) °C")
|
||||||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -35,14 +35,24 @@ struct FanView: View {
|
|||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
notice("fans.noData", symbol: "fan")
|
OnyxNotice(symbol: "fan",
|
||||||
|
text: String(localized: "fans.noData"),
|
||||||
|
placement: .leading)
|
||||||
}
|
}
|
||||||
case .requiresApproval:
|
case .requiresApproval:
|
||||||
notice("fans.approval.needed", symbol: "hand.raised") {
|
OnyxNotice(symbol: "hand.raised",
|
||||||
control.openApprovalSettings()
|
text: String(localized: "fans.approval.needed"),
|
||||||
|
placement: .leading) {
|
||||||
|
OnyxPillButton(String(localized: "fans.install")) {
|
||||||
|
control.openApprovalSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .notInstalled:
|
case .notInstalled:
|
||||||
notice("fans.notInstalled", symbol: "fan") { control.install() }
|
OnyxNotice(symbol: "fan",
|
||||||
|
text: String(localized: "fans.notInstalled"),
|
||||||
|
placement: .leading) {
|
||||||
|
OnyxPillButton(String(localized: "fans.install")) { control.install() }
|
||||||
|
}
|
||||||
case .failed(let reason):
|
case .failed(let reason):
|
||||||
Text(reason)
|
Text(reason)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
@@ -54,29 +64,6 @@ struct FanView: View {
|
|||||||
.onAppear { control.refreshInstallState() }
|
.onAppear { control.refreshInstallState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func notice(_ key: LocalizedStringKey, symbol: String,
|
|
||||||
action: (() -> Void)? = nil) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Image(systemName: symbol)
|
|
||||||
.font(.system(size: 15, weight: .light))
|
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
|
||||||
Text(key)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
if let action {
|
|
||||||
Button(action: action) {
|
|
||||||
Text("fans.install")
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.surface)
|
|
||||||
.padding(.horizontal, 10).padding(.vertical, 4)
|
|
||||||
.background(Onyx.Color.accent, in: .capsule)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct FanRow: View {
|
private struct FanRow: View {
|
||||||
@@ -86,8 +73,8 @@ private struct FanRow: View {
|
|||||||
let roomy: Bool
|
let roomy: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Text("fans.fan \(fan.index + 1)")
|
Text("fans.fan \(fan.index + 1)")
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
@@ -98,29 +85,16 @@ private struct FanRow: View {
|
|||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: Onyx.Space.md) {
|
||||||
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
|
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
|
||||||
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
|
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
|
||||||
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
|
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
|
||||||
// aus wie ein Link und nicht wie ein Zustand.
|
// aus wie ein Link und nicht wie ein Zustand.
|
||||||
Button { control.setAutomatic(index: index) } label: {
|
OnyxPillButton(String(localized: "fans.auto"),
|
||||||
Text("fans.auto")
|
isFilled: !fan.isManual,
|
||||||
.font(Onyx.Font.metricSmall)
|
size: .compact) {
|
||||||
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
|
control.setAutomatic(index: index)
|
||||||
: Onyx.Color.surface)
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background {
|
|
||||||
Capsule()
|
|
||||||
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
|
|
||||||
.overlay {
|
|
||||||
Capsule().strokeBorder(
|
|
||||||
fan.isManual ? Onyx.Color.hairline : .clear,
|
|
||||||
lineWidth: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!fan.isManual)
|
.disabled(!fan.isManual)
|
||||||
|
|
||||||
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
|
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
|
||||||
@@ -184,7 +158,7 @@ final class FanMenuBarModule: MenuBarModule {
|
|||||||
|
|
||||||
func makePopoverView() -> AnyView {
|
func makePopoverView() -> AnyView {
|
||||||
AnyView(FanView(control: control, roomy: true)
|
AnyView(FanView(control: control, roomy: true)
|
||||||
.padding(14)
|
.padding(Onyx.Space.xl)
|
||||||
.frame(width: 260))
|
.frame(width: 260))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
|
||||||
|
/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die
|
||||||
|
/// Notch anzufahren.
|
||||||
|
///
|
||||||
|
/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt
|
||||||
|
/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene
|
||||||
|
/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf
|
||||||
|
/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese
|
||||||
|
/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also
|
||||||
|
/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher
|
||||||
|
/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle
|
||||||
|
/// statt erfundener.
|
||||||
|
///
|
||||||
|
/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug,
|
||||||
|
/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten
|
||||||
|
/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu
|
||||||
|
/// übersetzen.
|
||||||
|
struct GalleryView: View {
|
||||||
|
|
||||||
|
let model: AppModel
|
||||||
|
|
||||||
|
/// Bleibt stehen, wo man zuletzt war.
|
||||||
|
///
|
||||||
|
/// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt
|
||||||
|
/// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den
|
||||||
|
/// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne
|
||||||
|
/// Zwischenklick.
|
||||||
|
@AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Picker("", selection: $section) {
|
||||||
|
ForEach(GallerySection.allCases) { Text($0.title).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.padding(.horizontal, Onyx.Space.window)
|
||||||
|
.padding(.top, Onyx.Space.row)
|
||||||
|
.padding(.bottom, Onyx.Space.xl)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
switch section {
|
||||||
|
case .tiles: TileGallery()
|
||||||
|
case .panel: PanelGallery(model: model)
|
||||||
|
case .components: ComponentGallery()
|
||||||
|
case .tokens: TokenGallery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
.frame(minWidth: 760, minHeight: 520)
|
||||||
|
// Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GallerySection: String, CaseIterable, Identifiable {
|
||||||
|
case tiles, panel, components, tokens
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .tiles: "Kacheln"
|
||||||
|
case .panel: "Panel"
|
||||||
|
case .components: "Bauteile"
|
||||||
|
case .tokens: "Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kacheln
|
||||||
|
|
||||||
|
/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche.
|
||||||
|
///
|
||||||
|
/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in
|
||||||
|
/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200
|
||||||
|
/// auf schwarzem Stein ist der Ort, an dem sie später steht.
|
||||||
|
private struct TileGallery: View {
|
||||||
|
|
||||||
|
private struct Entry: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let widget: any OnyxWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) }
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
|
if entries.isEmpty {
|
||||||
|
Text("Keine Kacheln angemeldet.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width,
|
||||||
|
maximum: LayoutEngine.tileSize.width),
|
||||||
|
spacing: Onyx.Metric.tileSpacing)],
|
||||||
|
alignment: .leading,
|
||||||
|
spacing: Onyx.Space.xxl
|
||||||
|
) {
|
||||||
|
ForEach(entries) { entry in
|
||||||
|
VStack(spacing: Onyx.Space.sm) {
|
||||||
|
// Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel,
|
||||||
|
// dann Rahmen, dann beschneiden. Wer erst
|
||||||
|
// beschneidet, sieht Überlauf nicht, den das Panel
|
||||||
|
// später zeigt.
|
||||||
|
OnyxTile { entry.widget.makeView() }
|
||||||
|
.frame(width: LayoutEngine.tileSize.width,
|
||||||
|
height: LayoutEngine.tileSize.height)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
Text(entry.id)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Onyx.Metric.panelPadding)
|
||||||
|
.background(OnyxSurface())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Onyx.Space.window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Panel
|
||||||
|
|
||||||
|
/// Das ganze Panel, so breit, wie man es einstellt.
|
||||||
|
///
|
||||||
|
/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine`
|
||||||
|
/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen,
|
||||||
|
/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen,
|
||||||
|
/// ohne dafür einen Bildschirm anzustecken.
|
||||||
|
private struct PanelGallery: View {
|
||||||
|
|
||||||
|
let model: AppModel
|
||||||
|
|
||||||
|
@State private var availableWidth: Double = 1680
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let width = CGFloat(availableWidth)
|
||||||
|
let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.xl) {
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Text("Verfügbare Breite")
|
||||||
|
.font(.callout)
|
||||||
|
Slider(value: $availableWidth, in: 400...3000)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
Text("\(Int(availableWidth)) pt")
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · "
|
||||||
|
+ "\(LayoutEngine.columns(availableWidth: width)) Spalten · "
|
||||||
|
+ "\(model.layout.count) Kacheln")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
WidgetGrid(placements: model.layout, availableWidth: width)
|
||||||
|
.background(OnyxSurface())
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Onyx.Space.window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bauteile
|
||||||
|
|
||||||
|
/// Jedes Bauteil in jedem Zustand.
|
||||||
|
///
|
||||||
|
/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle
|
||||||
|
/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von
|
||||||
|
/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier
|
||||||
|
/// kann man ihn also einfach hinschreiben.
|
||||||
|
private struct ComponentGallery: View {
|
||||||
|
|
||||||
|
@State private var volume = 0.62
|
||||||
|
@State private var overdrive = 1.8
|
||||||
|
@State private var targeted = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
|
||||||
|
group("OnyxCard mit OnyxHairline und OnyxSectionTitle") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
|
OnyxSectionTitle("System")
|
||||||
|
OnyxCard {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
row("MacBook Pro-Lautsprecher", "41 %")
|
||||||
|
OnyxHairline()
|
||||||
|
row("MacBook Pro-Mikrofon", "27 %")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
group("OnyxNotice") {
|
||||||
|
HStack(alignment: .top, spacing: Onyx.Space.lg) {
|
||||||
|
tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") }
|
||||||
|
tile {
|
||||||
|
OnyxNotice(symbol: "hand.raised",
|
||||||
|
text: "Der Helfer wartet auf deine Freigabe.") {
|
||||||
|
OnyxPillButton("Einstellungen") {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tile {
|
||||||
|
OnyxNotice(symbol: "fan",
|
||||||
|
text: "Noch keine Lüfterdaten.",
|
||||||
|
placement: .leading)
|
||||||
|
}
|
||||||
|
tile {
|
||||||
|
OnyxNotice(symbol: "fan",
|
||||||
|
text: "Der Helfer ist nicht installiert.",
|
||||||
|
placement: .leading) {
|
||||||
|
OnyxPillButton("Installieren") {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group("OnyxPillButton") {
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
OnyxPillButton("Einschalten") {}
|
||||||
|
OnyxPillButton("Automatik", isFilled: false) {}
|
||||||
|
OnyxPillButton("Auto", size: .compact) {}
|
||||||
|
OnyxPillButton("Auto", isFilled: false, size: .compact) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group("OnyxSlider") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
|
sliderRow("normal") {
|
||||||
|
OnyxSlider(value: volume) { volume = $0 }
|
||||||
|
}
|
||||||
|
sliderRow("mit Pegel") {
|
||||||
|
OnyxSlider(value: volume, level: 0.35) { volume = $0 }
|
||||||
|
}
|
||||||
|
sliderRow("stumm") {
|
||||||
|
OnyxSlider(value: volume, isActive: false) { volume = $0 }
|
||||||
|
}
|
||||||
|
sliderRow("Overdrive") {
|
||||||
|
OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
group("OnyxDropZone") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
|
Toggle("Zeiger schwebt darüber", isOn: $targeted)
|
||||||
|
.font(Onyx.Font.body)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
tile {
|
||||||
|
OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen")
|
||||||
|
.onyxDropZone(isTargeted: targeted, style: .always)
|
||||||
|
}
|
||||||
|
tile {
|
||||||
|
OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an")
|
||||||
|
.onyxDropZone(isTargeted: targeted, style: .whenTargeted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Onyx.Metric.panelPadding)
|
||||||
|
.background(OnyxSurface())
|
||||||
|
.padding(Onyx.Space.window)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ name: String, _ value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
Spacer(minLength: Onyx.Space.md)
|
||||||
|
Text(value)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Onyx.Space.md)
|
||||||
|
.padding(.vertical, Onyx.Space.md)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein
|
||||||
|
/// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun
|
||||||
|
/// kann.
|
||||||
|
private func tile(@ViewBuilder content: () -> some View) -> some View {
|
||||||
|
OnyxTile { content() }
|
||||||
|
.frame(width: LayoutEngine.tileSize.width, height: 130)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sliderRow(_ name: String,
|
||||||
|
@ViewBuilder content: () -> some View) -> some View {
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.micro)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 72, alignment: .leading)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func group(_ title: String,
|
||||||
|
@ViewBuilder content: () -> some View) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
|
Text(title)
|
||||||
|
.font(Onyx.Font.title)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token
|
||||||
|
|
||||||
|
/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind.
|
||||||
|
///
|
||||||
|
/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline`
|
||||||
|
/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da.
|
||||||
|
private struct TokenGallery: View {
|
||||||
|
|
||||||
|
@State private var moved = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
|
||||||
|
colors
|
||||||
|
spaces
|
||||||
|
radii
|
||||||
|
fonts
|
||||||
|
metrics
|
||||||
|
motion
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Onyx.Metric.panelPadding)
|
||||||
|
.background(OnyxSurface())
|
||||||
|
.padding(Onyx.Space.window)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var colors: some View {
|
||||||
|
let swatches: [(String, Color)] = [
|
||||||
|
("surface", Onyx.Color.surface),
|
||||||
|
("elevated", Onyx.Color.elevated),
|
||||||
|
("hairline", Onyx.Color.hairline),
|
||||||
|
("textPrimary", Onyx.Color.textPrimary),
|
||||||
|
("textSecondary", Onyx.Color.textSecondary),
|
||||||
|
("textTertiary", Onyx.Color.textTertiary),
|
||||||
|
("accent", Onyx.Color.accent),
|
||||||
|
("download", Onyx.Color.download),
|
||||||
|
("upload", Onyx.Color.upload),
|
||||||
|
("positive", Onyx.Color.positive),
|
||||||
|
("warning", Onyx.Color.warning),
|
||||||
|
("critical", Onyx.Color.critical),
|
||||||
|
]
|
||||||
|
|
||||||
|
return section("Farben") {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)],
|
||||||
|
alignment: .leading, spacing: Onyx.Space.row) {
|
||||||
|
ForEach(swatches, id: \.0) { name, color in
|
||||||
|
HStack(spacing: Onyx.Space.md) {
|
||||||
|
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
||||||
|
.fill(color)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
||||||
|
.strokeBorder(Onyx.Color.hairline,
|
||||||
|
lineWidth: Onyx.Metric.hairlineWidth)
|
||||||
|
}
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var spaces: some View {
|
||||||
|
let steps: [(String, CGFloat)] = [
|
||||||
|
("xxs", Onyx.Space.xxs),
|
||||||
|
("xs", Onyx.Space.xs),
|
||||||
|
("sm", Onyx.Space.sm),
|
||||||
|
("md", Onyx.Space.md),
|
||||||
|
("lg", Onyx.Space.lg),
|
||||||
|
("xl", Onyx.Space.xl),
|
||||||
|
("xxl", Onyx.Space.xxl),
|
||||||
|
]
|
||||||
|
|
||||||
|
return section("Abstände") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
|
ForEach(steps, id: \.0) { name, value in
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.micro)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 40, alignment: .leading)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: value, height: 12)
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(Onyx.Font.metricMicro)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var radii: some View {
|
||||||
|
let steps: [(String, CGFloat)] = [
|
||||||
|
("chip", Onyx.Radius.chip),
|
||||||
|
("control", Onyx.Radius.control),
|
||||||
|
("card", Onyx.Radius.card),
|
||||||
|
("tile", Onyx.Radius.tile),
|
||||||
|
("notch", Onyx.Radius.notch),
|
||||||
|
("panel", Onyx.Radius.panel),
|
||||||
|
]
|
||||||
|
|
||||||
|
return section("Rundungen") {
|
||||||
|
HStack(alignment: .top, spacing: Onyx.Space.lg) {
|
||||||
|
ForEach(steps, id: \.0) { name, value in
|
||||||
|
VStack(spacing: Onyx.Space.sm) {
|
||||||
|
RoundedRectangle(cornerRadius: value, style: .continuous)
|
||||||
|
.fill(Onyx.Color.elevated)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: value, style: .continuous)
|
||||||
|
.strokeBorder(Onyx.Color.hairline,
|
||||||
|
lineWidth: Onyx.Metric.hairlineWidth)
|
||||||
|
}
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
Text("\(name) \(Int(value))")
|
||||||
|
.font(Onyx.Font.micro)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fonts: some View {
|
||||||
|
let specimens: [(String, Font)] = [
|
||||||
|
("micro", Onyx.Font.micro),
|
||||||
|
("caption", Onyx.Font.caption),
|
||||||
|
("footnote", Onyx.Font.footnote),
|
||||||
|
("body", Onyx.Font.body),
|
||||||
|
("title", Onyx.Font.title),
|
||||||
|
("headline", Onyx.Font.headline),
|
||||||
|
("display", Onyx.Font.display),
|
||||||
|
("displayLarge", Onyx.Font.displayLarge),
|
||||||
|
("metricMicro", Onyx.Font.metricMicro),
|
||||||
|
("metricSmall", Onyx.Font.metricSmall),
|
||||||
|
("metric", Onyx.Font.metric),
|
||||||
|
("metricLarge", Onyx.Font.metricLarge),
|
||||||
|
("symbolHero", Onyx.Font.symbolHero),
|
||||||
|
]
|
||||||
|
|
||||||
|
return section("Schrift") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
|
ForEach(specimens, id: \.0) { name, font in
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) {
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.micro)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 96, alignment: .leading)
|
||||||
|
Text("Onyx 0123456789")
|
||||||
|
.font(font)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metrics: some View {
|
||||||
|
let values: [(String, CGFloat)] = [
|
||||||
|
("panelPadding", Onyx.Metric.panelPadding),
|
||||||
|
("tileSpacing", Onyx.Metric.tileSpacing),
|
||||||
|
("hairlineWidth", Onyx.Metric.hairlineWidth),
|
||||||
|
]
|
||||||
|
|
||||||
|
return section("Panelgeometrie") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
|
ForEach(values, id: \.0) { name, value in
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.micro)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 96, alignment: .leading)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: max(value, 1), height: 12)
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(Onyx.Font.metricMicro)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf
|
||||||
|
/// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten.
|
||||||
|
private var motion: some View {
|
||||||
|
let curves: [(String, Animation)] = [
|
||||||
|
("expand", Onyx.Motion.expand),
|
||||||
|
("collapse", Onyx.Motion.collapse),
|
||||||
|
("value", Onyx.Motion.value),
|
||||||
|
]
|
||||||
|
|
||||||
|
return section("Bewegung") {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
|
Picker("Profil", selection: Binding(
|
||||||
|
get: { OnyxTheme.shared.motion },
|
||||||
|
set: { OnyxTheme.shared.motion = $0 })) {
|
||||||
|
ForEach(MotionProfile.allCases, id: \.self) { profile in
|
||||||
|
Text(profile.rawValue).tag(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 280)
|
||||||
|
|
||||||
|
ForEach(curves, id: \.0) { name, animation in
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Text(name)
|
||||||
|
.font(Onyx.Font.micro)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 96, alignment: .leading)
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Onyx.Color.hairline)
|
||||||
|
.frame(height: 4)
|
||||||
|
Circle()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.offset(x: moved ? 246 : 0)
|
||||||
|
.animation(animation, value: moved)
|
||||||
|
}
|
||||||
|
.frame(width: 260, height: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(moved ? "Zurück" : "Los") { moved.toggle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
|
Text(title)
|
||||||
|
.font(Onyx.Font.title)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Führt das Schaufenster.
|
||||||
|
///
|
||||||
|
/// Aufgebaut wie `SettingsWindowController` — aus demselben Grund: SwiftUIs
|
||||||
|
/// eigene Fensterszenen kommen bei einer App ohne Menüleiste unzuverlässig an.
|
||||||
|
@MainActor
|
||||||
|
final class GalleryWindowController: NSObject, NSWindowDelegate {
|
||||||
|
|
||||||
|
private var window: NSWindow?
|
||||||
|
|
||||||
|
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
|
||||||
|
/// zurückgeschaltet wird.
|
||||||
|
private var keepsDockIcon = false
|
||||||
|
|
||||||
|
/// Ob das Schaufenster überhaupt erreichbar ist.
|
||||||
|
///
|
||||||
|
/// Im Debug immer, im Release nur auf ausdrücklichen Wunsch. Es ist
|
||||||
|
/// Werkzeug, kein Programmteil — und ein Menüeintrag, den niemand braucht,
|
||||||
|
/// ist selbst schon Unordnung.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// defaults write com.scarriffleservices.onyx onyx.gallery -bool YES
|
||||||
|
/// ```
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
true
|
||||||
|
#else
|
||||||
|
UserDefaults.standard.bool(forKey: "onyx.gallery")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob es beim Start gleich aufgehen soll.
|
||||||
|
///
|
||||||
|
/// Der Werkstatt-Kreislauf ist bauen, starten, hinsehen. Ohne das ist der
|
||||||
|
/// dritte Schritt ein Griff zur Maus und zwei Klicks ins Statusmenü — bei
|
||||||
|
/// jedem Durchlauf. `UserDefaults` liest Startargumente von selbst mit:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// open Onyx.app --args -onyx.gallery.open YES
|
||||||
|
/// ```
|
||||||
|
static var opensAtLaunch: Bool {
|
||||||
|
isAvailable && UserDefaults.standard.bool(forKey: "onyx.gallery.open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(model: AppModel) {
|
||||||
|
keepsDockIcon = model.showsDockIcon
|
||||||
|
|
||||||
|
// Solange ein Fenster offen ist, gehört Onyx in den Dock — sonst ist es
|
||||||
|
// weder über den Dock noch über ⌘-Tab zurückzuholen.
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
|
||||||
|
if window == nil {
|
||||||
|
let hosting = NSHostingController(rootView: GalleryView(model: model))
|
||||||
|
|
||||||
|
let window = NSWindow(contentViewController: hosting)
|
||||||
|
window.title = "Onyx — Schaufenster"
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.titlebarSeparatorStyle = .none
|
||||||
|
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||||
|
window.setContentSize(NSSize(width: 1040, height: 720))
|
||||||
|
window.contentMinSize = NSSize(width: 780, height: 540)
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.delegate = self
|
||||||
|
OnyxWindowPlacement.center(window)
|
||||||
|
window.setFrameAutosaveName("onyx.gallery")
|
||||||
|
self.window = window
|
||||||
|
}
|
||||||
|
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
window?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der rote Knopf blendet aus, statt zu schließen — wie bei den
|
||||||
|
/// Einstellungen, und aus demselben Grund: Onyx hat kein Hauptfenster, und
|
||||||
|
/// manche Hilfsprogramme beenden eine App, sobald ihr letztes Fenster
|
||||||
|
/// verschwindet.
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
hide()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
|
|
||||||
|
// Nur zurück in den Hintergrund, wenn wirklich nichts mehr offen ist.
|
||||||
|
// Das Einstellungsfenster kann noch stehen — es hier mit auszublenden
|
||||||
|
// sähe aus wie ein Absturz.
|
||||||
|
let otherVisible = NSApp.windows.contains {
|
||||||
|
$0 !== window && $0.isVisible && $0.styleMask.contains(.titled)
|
||||||
|
}
|
||||||
|
guard !otherVisible else { return }
|
||||||
|
|
||||||
|
if !keepsDockIcon {
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
|
NSApp.hide(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ final class MixerMenuBarModule: MenuBarModule {
|
|||||||
AnyView(
|
AnyView(
|
||||||
ScrollView {
|
ScrollView {
|
||||||
MixerView(mixer: mixer, system: system)
|
MixerView(mixer: mixer, system: system)
|
||||||
.padding(16)
|
.padding(Onyx.Space.xl)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.automatic)
|
.scrollIndicators(.automatic)
|
||||||
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
|
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
|
||||||
|
|||||||
@@ -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
@@ -33,7 +33,7 @@ struct OnboardingView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
OnyxFormCard {
|
||||||
OnboardingRow(
|
OnboardingRow(
|
||||||
symbol: "calendar",
|
symbol: "calendar",
|
||||||
title: "onboarding.calendar",
|
title: "onboarding.calendar",
|
||||||
@@ -74,9 +74,10 @@ struct OnboardingView: View {
|
|||||||
fanControl.openApprovalSettings()
|
fanControl.openApprovalSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 22)
|
.padding(.horizontal, Onyx.Space.xxl)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, Onyx.Space.lg)
|
||||||
}
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
footer
|
footer
|
||||||
@@ -95,10 +96,10 @@ struct OnboardingView: View {
|
|||||||
// MARK: - Kopf und Fuß
|
// MARK: - Kopf und Fuß
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: Onyx.Space.xl) {
|
||||||
Image(nsImage: NSApp.applicationIconImage)
|
Image(nsImage: NSApp.applicationIconImage)
|
||||||
.resizable().frame(width: 52, height: 52)
|
.resizable().frame(width: 52, height: 52)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
Text("onboarding.title").font(.title2).fontWeight(.semibold)
|
Text("onboarding.title").font(.title2).fontWeight(.semibold)
|
||||||
Text("onboarding.subtitle")
|
Text("onboarding.subtitle")
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
@@ -106,7 +107,7 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(22)
|
.padding(Onyx.Space.xxl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
@@ -128,7 +129,7 @@ struct OnboardingView: View {
|
|||||||
Button("onboarding.done", action: onFinish)
|
Button("onboarding.done", action: onFinish)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.padding(22)
|
.padding(Onyx.Space.xxl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zustände
|
// MARK: - Zustände
|
||||||
@@ -169,33 +170,25 @@ private struct OnboardingRow: View {
|
|||||||
enum State { case granted, denied, undetermined }
|
enum State { case granted, denied, undetermined }
|
||||||
|
|
||||||
let symbol: String
|
let symbol: String
|
||||||
let title: LocalizedStringKey
|
let title: String.LocalizationValue
|
||||||
let detail: LocalizedStringKey
|
let detail: String.LocalizationValue
|
||||||
let state: State
|
let state: State
|
||||||
let request: () async -> Void
|
let request: () async -> Void
|
||||||
let openSettings: () -> Void
|
let openSettings: () -> Void
|
||||||
|
|
||||||
|
/// Dieselbe Zeile wie in den Einstellungen. Vorher war es eine eigene, die
|
||||||
|
/// zufällig gleich aussah — bis auf die Trennlinie, die sie sich selbst
|
||||||
|
/// unterlegte, und die deshalb auch unter der letzten stand.
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 14) {
|
OnyxFormRow(String(localized: title),
|
||||||
Image(systemName: symbol)
|
hint: String(localized: detail),
|
||||||
.font(.system(size: 15))
|
symbol: symbol,
|
||||||
.foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary)
|
tone: state == .granted ? .normal : .normal) {
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title).fontWeight(.medium)
|
|
||||||
Text(detail)
|
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
case .granted:
|
case .granted:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Onyx.Color.positive)
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
.font(.system(size: 16))
|
.font(.system(size: Onyx.Symbol.large))
|
||||||
case .undetermined:
|
case .undetermined:
|
||||||
Button("onboarding.allow") { Task { await request() } }
|
Button("onboarding.allow") { Task { await request() } }
|
||||||
case .denied:
|
case .denied:
|
||||||
@@ -204,7 +197,5 @@ private struct OnboardingRow: View {
|
|||||||
Button("onboarding.openSettings", action: openSettings)
|
Button("onboarding.openSettings", action: openSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 12)
|
|
||||||
.overlay(alignment: .bottom) { Divider() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
|||||||
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
|
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
|
||||||
onFinish: { [weak self] in self?.finish() }))
|
onFinish: { [weak self] in self?.finish() }))
|
||||||
|
|
||||||
let window = NSWindow(contentViewController: hosting)
|
// Derselbe Grund wie im Einstellungsfenster — siehe
|
||||||
|
// `OnyxBackdropController`.
|
||||||
|
let window = NSWindow(contentViewController:
|
||||||
|
OnyxBackdropController(content: hosting))
|
||||||
window.title = "Onyx"
|
window.title = "Onyx"
|
||||||
window.styleMask = [.titled, .closable]
|
window.styleMask = [.titled, .closable]
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
|
|||||||
+264
-7
@@ -12,6 +12,9 @@ import MetricsProvider
|
|||||||
import NetworkProvider
|
import NetworkProvider
|
||||||
import AudioProvider
|
import AudioProvider
|
||||||
import ShelfProvider
|
import ShelfProvider
|
||||||
|
import ClipboardProvider
|
||||||
|
import NotesProvider
|
||||||
|
import ActivityProvider
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
|
||||||
@@ -42,18 +45,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var weatherModel: WeatherModel?
|
private var weatherModel: WeatherModel?
|
||||||
private var mediaModel: MediaModel?
|
private var mediaModel: MediaModel?
|
||||||
private var metricsModel: MetricsModel?
|
private var metricsModel: MetricsModel?
|
||||||
|
|
||||||
|
/// Woran erkannt wird, dass sich etwas **geändert** hat. Ohne dieses
|
||||||
|
/// Gedächtnis meldete jede Messung dasselbe noch einmal.
|
||||||
|
/// Entscheidet, ob ein Messwert überhaupt ein Ereignis ist. Geprüft in
|
||||||
|
/// `PowerWatcherTests`.
|
||||||
|
private var powerWatcher = PowerWatcher()
|
||||||
|
/// Woran ein Titelwechsel erkannt wird — Titel und Interpret, nicht der
|
||||||
|
/// ganze Zustand: die Abspielposition wandert im Sekundentakt weiter, und
|
||||||
|
/// das ist kein Ereignis.
|
||||||
|
private var lastTrack: String?
|
||||||
|
|
||||||
|
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
|
||||||
|
/// Helligkeit bleibt sie leer und meldet nie etwas.
|
||||||
|
private let brightness = DisplayBrightness()
|
||||||
|
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
|
||||||
|
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
|
||||||
|
private var lastBrightness: Double?
|
||||||
private var networkModel: NetworkModel?
|
private var networkModel: NetworkModel?
|
||||||
private var shelfStore: ShelfStore?
|
private var shelfStore: ShelfStore?
|
||||||
|
private var clipboardStore: ClipboardStore?
|
||||||
|
private var notesStore: NotesStore?
|
||||||
private let settingsWindow = SettingsWindowController()
|
private let settingsWindow = SettingsWindowController()
|
||||||
private let fanControl = FanControl()
|
private let fanControl = FanControl()
|
||||||
private let audioMixer = AudioMixer()
|
private let audioMixer = AudioMixer()
|
||||||
private let systemAudio = SystemAudio()
|
private let systemAudio = SystemAudio()
|
||||||
private let launchAtLogin = LaunchAtLogin()
|
private let launchAtLogin = LaunchAtLogin()
|
||||||
private let onboarding = OnboardingWindowController()
|
private let onboarding = OnboardingWindowController()
|
||||||
|
private let galleryWindow = GalleryWindowController()
|
||||||
|
private let activities = ActivityPresenter()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
|
// Werkstattschalter: die Erscheinung **nur dieser App** umstellen, um
|
||||||
|
// Fenster hell und dunkel nebeneinander prüfen zu können. Die
|
||||||
|
// Systemeinstellung dafür zu drehen wäre ein Eingriff in die Umgebung
|
||||||
|
// des Nutzers — für einen Blick auf ein Fenster zu viel.
|
||||||
|
//
|
||||||
|
// open Onyx.app --args -onyx.appearance light
|
||||||
|
//
|
||||||
|
// **Nur im Debug.** Der Schalter liegt im flüchtigen Argument-Bereich
|
||||||
|
// und überlebt keinen Neustart — aber eine ausgelieferte App, die sich
|
||||||
|
// über die Erscheinungswahl des Systems hinwegsetzen *kann*, ist eine
|
||||||
|
// Falle, die irgendwann jemand auslöst.
|
||||||
|
#if DEBUG
|
||||||
|
switch UserDefaults.standard.string(forKey: "onyx.appearance") {
|
||||||
|
case "light": NSApp.appearance = NSAppearance(named: .aqua)
|
||||||
|
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
|
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
|
||||||
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
|
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
|
||||||
// Notch — das sieht aus wie ein Ruckler und ist keiner.
|
// Notch — das sieht aus wie ein Ruckler und ist keiner.
|
||||||
@@ -74,14 +117,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
let coordinator = NotchCoordinator(policy: model.displayPolicy,
|
let coordinator = NotchCoordinator(policy: model.displayPolicy,
|
||||||
trigger: model.triggerArea) { displayID, presentation in
|
trigger: model.triggerArea) { displayID, presentation in
|
||||||
NotchHostingView(
|
NotchHostingView(
|
||||||
rootView: NotchContainer(presentation: presentation) {
|
rootView: NotchContainer(
|
||||||
NotchPanelContent(model: model, displayID: displayID)
|
presentation: presentation,
|
||||||
},
|
leading: { [weak self] in
|
||||||
|
NotchBandSlot(presentation: presentation) {
|
||||||
|
BandSlotView(model: model, edge: .leading,
|
||||||
|
weather: self?.weatherModel,
|
||||||
|
metrics: self?.metricsModel)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailing: { [weak self] in
|
||||||
|
NotchBandSlot(presentation: presentation) {
|
||||||
|
BandSlotView(model: model, edge: .trailing,
|
||||||
|
weather: self?.weatherModel,
|
||||||
|
metrics: self?.metricsModel)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ambient: { [weak self] in
|
||||||
|
if let self { NotchAmbientSlot(presenter: activities) }
|
||||||
|
},
|
||||||
|
content: { NotchPanelContent(model: model, displayID: displayID) }),
|
||||||
presentation: presentation)
|
presentation: presentation)
|
||||||
}
|
}
|
||||||
|
coordinator.showsIdleIsland = model.showsIdleIsland
|
||||||
|
coordinator.style = model.notchStyle
|
||||||
|
coordinator.virtualWidthOverrides = model.notchWidths
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
self.coordinator = coordinator
|
self.coordinator = coordinator
|
||||||
|
|
||||||
|
// Die Live Activities: der Presenter entscheidet, die Controller
|
||||||
|
// führen aus. Beides je Bildschirm, weil jede Notch ihr eigenes
|
||||||
|
// Fenster hat.
|
||||||
|
activities.onChange = { [weak self] activity in
|
||||||
|
guard let self, let coordinator = self.coordinator else { return }
|
||||||
|
for controller in coordinator.activeControllers {
|
||||||
|
controller.setAmbient(activity == nil ? nil : self.activities.contentSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeVolume()
|
||||||
|
observePower()
|
||||||
|
observeMedia()
|
||||||
|
observeBrightness()
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(screensChanged),
|
self, selector: #selector(screensChanged),
|
||||||
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||||
@@ -97,6 +174,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
self?.coordinator?.policy = policy
|
self?.coordinator?.policy = policy
|
||||||
self?.applyContentSize()
|
self?.applyContentSize()
|
||||||
}
|
}
|
||||||
|
model.onShowsIdleIslandChanged = { [weak self] shows in
|
||||||
|
self?.coordinator?.showsIdleIsland = shows
|
||||||
|
}
|
||||||
|
model.onNotchStyleChanged = { [weak self] style in
|
||||||
|
self?.coordinator?.style = style
|
||||||
|
}
|
||||||
|
model.onNotchWidthsChanged = { [weak self] widths in
|
||||||
|
self?.coordinator?.virtualWidthOverrides = widths
|
||||||
|
}
|
||||||
|
|
||||||
let menuBar = MenuBarController()
|
let menuBar = MenuBarController()
|
||||||
if let metricsModel {
|
if let metricsModel {
|
||||||
@@ -133,6 +219,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
installOnyxStatusItem()
|
installOnyxStatusItem()
|
||||||
observeMenuBarPreference()
|
observeMenuBarPreference()
|
||||||
requestPermissions()
|
requestPermissions()
|
||||||
|
|
||||||
|
if GalleryWindowController.opensAtLaunch {
|
||||||
|
galleryWindow.show(model: model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derselbe Werkstattgriff wie beim Schaufenster: bauen, starten,
|
||||||
|
// hinsehen — ohne den Umweg übers Statusmenü.
|
||||||
|
//
|
||||||
|
// open Onyx.app --args -onyx.settings.open YES \
|
||||||
|
// -onyx.settings.section appearance
|
||||||
|
if UserDefaults.standard.bool(forKey: "onyx.settings.open") {
|
||||||
|
let name = UserDefaults.standard.string(forKey: "onyx.settings.section") ?? ""
|
||||||
|
showSettings(section: SettingsSection(rawValue: name) ?? .general)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
|
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
|
||||||
@@ -178,7 +278,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
|
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
|
||||||
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
|
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
|
||||||
// genügt der Berechtigungsbereich der Einstellungen.
|
// genügt der Berechtigungsbereich der Einstellungen.
|
||||||
showSettings(tab: .permissions)
|
showSettings(section: .permissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
|
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
|
||||||
@@ -246,6 +346,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
|
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
|
||||||
// eine leere Ablegefläche waren ein Drittel des Panels.
|
// eine leere Ablegefläche waren ein Drittel des Panels.
|
||||||
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
|
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
|
||||||
|
|
||||||
|
let clipboardStore = ClipboardStore()
|
||||||
|
self.clipboardStore = clipboardStore
|
||||||
|
WidgetRegistry.shared.register(ClipboardWidget(store: clipboardStore))
|
||||||
|
|
||||||
|
let notesStore = NotesStore()
|
||||||
|
self.notesStore = notesStore
|
||||||
|
WidgetRegistry.shared.register(NotesWidget(store: notesStore))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Je Bildschirm eine eigene Größe.
|
/// Je Bildschirm eine eigene Größe.
|
||||||
@@ -292,6 +400,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
action: #selector(openSettings), keyEquivalent: ",").target = self
|
action: #selector(openSettings), keyEquivalent: ",").target = self
|
||||||
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
|
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
|
||||||
action: #selector(openOnboarding), keyEquivalent: "").target = self
|
action: #selector(openOnboarding), keyEquivalent: "").target = self
|
||||||
|
// Werkzeug, kein Programmteil: unlokalisiert, und im Release nur, wenn
|
||||||
|
// jemand den Schlüssel ausdrücklich setzt. Siehe
|
||||||
|
// `GalleryWindowController.isAvailable`.
|
||||||
|
if GalleryWindowController.isAvailable {
|
||||||
|
menu.addItem(withTitle: "Schaufenster",
|
||||||
|
action: #selector(openGallery), keyEquivalent: "").target = self
|
||||||
|
}
|
||||||
menu.addItem(.separator())
|
menu.addItem(.separator())
|
||||||
menu.addItem(withTitle: String(localized: "menu.quit"),
|
menu.addItem(withTitle: String(localized: "menu.quit"),
|
||||||
action: #selector(quit), keyEquivalent: "q").target = self
|
action: #selector(quit), keyEquivalent: "q").target = self
|
||||||
@@ -302,6 +417,148 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
@objc private func openOnboarding() { showOnboarding() }
|
@objc private func openOnboarding() { showOnboarding() }
|
||||||
|
|
||||||
|
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
|
||||||
|
///
|
||||||
|
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
|
||||||
|
/// `SystemAudio` hängt bereits mit CoreAudio-Listenern an Lautstärke und
|
||||||
|
/// Stummschaltung. Ein zweiter Weg dorthin wäre eine zweite Wahrheit.
|
||||||
|
///
|
||||||
|
/// Die Meldung kommt **vor** dem Schreiben; der neue Wert steht erst
|
||||||
|
/// danach. Deshalb wird er in einer Aufgabe gelesen, nicht im Rückruf.
|
||||||
|
private func observeVolume() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = systemAudio.outputVolume
|
||||||
|
_ = systemAudio.outputMuted
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
activities.raise(VolumeActivity(level: systemAudio.outputVolume,
|
||||||
|
isMuted: systemAudio.outputMuted))
|
||||||
|
observeVolume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet jeden **Wechsel** am Strom als Live Activity an.
|
||||||
|
///
|
||||||
|
/// Nicht jeden Messwert: der Ladestand ändert sich ständig, und eine Notch,
|
||||||
|
/// die das begleitet, wäre eine Leuchtreklame. Angemeldet wird nur, wenn
|
||||||
|
/// sich der Zustand ändert — und der Zustand ist das, was ``ChargeActivity``
|
||||||
|
/// aus Laden, Netz, Ladestand und Limit ableitet.
|
||||||
|
///
|
||||||
|
/// Der erste gelesene Zustand meldet **nichts**. Sonst begrüßte Onyx jeden
|
||||||
|
/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen
|
||||||
|
/// ist.
|
||||||
|
private func observePower() {
|
||||||
|
guard let metricsModel else { return }
|
||||||
|
withObservationTracking {
|
||||||
|
_ = metricsModel.snapshot.battery
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { observePower() }
|
||||||
|
guard let battery = self.metricsModel?.snapshot.battery else { return }
|
||||||
|
guard let event = self.powerWatcher.observe(
|
||||||
|
isCharging: battery.isCharging,
|
||||||
|
isPluggedIn: battery.isPluggedIn,
|
||||||
|
charge: battery.charge,
|
||||||
|
limit: self.metricsModel?.chargeControl?.chargeLimit)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let kind: PowerActivity.Kind = switch event {
|
||||||
|
case .charging: .charging
|
||||||
|
case .plugged: .plugged
|
||||||
|
case .unplugged: .unplugged
|
||||||
|
case .atLimit(let limit): .atLimit(limit)
|
||||||
|
case .low: .low
|
||||||
|
}
|
||||||
|
self.activities.raise(PowerActivity(kind: kind,
|
||||||
|
charge: battery.charge,
|
||||||
|
isCharging: battery.isCharging,
|
||||||
|
isPluggedIn: battery.isPluggedIn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet jede Helligkeitsänderung als Live Activity an.
|
||||||
|
///
|
||||||
|
/// Nur, wenn dieser Rechner überhaupt eine lesbare Helligkeit hat — auf
|
||||||
|
/// einem Mini am Monitor gibt es keine, und dann soll auch nichts
|
||||||
|
/// erscheinen. Siehe ``DisplayBrightness``.
|
||||||
|
private func observeBrightness() {
|
||||||
|
guard brightness.isAvailable else { return }
|
||||||
|
withObservationTracking {
|
||||||
|
_ = brightness.level
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { observeBrightness() }
|
||||||
|
guard let level = self.brightness.level else { return }
|
||||||
|
|
||||||
|
guard let previous = self.lastBrightness else {
|
||||||
|
self.lastBrightness = level
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.lastBrightness = level
|
||||||
|
// Ein Hauch Unterschied ist kein Ereignis: die Meldung kommt
|
||||||
|
// auch bei automatischer Anpassung an das Umgebungslicht, und
|
||||||
|
// die kriecht in winzigen Schritten.
|
||||||
|
guard abs(level - previous) >= Self.brightnessStep else { return }
|
||||||
|
self.activities.raise(BrightnessActivity(level: level))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ab welchem Sprung eine Helligkeitsänderung als Handgriff gilt.
|
||||||
|
///
|
||||||
|
/// Ein Tastendruck ändert sie um rund ein Sechzehntel; das Umgebungslicht
|
||||||
|
/// kriecht in viel kleineren Schritten.
|
||||||
|
private static let brightnessStep = 0.02
|
||||||
|
|
||||||
|
/// Meldet jeden **Titelwechsel** als Live Activity an.
|
||||||
|
///
|
||||||
|
/// Nicht jede Änderung an `nowPlaying`: dort wandert die Abspielposition
|
||||||
|
/// im Sekundentakt weiter, und das ist kein Ereignis. Verglichen wird
|
||||||
|
/// deshalb Titel und Interpret, nicht der ganze Zustand.
|
||||||
|
///
|
||||||
|
/// Auch hier meldet der erste gelesene Titel nichts — beim Start läuft
|
||||||
|
/// vielleicht seit einer Stunde dasselbe Stück.
|
||||||
|
private func observeMedia() {
|
||||||
|
guard let mediaModel else { return }
|
||||||
|
withObservationTracking {
|
||||||
|
_ = mediaModel.nowPlaying
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { observeMedia() }
|
||||||
|
guard let playing = self.mediaModel?.nowPlaying, !playing.title.isEmpty else {
|
||||||
|
self.lastTrack = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let track = "\(playing.title)\u{1}\(playing.artist)"
|
||||||
|
guard let previous = self.lastTrack else {
|
||||||
|
self.lastTrack = track
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard track != previous else { return }
|
||||||
|
self.lastTrack = track
|
||||||
|
// Ein pausierter Titel wechselt nicht von selbst — was hier
|
||||||
|
// ankommt, während nichts läuft, ist ein Nachtrag der Quelle
|
||||||
|
// und kein Ereignis.
|
||||||
|
guard playing.isPlaying else { return }
|
||||||
|
self.activities.raise(MediaActivity(title: playing.title,
|
||||||
|
artist: playing.artist,
|
||||||
|
artwork: playing.artwork))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openGallery() {
|
||||||
|
guard let model else { return }
|
||||||
|
galleryWindow.show(model: model)
|
||||||
|
}
|
||||||
|
|
||||||
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
||||||
///
|
///
|
||||||
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
|
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
|
||||||
@@ -362,16 +619,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
|
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
|
||||||
|
|
||||||
@objc private func openSettings() { showSettings(tab: .widgets) }
|
@objc private func openSettings() { showSettings(section: .general) }
|
||||||
|
|
||||||
private func showSettings(tab: SettingsTab) {
|
private func showSettings(section: SettingsSection) {
|
||||||
guard let model, let calendarModel, let weatherModel else { return }
|
guard let model, let calendarModel, let weatherModel else { return }
|
||||||
settingsWindow.show(model: model, calendarModel: calendarModel,
|
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||||
weatherModel: weatherModel, metricsModel: metricsModel,
|
weatherModel: weatherModel, metricsModel: metricsModel,
|
||||||
shelfStore: shelfStore, fanControl: fanControl,
|
shelfStore: shelfStore, fanControl: fanControl,
|
||||||
launchAtLogin: launchAtLogin,
|
launchAtLogin: launchAtLogin,
|
||||||
showOnboarding: { [weak self] in self?.showOnboarding() },
|
showOnboarding: { [weak self] in self?.showOnboarding() },
|
||||||
tab: tab)
|
section: section)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() { NSApp.terminate(nil) }
|
@objc private func quit() { NSApp.terminate(nil) }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -9,10 +9,6 @@ import MetricsProvider
|
|||||||
import OnyxHelperProtocol
|
import OnyxHelperProtocol
|
||||||
import WeatherProvider
|
import WeatherProvider
|
||||||
|
|
||||||
enum SettingsTab: Hashable {
|
|
||||||
case widgets, display, colors, sources, menubar, fans, permissions
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
@@ -22,87 +18,215 @@ struct SettingsView: View {
|
|||||||
let fanControl: FanControl
|
let fanControl: FanControl
|
||||||
let launchAtLogin: LaunchAtLogin
|
let launchAtLogin: LaunchAtLogin
|
||||||
let showOnboarding: () -> Void
|
let showOnboarding: () -> Void
|
||||||
/// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes
|
/// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
|
||||||
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
|
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
|
||||||
@Binding var selectedTab: SettingsTab
|
@Binding var section: SettingsSection
|
||||||
|
|
||||||
/// Die Reiter in der Reihenfolge, in der sie oben stehen.
|
|
||||||
private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [
|
|
||||||
(.widgets, "settings.tab.widgets"),
|
|
||||||
(.display, "settings.tab.display"),
|
|
||||||
(.colors, "settings.tab.colors"),
|
|
||||||
(.sources, "settings.tab.options"),
|
|
||||||
(.menubar, "settings.tab.menubar"),
|
|
||||||
(.fans, "settings.tab.fans"),
|
|
||||||
(.permissions, "settings.tab.permissions"),
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Eigene Reiterleiste statt `TabView`.
|
// **Zweiteilung von Hand, kein `NavigationSplitView`.**
|
||||||
//
|
//
|
||||||
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
|
// Der malt Seitenleiste und Inhaltsfläche selbst deckend zu. Die
|
||||||
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf.
|
// Vibrancy-Ebene des Fensters kam deshalb nur unter dem Titelbalken
|
||||||
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
|
// durch — oben durchsichtig, darunter eine Platte.
|
||||||
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
|
// `.scrollContentBackground(.hidden)` erreicht sie nicht: es gilt für
|
||||||
VStack(spacing: 0) {
|
// Listen und Scrollflächen, nicht für den Rahmen darum.
|
||||||
Picker("", selection: $selectedTab) {
|
//
|
||||||
ForEach(Self.tabs, id: \.tab) { entry in
|
// Gebraucht wird hier ohnehin keine Navigation, sondern eine Auswahl
|
||||||
Text(entry.label).tag(entry.tab)
|
// und zwei Spalten.
|
||||||
}
|
HStack(spacing: 0) {
|
||||||
}
|
sidebar
|
||||||
.pickerStyle(.segmented)
|
.frame(width: 200)
|
||||||
.labelsHidden()
|
|
||||||
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund
|
Divider()
|
||||||
// 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch
|
|
||||||
// dann, wenn das Fenster schmaler war — sie ragte dann links und
|
|
||||||
// rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt
|
|
||||||
// dafür, dass hier trotzdem nichts gestaucht wird.
|
|
||||||
.frame(maxWidth: 1000)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
|
|
||||||
content
|
content
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
// Eine Fläche für alles.
|
|
||||||
//
|
|
||||||
// Vorher hatte jeder Bereich seinen eigenen Untergrund: die
|
|
||||||
// Formulare bringen einen mit, der Rest des Fensters hat den des
|
|
||||||
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
|
|
||||||
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
|
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
|
||||||
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
|
|
||||||
//
|
|
||||||
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
|
|
||||||
// View — SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
|
|
||||||
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
|
|
||||||
// galt, während der Farbwähler daneben schon die neue zeigte.
|
|
||||||
.tint(Onyx.Color.accent)
|
.tint(Onyx.Color.accent)
|
||||||
.labelStyle(.onyx)
|
.labelStyle(.onyx)
|
||||||
|
// Außerhalb eines `Form` zeichnet macOS jeden `Toggle` als
|
||||||
|
// Ankreuzfeld. In einer Karte mit Schaltern sieht das aus wie ein
|
||||||
|
// Formular von 2009 — und schlimmer: die beiden Formen bedeuten
|
||||||
|
// dasselbe und sehen verschieden aus.
|
||||||
|
.toggleStyle(.switch)
|
||||||
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
||||||
// wachsen, der Inhalt aber nicht mit.
|
// wachsen, der Inhalt aber nicht mit.
|
||||||
.frame(minWidth: 740, minHeight: 460)
|
.frame(minWidth: 760, minHeight: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebar: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Onyx.Space.xxs) {
|
||||||
|
ForEach(SettingsSection.allCases) { entry in
|
||||||
|
Button { section = entry } label: {
|
||||||
|
OnyxSidebarLabel(entry.title, symbol: entry.symbol, tint: entry.tint)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, Onyx.Space.md)
|
||||||
|
.padding(.vertical, Onyx.Space.sm)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
||||||
|
.fill(section == entry ? AnyShapeStyle(Onyx.Color.accent)
|
||||||
|
: AnyShapeStyle(.clear))
|
||||||
|
}
|
||||||
|
// Auf der Akzentfarbe steht Weiß, nicht die
|
||||||
|
// Textfarbe des Systems — die wäre im Hellmodus
|
||||||
|
// schwarz auf kräftigem Blau.
|
||||||
|
.foregroundStyle(section == entry ? AnyShapeStyle(.white)
|
||||||
|
: AnyShapeStyle(.primary))
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Onyx.Space.md)
|
||||||
|
.padding(.vertical, Onyx.Space.row)
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
switch selectedTab {
|
switch section {
|
||||||
|
case .general:
|
||||||
|
GeneralSettings(model: model, launchAtLogin: launchAtLogin,
|
||||||
|
showOnboarding: showOnboarding)
|
||||||
case .widgets:
|
case .widgets:
|
||||||
WidgetSettings(model: model, shelfStore: shelfStore)
|
WidgetSettings(model: model)
|
||||||
case .display:
|
case .widgetOptions:
|
||||||
DisplaySettings(model: model)
|
|
||||||
case .colors:
|
|
||||||
ColorSettingsView(theme: OnyxTheme.shared)
|
|
||||||
case .sources:
|
|
||||||
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||||
shelfStore: shelfStore)
|
shelfStore: shelfStore)
|
||||||
|
case .notch:
|
||||||
|
NotchSettings(model: model)
|
||||||
|
case .appearance:
|
||||||
|
AppearanceSettingsView(theme: OnyxTheme.shared)
|
||||||
case .menubar:
|
case .menubar:
|
||||||
MenuBarSettings(model: model, metricsModel: metricsModel)
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||||
case .fans:
|
case .fans:
|
||||||
FanSettingsView(control: fanControl)
|
FanSettingsView(control: fanControl)
|
||||||
case .permissions:
|
case .permissions:
|
||||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||||
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Allgemein
|
||||||
|
|
||||||
|
private struct GeneralSettings: View {
|
||||||
|
@Bindable var model: AppModel
|
||||||
|
let launchAtLogin: LaunchAtLogin
|
||||||
|
let showOnboarding: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SettingsPage {
|
||||||
|
OnyxFormCard(String(localized: "settings.general.behaviour")) {
|
||||||
|
OnyxFormRow(String(localized: "settings.launchAtLogin")) {
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { launchAtLogin.isEnabled },
|
||||||
|
set: { _ = launchAtLogin.set($0) }))
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(launchAtLogin.needsSystemSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Zustand, der sonst in eine Schleife führt: der Schalter
|
||||||
|
// steht auf aus, Drücken bewirkt nichts, weil das System den
|
||||||
|
// Eintrag blockiert. Also steht hier, wo es weitergeht.
|
||||||
|
if launchAtLogin.needsSystemSettings {
|
||||||
|
OnyxFormRow(String(localized: "settings.launchAtLogin.blocked")) {
|
||||||
|
Button("settings.action.open") {
|
||||||
|
launchAtLogin.openSystemSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormRow(String(localized: "settings.onboarding.title"),
|
||||||
|
hint: String(localized: "settings.onboarding.hint")) {
|
||||||
|
Button("settings.action.show", action: showOnboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormCard(String(localized: "settings.general.appearanceGroup")) {
|
||||||
|
OnyxFormRow(String(localized: "settings.display.menuBarIcon"),
|
||||||
|
hint: String(localized: "settings.display.menuBarIcon.hint")) {
|
||||||
|
Toggle("", isOn: $model.showsMenuBarIcon).labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormRow(String(localized: "settings.display.dockIcon"),
|
||||||
|
hint: String(localized: "settings.display.dockIcon.hint")) {
|
||||||
|
Toggle("", isOn: $model.showsDockIcon).labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { launchAtLogin.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notch
|
||||||
|
|
||||||
|
private struct NotchSettings: View {
|
||||||
|
@Bindable var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SettingsPage {
|
||||||
|
OnyxFormCard(String(localized: "settings.notch.displays")) {
|
||||||
|
OnyxFormRow(String(localized: "settings.display.policy"),
|
||||||
|
hint: String(localized: "settings.display.hint")) {
|
||||||
|
Picker("", selection: $model.displayPolicy) {
|
||||||
|
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
|
||||||
|
Text("settings.display.mainDisplay").tag(DisplayPolicy.mainDisplay)
|
||||||
|
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormRow(String(localized: "settings.notch.style"),
|
||||||
|
hint: String(localized: "settings.notch.style.hint")) {
|
||||||
|
Picker("", selection: $model.notchStyle) {
|
||||||
|
Text("settings.notch.style.automatic").tag(NotchStyle.automatic)
|
||||||
|
Text("settings.notch.style.notch").tag(NotchStyle.notch)
|
||||||
|
Text("settings.notch.style.island").tag(NotchStyle.island)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormRow(String(localized: "settings.island.show"),
|
||||||
|
hint: String(localized: "settings.island.hint")) {
|
||||||
|
Toggle("", isOn: $model.showsIdleIsland).labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormCard(String(localized: "settings.band"),
|
||||||
|
hint: String(localized: "settings.band.hint")) {
|
||||||
|
OnyxFormRow(String(localized: "settings.band.leading")) {
|
||||||
|
Picker("", selection: $model.bandLeading) {
|
||||||
|
ForEach(BandAccessory.allCases, id: \.self) { kind in
|
||||||
|
Text(kind.localizedName).tag(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
OnyxFormRow(String(localized: "settings.band.trailing")) {
|
||||||
|
Picker("", selection: $model.bandTrailing) {
|
||||||
|
ForEach(BandAccessory.allCases, id: \.self) { kind in
|
||||||
|
Text(kind.localizedName).tag(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotchWidthSettings(model: model)
|
||||||
|
|
||||||
|
OnyxFormCard(String(localized: "settings.notch.triggerArea")) {
|
||||||
|
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
|
||||||
|
// niemandem etwas, ein nachgebildeter Bildschirm schon.
|
||||||
|
TriggerAreaSettings(trigger: $model.triggerArea)
|
||||||
|
.padding(.horizontal, OnyxForm.rowPadding)
|
||||||
|
.padding(.bottom, OnyxForm.rowPadding)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,8 +242,6 @@ struct SettingsView: View {
|
|||||||
private struct PermissionSettings: View {
|
private struct PermissionSettings: View {
|
||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
let weatherModel: WeatherModel
|
let weatherModel: WeatherModel
|
||||||
let launchAtLogin: LaunchAtLogin
|
|
||||||
let showOnboarding: () -> Void
|
|
||||||
@State private var calendarGranted = false
|
@State private var calendarGranted = false
|
||||||
|
|
||||||
private var locationState: PermissionRow.State {
|
private var locationState: PermissionRow.State {
|
||||||
@@ -131,67 +253,29 @@ private struct PermissionSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
SettingsPage {
|
||||||
Section("settings.general") {
|
OnyxFormCard(String(localized: "settings.permissions.group")) {
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { launchAtLogin.isEnabled },
|
|
||||||
set: { _ = launchAtLogin.set($0) })) {
|
|
||||||
Text("settings.launchAtLogin")
|
|
||||||
}
|
|
||||||
.disabled(launchAtLogin.needsSystemSettings)
|
|
||||||
|
|
||||||
// Der Zustand, der sonst in eine Schleife führt: der Schalter
|
|
||||||
// steht auf aus, Drücken bewirkt nichts, weil das System den
|
|
||||||
// Eintrag blockiert. Also steht hier, wo es weitergeht.
|
|
||||||
if launchAtLogin.needsSystemSettings {
|
|
||||||
HStack {
|
|
||||||
Text("settings.launchAtLogin.blocked")
|
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
Spacer()
|
|
||||||
Button("settings.permissions.openSystem") {
|
|
||||||
launchAtLogin.openSystemSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button("settings.onboarding.show", action: showOnboarding)
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
PermissionRow(
|
PermissionRow(
|
||||||
title: "settings.permissions.calendar",
|
title: String(localized: "settings.permissions.calendar"),
|
||||||
symbol: "calendar",
|
symbol: "calendar",
|
||||||
state: calendarGranted ? .granted
|
state: calendarGranted ? .granted
|
||||||
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
||||||
explanation: "settings.permissions.calendar.why",
|
explanation: String(localized: "settings.permissions.calendar.why"),
|
||||||
pane: .calendar,
|
pane: .calendar,
|
||||||
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
PermissionRow(
|
PermissionRow(
|
||||||
title: "settings.permissions.location",
|
title: String(localized: "settings.permissions.location"),
|
||||||
symbol: "location",
|
symbol: "location",
|
||||||
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
||||||
// sich, während dieses Fenster offen steht.
|
// sich, während dieses Fenster offen steht.
|
||||||
state: locationState,
|
state: locationState,
|
||||||
explanation: "settings.permissions.location.why",
|
explanation: String(localized: "settings.permissions.location.why"),
|
||||||
pane: .location,
|
pane: .location,
|
||||||
request: { weatherModel.requestLocationAuthorization() })
|
request: { weatherModel.requestLocationAuthorization() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
|
||||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.padding()
|
|
||||||
.task {
|
.task {
|
||||||
launchAtLogin.refresh()
|
|
||||||
calendarGranted = calendarModel.hasAccess
|
calendarGranted = calendarModel.hasAccess
|
||||||
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
|
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
|
||||||
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
|
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
|
||||||
@@ -226,17 +310,15 @@ private struct PermissionRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let title: LocalizedStringKey
|
let title: String
|
||||||
let symbol: String
|
let symbol: String
|
||||||
let state: State
|
let state: State
|
||||||
let explanation: LocalizedStringKey
|
let explanation: String
|
||||||
let pane: Pane
|
let pane: Pane
|
||||||
let request: () async -> Void
|
let request: () async -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
OnyxFormRow(title, hint: explanation, symbol: symbol) {
|
||||||
Label(title, systemImage: symbol)
|
|
||||||
Spacer()
|
|
||||||
switch state {
|
switch state {
|
||||||
case .granted:
|
case .granted:
|
||||||
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
||||||
@@ -260,10 +342,6 @@ private struct PermissionRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(explanation)
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +356,7 @@ private struct MenuBarSettings: View {
|
|||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let metricsModel: MetricsModel?
|
let metricsModel: MetricsModel?
|
||||||
|
|
||||||
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
|
private static let modules: [(id: String, label: String.LocalizationValue, symbol: String)] = [
|
||||||
("metric.cpu", "metric.cpu", "cpu"),
|
("metric.cpu", "metric.cpu", "cpu"),
|
||||||
("metric.gpu", "metric.gpu", "cpu.fill"),
|
("metric.gpu", "metric.gpu", "cpu.fill"),
|
||||||
("metric.memory", "metric.memory", "memorychip"),
|
("metric.memory", "metric.memory", "memorychip"),
|
||||||
@@ -290,22 +368,20 @@ private struct MenuBarSettings: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
SettingsPage {
|
||||||
Text("settings.menubar.hint")
|
Text("settings.menubar.hint")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
List {
|
OnyxFormCard {
|
||||||
ForEach(Self.modules, id: \.id) { module in
|
ForEach(Self.modules, id: \.id) { module in
|
||||||
ModuleRow(model: model, id: module.id,
|
ModuleRow(model: model, id: module.id,
|
||||||
label: module.label, symbol: module.symbol,
|
label: module.label, symbol: module.symbol,
|
||||||
metricsModel: metricsModel)
|
metricsModel: metricsModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
|
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
|
||||||
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
|
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
|
||||||
.id(model.menuBarRevision)
|
.id(model.menuBarRevision)
|
||||||
@@ -315,7 +391,7 @@ private struct MenuBarSettings: View {
|
|||||||
private struct ModuleRow: View {
|
private struct ModuleRow: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let id: String
|
let id: String
|
||||||
let label: LocalizedStringKey
|
let label: String.LocalizationValue
|
||||||
let symbol: String
|
let symbol: String
|
||||||
let metricsModel: MetricsModel?
|
let metricsModel: MetricsModel?
|
||||||
|
|
||||||
@@ -344,69 +420,68 @@ private struct ModuleRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let settings = model.menuBarSettings(for: id)
|
let settings = model.menuBarSettings(for: id)
|
||||||
|
|
||||||
HStack {
|
OnyxFormRow(String(localized: label), symbol: symbol) {
|
||||||
Toggle(isOn: Binding(
|
HStack(spacing: Onyx.Space.md) {
|
||||||
get: { settings.isEnabled },
|
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort
|
||||||
set: { enabled in
|
// das stille Maximum über alle Sensoren — eine Zahl, von der
|
||||||
var updated = settings
|
// niemand weiß, woher sie kommt.
|
||||||
updated.isEnabled = enabled
|
if id == "metric.temperature", let metricsModel {
|
||||||
model.setMenuBarSettings(updated, for: id)
|
Picker("", selection: Binding(
|
||||||
})) {
|
get: { metricsModel.selectedSensorKey ?? "" },
|
||||||
Label(label, systemImage: symbol)
|
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 CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide
|
||||||
|
// Werte stammen aus derselben Messung — ein zweites Element in
|
||||||
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das
|
// der ohnehin knappen Menüleiste wäre Verschwendung.
|
||||||
// stille Maximum über alle Sensoren — eine Zahl, von der niemand
|
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
|
||||||
// weiß, woher sie kommt.
|
Picker("", selection: Binding(
|
||||||
if id == "metric.temperature", let metricsModel {
|
get: { metricsModel.displayMode(for: metric) },
|
||||||
Picker("", selection: Binding(
|
set: { metricsModel.setDisplayMode($0, for: metric) })) {
|
||||||
get: { metricsModel.selectedSensorKey ?? "" },
|
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
|
||||||
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
|
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
|
||||||
Text("settings.menubar.sensor.hottest").tag("")
|
|
||||||
ForEach(metricsModel.snapshot.sensors) { sensor in
|
|
||||||
Text(sensor.name).tag(sensor.key)
|
|
||||||
}
|
}
|
||||||
}
|
.labelsHidden()
|
||||||
.labelsHidden()
|
.frame(width: 110)
|
||||||
.frame(width: 150)
|
.disabled(!settings.isEnabled)
|
||||||
.disabled(!settings.isEnabled)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
|
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
|
||||||
// stammen aus derselben Messung — ein zweites Element in der
|
// keinen einzelnen Messwert, den man als Zahl oder Verlauf
|
||||||
// ohnehin knappen Menüleiste wäre Verschwendung.
|
// zeigen könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
||||||
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
|
if !Self.presentations(for: id).isEmpty {
|
||||||
Picker("", selection: Binding(
|
Picker("", selection: Binding(
|
||||||
get: { metricsModel.displayMode(for: metric) },
|
get: { settings.presentation },
|
||||||
set: { metricsModel.setDisplayMode($0, for: metric) })) {
|
set: { presentation in
|
||||||
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
|
var updated = settings
|
||||||
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
|
updated.presentation = presentation
|
||||||
}
|
model.setMenuBarSettings(updated, for: id)
|
||||||
.labelsHidden()
|
})) {
|
||||||
.frame(width: 110)
|
ForEach(Self.presentations(for: id)) { presentation in
|
||||||
.disabled(!settings.isEnabled)
|
Text(presentation.localizedName).tag(presentation)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 150)
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
|
Toggle("", isOn: Binding(
|
||||||
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen
|
get: { settings.isEnabled },
|
||||||
// könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
set: { enabled in
|
||||||
if !Self.presentations(for: id).isEmpty {
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { settings.presentation },
|
|
||||||
set: { presentation in
|
|
||||||
var updated = settings
|
var updated = settings
|
||||||
updated.presentation = presentation
|
updated.isEnabled = enabled
|
||||||
model.setMenuBarSettings(updated, for: id)
|
model.setMenuBarSettings(updated, for: id)
|
||||||
})) {
|
}))
|
||||||
ForEach(Self.presentations(for: id)) { presentation in
|
|
||||||
Text(presentation.localizedName).tag(presentation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(width: 150)
|
|
||||||
.disabled(!settings.isEnabled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,22 +505,30 @@ extension MenuBarPresentation {
|
|||||||
|
|
||||||
private struct WidgetSettings: View {
|
private struct WidgetSettings: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let shelfStore: ShelfStore?
|
|
||||||
|
/// Welches Widget rechts gezeigt wird.
|
||||||
|
///
|
||||||
|
/// Der Name allein sagt nicht, was eine Kachel zeigt — „Sensoren" kann
|
||||||
|
/// vieles heißen. Und ausprobieren hieß bisher: hinzufügen, Notch
|
||||||
|
/// anfahren, ansehen, wieder entfernen.
|
||||||
|
@State private var previewID: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
HStack(spacing: 0) {
|
||||||
Text("settings.widgets.hint")
|
// Eine Liste, keine Karte: das Ziehen zum Umsortieren kommt von
|
||||||
.font(.callout)
|
// `List.onMove`. Und **nur** die Liste, ohne Rahmen darum — eine
|
||||||
.foregroundStyle(.secondary)
|
// Liste in einer Spalte mit eigenem Rand lief unter den
|
||||||
|
// Fensterknöpfen hindurch, weil sie ihre eigene Scrollfläche
|
||||||
List {
|
// mitbringt und die dann zwei Ränder gegeneinander schieben.
|
||||||
Section("settings.widgets.inPanel") {
|
List(selection: $previewID) {
|
||||||
|
Section {
|
||||||
// Die Reihenfolge hier ist die Reihenfolge im Panel: die
|
// Die Reihenfolge hier ist die Reihenfolge im Panel: die
|
||||||
// Layout-Engine setzt die Widgets von oben links der Reihe
|
// Layout-Engine setzt die Widgets von oben links der Reihe
|
||||||
// nach ein.
|
// nach ein.
|
||||||
ForEach(model.layout) { placement in
|
ForEach(model.layout) { placement in
|
||||||
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
|
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
|
||||||
PlacementRow(model: model, placement: placement, widget: widget)
|
PlacementRow(model: model, placement: placement, widget: widget)
|
||||||
|
.tag(placement.widgetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onMove { model.move(fromOffsets: $0, toOffset: $1) }
|
.onMove { model.move(fromOffsets: $0, toOffset: $1) }
|
||||||
@@ -454,6 +537,13 @@ private struct WidgetSettings: View {
|
|||||||
Text("settings.widgets.empty")
|
Text("settings.widgets.empty")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("settings.widgets.inPanel")
|
||||||
|
} footer: {
|
||||||
|
Text("settings.widgets.hintShort")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) }
|
let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) }
|
||||||
@@ -466,47 +556,69 @@ private struct WidgetSettings: View {
|
|||||||
Button("settings.widgets.add") { model.add(widget) }
|
Button("settings.widgets.add") { model.add(widget) }
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
|
.tag(widget.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
WidgetPreview(widgetID: previewID)
|
||||||
|
.frame(width: 252)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// Beim Öffnen gleich etwas zeigen: eine leere Vorschau neben einer
|
||||||
|
// vollen Liste sieht aus, als wäre sie kaputt.
|
||||||
|
guard previewID == nil else { return }
|
||||||
|
previewID = model.layout.first?.widgetID ?? WidgetRegistry.shared.all.first?.id
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Einstellungen der Ablage.
|
/// Die gewählte Kachel, so wie sie in der Notch aussieht.
|
||||||
///
|
///
|
||||||
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
|
/// Echte Größe (180 × 200) auf echter Paneloberfläche und mit den echten
|
||||||
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
|
/// Modellen — eine verkleinerte oder erfundene Vorschau wäre eine Behauptung.
|
||||||
/// Papierkorb mit Zeitschaltuhr.
|
/// Dieselben Schritte wie in `WidgetGrid`: Kachel, Rahmen, beschneiden.
|
||||||
private struct ShelfSettingsRows: View {
|
private struct WidgetPreview: View {
|
||||||
@Bindable var store: ShelfStore
|
let widgetID: String?
|
||||||
|
|
||||||
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker(selection: $store.autoCleanupDays) {
|
VStack(spacing: Onyx.Space.row) {
|
||||||
ForEach(Self.choices, id: \.self) { days in
|
if let widgetID, let widget = WidgetRegistry.shared.widget(id: widgetID) {
|
||||||
if let days {
|
OnyxTile { widget.makeView() }
|
||||||
Text("settings.shelf.days \(days)").tag(Optional(days))
|
.frame(width: LayoutEngine.tileSize.width,
|
||||||
} else {
|
height: LayoutEngine.tileSize.height)
|
||||||
Text("settings.shelf.never").tag(Int?.none)
|
.clipped()
|
||||||
}
|
.padding(Onyx.Metric.panelPadding)
|
||||||
}
|
// Die Fläche bleibt schwarzer Stein, auch wenn das Fenster
|
||||||
} label: {
|
// hell ist — sie zeigt die Notch, nicht das Fenster.
|
||||||
Text("settings.shelf.cleanup")
|
.background(OnyxSurface(cornerRadius: Onyx.Radius.tile))
|
||||||
}
|
.tint(Onyx.Color.accent)
|
||||||
|
|
||||||
HStack {
|
VStack(spacing: Onyx.Space.xxs) {
|
||||||
Text("settings.shelf.count \(store.items.count)")
|
Text(widget.displayName)
|
||||||
.foregroundStyle(.secondary)
|
.font(.callout)
|
||||||
Spacer()
|
Text("settings.widgets.preview.hint")
|
||||||
Button("settings.shelf.reveal") { store.revealInFinder() }
|
.font(.caption)
|
||||||
.buttonStyle(.borderless)
|
.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
|
@Bindable var model: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
OnyxFormCard(String(localized: "settings.notch.width"),
|
||||||
Picker("settings.display.policy", selection: $model.displayPolicy) {
|
hint: String(localized: "settings.notch.width.hint")) {
|
||||||
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
|
ForEach(NSScreen.screens, id: \.displayID) { screen in
|
||||||
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
|
row(for: screen)
|
||||||
}
|
}
|
||||||
.pickerStyle(.radioGroup)
|
|
||||||
|
|
||||||
Text("settings.display.hint")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
|
|
||||||
// niemandem etwas, ein nachgebildeter Bildschirm schon.
|
|
||||||
TriggerAreaSettings(trigger: $model.triggerArea)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
|
|
||||||
Text("settings.display.menuBarIcon.hint")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
|
|
||||||
Text("settings.display.dockIcon.hint")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
}
|
||||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
|
||||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
@ViewBuilder
|
||||||
.scrollContentBackground(.hidden)
|
private func row(for screen: NSScreen) -> some View {
|
||||||
.padding()
|
let id = screen.displayID
|
||||||
|
// Dieselbe Frage, die auch die Geometrie stellt — und dieselbe Antwort.
|
||||||
|
let hasHardware = NotchGeometry.hardwareRect(screen.onyxMetrics) != nil
|
||||||
|
let merged = model.notchStyle.merges(withHardware: hasHardware)
|
||||||
|
|
||||||
|
OnyxFormRow(screen.localizedName,
|
||||||
|
hint: merged ? String(localized: "settings.notch.width.hardware") : nil) {
|
||||||
|
if merged {
|
||||||
|
Text("settings.notch.width.fixed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Slider(value: binding(for: id),
|
||||||
|
in: NotchCoordinator.widthRange, step: 2)
|
||||||
|
.frame(width: 180)
|
||||||
|
Text(verbatim: "\(Int(model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width))")
|
||||||
|
.font(.body.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 32, alignment: .trailing)
|
||||||
|
Button {
|
||||||
|
model.setNotchWidth(nil, for: id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(model.notchWidth(for: id) == nil)
|
||||||
|
.help(Text("settings.notch.width.reset"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func binding(for id: CGDirectDisplayID) -> Binding<CGFloat> {
|
||||||
|
Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width },
|
||||||
|
set: { model.setNotchWidth($0, for: id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ import ShelfProvider
|
|||||||
final class SettingsWindowController: NSObject, NSWindowDelegate {
|
final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||||
|
|
||||||
@ObservationIgnored private var window: NSWindow?
|
@ObservationIgnored private var window: NSWindow?
|
||||||
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
|
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Bereich
|
||||||
private var selectedTab: SettingsTab = .widgets
|
/// setzen kann.
|
||||||
|
private var section: SettingsSection = .general
|
||||||
|
|
||||||
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
|
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
|
||||||
/// zurückgeschaltet wird.
|
/// zurückgeschaltet wird.
|
||||||
@@ -37,8 +38,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||||
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
||||||
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
|
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
|
||||||
tab: SettingsTab = .widgets) {
|
section: SettingsSection = .general) {
|
||||||
selectedTab = tab
|
self.section = section
|
||||||
keepsDockIcon = model.showsDockIcon
|
keepsDockIcon = model.showsDockIcon
|
||||||
|
|
||||||
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
|
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
|
||||||
@@ -59,39 +60,48 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
fanControl: fanControl,
|
fanControl: fanControl,
|
||||||
launchAtLogin: launchAtLogin,
|
launchAtLogin: launchAtLogin,
|
||||||
showOnboarding: showOnboarding,
|
showOnboarding: showOnboarding,
|
||||||
selectedTab: Binding(
|
section: Binding(
|
||||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
get: { [weak self] in self?.section ?? .general },
|
||||||
set: { [weak self] in self?.selectedTab = $0 })))
|
set: { [weak self] in self?.section = $0 })))
|
||||||
|
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting))
|
||||||
window.title = "Onyx"
|
window.title = "Onyx"
|
||||||
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
|
// **Kein durchsichtiger Titelbalken mehr.**
|
||||||
// verschwindet.
|
|
||||||
//
|
//
|
||||||
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
|
// Er war für die selbstgebaute Reiterleiste da: macOS zieht unter
|
||||||
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
|
// dem Titelbalken einen Trenner quer durchs Fenster, und die
|
||||||
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
|
// Leiste saß mittig darauf. Die Leiste gibt es nicht mehr — und
|
||||||
// Titel auf eine Trennlinie.
|
// durchsichtig ist er jetzt schädlich: Seitenleiste und Inhalt
|
||||||
|
// zeichnen darunter weiter, statt darunter zu beginnen. Sichtbar
|
||||||
|
// wurde das im Widgets-Bereich, wo die Liste unter den
|
||||||
|
// Fensterknöpfen hindurchlief.
|
||||||
|
window.titlebarSeparatorStyle = .automatic
|
||||||
|
// **Kein `isOpaque = false`, kein durchsichtiger Hintergrund.**
|
||||||
|
//
|
||||||
|
// Beides klingt nach dem Weg zur Durchlässigkeit und ist ihr
|
||||||
|
// Gegenteil, sobald die Vibrancy-Ebene selbst der Fensterinhalt
|
||||||
|
// ist: erklärt sich das Fenster für durchsichtig, stellt der
|
||||||
|
// Fensterserver ihm keinen Hintergrund mehr zum Abtasten bereit,
|
||||||
|
// und `behindWindow` hat nichts, woraus es Unschärfe machen könnte.
|
||||||
|
// Der Titelbalken teilt sich die Fläche mit dem Inhalt. Ohne das
|
||||||
|
// zeichnet macOS dort seine **eigene**, deutlich durchlässigere
|
||||||
|
// Vibrancy — sichtbar als heller Streifen quer über den oberen
|
||||||
|
// Rand, durch den man den Hintergrund lesen kann.
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.titlebarSeparatorStyle = .none
|
|
||||||
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
|
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
|
||||||
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
|
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
|
||||||
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
|
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
|
||||||
// Bildschirm frei ist, ist eine Zumutung.
|
// Bildschirm frei ist, ist eine Zumutung.
|
||||||
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||||
window.setContentSize(NSSize(width: 920, height: 680))
|
window.setContentSize(NSSize(width: 940, height: 700))
|
||||||
// Breit genug für die Reiterleiste.
|
// Seit die Bereiche in einer Seitenleiste stehen, hängt die
|
||||||
//
|
// Mindestbreite am Inhalt statt an der Beschriftung: das
|
||||||
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880
|
// Reiterband brauchte 900 Punkte, nur damit der erste und der
|
||||||
// Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten
|
// letzte Reiter nicht über die Kante hinausragten.
|
||||||
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner
|
window.contentMinSize = NSSize(width: 780, height: 480)
|
||||||
// dazu. Ist das Fenster schmaler, ragt die Leiste über beide
|
|
||||||
// Kanten hinaus, und der erste wie der letzte Reiter sind nicht
|
|
||||||
// mehr erreichbar.
|
|
||||||
window.contentMinSize = NSSize(width: 900, height: 460)
|
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
Self.place(window)
|
OnyxWindowPlacement.center(window)
|
||||||
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
||||||
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
||||||
window.setFrameAutosaveName("onyx.settings")
|
window.setFrameAutosaveName("onyx.settings")
|
||||||
@@ -104,27 +114,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
window?.makeKeyAndOrderFront(nil)
|
window?.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
|
|
||||||
/// klebt.
|
|
||||||
///
|
|
||||||
/// `center()` setzt etwas oberhalb der Mitte — bei einem 452 Punkt hohen
|
|
||||||
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
|
|
||||||
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
|
|
||||||
/// plötzlich das Panel vor sich.
|
|
||||||
private static func place(_ window: NSWindow) {
|
|
||||||
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
|
|
||||||
let visible = screen.visibleFrame
|
|
||||||
let size = window.frame.size
|
|
||||||
// Wirklich mittig, nicht „etwas unterhalb der Notch".
|
|
||||||
//
|
|
||||||
// `center()` setzt oberhalb der Bildschirmmitte, und mein erster
|
|
||||||
// Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag
|
|
||||||
// weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die
|
|
||||||
// Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete.
|
|
||||||
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
|
|
||||||
y: visible.midY - size.height / 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Der rote Knopf blendet aus, statt zu schließen.
|
/// Der rote Knopf blendet aus, statt zu schließen.
|
||||||
///
|
///
|
||||||
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
||||||
@@ -147,3 +136,116 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
NSApp.hide(nil)
|
NSApp.hide(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Legt eine Vibrancy-Ebene **unter das ganze Fenster**, Titelbalken
|
||||||
|
/// eingeschlossen.
|
||||||
|
///
|
||||||
|
/// In SwiftUI ginge das nicht: ein `.background` dort endet an der sicheren
|
||||||
|
/// Fläche, und über dem oberen Rand blieb ein Streifen, durch den man den
|
||||||
|
/// Hintergrund lesen konnte. Hier liegt die Ebene im Fensterinhalt und wächst
|
||||||
|
/// mit ihm.
|
||||||
|
///
|
||||||
|
/// Zwei Schichten, nicht eine: die Vibrancy allein ist zu durchlässig — man
|
||||||
|
/// liest den Schreibtisch mit, statt die Einstellung. Der getönte Grund darüber
|
||||||
|
/// lässt die Unschärfe stehen und nimmt ihr die Lesbarkeit.
|
||||||
|
@MainActor
|
||||||
|
final class OnyxBackdropController: NSViewController {
|
||||||
|
|
||||||
|
/// Wie viel vom Fensterhintergrund über der Vibrancy stehen bleibt.
|
||||||
|
///
|
||||||
|
/// 1 wäre undurchlässig, 0 ließe das nackte Material stehen — dann ist der
|
||||||
|
/// Hintergrund stellenweise mitzulesen. **Das ist die Schraube**, wenn es
|
||||||
|
/// zu durchlässig oder zu deckend wirkt.
|
||||||
|
static let tintOpacity: CGFloat = 0
|
||||||
|
|
||||||
|
private let content: NSViewController
|
||||||
|
|
||||||
|
init(content: NSViewController) {
|
||||||
|
self.content = content
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
// Die Vibrancy-Ebene **ist** der Fensterinhalt, sie liegt nicht darin.
|
||||||
|
//
|
||||||
|
// Als Unteransicht in einem gewöhnlichen `NSView` blieb sie wirkungslos
|
||||||
|
// — sichtbar wurde nur der Titelbalken, den macOS selbst zeichnet. Als
|
||||||
|
// `contentView` greift `behindWindow` zuverlässig, und zusammen mit
|
||||||
|
// `.fullSizeContentView` am Fenster reicht sie auch unter den
|
||||||
|
// Titelbalken.
|
||||||
|
let container = NSVisualEffectView()
|
||||||
|
// `.sidebar` und nicht `.underWindowBackground`: letzteres ist so
|
||||||
|
// deckend, dass man von der Unschärfe nichts sieht, sobald hinter dem
|
||||||
|
// Fenster etwas Dunkles liegt. `.sidebar` ist das Material, das man von
|
||||||
|
// Finder und Systemeinstellungen kennt.
|
||||||
|
container.material = .sidebar
|
||||||
|
container.blendingMode = .behindWindow
|
||||||
|
// `.active` statt `.followsWindowActiveState`: sonst fällt die Ebene
|
||||||
|
// zurück, sobald das Fenster nicht vorn ist — dann sieht dasselbe
|
||||||
|
// Fenster je nach Aufmerksamkeit verschieden aus, und das liest sich
|
||||||
|
// als Fehler, nicht als Zustand.
|
||||||
|
container.state = .active
|
||||||
|
|
||||||
|
let tint = TintView()
|
||||||
|
tint.autoresizingMask = [.width, .height]
|
||||||
|
container.addSubview(tint)
|
||||||
|
|
||||||
|
addChild(content)
|
||||||
|
content.view.autoresizingMask = [.width, .height]
|
||||||
|
// Ohne das bleibt die Vibrancy unsichtbar: `NSHostingView` legt einen
|
||||||
|
// deckenden Grund unter den SwiftUI-Inhalt, und der steht genau
|
||||||
|
// dazwischen. Nur der Titelbalken kam vorbei — er wird von macOS
|
||||||
|
// gezeichnet, nicht von SwiftUI.
|
||||||
|
content.view.wantsLayer = true
|
||||||
|
content.view.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
container.addSubview(content.view)
|
||||||
|
|
||||||
|
self.view = container
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayout() {
|
||||||
|
super.viewDidLayout()
|
||||||
|
for subview in view.subviews { subview.frame = view.bounds }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Titelbalken bekommt keinen eigenen Grund — er liegt auf derselben
|
||||||
|
/// Fläche wie alles andere.
|
||||||
|
override func viewDidAppear() {
|
||||||
|
super.viewDidAppear()
|
||||||
|
view.window?.styleMask.insert(.fullSizeContentView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Der getönte Grund über der Vibrancy.
|
||||||
|
///
|
||||||
|
/// Eine eigene Klasse, weil die Farbe erscheinungsabhängig ist: eine einmal
|
||||||
|
/// gelesene `CGColor` bliebe beim Wechsel auf Hell dunkel stehen.
|
||||||
|
/// `viewDidChangeEffectiveAppearance` gibt es nur an der View, nicht am
|
||||||
|
/// Controller — daran ist der erste Versuch gescheitert.
|
||||||
|
private final class TintView: NSView {
|
||||||
|
|
||||||
|
override var wantsUpdateLayer: Bool { true }
|
||||||
|
|
||||||
|
override init(frame: NSRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
wantsLayer = true
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") }
|
||||||
|
|
||||||
|
override func updateLayer() {
|
||||||
|
effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||||
|
layer?.backgroundColor = NSColor.windowBackgroundColor
|
||||||
|
.withAlphaComponent(OnyxBackdropController.tintOpacity).cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidChangeEffectiveAppearance() {
|
||||||
|
super.viewDidChangeEffectiveAppearance()
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+115
-118
@@ -17,26 +17,13 @@ struct WidgetOptionsView: View {
|
|||||||
let shelfStore: ShelfStore?
|
let shelfStore: ShelfStore?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
SettingsPage {
|
||||||
Section("options.calendar") {
|
CalendarOptions(model: calendarModel)
|
||||||
CalendarOptions(model: calendarModel)
|
WeatherOptions(model: weatherModel)
|
||||||
}
|
|
||||||
|
|
||||||
Section("options.weather") {
|
|
||||||
WeatherOptions(model: weatherModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let shelfStore {
|
if let shelfStore {
|
||||||
Section("options.shelf") {
|
ShelfOptions(store: shelfStore)
|
||||||
ShelfOptions(store: shelfStore)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
|
||||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,82 +31,93 @@ struct WidgetOptionsView: View {
|
|||||||
|
|
||||||
private struct CalendarOptions: View {
|
private struct CalendarOptions: View {
|
||||||
@Bindable var model: CalendarModel
|
@Bindable var model: CalendarModel
|
||||||
@State private var calendarrState: CalendarSourceState?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker(selection: $model.kind) {
|
OnyxFormCard(String(localized: "options.calendar")) {
|
||||||
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
OnyxFormRow(String(localized: "options.calendar.source")) {
|
||||||
Text(kind.localizedName).tag(kind)
|
Picker("", selection: $model.kind) {
|
||||||
}
|
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||||||
} label: {
|
Text(kind.localizedName).tag(kind)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
if model.kind != .appleCalendar {
|
||||||
CalendarrServerSettings(model: model)
|
OnyxFormCard(String(localized: "options.calendar.serverGroup")) {
|
||||||
CalendarrStatus(state: calendarrState,
|
CalendarrServerSettings(model: model)
|
||||||
isLoading: model.isLoading,
|
|
||||||
onRefresh: { model.refresh() })
|
// Der Zustand kommt aus dem Modell, nicht aus einem zweiten
|
||||||
.task(id: model.kind) {
|
// eigenen Aufruf. Der las immer den Schnappschuss — auch wenn
|
||||||
guard model.kind != .appleCalendar else { return }
|
// längst der Server gefragt wurde — und der Knopf daneben
|
||||||
let now = Date()
|
// aktualisierte ihn nie.
|
||||||
calendarrState = await CalendarrSource().load(
|
CalendarrStatus(state: model.calendarrState,
|
||||||
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
usesServer: model.usesCalendarrServer,
|
||||||
end: now.addingTimeInterval(60 * 86_400)))
|
isLoading: model.isLoading,
|
||||||
}
|
onRefresh: { model.refresh() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
|
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
|
||||||
///
|
///
|
||||||
/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den
|
/// Mit einem Knopf zum Nachsehen. Ohne Zugangsdaten liest Onyx den
|
||||||
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
|
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
|
||||||
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
|
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
|
||||||
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen.
|
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. Mit
|
||||||
|
/// Zugangsdaten fragt Onyx den Server selbst — dann sagt diese Zeile, wann das
|
||||||
|
/// zuletzt gelang.
|
||||||
private struct CalendarrStatus: View {
|
private struct CalendarrStatus: View {
|
||||||
let state: CalendarSourceState?
|
let state: CalendarSourceState?
|
||||||
|
/// Ob gerade direkt vom Server gelesen wird oder aus dem Schnappschuss.
|
||||||
|
///
|
||||||
|
/// Ohne diese Auskunft ist der Zeitstempel daneben nicht zu deuten: ein
|
||||||
|
/// altes Datum heißt beim Schnappschuss „Calendarr lief lange nicht" und
|
||||||
|
/// beim Server „etwas stimmt nicht".
|
||||||
|
let usesServer: Bool
|
||||||
let isLoading: Bool
|
let isLoading: Bool
|
||||||
let onRefresh: () -> Void
|
let onRefresh: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
OnyxFormRow(String(localized: usesServer ? "options.calendar.viaServer"
|
||||||
|
: "options.calendar.viaSnapshot"),
|
||||||
|
hint: usesServer ? nil
|
||||||
|
: String(localized: "options.calendar.viaSnapshot.hint")) {
|
||||||
message
|
message
|
||||||
Spacer(minLength: 8)
|
|
||||||
if case .ready(let window) = state {
|
if case .ready(let window) = state {
|
||||||
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
|
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -180,44 +178,40 @@ private struct WeatherOptions: View {
|
|||||||
private var usesCurrentLocation: Bool { model.place.isCurrent }
|
private var usesCurrentLocation: Bool { model.place.isCurrent }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Toggle(isOn: Binding(
|
OnyxFormCard(String(localized: "options.weather")) {
|
||||||
get: { usesCurrentLocation },
|
OnyxFormRow(String(localized: "options.weather.automatic"),
|
||||||
set: { model.place = $0 ? .current : model.place })) {
|
// Ohne Ortungsberechtigung nützt die Automatik nichts —
|
||||||
Text("options.weather.automatic")
|
// 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 {
|
if case .fixed(let name, _, _) = model.place {
|
||||||
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
|
OnyxFormRow(String(localized: "options.weather.place")) {
|
||||||
// steht hier, statt dass das Widget stumm leer bleibt.
|
Text(name).foregroundStyle(.secondary)
|
||||||
if model.locationIsDenied {
|
}
|
||||||
Text("options.weather.needsLocation")
|
|
||||||
.font(.callout).foregroundStyle(Onyx.Color.warning)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
}
|
||||||
} else if case .fixed(let name, _, _) = model.place {
|
|
||||||
LabeledContent {
|
|
||||||
Text(name).foregroundStyle(.secondary)
|
|
||||||
} label: {
|
|
||||||
Text("options.weather.place")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
OnyxFormRow(String(localized: "options.weather.search"),
|
||||||
TextField("options.weather.search", text: $query)
|
hint: notFound ? String(localized: "options.weather.notFound") : nil) {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("", text: $query)
|
||||||
.onSubmit { Task { await search() } }
|
.textFieldStyle(.roundedBorder)
|
||||||
if searching {
|
.frame(width: 160)
|
||||||
ProgressView().controlSize(.small)
|
.onSubmit { Task { await search() } }
|
||||||
} else {
|
if searching {
|
||||||
Button("options.weather.set") { Task { await search() } }
|
ProgressView().controlSize(.small)
|
||||||
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
|
} 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 {
|
private func search() async {
|
||||||
@@ -241,23 +235,26 @@ private struct ShelfOptions: View {
|
|||||||
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker(selection: $store.autoCleanupDays) {
|
OnyxFormCard(String(localized: "options.shelf")) {
|
||||||
ForEach(Self.choices, id: \.self) { days in
|
OnyxFormRow(String(localized: "settings.shelf.cleanup")) {
|
||||||
if let days {
|
Picker("", selection: $store.autoCleanupDays) {
|
||||||
Text("settings.shelf.days \(days)").tag(Optional(days))
|
ForEach(Self.choices, id: \.self) { days in
|
||||||
} else {
|
if let days {
|
||||||
Text("settings.shelf.never").tag(Int?.none)
|
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 {
|
OnyxFormRow(String(localized: "options.shelf")) {
|
||||||
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
|
Text("settings.shelf.count \(store.items.count)")
|
||||||
Spacer()
|
.foregroundStyle(.secondary)
|
||||||
Button("settings.shelf.reveal") { store.revealInFinder() }
|
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||||||
.buttonStyle(.borderless)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +295,7 @@ private struct CalendarrServerSettings: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if let account {
|
if let account {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: Onyx.Space.row) {
|
||||||
Text(account.username).foregroundStyle(.secondary)
|
Text(account.username).foregroundStyle(.secondary)
|
||||||
Button {
|
Button {
|
||||||
credentials.clear()
|
credentials.clear()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ let package = Package(
|
|||||||
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
||||||
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
||||||
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
|
.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: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -73,8 +77,23 @@ let package = Package(
|
|||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
.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"],
|
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
resources: [.process("Localizable.xcstrings")]),
|
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: "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
|
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
|
||||||
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
|
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
|
||||||
// nichts einschalten müssen.
|
// nichts einschalten müssen.
|
||||||
SectionTitle("mixer.section.system", compact: compact)
|
OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module))
|
||||||
|
.padding(.top, compact ? 0 : Onyx.Space.xxs)
|
||||||
SystemSection(system: system, compact: compact)
|
SystemSection(system: system, compact: compact)
|
||||||
|
|
||||||
SectionTitle("mixer.section.apps", compact: compact)
|
OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module))
|
||||||
|
.padding(.top, compact ? 0 : Onyx.Space.xxs)
|
||||||
|
|
||||||
if mixer.isEnabled {
|
if mixer.isEnabled {
|
||||||
if mixer.groups.isEmpty {
|
if mixer.groups.isEmpty {
|
||||||
@@ -42,24 +44,20 @@ public struct MixerView: View {
|
|||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, Onyx.Space.md)
|
||||||
} else {
|
} else {
|
||||||
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||||||
// zusammengehört, und trennt es vom Ausschalter darunter.
|
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||||||
VStack(spacing: 0) {
|
OnyxCard {
|
||||||
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||||||
id: \.element.id) { index, group in
|
id: \.element.id) { index, group in
|
||||||
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
|
if index > 0 { OnyxHairline() }
|
||||||
ProcessRow(mixer: mixer, group: group, compact: compact)
|
ProcessRow(mixer: mixer, group: group, compact: compact)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, Onyx.Space.row)
|
||||||
.padding(.vertical, compact ? 6 : 8)
|
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Onyx.Color.elevated.opacity(0.55),
|
|
||||||
in: .rect(cornerRadius: 10, style: .continuous))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
||||||
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +76,7 @@ public struct MixerView: View {
|
|||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.top, 2)
|
.padding(.top, Onyx.Space.xxs)
|
||||||
} else {
|
} else {
|
||||||
OffState(mixer: mixer, compact: compact)
|
OffState(mixer: mixer, compact: compact)
|
||||||
autostartToggle
|
autostartToggle
|
||||||
@@ -128,10 +126,10 @@ private struct OffState: View {
|
|||||||
let compact: Bool
|
let compact: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: Onyx.Space.row) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: Onyx.Space.md) {
|
||||||
Image(systemName: "slider.horizontal.3")
|
Image(systemName: "slider.horizontal.3")
|
||||||
.font(.system(size: 15, weight: .light))
|
.font(.system(size: Onyx.Symbol.large, weight: .light))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Text("mixer.off.explain", bundle: .module)
|
Text("mixer.off.explain", bundle: .module)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
@@ -146,15 +144,7 @@ private struct OffState: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button { mixer.enable() } label: {
|
OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
|
||||||
Text("mixer.on", bundle: .module)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.surface)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Onyx.Color.accent, in: .capsule)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +164,7 @@ private struct ProcessRow: View {
|
|||||||
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
|
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
|
||||||
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
|
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
|
||||||
// hält den Zeiger auf das Symbol.
|
// hält den Zeiger auf das Symbol.
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: Onyx.Space.row) {
|
||||||
icon
|
icon
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -182,7 +172,7 @@ private struct ProcessRow: View {
|
|||||||
mixer.setMuted(!muted, for: group.bundleID)
|
mixer.setMuted(!muted, for: group.bundleID)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: Onyx.Symbol.small))
|
||||||
.frame(width: 15, alignment: .leading)
|
.frame(width: 15, alignment: .leading)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -190,8 +180,8 @@ private struct ProcessRow: View {
|
|||||||
: (managed ? Onyx.Color.textSecondary
|
: (managed ? Onyx.Color.textSecondary
|
||||||
: Onyx.Color.textTertiary))
|
: Onyx.Color.textTertiary))
|
||||||
|
|
||||||
VolumeBar(
|
OnyxSlider(
|
||||||
volume: volume,
|
value: volume,
|
||||||
maximum: Gain.maximum(overdrive: overdriven),
|
maximum: Gain.maximum(overdrive: overdriven),
|
||||||
level: mixer.level(for: group.bundleID),
|
level: mixer.level(for: group.bundleID),
|
||||||
isActive: managed && !muted,
|
isActive: managed && !muted,
|
||||||
@@ -238,7 +228,7 @@ private struct ProcessRow: View {
|
|||||||
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "bolt.fill")
|
Image(systemName: "bolt.fill")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
|
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
|
||||||
@@ -265,7 +255,7 @@ private struct ProcessRow: View {
|
|||||||
mixer.setVolume(1, for: group.bundleID)
|
mixer.setVolume(1, for: group.bundleID)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.counterclockwise")
|
Image(systemName: "arrow.counterclockwise")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
@@ -304,7 +294,7 @@ private struct ProcessRow: View {
|
|||||||
.opacity(managed ? 1 : 0.65)
|
.opacity(managed ? 1 : 0.65)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "app.dashed")
|
Image(systemName: "app.dashed")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: Onyx.Symbol.large))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,95 +316,6 @@ private struct ProcessRow: View {
|
|||||||
return "\(group.name) (\(group.processes.count))"
|
return "\(group.name) (\(group.processes.count))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Lautstärkeregler.
|
|
||||||
///
|
|
||||||
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
|
|
||||||
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
|
|
||||||
/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und
|
|
||||||
/// die Grenze bei 100 %.
|
|
||||||
private struct VolumeBar: View {
|
|
||||||
let volume: Double
|
|
||||||
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
|
|
||||||
let maximum: Double
|
|
||||||
let level: Double
|
|
||||||
let isActive: Bool
|
|
||||||
let onChange: (Double) -> Void
|
|
||||||
|
|
||||||
@State private var isDragging = false
|
|
||||||
|
|
||||||
private let trackHeight: CGFloat = 6
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
let width = geometry.size.width
|
|
||||||
let fraction = min(max(volume / maximum, 0), 1)
|
|
||||||
let knob: CGFloat = isDragging ? 13 : 11
|
|
||||||
// Der Knopf soll an den Enden nicht überstehen.
|
|
||||||
let travel = max(width - knob, 1)
|
|
||||||
let fillWidth = travel * fraction + knob / 2
|
|
||||||
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
Capsule()
|
|
||||||
.fill(Onyx.Color.surface)
|
|
||||||
.frame(height: trackHeight)
|
|
||||||
|
|
||||||
// Markierung bei 100 %: darüber wird verstärkt, und man soll
|
|
||||||
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
|
|
||||||
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
|
|
||||||
if maximum > 1 {
|
|
||||||
Rectangle()
|
|
||||||
.fill(Onyx.Color.hairline)
|
|
||||||
.frame(width: 1, height: trackHeight + 4)
|
|
||||||
.offset(x: knob / 2 + travel / maximum)
|
|
||||||
}
|
|
||||||
|
|
||||||
Capsule()
|
|
||||||
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
|
|
||||||
.frame(width: fillWidth, height: trackHeight)
|
|
||||||
.opacity(isActive ? 1 : 0.3)
|
|
||||||
|
|
||||||
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
|
|
||||||
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
|
|
||||||
// dieselbe Auskunft.
|
|
||||||
if isActive, level > 0.001 {
|
|
||||||
Capsule()
|
|
||||||
.fill(.white.opacity(0.4))
|
|
||||||
.frame(width: fillWidth * meterFraction, height: trackHeight)
|
|
||||||
.blendMode(.plusLighter)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(.white)
|
|
||||||
.frame(width: knob, height: knob)
|
|
||||||
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
|
||||||
.offset(x: travel * fraction)
|
|
||||||
.opacity(isActive ? 1 : 0.55)
|
|
||||||
}
|
|
||||||
.frame(height: 16)
|
|
||||||
.contentShape(.rect)
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { value in
|
|
||||||
isDragging = true
|
|
||||||
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
|
|
||||||
onChange(position * maximum)
|
|
||||||
}
|
|
||||||
.onEnded { _ in isDragging = false })
|
|
||||||
}
|
|
||||||
.frame(height: 16)
|
|
||||||
.animation(Onyx.Motion.value, value: isDragging)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
|
|
||||||
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
|
|
||||||
/// Bereich so weit, dass man überhaupt etwas sieht.
|
|
||||||
private var meterFraction: Double {
|
|
||||||
min(1, sqrt(min(max(level, 0), 1)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Widget
|
// MARK: - Widget
|
||||||
|
|
||||||
public struct AudioMixerWidget: OnyxWidget {
|
public struct AudioMixerWidget: OnyxWidget {
|
||||||
@@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget {
|
|||||||
|
|
||||||
// MARK: - Systemzeilen
|
// MARK: - Systemzeilen
|
||||||
|
|
||||||
private struct SectionTitle: View {
|
|
||||||
let key: LocalizedStringKey
|
|
||||||
let compact: Bool
|
|
||||||
|
|
||||||
init(_ key: LocalizedStringKey, compact: Bool) {
|
|
||||||
self.key = key
|
|
||||||
self.compact = compact
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(key, bundle: .module)
|
|
||||||
.font(Onyx.Font.metricSmall)
|
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
.padding(.top, compact ? 0 : 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ausgabe, Eingabe und Gerätewahl.
|
/// Ausgabe, Eingabe und Gerätewahl.
|
||||||
///
|
///
|
||||||
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
|
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
|
||||||
@@ -471,7 +354,8 @@ private struct SystemSection: View {
|
|||||||
let compact: Bool
|
let compact: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
OnyxCard {
|
||||||
|
VStack(spacing: 0) {
|
||||||
SystemRow(
|
SystemRow(
|
||||||
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
title: outputName,
|
title: outputName,
|
||||||
@@ -486,7 +370,7 @@ private struct SystemSection: View {
|
|||||||
onMute: { system.setOutputMuted(!system.outputMuted) },
|
onMute: { system.setOutputMuted(!system.outputMuted) },
|
||||||
onSelect: { system.selectOutput($0) })
|
onSelect: { system.selectOutput($0) })
|
||||||
|
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
|
|
||||||
SystemRow(
|
SystemRow(
|
||||||
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
||||||
@@ -504,12 +388,7 @@ private struct SystemSection: View {
|
|||||||
onVolume: { system.setInputVolume($0) },
|
onVolume: { system.setInputVolume($0) },
|
||||||
onMute: { system.setInputMuted(!system.inputMuted) },
|
onMute: { system.setInputMuted(!system.inputMuted) },
|
||||||
onSelect: { system.selectInput($0) })
|
onSelect: { system.selectInput($0) })
|
||||||
}
|
}
|
||||||
.background(Onyx.Color.elevated.opacity(0.55),
|
|
||||||
in: .rect(cornerRadius: 10, style: .continuous))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
||||||
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,14 +434,14 @@ private struct SystemRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
.font(.system(size: 7))
|
.font(.system(size: Onyx.Symbol.caption))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -571,18 +450,18 @@ private struct SystemRow: View {
|
|||||||
.menuIndicator(.hidden)
|
.menuIndicator(.hidden)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|
||||||
HStack(spacing: 9) {
|
HStack(spacing: Onyx.Space.row) {
|
||||||
Button(action: onMute) {
|
Button(action: onMute) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: Onyx.Symbol.small))
|
||||||
.frame(width: 15, alignment: .leading)
|
.frame(width: 15, alignment: .leading)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||||||
|
|
||||||
if adjustable {
|
if adjustable {
|
||||||
SystemVolumeBar(volume: volume, level: level,
|
OnyxSlider(value: volume, level: level,
|
||||||
isActive: !muted, onChange: onVolume)
|
isActive: !muted, onChange: onVolume)
|
||||||
if let metering { metering }
|
if let metering { metering }
|
||||||
Text("\(Int(volume * 100)) %")
|
Text("\(Int(volume * 100)) %")
|
||||||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
.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)
|
.padding(.vertical, compact ? 7 : 9)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,7 +496,7 @@ struct MeteringButton: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: toggle) {
|
Button(action: toggle) {
|
||||||
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
|
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: Onyx.Symbol.medium))
|
||||||
.frame(width: 14, height: 14)
|
.frame(width: 14, height: 14)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -625,68 +504,3 @@ struct MeteringButton: View {
|
|||||||
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
|
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
|
|
||||||
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
|
|
||||||
private struct SystemVolumeBar: View {
|
|
||||||
let volume: Double
|
|
||||||
let level: Double
|
|
||||||
let isActive: Bool
|
|
||||||
let onChange: (Double) -> Void
|
|
||||||
|
|
||||||
@State private var isDragging = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
let knob: CGFloat = isDragging ? 13 : 11
|
|
||||||
let travel = max(geometry.size.width - knob, 1)
|
|
||||||
let fraction = min(max(volume, 0), 1)
|
|
||||||
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
Capsule().fill(Onyx.Color.surface).frame(height: 6)
|
|
||||||
let fillWidth = travel * fraction + knob / 2
|
|
||||||
|
|
||||||
Capsule()
|
|
||||||
.fill(Onyx.Color.accent)
|
|
||||||
.frame(width: fillWidth, height: 6)
|
|
||||||
.opacity(isActive ? 1 : 0.3)
|
|
||||||
|
|
||||||
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
|
|
||||||
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
|
|
||||||
// so viel Grafik für dieselbe Auskunft wäre.
|
|
||||||
if isActive, level > 0.001 {
|
|
||||||
Capsule()
|
|
||||||
.fill(.white.opacity(0.4))
|
|
||||||
.frame(width: fillWidth * meterFraction, height: 6)
|
|
||||||
.blendMode(.plusLighter)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(.white)
|
|
||||||
.frame(width: knob, height: knob)
|
|
||||||
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
|
||||||
.offset(x: travel * fraction)
|
|
||||||
.opacity(isActive ? 1 : 0.55)
|
|
||||||
}
|
|
||||||
.frame(height: 16)
|
|
||||||
.contentShape(.rect)
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { value in
|
|
||||||
isDragging = true
|
|
||||||
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
|
|
||||||
}
|
|
||||||
.onEnded { _ in isDragging = false })
|
|
||||||
}
|
|
||||||
.frame(height: 16)
|
|
||||||
.animation(Onyx.Motion.value, value: isDragging)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
|
|
||||||
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
|
|
||||||
/// Bereich so weit, dass man überhaupt etwas sieht.
|
|
||||||
private var meterFraction: Double {
|
|
||||||
min(1, sqrt(min(max(level, 0), 1)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ final class ProcessTap {
|
|||||||
private var procID: AudioDeviceIOProcID?
|
private var procID: AudioDeviceIOProcID?
|
||||||
private let tapUUID = UUID()
|
private let tapUUID = UUID()
|
||||||
|
|
||||||
|
/// Der erste Eingangspuffer, der dem Tap gehört.
|
||||||
|
///
|
||||||
|
/// **Nicht immer null.** Bringt das Ausgabegerät selbst einen Eingang mit —
|
||||||
|
/// jedes Headset tut das, jedes USB-Interface auch —, dann reiht das
|
||||||
|
/// Aggregate-Gerät dessen Puffer vor die des Taps. Wird in `start()`
|
||||||
|
/// bestimmt und im Echtzeitpfad nur gelesen; geschrieben wird ausschließlich
|
||||||
|
/// vor `AudioDeviceStart` und nach `AudioDeviceStop`, also nie nebenläufig.
|
||||||
|
private var tapBufferOffset = 0
|
||||||
|
|
||||||
/// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund.
|
/// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund.
|
||||||
private(set) var isRunning = false
|
private(set) var isRunning = false
|
||||||
|
|
||||||
@@ -131,6 +140,24 @@ final class ProcessTap {
|
|||||||
}
|
}
|
||||||
aggregateID = device
|
aggregateID = device
|
||||||
|
|
||||||
|
// Wo der Tap im Eingang liegt, steht erst fest, wenn das Gerät gebaut
|
||||||
|
// ist. Danach fragen, vorher nichts starten: liegt er nicht dort, wo
|
||||||
|
// wir ihn erwarten, wäre die Folge Stille bei stummgeschaltetem
|
||||||
|
// Original — genau der Fehler, der am Headset auftrat.
|
||||||
|
guard let subDeviceID = Self.device(forUID: outputDeviceUID),
|
||||||
|
let subDeviceInputs = Self.inputChannelCounts(subDeviceID),
|
||||||
|
let aggregateInputs = Self.inputChannelCounts(aggregateID),
|
||||||
|
let tapChannels = Self.tapChannels(tapID),
|
||||||
|
let offset = TapBufferLayout.offset(
|
||||||
|
subDeviceInputBuffers: subDeviceInputs.count,
|
||||||
|
aggregateInputChannels: aggregateInputs,
|
||||||
|
tapChannels: tapChannels)
|
||||||
|
else {
|
||||||
|
cleanUp()
|
||||||
|
throw AudioMixerError.unexpectedLayout
|
||||||
|
}
|
||||||
|
tapBufferOffset = offset
|
||||||
|
|
||||||
var proc: AudioDeviceIOProcID?
|
var proc: AudioDeviceIOProcID?
|
||||||
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
|
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
|
||||||
[weak self] _, inputData, _, outputData, _ in
|
[weak self] _, inputData, _, outputData, _ in
|
||||||
@@ -149,7 +176,7 @@ final class ProcessTap {
|
|||||||
|
|
||||||
isRunning = true
|
isRunning = true
|
||||||
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
|
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
|
||||||
log.notice("Tap aktiv für \(self.process.name, privacy: .public)")
|
log.notice("Tap aktiv für \(self.process.name, privacy: .public), Eingangspuffer \(self.tapBufferOffset, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Echtzeitpfad
|
// MARK: - Echtzeitpfad
|
||||||
@@ -166,11 +193,14 @@ final class ProcessTap {
|
|||||||
var peak: Float = 0
|
var peak: Float = 0
|
||||||
|
|
||||||
for (index, outputBuffer) in outputList.enumerated() {
|
for (index, outputBuffer) in outputList.enumerated() {
|
||||||
guard index < inputList.count,
|
// Der Ausgang zählt ab null, der Eingang ab dem Puffer des Taps.
|
||||||
let source = inputList[index].mData,
|
// Ohne den Versatz stünde hier bei einem Headset das Mikrofon.
|
||||||
|
let sourceIndex = tapBufferOffset + index
|
||||||
|
guard sourceIndex < inputList.count,
|
||||||
|
let source = inputList[sourceIndex].mData,
|
||||||
let destination = outputBuffer.mData else { continue }
|
let destination = outputBuffer.mData else { continue }
|
||||||
|
|
||||||
let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize))
|
let count = Int(min(inputList[sourceIndex].mDataByteSize, outputBuffer.mDataByteSize))
|
||||||
/ MemoryLayout<Float>.size
|
/ MemoryLayout<Float>.size
|
||||||
let inputSamples = source.assumingMemoryBound(to: Float.self)
|
let inputSamples = source.assumingMemoryBound(to: Float.self)
|
||||||
let outputSamples = destination.assumingMemoryBound(to: Float.self)
|
let outputSamples = destination.assumingMemoryBound(to: Float.self)
|
||||||
@@ -224,6 +254,89 @@ final class ProcessTap {
|
|||||||
AudioHardwareDestroyProcessTap(tapID)
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
tapID = AudioObjectID(kAudioObjectUnknown)
|
tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
}
|
}
|
||||||
|
tapBufferOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Audio
|
||||||
|
|
||||||
|
private static func device(forUID uid: String) -> AudioObjectID? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var qualifier = uid as CFString
|
||||||
|
var device = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
let status = withUnsafeMutablePointer(to: &qualifier) {
|
||||||
|
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address,
|
||||||
|
UInt32(MemoryLayout<CFString>.size), $0, &size, &device)
|
||||||
|
}
|
||||||
|
guard status == noErr, device != AudioObjectID(kAudioObjectUnknown) else { return nil }
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kanäle je Eingangspuffer eines Geräts. Leer heißt: kein Eingang —
|
||||||
|
/// das ist ein gültiges Ergebnis, `nil` heißt, es ließ sich nicht lesen.
|
||||||
|
private static func inputChannelCounts(_ device: AudioObjectID) -> [UInt32]? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioObjectPropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr
|
||||||
|
else { return nil }
|
||||||
|
guard size > 0 else { return [] }
|
||||||
|
|
||||||
|
let raw = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { raw.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, raw) == noErr
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let list = UnsafeMutableAudioBufferListPointer(
|
||||||
|
raw.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return list.map(\.mNumberChannels)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tapChannels(_ tap: AudioObjectID) -> UInt32? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioTapPropertyFormat,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var format = AudioStreamBasicDescription()
|
||||||
|
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
||||||
|
guard AudioObjectGetPropertyData(tap, &address, 0, nil, &size, &format) == noErr
|
||||||
|
else { return nil }
|
||||||
|
return format.mChannelsPerFrame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wo im Eingang eines Aggregate-Geräts die Puffer des Taps liegen.
|
||||||
|
///
|
||||||
|
/// Ein Aggregate-Gerät reiht seine Eingangspuffer in fester Ordnung: erst die
|
||||||
|
/// des Untergeräts, dann die des Taps. Bei den eingebauten Lautsprechern gibt
|
||||||
|
/// es keine eigenen — dort liegt der Tap an null, und genau daran hat sich der
|
||||||
|
/// erste Entwurf gewöhnt. Ein Headset bringt sein Mikrofon mit und schiebt den
|
||||||
|
/// Tap eine Stelle weiter.
|
||||||
|
///
|
||||||
|
/// Als reine Funktion, weil sie sonst nur mit angestecktem Headset prüfbar wäre.
|
||||||
|
enum TapBufferLayout {
|
||||||
|
|
||||||
|
/// - Returns: der Index des ersten Tap-Puffers, oder `nil`, wenn die
|
||||||
|
/// Aufteilung nicht der erwarteten entspricht. Dann wird **nicht geraten**:
|
||||||
|
/// ein falsch geratener Puffer heißt Stille bei stummgeschaltetem
|
||||||
|
/// Original, ein abgewiesener Tap heißt nur, dass dieses Programm
|
||||||
|
/// ungeregelt bleibt — hörbar.
|
||||||
|
static func offset(subDeviceInputBuffers: Int,
|
||||||
|
aggregateInputChannels: [UInt32],
|
||||||
|
tapChannels: UInt32) -> Int? {
|
||||||
|
guard subDeviceInputBuffers >= 0, tapChannels > 0 else { return nil }
|
||||||
|
guard subDeviceInputBuffers < aggregateInputChannels.count else { return nil }
|
||||||
|
// Die Gegenprobe: an der erwarteten Stelle muss das Format des Taps
|
||||||
|
// stehen. Läge die Ordnung anders, fiele das hier auf, statt still zu
|
||||||
|
// bleiben.
|
||||||
|
guard aggregateInputChannels[subDeviceInputBuffers] == tapChannels else { return nil }
|
||||||
|
return subDeviceInputBuffers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +346,7 @@ public enum AudioMixerError: Error, LocalizedError {
|
|||||||
case ioProcFailed(OSStatus)
|
case ioProcFailed(OSStatus)
|
||||||
case startFailed
|
case startFailed
|
||||||
case noOutputDevice
|
case noOutputDevice
|
||||||
|
case unexpectedLayout
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -246,6 +360,8 @@ public enum AudioMixerError: Error, LocalizedError {
|
|||||||
"Das Mischgerät startet nicht."
|
"Das Mischgerät startet nicht."
|
||||||
case .noOutputDevice:
|
case .noOutputDevice:
|
||||||
"Kein Ausgabegerät gefunden."
|
"Kein Ausgabegerät gefunden."
|
||||||
|
case .unexpectedLayout:
|
||||||
|
"Das Mischgerät reicht den Ton an unerwarteter Stelle durch."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ public final class SystemAudio {
|
|||||||
public private(set) var inputIsAdjustable = false
|
public private(set) var inputIsAdjustable = false
|
||||||
|
|
||||||
private var listeners: [PropertyListener] = []
|
private var listeners: [PropertyListener] = []
|
||||||
|
/// Zuhörer, die am gewählten Gerät hängen und beim Wechsel neu gesetzt
|
||||||
|
/// werden müssen.
|
||||||
|
private var deviceListeners: [PropertyListener] = []
|
||||||
|
|
||||||
/// Die Pegel von Ausgabe und Eingabe.
|
/// Die Pegel von Ausgabe und Eingabe.
|
||||||
///
|
///
|
||||||
@@ -59,6 +62,9 @@ public final class SystemAudio {
|
|||||||
// MARK: - Lesen
|
// MARK: - Lesen
|
||||||
|
|
||||||
public func refresh() {
|
public func refresh() {
|
||||||
|
let previousOutput = outputDeviceID
|
||||||
|
let previousInput = inputDeviceID
|
||||||
|
|
||||||
outputDevices = Self.devices().filter(\.isOutput)
|
outputDevices = Self.devices().filter(\.isOutput)
|
||||||
inputDevices = Self.devices().filter(\.isInput)
|
inputDevices = Self.devices().filter(\.isInput)
|
||||||
|
|
||||||
@@ -79,6 +85,13 @@ public final class SystemAudio {
|
|||||||
alertVolume = alertDevice == outputDeviceID
|
alertVolume = alertDevice == outputDeviceID
|
||||||
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
|
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
|
||||||
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
|
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
|
||||||
|
// Nur bei einem echten Wechsel neu anmelden. Ein Abmelden mitten im
|
||||||
|
// Rückruf, der gerade läuft, ist eine Einladung zum Absturz.
|
||||||
|
if outputDeviceID != previousOutput || inputDeviceID != previousInput
|
||||||
|
|| deviceListeners.isEmpty {
|
||||||
|
observeCurrentDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setzen
|
// MARK: - Setzen
|
||||||
@@ -135,6 +148,48 @@ public final class SystemAudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zuhörer an Lautstärke und Stummschaltung der **gerade gewählten** Geräte.
|
||||||
|
///
|
||||||
|
/// **Das fehlte.** Beobachtet wurden nur Gerätewechsel; wer die Lautstärke
|
||||||
|
/// per Tastatur änderte, bekam davon nichts mit — der Regler im Mixer stand
|
||||||
|
/// dann auf einem Wert, den es nicht mehr gab, bis irgendetwas anderes ein
|
||||||
|
/// `refresh()` auslöste.
|
||||||
|
///
|
||||||
|
/// Die Zuhörer hängen am Gerät, nicht am Systemobjekt, und müssen deshalb
|
||||||
|
/// bei jedem Gerätewechsel neu gesetzt werden.
|
||||||
|
private func observeCurrentDevices() {
|
||||||
|
deviceListeners = []
|
||||||
|
for (device, scope) in [(outputDeviceID, kAudioDevicePropertyScopeOutput),
|
||||||
|
(inputDeviceID, kAudioDevicePropertyScopeInput)] {
|
||||||
|
guard device != AudioObjectID(kAudioObjectUnknown) else { continue }
|
||||||
|
for selector in [kAudioDevicePropertyVolumeScalar, kAudioDevicePropertyMute] {
|
||||||
|
// Manche Geräte führen keine Hauptlautstärke, sondern nur
|
||||||
|
// Kanäle. Dort scheitert das Anmelden — das ist kein Fehler,
|
||||||
|
// dort gibt es schlicht nichts zu hören.
|
||||||
|
if let listener = PropertyListener(object: device,
|
||||||
|
selector: selector,
|
||||||
|
scope: scope,
|
||||||
|
handler: { [weak self] in
|
||||||
|
Task { @MainActor in self?.readLevels() }
|
||||||
|
}) {
|
||||||
|
deviceListeners.append(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur die Pegel und Schalter neu lesen — ohne Geräteliste.
|
||||||
|
///
|
||||||
|
/// Getrennt von `refresh()`, weil dieser Weg aus einem Rückruf kommt, der
|
||||||
|
/// **während** einer Änderung feuert: dort auch noch die Geräte neu
|
||||||
|
/// aufzuzählen wäre Arbeit für nichts.
|
||||||
|
private func readLevels() {
|
||||||
|
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
|
||||||
|
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
|
||||||
|
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
}
|
||||||
|
|
||||||
private static func devices() -> [AudioDevice] {
|
private static func devices() -> [AudioDevice] {
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
mSelector: kAudioHardwarePropertyDevices,
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
@@ -325,14 +380,23 @@ public final class SystemAudio {
|
|||||||
/// geschrieben.
|
/// geschrieben.
|
||||||
private final class PropertyListener: @unchecked Sendable {
|
private final class PropertyListener: @unchecked Sendable {
|
||||||
|
|
||||||
private let object = AudioObjectID(kAudioObjectSystemObject)
|
private let object: AudioObjectID
|
||||||
private var address: AudioObjectPropertyAddress
|
private var address: AudioObjectPropertyAddress
|
||||||
private let block: AudioObjectPropertyListenerBlock
|
private let block: AudioObjectPropertyListenerBlock
|
||||||
|
|
||||||
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
|
/// - Parameters:
|
||||||
|
/// - object: Standardmäßig das Systemobjekt. Für Lautstärke und
|
||||||
|
/// Stummschaltung muss es das **Gerät** sein — die sind keine
|
||||||
|
/// Eigenschaften der Hardware im Allgemeinen, sondern dieser Kiste.
|
||||||
|
/// - scope: Ein- oder Ausgabe. Global genügt nur bei Geräteeigenschaften.
|
||||||
|
init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
selector: AudioObjectPropertySelector,
|
||||||
|
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
handler: @escaping @Sendable () -> Void) {
|
||||||
|
self.object = object
|
||||||
address = AudioObjectPropertyAddress(
|
address = AudioObjectPropertyAddress(
|
||||||
mSelector: selector,
|
mSelector: selector,
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
mScope: scope,
|
||||||
mElement: kAudioObjectPropertyElementMain)
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
block = { _, _ in handler() }
|
block = { _, _ in handler() }
|
||||||
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
|
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public struct CalendarSourceSettings: View {
|
|||||||
if model.kind != .appleCalendar {
|
if model.kind != .appleCalendar {
|
||||||
Section {
|
Section {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
statusText
|
statusText
|
||||||
Text("calendar.source.readOnly", bundle: .module)
|
Text("calendar.source.readOnly", bundle: .module)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -53,7 +53,7 @@ public struct CalendarSourceSettings: View {
|
|||||||
private var statusText: some View {
|
private var statusText: some View {
|
||||||
switch calendarrState {
|
switch calendarrState {
|
||||||
case .ready(let window):
|
case .ready(let window):
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
|
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
|
||||||
bundle: .module)
|
bundle: .module)
|
||||||
if let coverage = window.coverage {
|
if let coverage = window.coverage {
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ public final class CalendarModel {
|
|||||||
/// Die Kalender, die zur Auswahl stehen.
|
/// Die Kalender, die zur Auswahl stehen.
|
||||||
public private(set) var availableCalendars: [OnyxCalendar] = []
|
public private(set) var availableCalendars: [OnyxCalendar] = []
|
||||||
|
|
||||||
|
/// Der Zustand der Calendarr-Quelle **allein**, für die Einstellungen.
|
||||||
|
///
|
||||||
|
/// Getrennt von `state`, weil das bei „beide" auch Apple-Termine enthält —
|
||||||
|
/// eine Zahl daraus sagt über Calendarr nichts.
|
||||||
|
///
|
||||||
|
/// Und getrennt geführt statt in den Einstellungen nachgeladen: dort stand
|
||||||
|
/// bisher ein zweiter, eigener Aufruf von `CalendarrSource`, also immer der
|
||||||
|
/// **Schnappschuss** — auch wenn längst der Server gefragt wurde. Der
|
||||||
|
/// Zeitstempel daneben war deshalb das Alter einer Datei, die niemand mehr
|
||||||
|
/// liest, und sah aus wie ein Kalender, der seit Wochen stillsteht.
|
||||||
|
public private(set) var calendarrState: CalendarSourceState?
|
||||||
|
|
||||||
private static let kindKey = "calendar.source"
|
private static let kindKey = "calendar.source"
|
||||||
private static let hiddenKey = "calendar.hiddenCalendars"
|
private static let hiddenKey = "calendar.hiddenCalendars"
|
||||||
private static let gridKey = "calendar.showsMonthGrid"
|
private static let gridKey = "calendar.showsMonthGrid"
|
||||||
@@ -194,6 +206,7 @@ public final class CalendarModel {
|
|||||||
let interval = DateInterval(start: start, end: end)
|
let interval = DateInterval(start: start, end: end)
|
||||||
|
|
||||||
var states: [CalendarSourceState] = []
|
var states: [CalendarSourceState] = []
|
||||||
|
let calendarr = kind == .appleCalendar ? nil : calendarrChoice as AnyObject
|
||||||
for source in activeSources {
|
for source in activeSources {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||||
@@ -201,7 +214,9 @@ public final class CalendarModel {
|
|||||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||||
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
||||||
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
let loaded = source.isAuthorized ? await source.load(interval) : .denied
|
||||||
|
if let calendarr, source as AnyObject === calendarr { calendarrState = loaded }
|
||||||
|
states.append(loaded)
|
||||||
}
|
}
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
|
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
|
||||||
@@ -266,41 +281,26 @@ private struct CalendarWidgetView: View {
|
|||||||
AgendaList(events: model.upcoming, coverage: window.coverage)
|
AgendaList(events: model.upcoming, coverage: window.coverage)
|
||||||
}
|
}
|
||||||
case .denied:
|
case .denied:
|
||||||
CalendarNotice(symbol: "lock", text: "widget.calendar.denied")
|
OnyxNotice(symbol: "lock",
|
||||||
|
text: String(localized: "widget.calendar.denied", bundle: .module))
|
||||||
case .neverWritten:
|
case .neverWritten:
|
||||||
CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten")
|
OnyxNotice(symbol: "clock.arrow.circlepath",
|
||||||
|
text: String(localized: "widget.calendar.neverWritten", bundle: .module))
|
||||||
case .loggedOut:
|
case .loggedOut:
|
||||||
CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut")
|
OnyxNotice(symbol: "person.crop.circle.badge.xmark",
|
||||||
|
text: String(localized: "widget.calendar.loggedOut", bundle: .module))
|
||||||
case .incompatible:
|
case .incompatible:
|
||||||
CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible")
|
OnyxNotice(symbol: "exclamationmark.triangle",
|
||||||
|
text: String(localized: "widget.calendar.incompatible", bundle: .module))
|
||||||
case .unreadable:
|
case .unreadable:
|
||||||
CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable")
|
OnyxNotice(symbol: "questionmark.folder",
|
||||||
|
text: String(localized: "widget.calendar.unreadable", bundle: .module))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { model.refresh() }
|
.task { model.refresh() }
|
||||||
.onDisappear { model.stop() }
|
.onDisappear { model.stop() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist.
|
|
||||||
private struct CalendarNotice: View {
|
|
||||||
let symbol: String
|
|
||||||
let text: LocalizedStringKey
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: symbol)
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
|
||||||
Text(text, bundle: .module)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mini-Monat
|
// MARK: - Mini-Monat
|
||||||
|
|
||||||
private struct LargeCalendar: View {
|
private struct LargeCalendar: View {
|
||||||
@@ -310,9 +310,9 @@ private struct LargeCalendar: View {
|
|||||||
private var calendar: Calendar { .autoupdatingCurrent }
|
private var calendar: Calendar { .autoupdatingCurrent }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
MiniMonth(window: window)
|
MiniMonth(window: window)
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage)
|
AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,11 +328,11 @@ private struct MiniMonth: View {
|
|||||||
let busy = window.events.busyDays(calendar: calendar)
|
let busy = window.events.busyDays(calendar: calendar)
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
VStack(spacing: 3) {
|
VStack(spacing: Onyx.Space.xs) {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(weekdaySymbols, id: \.self) { symbol in
|
ForEach(weekdaySymbols, id: \.self) { symbol in
|
||||||
Text(symbol)
|
Text(symbol)
|
||||||
.font(.system(size: 8, weight: .medium))
|
.font(Onyx.Font.micro)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@@ -370,7 +370,7 @@ private struct DayCell: View {
|
|||||||
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
|
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
|
||||||
}
|
}
|
||||||
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
|
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
|
||||||
.font(.system(size: 9, weight: isToday ? .semibold : .regular))
|
.font(Onyx.Font.micro.weight(isToday ? .semibold : .regular))
|
||||||
.foregroundStyle(textColor)
|
.foregroundStyle(textColor)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -413,10 +413,10 @@ private struct AgendaList: View {
|
|||||||
} else {
|
} else {
|
||||||
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
|
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
|
||||||
let days = AgendaGrouping.group(events)
|
let days = AgendaGrouping.group(events)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
|
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
|
||||||
Text(AgendaGrouping.label(for: day.date))
|
Text(AgendaGrouping.label(for: day.date))
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(Onyx.Font.micro.weight(.semibold))
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
// Luft nur zwischen den Tagen, nicht über dem ersten.
|
// Luft nur zwischen den Tagen, nicht über dem ersten.
|
||||||
@@ -435,18 +435,18 @@ private struct EventRow: View {
|
|||||||
let event: OnyxEvent
|
let event: OnyxEvent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
||||||
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
|
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
|
||||||
.frame(width: 3)
|
.frame(width: 3)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
Text(event.title)
|
Text(event.title)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(timeText)
|
Text(timeText)
|
||||||
.font(.system(size: 9))
|
.font(Onyx.Font.micro)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@@ -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
|
let model: MediaModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: Onyx.Space.md) {
|
||||||
Text("widget.media.nothing", bundle: .module)
|
Text("widget.media.nothing", bundle: .module)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
@@ -59,12 +59,12 @@ private struct Playing: View {
|
|||||||
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
|
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
|
||||||
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
|
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
|
||||||
// steht dort „Bernhard H…" statt eines Titels.
|
// steht dort „Bernhard H…" statt eines Titels.
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
Artwork(data: state.artwork)
|
Artwork(data: state.artwork)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 88)
|
.frame(height: 88)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
Text(state.title)
|
Text(state.title)
|
||||||
.font(Onyx.Font.caption.weight(.medium))
|
.font(Onyx.Font.caption.weight(.medium))
|
||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
@@ -91,7 +91,7 @@ private struct Playing: View {
|
|||||||
// greift: dann fehlen Cover und Browser-Wiedergaben,
|
// greift: dann fehlen Cover und Browser-Wiedergaben,
|
||||||
// und das soll erklärbar sein statt rätselhaft.
|
// und das soll erklärbar sein statt rätselhaft.
|
||||||
Image(systemName: "applescript")
|
Image(systemName: "applescript")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: Onyx.Symbol.caption))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.help(Text("widget.media.viaAppleScript", bundle: .module))
|
.help(Text("widget.media.viaAppleScript", bundle: .module))
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ private struct Artwork: View {
|
|||||||
let data: Data?
|
let data: Data?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
|
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous)
|
||||||
|
|
||||||
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
|
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
|
||||||
// beschneidet man das Bild auf seine eigene, viel größere Größe — und es
|
// beschneidet man das Bild auf seine eigene, viel größere Größe — und es
|
||||||
@@ -122,7 +122,7 @@ private struct Artwork: View {
|
|||||||
shape.fill(Onyx.Color.elevated)
|
shape.fill(Onyx.Color.elevated)
|
||||||
.overlay {
|
.overlay {
|
||||||
Image(systemName: "music.note")
|
Image(systemName: "music.note")
|
||||||
.font(.system(size: 18))
|
.font(.system(size: Onyx.Symbol.xlarge))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ private struct Scrubber: View {
|
|||||||
let now = model.tick
|
let now = model.tick
|
||||||
let progress = state.progress(at: now)
|
let progress = state.progress(at: now)
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: Onyx.Space.xxs) {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Capsule().fill(Onyx.Color.hairline)
|
Capsule().fill(Onyx.Color.hairline)
|
||||||
@@ -166,7 +166,7 @@ private struct Scrubber: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(format(state.duration ?? 0))
|
Text(format(state.duration ?? 0))
|
||||||
}
|
}
|
||||||
.font(.system(size: 8))
|
.font(Onyx.Font.micro)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ private struct TransportControls: View {
|
|||||||
let size: CGFloat
|
let size: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
button("backward.fill") { model.send(.previous) }
|
button("backward.fill") { model.send(.previous) }
|
||||||
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
|
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
|
||||||
model.send(.playPause)
|
model.send(.playPause)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import OnyxDesign
|
||||||
import OnyxMenuBar
|
import OnyxMenuBar
|
||||||
|
|
||||||
/// Der Akku, gezeichnet wie der von macOS.
|
/// Der Akku, gezeichnet wie der von macOS.
|
||||||
@@ -259,9 +260,9 @@ public struct BatteryGlyphView: View {
|
|||||||
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
HStack(spacing: 1 * scale) {
|
HStack(spacing: Onyx.Space.xxs * scale) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
|
||||||
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
||||||
|
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
@@ -296,7 +297,7 @@ public struct BatteryGlyphView: View {
|
|||||||
}
|
}
|
||||||
.compositingGroup()
|
.compositingGroup()
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
|
||||||
.fill(.primary.opacity(0.45))
|
.fill(.primary.opacity(0.45))
|
||||||
.frame(width: 2 * scale, height: 5 * scale)
|
.frame(width: 2 * scale, height: 5 * scale)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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 {
|
public var body: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Text("charge.limit", bundle: .module)
|
Text("charge.limit", bundle: .module)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
@@ -62,7 +62,7 @@ public struct ChargeLimitControl: View {
|
|||||||
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
|
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.up.forward.app")
|
Image(systemName: "arrow.up.forward.app")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: Onyx.Symbol.small))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -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
|
let metric: MetricKind
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
|
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
|
||||||
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
|
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
|
||||||
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||||
@@ -25,7 +25,7 @@ struct MetricPopoverContent: View {
|
|||||||
case .temperature: SensorDetail(snapshot: model.snapshot)
|
case .temperature: SensorDetail(snapshot: model.snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(Onyx.Space.xl)
|
||||||
.frame(width: 280)
|
.frame(width: 280)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,17 +34,6 @@ struct MetricPopoverContent: View {
|
|||||||
|
|
||||||
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
|
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
|
||||||
/// lange Werteliste.
|
/// lange Werteliste.
|
||||||
private struct SectionTitle: View {
|
|
||||||
let text: LocalizedStringKey
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(text, bundle: .module)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Onyx.Color.accent)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Row: View {
|
private struct Row: View {
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
@@ -88,7 +77,7 @@ private struct Dial: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
|
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
|
||||||
// sah aus, als gehörte sie noch hinein.
|
// sah aus, als gehörte sie noch hinein.
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: Onyx.Space.sm) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().stroke(.quaternary, lineWidth: 5)
|
Circle().stroke(.quaternary, lineWidth: 5)
|
||||||
Circle()
|
Circle()
|
||||||
@@ -96,13 +85,13 @@ private struct Dial: View {
|
|||||||
.stroke(tint ?? Onyx.Color.accent,
|
.stroke(tint ?? Onyx.Color.accent,
|
||||||
style: .init(lineWidth: 5, lineCap: .round))
|
style: .init(lineWidth: 5, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
|
Text(text).font(Onyx.Font.headline.monospacedDigit())
|
||||||
}
|
}
|
||||||
.frame(width: 62, height: 62)
|
.frame(width: 62, height: 62)
|
||||||
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
|
||||||
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, Onyx.Space.xxs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +104,7 @@ private struct ProcessorDetail: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let snapshot = model.snapshot
|
let snapshot = model.snapshot
|
||||||
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: Onyx.Space.xl) {
|
||||||
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
|
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
|
||||||
caption: metric == .cpu ? "CPU" : "GPU",
|
caption: metric == .cpu ? "CPU" : "GPU",
|
||||||
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
|
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
|
||||||
@@ -135,11 +124,11 @@ private struct ProcessorDetail: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
|
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
|
||||||
SectionTitle(text: "popover.perCore")
|
OnyxPopoverTitle(String(localized: "popover.perCore", bundle: .module))
|
||||||
CoreGrid(values: model.snapshot.cpu.perCore)
|
CoreGrid(values: model.snapshot.cpu.perCore)
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionTitle(text: "popover.power")
|
OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
|
||||||
if let cpu = snapshot.power.cpu {
|
if let cpu = snapshot.power.cpu {
|
||||||
Row(label: String(localized: "popover.power.cpu", bundle: .module),
|
Row(label: String(localized: "popover.power.cpu", bundle: .module),
|
||||||
value: MetricFormat.watts(cpu))
|
value: MetricFormat.watts(cpu))
|
||||||
@@ -154,7 +143,7 @@ private struct ProcessorDetail: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !snapshot.fans.isEmpty {
|
if !snapshot.fans.isEmpty {
|
||||||
SectionTitle(text: "popover.fans")
|
OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
|
||||||
ForEach(snapshot.fans) { fan in
|
ForEach(snapshot.fans) { fan in
|
||||||
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
|
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
|
||||||
}
|
}
|
||||||
@@ -181,13 +170,13 @@ private struct CoreGrid: View {
|
|||||||
let values: [Double]
|
let values: [Double]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: Onyx.Space.xs), count: 8),
|
||||||
spacing: 3) {
|
spacing: Onyx.Space.xs) {
|
||||||
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
|
||||||
.fill(Onyx.Color.accent.opacity(0.18))
|
.fill(Onyx.Color.accent.opacity(0.18))
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
|
||||||
.fill(Onyx.Color.accent)
|
.fill(Onyx.Color.accent)
|
||||||
.frame(height: max(14 * value, 1.5))
|
.frame(height: max(14 * value, 1.5))
|
||||||
}
|
}
|
||||||
@@ -205,12 +194,12 @@ private struct MemoryDetail: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let memory = snapshot.memory
|
let memory = snapshot.memory
|
||||||
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: Onyx.Space.xl) {
|
||||||
Dial(value: memory.usedFraction,
|
Dial(value: memory.usedFraction,
|
||||||
caption: String(localized: "popover.memory.used", bundle: .module),
|
caption: String(localized: "popover.memory.used", bundle: .module),
|
||||||
text: MetricFormat.percent(memory.usedFraction),
|
text: MetricFormat.percent(memory.usedFraction),
|
||||||
tint: pressureColor)
|
tint: pressureColor)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
|
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
|
||||||
legend(.purple, "popover.memory.wired", memory.wired)
|
legend(.purple, "popover.memory.wired", memory.wired)
|
||||||
legend(.teal, "popover.memory.compressed", memory.compressed)
|
legend(.teal, "popover.memory.compressed", memory.compressed)
|
||||||
@@ -218,7 +207,7 @@ private struct MemoryDetail: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionTitle(text: "popover.memory.swap")
|
OnyxPopoverTitle(String(localized: "popover.memory.swap", bundle: .module))
|
||||||
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
|
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
|
||||||
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
|
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
|
||||||
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
|
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
|
||||||
@@ -226,7 +215,7 @@ private struct MemoryDetail: View {
|
|||||||
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
|
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
|
||||||
|
|
||||||
if !snapshot.topMemoryProcesses.isEmpty {
|
if !snapshot.topMemoryProcesses.isEmpty {
|
||||||
SectionTitle(text: "popover.processes")
|
OnyxPopoverTitle(String(localized: "popover.processes", bundle: .module))
|
||||||
ForEach(snapshot.topMemoryProcesses) { process in
|
ForEach(snapshot.topMemoryProcesses) { process in
|
||||||
Row(label: process.name, value: bytes(process.bytes))
|
Row(label: process.name, value: bytes(process.bytes))
|
||||||
}
|
}
|
||||||
@@ -242,7 +231,7 @@ private struct MemoryDetail: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
|
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Circle().fill(color).frame(width: 7, height: 7)
|
Circle().fill(color).frame(width: 7, height: 7)
|
||||||
Text(key, bundle: .module).font(.caption)
|
Text(key, bundle: .module).font(.caption)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
@@ -263,7 +252,7 @@ private struct BatteryDetail: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let battery = snapshot.battery {
|
if let battery = snapshot.battery {
|
||||||
HStack(alignment: .dialCenter, spacing: 14) {
|
HStack(alignment: .dialCenter, spacing: Onyx.Space.xl) {
|
||||||
Dial(value: battery.charge,
|
Dial(value: battery.charge,
|
||||||
caption: String(localized: "metric.battery", bundle: .module),
|
caption: String(localized: "metric.battery", bundle: .module),
|
||||||
text: MetricFormat.percent(battery.charge),
|
text: MetricFormat.percent(battery.charge),
|
||||||
@@ -275,7 +264,7 @@ private struct BatteryDetail: View {
|
|||||||
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
|
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
|
||||||
// und die zweite stand allein unter dem Diagramm, wo sie
|
// und die zweite stand allein unter dem Diagramm, wo sie
|
||||||
// aussah, als gehörte sie zu dessen Beschriftung.
|
// aussah, als gehörte sie zu dessen Beschriftung.
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
if let remaining = battery.timeRemaining {
|
if let remaining = battery.timeRemaining {
|
||||||
Text(duration(remaining)).font(.title3.monospacedDigit())
|
Text(duration(remaining)).font(.title3.monospacedDigit())
|
||||||
Text(battery.isCharging ? "popover.battery.untilFull"
|
Text(battery.isCharging ? "popover.battery.untilFull"
|
||||||
@@ -302,11 +291,11 @@ private struct BatteryDetail: View {
|
|||||||
// eine Akkusache, und man greift danach, während man auf den
|
// eine Akkusache, und man greift danach, während man auf den
|
||||||
// Ladestand schaut.
|
// Ladestand schaut.
|
||||||
if let control {
|
if let control {
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
ChargeLimitControl(control: control, roomy: true)
|
ChargeLimitControl(control: control, roomy: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionTitle(text: "popover.battery.condition")
|
OnyxPopoverTitle(String(localized: "popover.battery.condition", bundle: .module))
|
||||||
if let health = battery.health {
|
if let health = battery.health {
|
||||||
Row(label: String(localized: "popover.battery.health", bundle: .module),
|
Row(label: String(localized: "popover.battery.health", bundle: .module),
|
||||||
value: MetricFormat.percent(health))
|
value: MetricFormat.percent(health))
|
||||||
@@ -320,7 +309,7 @@ private struct BatteryDetail: View {
|
|||||||
value: MetricFormat.temperature(sensor.celsius))
|
value: MetricFormat.temperature(sensor.celsius))
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionTitle(text: "popover.power")
|
OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
|
||||||
if let watts = battery.watts {
|
if let watts = battery.watts {
|
||||||
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
|
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
|
||||||
: "popover.battery.draining",
|
: "popover.battery.draining",
|
||||||
@@ -354,7 +343,7 @@ private struct BatteryDetail: View {
|
|||||||
isPluggedIn: battery.isPluggedIn,
|
isPluggedIn: battery.isPluggedIn,
|
||||||
charge: battery.charge,
|
charge: battery.charge,
|
||||||
limit: control?.chargeLimit)
|
limit: control?.chargeLimit)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Image(systemName: symbol(for: activity))
|
Image(systemName: symbol(for: activity))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(tint(for: activity))
|
.foregroundStyle(tint(for: activity))
|
||||||
@@ -401,7 +390,7 @@ private struct SensorDetail: View {
|
|||||||
let snapshot: MetricsSnapshot
|
let snapshot: MetricsSnapshot
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SectionTitle(text: "popover.temperature")
|
OnyxPopoverTitle(String(localized: "popover.temperature", bundle: .module))
|
||||||
ForEach(snapshot.sensors) { sensor in
|
ForEach(snapshot.sensors) { sensor in
|
||||||
HStack {
|
HStack {
|
||||||
Text(sensor.name).font(.callout)
|
Text(sensor.name).font(.callout)
|
||||||
@@ -421,9 +410,9 @@ private struct SensorDetail: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !snapshot.fans.isEmpty {
|
if !snapshot.fans.isEmpty {
|
||||||
SectionTitle(text: "popover.fans")
|
OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
|
||||||
ForEach(snapshot.fans) { fan in
|
ForEach(snapshot.fans) { fan in
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(fan.name).font(.callout)
|
Text(fan.name).font(.callout)
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
|
|||||||
@@ -82,14 +82,14 @@ private struct CompactMetric: View {
|
|||||||
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
|
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
Label {
|
Label {
|
||||||
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: metric.symbolName)
|
Image(systemName: metric.symbolName)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: Onyx.Symbol.small))
|
||||||
.foregroundStyle(Onyx.Color.accent)
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ private struct CompactMetric: View {
|
|||||||
|
|
||||||
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
||||||
Text(detail)
|
Text(detail)
|
||||||
.font(.system(size: 9))
|
.font(Onyx.Font.micro)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -121,7 +121,7 @@ private struct WideMetric: View {
|
|||||||
// Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein
|
// Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein
|
||||||
// Verlauf über die volle Breite liest sich besser als einer, der sich
|
// Verlauf über die volle Breite liest sich besser als einer, der sich
|
||||||
// die Zeile mit der Zahl teilt.
|
// die Zeile mit der Zahl teilt.
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
CompactMetric(model: model, metric: metric)
|
CompactMetric(model: model, metric: metric)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Sparkline(values: model.series(metric),
|
Sparkline(values: model.series(metric),
|
||||||
@@ -200,11 +200,11 @@ private struct SensorList: View {
|
|||||||
let detailed: Bool
|
let detailed: Bool
|
||||||
|
|
||||||
var body: some View {
|
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
|
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Text(sensor.name)
|
Text(sensor.name)
|
||||||
.font(.system(size: 10))
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -215,17 +215,17 @@ private struct SensorList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if detailed, !snapshot.fans.isEmpty {
|
if detailed, !snapshot.fans.isEmpty {
|
||||||
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
|
OnyxHairline().padding(.vertical, Onyx.Space.xxs)
|
||||||
ForEach(snapshot.fans) { fan in
|
ForEach(snapshot.fans) { fan in
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Image(systemName: "fan")
|
Image(systemName: "fan")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: Onyx.Symbol.caption))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Text("\(Int(fan.rpm))")
|
Text("\(Int(fan.rpm))")
|
||||||
.font(Onyx.Font.metricSmall)
|
.font(Onyx.Font.metricSmall)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
Text(verbatim: "U/min")
|
Text(verbatim: "U/min")
|
||||||
.font(.system(size: 9))
|
.font(Onyx.Font.micro)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -351,8 +351,10 @@ private struct BatteryTile: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let battery = model.snapshot.battery
|
let battery = model.snapshot.battery
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 7) {
|
// 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 %
|
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
|
||||||
// dasselbe wie bei 95 % und trägt nichts bei.
|
// dasselbe wie bei 95 % und trägt nichts bei.
|
||||||
if let battery {
|
if let battery {
|
||||||
@@ -362,7 +364,7 @@ private struct BatteryTile: View {
|
|||||||
.foregroundStyle(tint(battery))
|
.foregroundStyle(tint(battery))
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: BatterySymbol.unavailable)
|
Image(systemName: BatterySymbol.unavailable)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: Onyx.Symbol.medium))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
Text(MetricFormat.percent(battery?.charge ?? 0))
|
Text(MetricFormat.percent(battery?.charge ?? 0))
|
||||||
@@ -386,7 +388,7 @@ private struct BatteryTile: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
if let watts = battery.watts, abs(watts) > 0.1 {
|
if let watts = battery.watts, abs(watts) > 0.1 {
|
||||||
row(watts > 0 ? "battery.charging" : "battery.draining",
|
row(watts > 0 ? "battery.charging" : "battery.draining",
|
||||||
value: String(format: "%.1f W", abs(watts)))
|
value: String(format: "%.1f W", abs(watts)))
|
||||||
@@ -405,19 +407,23 @@ private struct BatteryTile: View {
|
|||||||
// Der Energiesparmodus erklärt, warum die Maschine langsamer
|
// Der Energiesparmodus erklärt, warum die Maschine langsamer
|
||||||
// wirkt. Ohne diesen Hinweis sucht man den Grund woanders.
|
// wirkt. Ohne diesen Hinweis sucht man den Grund woanders.
|
||||||
if battery.isLowPower {
|
if battery.isLowPower {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Image(systemName: "leaf.fill").font(.system(size: 9))
|
Image(systemName: "leaf.fill").font(.system(size: Onyx.Symbol.caption))
|
||||||
Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall)
|
Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Onyx.Color.warning)
|
.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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ key: String.LocalizationValue, value: String) -> some View {
|
private func row(_ key: String.LocalizationValue, value: String) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Text(String(localized: key, bundle: .module))
|
Text(String(localized: key, bundle: .module))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public final class MetricsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public private(set) var snapshot = MetricsSnapshot()
|
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.
|
/// Verlauf für die Graphen, jüngster Wert zuletzt.
|
||||||
public private(set) var history: [MetricsSnapshot] = []
|
public private(set) var history: [MetricsSnapshot] = []
|
||||||
|
|
||||||
@@ -132,6 +140,7 @@ public final class MetricsModel {
|
|||||||
private func sample() {
|
private func sample() {
|
||||||
let value = source.sample()
|
let value = source.sample()
|
||||||
snapshot = value
|
snapshot = value
|
||||||
|
if let battery = value.battery { batteryHistory.record(battery) }
|
||||||
history.append(value)
|
history.append(value)
|
||||||
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
|
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct NetworkPopoverContent: View {
|
|||||||
let model: NetworkModel
|
let model: NetworkModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
|
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -43,17 +43,17 @@ struct NetworkPopoverContent: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
section("network.addresses")
|
OnyxPopoverTitle(String(localized: "network.addresses", bundle: .module))
|
||||||
field("network.field.interface", model.snapshot.interfaceName)
|
field("network.field.interface", model.snapshot.interfaceName)
|
||||||
field("network.field.ipv4", model.snapshot.ipv4)
|
field("network.field.ipv4", model.snapshot.ipv4)
|
||||||
field("network.field.ipv6", model.snapshot.ipv6)
|
field("network.field.ipv6", model.snapshot.ipv6)
|
||||||
field("network.field.router", model.snapshot.router)
|
field("network.field.router", model.snapshot.router)
|
||||||
|
|
||||||
section("network.transferred")
|
OnyxPopoverTitle(String(localized: "network.transferred", bundle: .module))
|
||||||
field("network.total.received", bytes(model.snapshot.totalReceived))
|
field("network.total.received", bytes(model.snapshot.totalReceived))
|
||||||
field("network.total.sent", bytes(model.snapshot.totalSent))
|
field("network.total.sent", bytes(model.snapshot.totalSent))
|
||||||
|
|
||||||
section("network.public")
|
OnyxPopoverTitle(String(localized: "network.public", bundle: .module))
|
||||||
if model.showsPublicAddress {
|
if model.showsPublicAddress {
|
||||||
HStack {
|
HStack {
|
||||||
if model.publicAddress.isLoading {
|
if model.publicAddress.isLoading {
|
||||||
@@ -86,7 +86,7 @@ struct NetworkPopoverContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !model.processes.isEmpty {
|
if !model.processes.isEmpty {
|
||||||
section("network.processes")
|
OnyxPopoverTitle(String(localized: "network.processes", bundle: .module))
|
||||||
ForEach(model.processes) { process in
|
ForEach(model.processes) { process in
|
||||||
HStack {
|
HStack {
|
||||||
Text(process.name).font(.callout).lineLimit(1)
|
Text(process.name).font(.callout).lineLimit(1)
|
||||||
@@ -101,7 +101,7 @@ struct NetworkPopoverContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(Onyx.Space.xl)
|
||||||
.frame(width: 300)
|
.frame(width: 300)
|
||||||
.onAppear { model.addDetailConsumer() }
|
.onAppear { model.addDetailConsumer() }
|
||||||
.onDisappear { model.removeDetailConsumer() }
|
.onDisappear { model.removeDetailConsumer() }
|
||||||
@@ -117,10 +117,10 @@ struct NetworkPopoverContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
|
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
Text(Throughput.formatted(value))
|
Text(Throughput.formatted(value))
|
||||||
.font(.system(size: 18, weight: .medium).monospacedDigit())
|
.font(Onyx.Font.display.monospacedDigit())
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Circle().fill(tint).frame(width: 6, height: 6)
|
Circle().fill(tint).frame(width: 6, height: 6)
|
||||||
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -128,19 +128,12 @@ struct NetworkPopoverContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
|
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
|
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
|
||||||
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
|
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func section(_ key: LocalizedStringKey) -> some View {
|
|
||||||
Text(key, bundle: .module)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Onyx.Color.accent)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
||||||
if let value {
|
if let value {
|
||||||
@@ -175,16 +168,16 @@ private struct TrafficChart: View {
|
|||||||
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
|
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
|
||||||
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
|
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
|
||||||
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
|
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
|
||||||
totalWidth: geometry.size.width, spacing: 1)
|
totalWidth: geometry.size.width, spacing: Onyx.Space.xxs)
|
||||||
let half = geometry.size.height / 2
|
let half = geometry.size.height / 2
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 1) {
|
HStack(alignment: .center, spacing: Onyx.Space.xxs) {
|
||||||
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
|
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
|
||||||
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
|
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
||||||
VStack(spacing: 1) {
|
VStack(spacing: Onyx.Space.xxs) {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Onyx.Color.upload)
|
.fill(Onyx.Color.upload)
|
||||||
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
||||||
|
|||||||
@@ -141,10 +141,10 @@ private struct NetworkWidgetView: View {
|
|||||||
@State private var token: UUID?
|
@State private var token: UUID?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
Image(systemName: model.snapshot.interfaceKind.symbolName)
|
Image(systemName: model.snapshot.interfaceKind.symbolName)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: Onyx.Symbol.small))
|
||||||
.foregroundStyle(Onyx.Color.download)
|
.foregroundStyle(Onyx.Color.download)
|
||||||
Text(connectionName)
|
Text(connectionName)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
@@ -158,7 +158,7 @@ private struct NetworkWidgetView: View {
|
|||||||
capacity: NetworkModel.historyLength)
|
capacity: NetworkModel.historyLength)
|
||||||
.frame(height: 18)
|
.frame(height: 18)
|
||||||
|
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
Details(snapshot: model.snapshot)
|
Details(snapshot: model.snapshot)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -189,16 +189,16 @@ private struct Rates: View {
|
|||||||
let compact: Bool
|
let compact: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
|
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
|
||||||
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
|
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
|
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.system(size: 8, weight: .bold))
|
.font(.system(size: Onyx.Symbol.caption, weight: .bold))
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
|
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
|
||||||
@@ -212,7 +212,7 @@ private struct Details: View {
|
|||||||
let snapshot: NetworkSnapshot
|
let snapshot: NetworkSnapshot
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
field("network.field.interface", snapshot.interfaceName)
|
field("network.field.interface", snapshot.interfaceName)
|
||||||
field("network.field.ipv4", snapshot.ipv4)
|
field("network.field.ipv4", snapshot.ipv4)
|
||||||
field("network.field.router", snapshot.router)
|
field("network.field.router", snapshot.router)
|
||||||
@@ -222,12 +222,12 @@ private struct Details: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
||||||
if let value {
|
if let value {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
Text(label, bundle: .module)
|
Text(label, bundle: .module)
|
||||||
.font(.system(size: 9))
|
.font(Onyx.Font.micro)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(size: 9))
|
.font(Onyx.Font.micro)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
|||||||
@@ -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) {}
|
public func updateNSView(_ view: NSVisualEffectView, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Hintergrund des Panels: Vibrancy, Tint, ein sehr flacher Glanz von oben
|
/// Der Stein selbst — auf einer beliebigen Form.
|
||||||
/// und eine Haarlinie als Innenkante.
|
///
|
||||||
public struct OnyxSurface: View {
|
/// Tint, Bänderung, Kante. Drei Lagen, die zusammen den Werkstoff ergeben, und
|
||||||
private let cornerRadius: CGFloat
|
/// 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) {
|
public init(_ shape: S) {
|
||||||
self.cornerRadius = cornerRadius
|
self.shape = shape
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
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
|
shape
|
||||||
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
|
.fill(Onyx.Color.surface.opacity(material.fillOpacity))
|
||||||
.background(OnyxVisualEffect().clipShape(shape))
|
|
||||||
.overlay {
|
.overlay {
|
||||||
// Die Bänderung des Steins: oben ein Hauch heller, nach 40 %
|
// 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(
|
shape.fill(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
stops: [
|
stops: [
|
||||||
.init(color: .white.opacity(0.02), location: 0),
|
.init(color: .white.opacity(material.sheen), location: 0),
|
||||||
.init(color: .clear, location: 0.4),
|
.init(color: .clear, location: 0.4),
|
||||||
],
|
],
|
||||||
startPoint: .top, endPoint: .bottom))
|
startPoint: .top, endPoint: .bottom))
|
||||||
}
|
}
|
||||||
.overlay {
|
.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 {
|
public var body: some View {
|
||||||
let shape = RoundedRectangle(cornerRadius: Onyx.Metric.tileCornerRadius, style: .continuous)
|
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.tile, style: .continuous)
|
||||||
|
|
||||||
content
|
content
|
||||||
.padding(12)
|
.padding(Onyx.Space.lg)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.background(shape.fill(Onyx.Color.elevated))
|
.background(shape.fill(Onyx.Color.elevated))
|
||||||
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth))
|
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
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
|
/// **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,
|
/// 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.
|
/// 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)
|
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
|
||||||
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
|
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
|
||||||
/// aber eine verbreitete. Wer sie anders kennt, dreht sie um.
|
/// aber eine verbreitete.
|
||||||
public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592)
|
///
|
||||||
|
/// **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 static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
|
||||||
|
|
||||||
public var accent: Color {
|
public var accent: Color {
|
||||||
@@ -34,12 +44,31 @@ public final class OnyxTheme {
|
|||||||
didSet { store(upload, as: Keys.upload) }
|
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 let defaults: UserDefaults
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
static let accent = "onyx.theme.accent"
|
static let accent = "onyx.theme.accent"
|
||||||
static let download = "onyx.theme.download"
|
static let download = "onyx.theme.download"
|
||||||
static let upload = "onyx.theme.upload"
|
static let upload = "onyx.theme.upload"
|
||||||
|
static let motion = "onyx.theme.motion"
|
||||||
|
static let material = "onyx.theme.material"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(defaults: UserDefaults = .standard) {
|
public init(defaults: UserDefaults = .standard) {
|
||||||
@@ -47,10 +76,17 @@ public final class OnyxTheme {
|
|||||||
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
|
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
|
||||||
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
|
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
|
||||||
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
|
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,
|
/// Ob **die Farben** auf den Werkseinstellungen stehen — für den
|
||||||
/// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt.
|
/// 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 {
|
public var isDefault: Bool {
|
||||||
accent.onyxHex == Self.defaultAccent.onyxHex
|
accent.onyxHex == Self.defaultAccent.onyxHex
|
||||||
&& download.onyxHex == Self.defaultDownload.onyxHex
|
&& download.onyxHex == Self.defaultDownload.onyxHex
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
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
|
/// 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
|
/// Glas. Das Panel benutzt deshalb bewusst **nicht** die Liquid-Glass-APIs von
|
||||||
@@ -10,8 +10,13 @@ public enum Onyx {
|
|||||||
// MARK: - Farben
|
// MARK: - Farben
|
||||||
|
|
||||||
public enum Color {
|
public enum Color {
|
||||||
/// Grundfläche des Panels.
|
/// Grundfläche des Panels — **wirklich schwarz**.
|
||||||
public static let surface = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
|
///
|
||||||
|
/// 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.
|
/// Kacheln auf der Grundfläche.
|
||||||
public static let elevated = SwiftUI.Color(red: 0.078, green: 0.078, blue: 0.090)
|
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.
|
/// 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 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)
|
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
|
/// Eine Karte **innerhalb** einer Kachel — etwas heller als die Kachel,
|
||||||
/// Hintergrund durch, darüber wirkt das Panel wie eine schwarze Platte.
|
/// aber nicht so hell, dass sie selbst wie eine Kachel wirkt.
|
||||||
public static let surfaceOpacity: Double = 0.74
|
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
|
// 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 enum Metric {
|
||||||
public static let panelCornerRadius: CGFloat = 26
|
|
||||||
public static let tileCornerRadius: CGFloat = 16
|
|
||||||
public static let panelPadding: CGFloat = 16
|
public static let panelPadding: CGFloat = 16
|
||||||
public static let tileSpacing: CGFloat = 10
|
public static let tileSpacing: CGFloat = 10
|
||||||
public static let hairlineWidth: CGFloat = 1
|
public static let hairlineWidth: CGFloat = 1
|
||||||
@@ -58,31 +154,89 @@ public enum Onyx {
|
|||||||
|
|
||||||
// MARK: - Bewegung
|
// 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 {
|
public enum Motion {
|
||||||
/// Ausfahren: ein Spring mit einem Hauch Nachschwingen. Das gibt dem
|
/// Ausfahren.
|
||||||
/// Panel Masse, statt es nur größer werden zu lassen.
|
@MainActor public static var expand: Animation { OnyxTheme.shared.motion.expand }
|
||||||
public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86)
|
/// Einfahren.
|
||||||
|
@MainActor public static var collapse: Animation { OnyxTheme.shared.motion.collapse }
|
||||||
/// 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)
|
|
||||||
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
|
/// 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
|
// 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 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)
|
public static let caption = SwiftUI.Font.system(size: 10, weight: .medium)
|
||||||
/// Für alle Messwerte — ohne feste Ziffernbreite zappeln die Zahlen.
|
public static let footnote = SwiftUI.Font.system(size: 11, weight: .regular)
|
||||||
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
|
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()
|
.monospacedDigit()
|
||||||
public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium)
|
public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium)
|
||||||
.monospacedDigit()
|
.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
|
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
|
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.
|
/// Reduziert den Bildschirm auf genau die Werte, die die Geometrie braucht.
|
||||||
/// Alles Weitere bleibt draußen, damit `NotchGeometry` ohne Fenstersystem
|
/// Alles Weitere bleibt draußen, damit `NotchGeometry` ohne Fenstersystem
|
||||||
/// prüfbar bleibt.
|
/// prüfbar bleibt.
|
||||||
@@ -21,9 +29,21 @@ public extension NSScreen {
|
|||||||
auxiliaryTopLeft: auxiliaryTopLeftArea,
|
auxiliaryTopLeft: auxiliaryTopLeftArea,
|
||||||
auxiliaryTopRight: auxiliaryTopRightArea,
|
auxiliaryTopRight: auxiliaryTopRightArea,
|
||||||
isBuiltIn: isBuiltInDisplay,
|
isBuiltIn: isBuiltInDisplay,
|
||||||
|
menuBarHeight: onyxMenuBarHeight,
|
||||||
|
isMain: isMainDisplay,
|
||||||
displayID: displayID)
|
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?
|
/// Verdeckt gerade ein Vollbildfenster die Menüleiste?
|
||||||
///
|
///
|
||||||
/// Es gibt keine öffentliche API, die „hier läuft ein Vollbildvideo" meldet.
|
/// 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() }
|
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 controllers: [CGDirectDisplayID: NotchScreenController] = [:]
|
||||||
private var monitors: [Any] = []
|
private var monitors: [Any] = []
|
||||||
private var suppressionTimer: Timer?
|
private var suppressionTimer: Timer?
|
||||||
@@ -50,7 +105,7 @@ public final class NotchCoordinator {
|
|||||||
|
|
||||||
public init(policy: DisplayPolicy = .builtInOnly,
|
public init(policy: DisplayPolicy = .builtInOnly,
|
||||||
trigger: TriggerArea = .standard,
|
trigger: TriggerArea = .standard,
|
||||||
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
|
virtualNotchSize: CGSize = NotchCoordinator.defaultVirtualSize,
|
||||||
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
|
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.trigger = trigger
|
self.trigger = trigger
|
||||||
@@ -108,14 +163,17 @@ public final class NotchCoordinator {
|
|||||||
if let existing = controllers[metrics.displayID] {
|
if let existing = controllers[metrics.displayID] {
|
||||||
// Bestehenden Controller aktualisieren statt neu zu bauen:
|
// Bestehenden Controller aktualisieren statt neu zu bauen:
|
||||||
// ein Auflösungswechsel darf ein offenes Panel nicht wegreißen.
|
// 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 {
|
} else {
|
||||||
let id = metrics.displayID
|
let id = metrics.displayID
|
||||||
let controller = NotchScreenController(
|
let controller = NotchScreenController(
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
virtualSize: virtualNotchSize,
|
virtualSize: virtualSize(for: id),
|
||||||
trigger: trigger,
|
trigger: trigger,
|
||||||
|
style: style,
|
||||||
makeContent: { presentation in makeContent(id, presentation) })
|
makeContent: { presentation in makeContent(id, presentation) })
|
||||||
|
controller.showsIdleIsland = showsIdleIsland
|
||||||
controller.onVisibilityChanged = { [weak self] _ in
|
controller.onVisibilityChanged = { [weak self] _ in
|
||||||
self?.updatePolling()
|
self?.updatePolling()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable {
|
|||||||
public let auxiliaryTopLeft: CGRect?
|
public let auxiliaryTopLeft: CGRect?
|
||||||
public let auxiliaryTopRight: CGRect?
|
public let auxiliaryTopRight: CGRect?
|
||||||
public let isBuiltIn: Bool
|
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
|
/// `CGDirectDisplayID` — stabil über Anordnungswechsel hinweg, anders als
|
||||||
/// der Index in `NSScreen.screens`.
|
/// der Index in `NSScreen.screens`.
|
||||||
public let displayID: UInt32
|
public let displayID: UInt32
|
||||||
@@ -19,12 +31,16 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable {
|
|||||||
auxiliaryTopLeft: CGRect?,
|
auxiliaryTopLeft: CGRect?,
|
||||||
auxiliaryTopRight: CGRect?,
|
auxiliaryTopRight: CGRect?,
|
||||||
isBuiltIn: Bool = false,
|
isBuiltIn: Bool = false,
|
||||||
|
menuBarHeight: CGFloat = 0,
|
||||||
|
isMain: Bool = false,
|
||||||
displayID: UInt32 = 0) {
|
displayID: UInt32 = 0) {
|
||||||
self.frame = frame
|
self.frame = frame
|
||||||
self.safeAreaTop = safeAreaTop
|
self.safeAreaTop = safeAreaTop
|
||||||
self.auxiliaryTopLeft = auxiliaryTopLeft
|
self.auxiliaryTopLeft = auxiliaryTopLeft
|
||||||
self.auxiliaryTopRight = auxiliaryTopRight
|
self.auxiliaryTopRight = auxiliaryTopRight
|
||||||
self.isBuiltIn = isBuiltIn
|
self.isBuiltIn = isBuiltIn
|
||||||
|
self.menuBarHeight = menuBarHeight
|
||||||
|
self.isMain = isMain
|
||||||
self.displayID = displayID
|
self.displayID = displayID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +49,14 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable {
|
|||||||
public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
|
public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
|
||||||
/// Nur das eingebaute Display.
|
/// Nur das eingebaute Display.
|
||||||
case builtInOnly
|
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.
|
/// Jedes angeschlossene Display, externe mit virtueller Notch.
|
||||||
case allDisplays
|
case allDisplays
|
||||||
|
|
||||||
@@ -40,6 +64,12 @@ public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .allDisplays:
|
case .allDisplays:
|
||||||
return available
|
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:
|
case .builtInOnly:
|
||||||
let builtIn = available.filter(\.isBuiltIn)
|
let builtIn = available.filter(\.isBuiltIn)
|
||||||
// Am Dock mit geschlossenem Deckel gibt es kein eingebautes Display.
|
// 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.
|
/// Wo die Notch sitzt und wo das Panel dazu hingehört.
|
||||||
///
|
///
|
||||||
/// Auf dem eingebauten Display wird die echte Notch aus den beiden Hilfsflächen
|
/// 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 rect: CGRect
|
||||||
public let screenFrame: 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,
|
/// Wie weit die Auslösefläche über die Notch hinausreicht — einstellbar,
|
||||||
/// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
|
/// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
|
||||||
public var trigger: TriggerArea = .standard
|
public var trigger: TriggerArea = .standard
|
||||||
@@ -87,40 +178,85 @@ public struct NotchGeometry: Equatable, Sendable {
|
|||||||
return copy
|
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.kind = kind
|
||||||
self.rect = rect
|
self.rect = rect
|
||||||
self.screenFrame = screenFrame
|
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,
|
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize,
|
||||||
trigger: TriggerArea = .standard) -> NotchGeometry {
|
trigger: TriggerArea = .standard,
|
||||||
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
|
style: NotchStyle = .automatic) -> NotchGeometry {
|
||||||
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
|
let hardware = hardwareRect(metrics)
|
||||||
// obwohl es nichts gibt, um das man herumlegen könnte.
|
|
||||||
if let left = metrics.auxiliaryTopLeft,
|
// Verschmelzen kann nur, was etwas zum Verschmelzen hat.
|
||||||
let right = metrics.auxiliaryTopRight,
|
if style.merges(withHardware: hardware != nil), let hardware {
|
||||||
metrics.safeAreaTop > 0 {
|
return NotchGeometry(kind: .physical, rect: hardware,
|
||||||
let width = metrics.frame.width - left.width - right.width
|
screenFrame: metrics.frame).with(trigger: trigger)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let width = min(virtualSize.width, metrics.frame.width)
|
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(
|
return NotchGeometry(
|
||||||
kind: .virtual,
|
kind: .virtual,
|
||||||
rect: CGRect(x: metrics.frame.midX - width / 2,
|
rect: CGRect(x: metrics.frame.midX - width / 2,
|
||||||
y: metrics.frame.maxY - virtualSize.height,
|
y: metrics.frame.maxY - clearance - height,
|
||||||
width: width,
|
width: width,
|
||||||
height: virtualSize.height),
|
height: height),
|
||||||
screenFrame: metrics.frame).with(trigger: trigger)
|
screenFrame: metrics.frame,
|
||||||
|
isDetached: detached,
|
||||||
|
topMargin: clearance).with(trigger: trigger)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Fläche, deren Betreten das Panel scharf schaltet.
|
/// 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)
|
let height = min(size.height, screenFrame.height)
|
||||||
|
|
||||||
return CGRect(x: rect.midX - width / 2,
|
return CGRect(x: rect.midX - width / 2,
|
||||||
y: screenFrame.maxY - height,
|
y: screenFrame.maxY - topMargin - height,
|
||||||
width: width,
|
width: width,
|
||||||
height: height)
|
height: height)
|
||||||
.clamped(to: screenFrame)
|
.clamped(to: screenFrame)
|
||||||
|
|||||||
@@ -28,16 +28,64 @@ public final class NotchPresentation {
|
|||||||
self.contentTopInset = contentTopInset
|
self.contentTopInset = contentTopInset
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste
|
/// Die Breite der Notch bzw. ihres virtuellen Ersatzes.
|
||||||
/// zuzudecken — also die Breite der Notch.
|
|
||||||
///
|
|
||||||
/// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr.
|
|
||||||
public var notchWidth: CGFloat { collapsedSize.width }
|
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.
|
/// 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
|
/// 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.
|
/// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen. Seit
|
||||||
public static let collapseDuration: TimeInterval = 0.30
|
/// 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.
|
/// 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,
|
/// und rechts davon aber die Menüleiste zu — bei sechs Kacheln die ganze,
|
||||||
/// samt aller Statuselemente.
|
/// samt aller Statuselemente.
|
||||||
///
|
///
|
||||||
/// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die
|
/// Also ein T: oben ein Steg, ab Unterkante der Menüleiste die volle Fläche.
|
||||||
/// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei.
|
/// 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 {
|
struct NotchShape: Shape, InsettableShape {
|
||||||
var cornerRadius: CGFloat
|
var cornerRadius: CGFloat
|
||||||
var topBandHeight: 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
|
var inset: CGFloat = 0
|
||||||
|
|
||||||
|
private var topRadius: CGFloat { isDetached ? cornerRadius : 0 }
|
||||||
|
|
||||||
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
||||||
get { .init(cornerRadius, notchWidth) }
|
get { .init(cornerRadius, topWidth) }
|
||||||
set { cornerRadius = newValue.first; notchWidth = newValue.second }
|
set { cornerRadius = newValue.first; topWidth = newValue.second }
|
||||||
}
|
}
|
||||||
|
|
||||||
func inset(by amount: CGFloat) -> NotchShape {
|
func inset(by amount: CGFloat) -> NotchShape {
|
||||||
@@ -69,30 +131,83 @@ struct NotchShape: Shape, InsettableShape {
|
|||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
let rect = rect.insetBy(dx: inset, dy: inset)
|
let rect = rect.insetBy(dx: inset, dy: inset)
|
||||||
// Ist das Panel nicht breiter als die Notch, ist der Steg die ganze
|
let band = min(max(topWidth - 2 * inset, 0), rect.width)
|
||||||
// Form — dann bleibt es die schlichte abgerundete Fläche.
|
|
||||||
let band = min(max(notchWidth - 2 * inset, 0), rect.width)
|
|
||||||
let radius = min(cornerRadius, rect.width / 2, rect.height / 2)
|
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()
|
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 {
|
guard topBandHeight > 0, band < rect.width - 0.5 else {
|
||||||
path.addRoundedRect(in: rect,
|
// Nur der Steg. Verschmolzen ist er oben eckig — dort ist die
|
||||||
cornerSize: CGSize(width: radius, height: radius),
|
// Bildschirmkante, und eine Rundung wäre ein Spalt zum Gehäuse.
|
||||||
style: .continuous)
|
// 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
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY,
|
let bandMinX = rect.midX - band / 2
|
||||||
width: band, height: topBandHeight + radius)
|
let bandMaxX = rect.midX + band / 2
|
||||||
let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight,
|
let top = min(topRadius, band / 2, max(topBandHeight, 0))
|
||||||
width: rect.width, height: rect.height - topBandHeight)
|
|
||||||
|
|
||||||
// Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und
|
path.move(to: CGPoint(x: bandMinX, y: rect.minY + top))
|
||||||
// eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen.
|
// Linke Stegkante hinunter, dann nach außen in den Körper.
|
||||||
path.addRect(bandRect)
|
path.addLine(to: CGPoint(x: bandMinX, y: bandBottom - shoulder))
|
||||||
path.addRoundedRect(in: body,
|
path.addQuadCurve(to: CGPoint(x: bandMinX - shoulder, y: bandBottom),
|
||||||
cornerSize: CGSize(width: radius, height: radius),
|
control: CGPoint(x: bandMinX, y: bandBottom))
|
||||||
style: .continuous)
|
// 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
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,34 +219,73 @@ struct NotchShape: Shape, InsettableShape {
|
|||||||
/// Hardware darüber, sodass beides als ein Körper wirkt. Eingeblendet wird nur der
|
/// 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
|
/// 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.
|
/// 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 presentation: NotchPresentation
|
||||||
|
private let leading: Leading
|
||||||
|
private let trailing: Trailing
|
||||||
|
private let ambient: Ambient
|
||||||
private let content: Content
|
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.presentation = presentation
|
||||||
|
self.leading = leading()
|
||||||
|
self.trailing = trailing()
|
||||||
|
self.ambient = ambient()
|
||||||
self.content = content()
|
self.content = content()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var size: CGSize {
|
private var size: CGSize {
|
||||||
presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize
|
presentation.isExpanded ? presentation.expandedSize : presentation.collapsedDisplaySize
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cornerRadius: CGFloat {
|
private var cornerRadius: CGFloat {
|
||||||
// Zusammengezogen die Rundung der Notch-Unterkante, ausgefahren die des
|
// Ausgefahren die Rundung des Panels. Ein fester Radius ließe die
|
||||||
// Panels. Ein fester Radius lässt die kleine Form aufgeblasen aussehen.
|
// kleine Form aufgeblasen aussehen.
|
||||||
presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10
|
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 {
|
/// Die Kurve für **jede** Größenänderung der Fläche.
|
||||||
presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse
|
///
|
||||||
|
/// 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 {
|
public var body: some View {
|
||||||
let shape = NotchShape(cornerRadius: cornerRadius,
|
let shape = NotchShape(cornerRadius: cornerRadius,
|
||||||
topBandHeight: presentation.contentTopInset,
|
topBandHeight: presentation.contentTopInset,
|
||||||
notchWidth: presentation.notchWidth)
|
topWidth: presentation.topStripWidth,
|
||||||
|
isDetached: presentation.isDetached)
|
||||||
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
// Das Weichzeichner-Material behält **feste** Größe; animiert wird
|
// 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 —
|
// Weichzeichner in jedem Einzelbild neu in anderer Größe berechnen —
|
||||||
// das ruckelt sichtbar, und zwar genau beim Schließen, wo die Fläche
|
// das ruckelt sichtbar, und zwar genau beim Schließen, wo die Fläche
|
||||||
// am schnellsten schrumpft. Eine Maske kostet dagegen nichts.
|
// am schnellsten schrumpft. Eine Maske kostet dagegen nichts.
|
||||||
OnyxVisualEffect()
|
if OnyxTheme.shared.material.showsVibrancy {
|
||||||
.frame(width: presentation.expandedSize.width,
|
OnyxVisualEffect()
|
||||||
height: presentation.expandedSize.height)
|
.frame(width: presentation.expandedSize.width,
|
||||||
.mask(alignment: .top) {
|
height: presentation.expandedSize.height)
|
||||||
shape.frame(width: size.width, height: size.height)
|
.mask(alignment: .top) {
|
||||||
}
|
shape.frame(width: size.width, height: size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tint, Bänderung und Kante sind reine SwiftUI-Formen. Die dürfen
|
// Tint, Bänderung und Kante sind reine SwiftUI-Formen. Die dürfen
|
||||||
// in der Größe animieren, das kostet nichts.
|
// in der Größe animieren, das kostet nichts.
|
||||||
shape
|
//
|
||||||
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
|
// Dasselbe Rezept wie in ``OnyxSurface`` — und zwar buchstäblich
|
||||||
.overlay {
|
// dasselbe, nicht eine zweite Fassung davon.
|
||||||
shape.fill(
|
OnyxStone(shape)
|
||||||
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) }
|
|
||||||
.frame(width: size.width, height: size.height)
|
.frame(width: size.width, height: size.height)
|
||||||
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
|
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
|
||||||
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
|
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
|
||||||
// ohnehin mit der Form mit.
|
// ohnehin mit der Form mit.
|
||||||
.shadow(color: .black.opacity(0.45), radius: 22, y: 10)
|
.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
|
content
|
||||||
.frame(width: presentation.expandedSize.width,
|
.frame(width: presentation.expandedSize.width,
|
||||||
height: presentation.expandedSize.height
|
height: presentation.expandedSize.height
|
||||||
@@ -191,7 +382,9 @@ public struct NotchContainer<Content: View>: View {
|
|||||||
.frame(width: presentation.expandedSize.width,
|
.frame(width: presentation.expandedSize.width,
|
||||||
height: presentation.expandedSize.height,
|
height: presentation.expandedSize.height,
|
||||||
alignment: .top)
|
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() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
public override func hitTest(_ point: NSPoint) -> NSView? {
|
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:
|
// Bildschirmkoordinaten wachsen nach oben, View-Koordinaten nach unten:
|
||||||
// die sichtbare Fläche hängt oben mittig im Fenster.
|
// die sichtbare Fläche hängt oben mittig im Fenster.
|
||||||
let visible = CGRect(x: (bounds.width - size.width) / 2,
|
let visible = CGRect(x: (bounds.width - size.width) / 2,
|
||||||
@@ -225,16 +419,34 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
|
|||||||
height: size.height)
|
height: size.height)
|
||||||
guard visible.contains(point) else { return nil }
|
guard visible.contains(point) else { return nil }
|
||||||
|
|
||||||
// Links und rechts neben der Notch gehört der oberste Streifen der
|
// Außerhalb des Stegs gehört der oberste Streifen der Menüleiste. Dort
|
||||||
// Menüleiste. Dort ist das Panel durchsichtig — und muss auch
|
// ist das Panel durchsichtig — und muss auch durchlässig sein, sonst
|
||||||
// durchlässig sein, sonst schluckt es Klicks auf fremde
|
// schluckt es Klicks auf fremde Statuselemente, ohne dass man sieht,
|
||||||
// Statuselemente, ohne dass man sieht, warum.
|
// 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 {
|
if presentation.isExpanded, point.y < presentation.contentTopInset {
|
||||||
let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0,
|
let strip = presentation.topStripWidth
|
||||||
width: presentation.notchWidth,
|
let band = CGRect(x: (bounds.width - strip) / 2, y: 0,
|
||||||
|
width: strip,
|
||||||
height: presentation.contentTopInset)
|
height: presentation.contentTopInset)
|
||||||
guard band.contains(point) else { return nil }
|
guard band.contains(point) else { return nil }
|
||||||
}
|
}
|
||||||
return super.hitTest(point)
|
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,
|
public init(metrics: ScreenMetrics,
|
||||||
virtualSize: CGSize,
|
virtualSize: CGSize,
|
||||||
trigger: TriggerArea = .standard,
|
trigger: TriggerArea = .standard,
|
||||||
|
style: NotchStyle = .automatic,
|
||||||
contentSize: CGSize = CGSize(width: 640, height: 320),
|
contentSize: CGSize = CGSize(width: 640, height: 320),
|
||||||
makeContent: (NotchPresentation) -> NSView) {
|
makeContent: (NotchPresentation) -> NSView) {
|
||||||
self.displayID = metrics.displayID
|
self.displayID = metrics.displayID
|
||||||
self.trigger = trigger
|
self.trigger = trigger
|
||||||
|
self.style = style
|
||||||
|
self.lastMetrics = metrics
|
||||||
|
self.lastVirtualSize = virtualSize
|
||||||
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
|
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
|
||||||
trigger: trigger)
|
trigger: trigger, style: style)
|
||||||
self.geometry = geometry
|
self.geometry = geometry
|
||||||
self.contentSize = contentSize
|
self.contentSize = contentSize
|
||||||
self.presentation = NotchPresentation(
|
self.presentation = NotchPresentation(
|
||||||
@@ -62,13 +80,18 @@ public final class NotchScreenController {
|
|||||||
expandedSize: CGSize(width: contentSize.width,
|
expandedSize: CGSize(width: contentSize.width,
|
||||||
height: contentSize.height + geometry.rect.height),
|
height: contentSize.height + geometry.rect.height),
|
||||||
contentTopInset: geometry.rect.height)
|
contentTopInset: geometry.rect.height)
|
||||||
|
presentation.isDetached = geometry.isDetached
|
||||||
self.panel = NotchPanel(contentRect: .zero)
|
self.panel = NotchPanel(contentRect: .zero)
|
||||||
panel.contentView = makeContent(presentation)
|
panel.contentView = makeContent(presentation)
|
||||||
layoutPanel()
|
layoutPanel()
|
||||||
|
applyIdleIsland()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var phase: NotchPhase { machine.phase }
|
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
|
/// Meldet, wenn das Panel sichtbar wird oder verschwindet. Der Koordinator
|
||||||
/// schaltet daran die Zeigerabfrage an und aus.
|
/// 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
|
/// und ist links und rechts der Notch dort durchsichtig — dieser Streifen
|
||||||
/// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick
|
/// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick
|
||||||
/// auf ein fremdes Statuselement für einen Klick ins Panel.
|
/// 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 {
|
public func containsVisiblePoint(_ point: CGPoint) -> Bool {
|
||||||
guard presentation.isExpanded, panel.frame.contains(point) else { return false }
|
guard presentation.isExpanded, panel.frame.contains(point) else { return false }
|
||||||
let menuBarBand = panel.frame.maxY - geometry.rect.height
|
let menuBarBand = panel.frame.maxY - geometry.rect.height
|
||||||
guard point.y > menuBarBand else { return true }
|
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) }
|
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) {
|
public func setSuppressed(_ suppressed: Bool) {
|
||||||
guard suppressed != machine.isSuppressed else { return }
|
guard suppressed != machine.isSuppressed else { return }
|
||||||
log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)")
|
log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)")
|
||||||
send(.suppressed(suppressed))
|
send(.suppressed(suppressed))
|
||||||
|
applyIdleIsland()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
|
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
|
||||||
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
|
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
|
||||||
|
lastMetrics = metrics
|
||||||
|
lastVirtualSize = virtualSize
|
||||||
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
|
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
|
||||||
trigger: trigger)
|
trigger: trigger, style: style)
|
||||||
|
presentation.isDetached = geometry.isDetached
|
||||||
presentation.collapsedSize = geometry.rect.size
|
presentation.collapsedSize = geometry.rect.size
|
||||||
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
|
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
|
||||||
// (virtueller Balken) ändert sich der Freiraum mit.
|
// (virtueller Balken) ändert sich der Freiraum mit.
|
||||||
presentation.contentTopInset = geometry.rect.height
|
presentation.contentTopInset = geometry.rect.height
|
||||||
presentation.expandedSize = expandedSize
|
presentation.expandedSize = expandedSize
|
||||||
layoutPanel()
|
layoutPanel()
|
||||||
|
applyIdleIsland()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func tearDown() {
|
public func tearDown() {
|
||||||
@@ -138,7 +231,7 @@ public final class NotchScreenController {
|
|||||||
/// Sichtbarkeit wird hier verglichen und nicht in `apply`: `handle` hat den
|
/// 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.
|
/// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück.
|
||||||
private func send(_ input: NotchInput) {
|
private func send(_ input: NotchInput) {
|
||||||
let wasVisible = machine.isVisible
|
let wasVisible = machine.isExpanded
|
||||||
let before = machine.phase
|
let before = machine.phase
|
||||||
apply(machine.handle(input))
|
apply(machine.handle(input))
|
||||||
// Jeden Zustandswechsel protokollieren — außer den beiden, die
|
// 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)")
|
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]) {
|
private func apply(_ effects: [NotchEffect]) {
|
||||||
@@ -186,6 +279,12 @@ public final class NotchScreenController {
|
|||||||
|
|
||||||
case .hide:
|
case .hide:
|
||||||
hide()
|
hide()
|
||||||
|
|
||||||
|
case .showAmbient:
|
||||||
|
showAmbient()
|
||||||
|
|
||||||
|
case .hideAmbient:
|
||||||
|
hideAmbient()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,6 +312,10 @@ public final class NotchScreenController {
|
|||||||
teardownTimer?.cancel()
|
teardownTimer?.cancel()
|
||||||
teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in
|
teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in
|
||||||
guard let self, !presentation.isExpanded else { return }
|
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)
|
panel.orderOut(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,3 +334,27 @@ public final class NotchScreenController {
|
|||||||
panel.setFrame(geometry.panelRect(for: expandedSize), display: presentation.isExpanded)
|
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
|
case closeTimerFired
|
||||||
/// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten.
|
/// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten.
|
||||||
case suppressed(Bool)
|
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
|
/// Was der Aufrufer nach einem Übergang zu tun hat. Die Maschine selbst
|
||||||
@@ -42,6 +48,10 @@ public enum NotchEffect: Equatable, Sendable {
|
|||||||
case cancelCloseTimer
|
case cancelCloseTimer
|
||||||
case show
|
case show
|
||||||
case hide
|
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.
|
/// 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.
|
/// Kommt er zurück, wird ohne weiteres Warten geöffnet.
|
||||||
private var debounceElapsed = false
|
private var debounceElapsed = false
|
||||||
|
|
||||||
|
/// Ob eine Live Activity anliegt.
|
||||||
|
public private(set) var hasAmbient = false
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
/// Sichtbar, egal aus welchem Grund.
|
/// Ausgefahren — die Fläche zeigt Kacheln.
|
||||||
public var isVisible: Bool {
|
public var isExpanded: Bool {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .open, .pinned, .closing: true
|
case .open, .pinned, .closing: true
|
||||||
case .idle, .arming: false
|
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
|
@discardableResult
|
||||||
public mutating func handle(_ input: NotchInput) -> [NotchEffect] {
|
public mutating func handle(_ input: NotchInput) -> [NotchEffect] {
|
||||||
// Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung.
|
// Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung.
|
||||||
@@ -94,12 +117,28 @@ public struct NotchStateMachine: Equatable, Sendable {
|
|||||||
guard active != isSuppressed else { return [] }
|
guard active != isSuppressed else { return [] }
|
||||||
isSuppressed = active
|
isSuppressed = active
|
||||||
|
|
||||||
guard active else { return [] }
|
guard active else {
|
||||||
let wasVisible = isVisible
|
// Unterdrückung vorbei: was eingeklappt anliegt, darf zurück.
|
||||||
|
return hasAmbient ? [.showAmbient] : []
|
||||||
|
}
|
||||||
|
let wasExpanded = isExpanded
|
||||||
phase = .idle
|
phase = .idle
|
||||||
return wasVisible
|
var effects: [NotchEffect] = [.cancelArmTimer, .cancelCloseTimer]
|
||||||
? [.cancelArmTimer, .cancelCloseTimer, .hide]
|
if wasExpanded { effects.append(.hide) }
|
||||||
: [.cancelArmTimer, .cancelCloseTimer]
|
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 [] }
|
guard !isSuppressed else { return [] }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public struct TriggerAreaSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||||||
Text("trigger.hint", bundle: .module)
|
Text("trigger.hint", bundle: .module)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -120,9 +120,9 @@ private struct TriggerPreview: View {
|
|||||||
.frame(width: width, height: height)
|
.frame(width: width, height: height)
|
||||||
// Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und
|
// Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und
|
||||||
// Fläche gemeinsam ab, sonst ragt etwas über die Ecken hinaus.
|
// 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 {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous)
|
||||||
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public struct WidgetGrid: View {
|
|||||||
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
|
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
|
||||||
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
|
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
|
||||||
OnyxTile {
|
OnyxTile {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: Onyx.Space.xs) {
|
||||||
Image(systemName: "questionmark.square.dashed")
|
Image(systemName: "questionmark.square.dashed")
|
||||||
Text(item.placement.widgetID)
|
Text(item.placement.widgetID)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ public struct AirDropView: View {
|
|||||||
if compact {
|
if compact {
|
||||||
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
|
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
|
||||||
// Symbol über Text kein Platz.
|
// Symbol über Text kein Platz.
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: Onyx.Space.md) {
|
||||||
Image(systemName: "shareplay")
|
Image(systemName: "shareplay")
|
||||||
.font(.system(size: 15, weight: .light))
|
.font(.system(size: Onyx.Symbol.large, weight: .light))
|
||||||
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
||||||
: Onyx.Color.textTertiary)
|
: Onyx.Color.textTertiary)
|
||||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||||
@@ -57,11 +57,11 @@ public struct AirDropView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, Onyx.Space.md)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: Onyx.Space.sm) {
|
||||||
Image(systemName: "shareplay")
|
Image(systemName: "shareplay")
|
||||||
.font(.system(size: 20, weight: .light))
|
.font(.system(size: Onyx.Symbol.xlarge, weight: .light))
|
||||||
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
.foregroundStyle(isTargeted ? Onyx.Color.accent
|
||||||
: Onyx.Color.textTertiary)
|
: Onyx.Color.textTertiary)
|
||||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||||
@@ -74,15 +74,12 @@ public struct AirDropView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background {
|
// Diese Fläche ist **nur** zum Ablegen da. Ihre Kante steht deshalb
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
// immer — ohne sie wüsste niemand, dass sie eine ist.
|
||||||
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
|
.onyxDropZone(isTargeted: isTargeted, style: .always)
|
||||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
||||||
}
|
|
||||||
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
|
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
|
||||||
// Versprechen an, nicht als fertige Datei.
|
// Versprechen an, nicht als fertige Datei.
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
|
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
|
||||||
.animation(Onyx.Motion.value, value: isTargeted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ public struct ShelfView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||||
if store.items.isEmpty {
|
if store.items.isEmpty {
|
||||||
EmptyShelf()
|
EmptyShelf()
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 4) {
|
LazyVStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
|
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
|
||||||
ShelfRow(item: item, store: store, thumbnails: thumbnails)
|
ShelfRow(item: item, store: store, thumbnails: thumbnails)
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ public struct ShelfView: View {
|
|||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: Onyx.Space.row) {
|
||||||
Button { store.removeAll() } label: {
|
Button { store.removeAll() } label: {
|
||||||
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
|
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
|
||||||
}
|
}
|
||||||
@@ -82,24 +82,17 @@ public struct ShelfView: View {
|
|||||||
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
|
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
|
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
|
||||||
.overlay {
|
// Die Kachel nimmt nebenbei Dateien an — die Kante erscheint deshalb
|
||||||
if isTargeted {
|
// erst, wenn wirklich etwas darüber schwebt.
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
.onyxDropZone(isTargeted: isTargeted, style: .whenTargeted)
|
||||||
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
|
||||||
.background(Onyx.Color.accent.opacity(0.08),
|
|
||||||
in: .rect(cornerRadius: 10, style: .continuous))
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(Onyx.Motion.value, value: isTargeted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct EmptyShelf: View {
|
private struct EmptyShelf: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||||
Image(systemName: "tray")
|
Image(systemName: "tray")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: Onyx.Symbol.large))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Text("shelf.empty", bundle: .module)
|
Text("shelf.empty", bundle: .module)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
@@ -117,9 +110,9 @@ private struct ShelfRow: View {
|
|||||||
@State private var isHovered = false
|
@State private var isHovered = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: Onyx.Space.md) {
|
||||||
thumbnail
|
thumbnail
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||||
Text(item.name)
|
Text(item.name)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
@@ -133,19 +126,19 @@ private struct ShelfRow: View {
|
|||||||
|
|
||||||
if isHovered {
|
if isHovered {
|
||||||
Button { AirDrop.send([item.url]) } label: {
|
Button { AirDrop.send([item.url]) } label: {
|
||||||
Image(systemName: "shareplay").font(.system(size: 10))
|
Image(systemName: "shareplay").font(.system(size: Onyx.Symbol.small))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
|
||||||
Button { store.remove(item) } label: {
|
Button { store.remove(item) } label: {
|
||||||
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
|
Image(systemName: "xmark.circle.fill").font(.system(size: Onyx.Symbol.small))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, Onyx.Space.xxs)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.onHover { isHovered = $0 }
|
.onHover { isHovered = $0 }
|
||||||
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
|
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
|
||||||
@@ -176,9 +169,9 @@ private struct ShelfRow: View {
|
|||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
.resizable().aspectRatio(contentMode: .fit)
|
.resizable().aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.clipShape(.rect(cornerRadius: 3, style: .continuous))
|
.clipShape(.rect(cornerRadius: Onyx.Radius.bar, style: .continuous))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
|
||||||
.fill(Onyx.Color.elevated)
|
.fill(Onyx.Color.elevated)
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
}
|
}
|
||||||
@@ -204,13 +197,13 @@ public struct ShelfWidget: OnyxWidget {
|
|||||||
|
|
||||||
public func makeView() -> AnyView {
|
public func makeView() -> AnyView {
|
||||||
AnyView(
|
AnyView(
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: Onyx.Space.md) {
|
||||||
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
|
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
|
||||||
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
|
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
|
||||||
AirDropView(compact: true)
|
AirDropView(compact: true)
|
||||||
.frame(height: 52)
|
.frame(height: 52)
|
||||||
|
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
|
|
||||||
ShelfView(store: store)
|
ShelfView(store: store)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,12 +195,15 @@ private struct WeatherWidgetView: View {
|
|||||||
if let lastKnown {
|
if let lastKnown {
|
||||||
content(lastKnown, isStale: true)
|
content(lastKnown, isStale: true)
|
||||||
} else {
|
} else {
|
||||||
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable")
|
OnyxNotice(symbol: "wifi.slash",
|
||||||
|
text: String(localized: "widget.weather.unavailable", bundle: .module))
|
||||||
}
|
}
|
||||||
case .locationDenied:
|
case .locationDenied:
|
||||||
Notice(symbol: "location.slash", text: "widget.weather.locationDenied")
|
OnyxNotice(symbol: "location.slash",
|
||||||
|
text: String(localized: "widget.weather.locationDenied", bundle: .module))
|
||||||
case .locationUndetermined:
|
case .locationUndetermined:
|
||||||
Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined")
|
OnyxNotice(symbol: "location.circle",
|
||||||
|
text: String(localized: "widget.weather.locationUndetermined", bundle: .module))
|
||||||
case .loading, .idle:
|
case .loading, .idle:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -217,25 +220,6 @@ private struct WeatherWidgetView: View {
|
|||||||
WideWeather(snapshot: snapshot, isStale: isStale)
|
WideWeather(snapshot: snapshot, isStale: isStale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct Notice: View {
|
|
||||||
let symbol: String
|
|
||||||
let text: LocalizedStringKey
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: symbol)
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
|
||||||
Text(text, bundle: .module)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WideWeather: View {
|
private struct WideWeather: View {
|
||||||
let snapshot: WeatherSnapshot
|
let snapshot: WeatherSnapshot
|
||||||
let isStale: Bool
|
let isStale: Bool
|
||||||
@@ -246,15 +230,15 @@ private struct WideWeather: View {
|
|||||||
// die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die
|
// die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die
|
||||||
// man auf Armlänge nicht entziffert.
|
// man auf Armlänge nicht entziffert.
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
HStack(alignment: .center, spacing: Onyx.Space.row) {
|
||||||
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||||
.font(.system(size: 34))
|
.font(.system(size: Onyx.Symbol.hero))
|
||||||
.symbolRenderingMode(.hierarchical)
|
.symbolRenderingMode(.hierarchical)
|
||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(snapshot.temperature.onyxFormatted)
|
Text(snapshot.temperature.onyxFormatted)
|
||||||
.font(.system(size: 30, weight: .medium))
|
.font(Onyx.Font.displayLarge)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Onyx.Color.textPrimary)
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
if let high = snapshot.high, let low = snapshot.low {
|
if let high = snapshot.high, let low = snapshot.low {
|
||||||
@@ -271,7 +255,7 @@ private struct WideWeather: View {
|
|||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.top, 2)
|
.padding(.top, Onyx.Space.xxs)
|
||||||
|
|
||||||
if snapshot.apparentTemperature != snapshot.temperature {
|
if snapshot.apparentTemperature != snapshot.temperature {
|
||||||
Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)",
|
Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)",
|
||||||
@@ -283,19 +267,19 @@ private struct WideWeather: View {
|
|||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
|
|
||||||
if !snapshot.hourly.isEmpty {
|
if !snapshot.hourly.isEmpty {
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
OnyxHairline()
|
||||||
|
|
||||||
// Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so
|
// Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so
|
||||||
// gut vierzig je Spalte, und die Zahlen lassen sich in einer
|
// gut vierzig je Spalte, und die Zahlen lassen sich in einer
|
||||||
// lesbaren Größe setzen.
|
// lesbaren Größe setzen.
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(snapshot.hourly.prefix(4)) { point in
|
ForEach(snapshot.hourly.prefix(4)) { point in
|
||||||
VStack(spacing: 3) {
|
VStack(spacing: Onyx.Space.xs) {
|
||||||
Text(point.date.formatted(.dateTime.hour()))
|
Text(point.date.formatted(.dateTime.hour()))
|
||||||
.font(Onyx.Font.metricSmall)
|
.font(Onyx.Font.metricSmall)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
Image(systemName: point.condition.symbolName(isDaylight: true))
|
Image(systemName: point.condition.symbolName(isDaylight: true))
|
||||||
.font(.system(size: 13))
|
.font(.system(size: Onyx.Symbol.medium))
|
||||||
.symbolRenderingMode(.hierarchical)
|
.symbolRenderingMode(.hierarchical)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
Text(point.temperature.onyxFormatted)
|
Text(point.temperature.onyxFormatted)
|
||||||
@@ -306,15 +290,15 @@ private struct WideWeather: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, Onyx.Space.md)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
if isStale {
|
if isStale {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: Onyx.Symbol.caption))
|
||||||
.foregroundStyle(Onyx.Color.warning)
|
.foregroundStyle(Onyx.Color.warning)
|
||||||
}
|
}
|
||||||
Text(stampText)
|
Text(stampText)
|
||||||
@@ -338,10 +322,13 @@ private struct WideWeather: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Measurement where UnitType == UnitTemperature {
|
public extension Measurement where UnitType == UnitTemperature {
|
||||||
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
|
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
|
||||||
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
|
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
|
||||||
/// die die Vorhersage nicht hat.
|
/// die die Vorhersage nicht hat.
|
||||||
|
///
|
||||||
|
/// Öffentlich, seit die Temperatur auch neben der Notch steht: dieselbe
|
||||||
|
/// Zahl zweimal verschieden zu runden wäre der Anfang zweier Wahrheiten.
|
||||||
var onyxFormatted: String {
|
var onyxFormatted: String {
|
||||||
formatted(.measurement(width: .narrow,
|
formatted(.measurement(width: .narrow,
|
||||||
usage: .weather,
|
usage: .weather,
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
#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)
|
#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")
|
@Test("Ohne Displays kein Panel — und kein Absturz")
|
||||||
func noScreensIsSafe() {
|
func noScreensIsSafe() {
|
||||||
#expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty)
|
#expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty)
|
||||||
#expect(DisplayPolicy.allDisplays.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")
|
@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)
|
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize)
|
||||||
|
|
||||||
#expect(g.kind == .virtual)
|
#expect(g.kind == .virtual)
|
||||||
// Mittig: 1800 + (2560 − 220)/2 = 2970
|
#expect(g.isDetached)
|
||||||
#expect(g.rect == CGRect(x: 2970, y: 1408, width: 220, height: 32))
|
// 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")
|
@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))
|
let panel = g.panelRect(for: CGSize(width: 640, height: 320))
|
||||||
|
|
||||||
#expect(panel.minX >= 1800)
|
#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
|
// MARK: - Sichtbarer Inhaltsbereich
|
||||||
@@ -181,3 +247,57 @@ struct NotchGeometryTests {
|
|||||||
#expect(panel.minY >= Self.builtIn.frame.minY)
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -99,6 +99,12 @@ targets:
|
|||||||
product: AudioProvider
|
product: AudioProvider
|
||||||
- package: OnyxKit
|
- package: OnyxKit
|
||||||
product: ShelfProvider
|
product: ShelfProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: ActivityProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: ClipboardProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: NotesProvider
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
||||||
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
||||||
|
|||||||
Reference in New Issue
Block a user