mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
commit
8ef4af908c
36 changed files with 2949 additions and 349 deletions
|
|
@ -19,25 +19,35 @@
|
||||||
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
|
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
|
||||||
7E6856482EA963FD00D261F5 /* Lock.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E6856472EA963FD00D261F5 /* Lock.json */; };
|
7E6856482EA963FD00D261F5 /* Lock.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E6856472EA963FD00D261F5 /* Lock.json */; };
|
||||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
||||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
|
|
||||||
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; };
|
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; };
|
||||||
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; };
|
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; };
|
||||||
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; };
|
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; };
|
||||||
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; };
|
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; };
|
||||||
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
||||||
7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */ = {isa = PBXBuildFile; productRef = 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */; };
|
|
||||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
||||||
|
7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; };
|
||||||
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; };
|
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
7EA69B1B2F5AE13500EB2D16 /* CopyFiles */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 8;
|
||||||
|
dstPath = Contents/Helpers;
|
||||||
|
dstSubfolderSpec = 1;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 1;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBrightness.framework; path = /System/Library/PrivateFrameworks/CoreBrightness.framework; sourceTree = "<absolute>"; };
|
7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBrightness.framework; path = /System/Library/PrivateFrameworks/CoreBrightness.framework; sourceTree = "<absolute>"; };
|
||||||
7E25ECA22E6C6E2600700E72 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = "<absolute>"; };
|
7E25ECA22E6C6E2600700E72 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = "<absolute>"; };
|
||||||
7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = "<absolute>"; };
|
7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = "<absolute>"; };
|
||||||
7E6856472EA963FD00D261F5 /* Lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Lock.json; sourceTree = "<group>"; };
|
7E6856472EA963FD00D261F5 /* Lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Lock.json; sourceTree = "<group>"; };
|
||||||
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
|
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
|
||||||
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
|
|
||||||
7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = "<group>"; };
|
7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = "<group>"; };
|
||||||
7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = "<group>"; };
|
7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = "<group>"; };
|
||||||
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = "<group>"; };
|
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = "<group>"; };
|
||||||
|
|
@ -49,7 +59,39 @@
|
||||||
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Helpers/NowPlayingTestClient,
|
||||||
|
);
|
||||||
|
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||||
|
7EA69B202F5AE63200EB2D16 /* Exceptions for "Contents" folder in "Copy Files" phase from "DynamicIsland" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
|
||||||
|
attributesByRelativePath = {
|
||||||
|
Helpers/NowPlayingTestClient = (CodeSignOnCopy, );
|
||||||
|
};
|
||||||
|
buildPhase = 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */;
|
||||||
|
membershipExceptions = (
|
||||||
|
Helpers/NowPlayingTestClient,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7EA69B1C2F5AE15C00EB2D16 /* Contents */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */,
|
||||||
|
7EA69B202F5AE63200EB2D16 /* Exceptions for "Contents" folder in "Copy Files" phase from "DynamicIsland" target */,
|
||||||
|
);
|
||||||
|
path = Contents;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = DynamicIsland;
|
path = DynamicIsland;
|
||||||
|
|
@ -62,6 +104,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */,
|
||||||
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */,
|
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */,
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */,
|
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */,
|
||||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */,
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */,
|
||||||
|
|
@ -72,7 +115,6 @@
|
||||||
7E6118172E24071E0050006C /* Collections in Frameworks */,
|
7E6118172E24071E0050006C /* Collections in Frameworks */,
|
||||||
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
|
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
|
||||||
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
|
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
|
||||||
7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */,
|
|
||||||
7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */,
|
7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */,
|
||||||
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
|
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
|
||||||
);
|
);
|
||||||
|
|
@ -93,7 +135,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */,
|
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */,
|
||||||
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */,
|
|
||||||
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */,
|
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */,
|
||||||
);
|
);
|
||||||
path = "mediaremote-adapter";
|
path = "mediaremote-adapter";
|
||||||
|
|
@ -126,6 +167,7 @@
|
||||||
F54406DE2CD4987A00C7AFD9 = {
|
F54406DE2CD4987A00C7AFD9 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
7EA69B1C2F5AE15C00EB2D16 /* Contents */,
|
||||||
7E8840D52E9A341F00C5CB8C /* LottieAnimations */,
|
7E8840D52E9A341F00C5CB8C /* LottieAnimations */,
|
||||||
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||||
|
|
@ -153,12 +195,14 @@
|
||||||
F54406E32CD4987A00C7AFD9 /* Sources */,
|
F54406E32CD4987A00C7AFD9 /* Sources */,
|
||||||
F54406E42CD4987A00C7AFD9 /* Frameworks */,
|
F54406E42CD4987A00C7AFD9 /* Frameworks */,
|
||||||
F54406E52CD4987A00C7AFD9 /* Resources */,
|
F54406E52CD4987A00C7AFD9 /* Resources */,
|
||||||
|
7EA69B1B2F5AE13500EB2D16 /* CopyFiles */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
|
7EA69B1C2F5AE15C00EB2D16 /* Contents */,
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||||
);
|
);
|
||||||
name = DynamicIsland;
|
name = DynamicIsland;
|
||||||
|
|
@ -173,7 +217,7 @@
|
||||||
146BC4362D366E9B0047C66A /* MacroVisionKit */,
|
146BC4362D366E9B0047C66A /* MacroVisionKit */,
|
||||||
7E61181E2E24071E0050006C /* SkyLightWindow */,
|
7E61181E2E24071E0050006C /* SkyLightWindow */,
|
||||||
7E288D112F1659EF0020C117 /* AtollExtensionKit */,
|
7E288D112F1659EF0020C117 /* AtollExtensionKit */,
|
||||||
7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */,
|
7EF19C162F577040005B9929 /* SwiftTerm */,
|
||||||
);
|
);
|
||||||
productName = DynamicIsland;
|
productName = DynamicIsland;
|
||||||
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
|
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
|
||||||
|
|
@ -219,7 +263,7 @@
|
||||||
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
|
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
|
||||||
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
|
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
|
||||||
7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */,
|
7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */,
|
||||||
7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */,
|
7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
|
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
|
||||||
|
|
@ -238,7 +282,6 @@
|
||||||
files = (
|
files = (
|
||||||
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */,
|
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */,
|
||||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
||||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
|
|
||||||
7E6856482EA963FD00D261F5 /* Lock.json in Resources */,
|
7E6856482EA963FD00D261F5 /* Lock.json in Resources */,
|
||||||
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */,
|
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */,
|
||||||
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */,
|
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */,
|
||||||
|
|
@ -387,14 +430,15 @@
|
||||||
AUTOMATION_APPLE_EVENTS = NO;
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = NO;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_CALENDARS = YES;
|
ENABLE_RESOURCE_ACCESS_CALENDARS = YES;
|
||||||
|
|
@ -431,7 +475,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = "1.3.0-beta";
|
MARKETING_VERSION = 2.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev;
|
||||||
PRODUCT_NAME = Atoll;
|
PRODUCT_NAME = Atoll;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
@ -458,14 +502,15 @@
|
||||||
AUTOMATION_APPLE_EVENTS = NO;
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = NO;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_CALENDARS = YES;
|
ENABLE_RESOURCE_ACCESS_CALENDARS = YES;
|
||||||
|
|
@ -502,7 +547,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = "1.3.0-beta";
|
MARKETING_VERSION = 2.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
||||||
PRODUCT_NAME = Atoll;
|
PRODUCT_NAME = Atoll;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
@ -593,12 +638,12 @@
|
||||||
kind = branch;
|
kind = branch;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */ = {
|
7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/Mijick/CalendarView";
|
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = upToNextMajorVersion;
|
||||||
minimumVersion = 1.1.1;
|
minimumVersion = 1.5.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = {
|
9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = {
|
||||||
|
|
@ -677,10 +722,10 @@
|
||||||
package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */;
|
package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */;
|
||||||
productName = SkyLightWindow;
|
productName = SkyLightWindow;
|
||||||
};
|
};
|
||||||
7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */ = {
|
7EF19C162F577040005B9929 /* SwiftTerm */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */;
|
package = 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||||
productName = MijickCalendarView;
|
productName = SwiftTerm;
|
||||||
};
|
};
|
||||||
9A987A0F2C73CA8D005CA465 /* Collections */ = {
|
9A987A0F2C73CA8D005CA465 /* Collections */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"originHash" : "802e15fea0dccd2d325dc082d8d36162007adcd2fa301070eb26448d0a2cf6c5",
|
"originHash" : "d05fd8c8292418b6563db3202a99d4ef453b0f70d83d4d4cf879984c14452017",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "atollextensionkit",
|
"identity" : "atollextensionkit",
|
||||||
|
|
@ -10,15 +10,6 @@
|
||||||
"revision" : "ebe35277572a349fad26abeb6fc552cb5251d584"
|
"revision" : "ebe35277572a349fad26abeb6fc552cb5251d584"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "calendarview",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/Mijick/CalendarView",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "168cf14a50d33684caa2a411bbd0b3f988e9dff6",
|
|
||||||
"version" : "1.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "defaults",
|
"identity" : "defaults",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
@ -109,6 +100,15 @@
|
||||||
"version" : "2.7.0"
|
"version" : "2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||||
|
"version" : "1.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-collections",
|
"identity" : "swift-collections",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
@ -127,6 +127,15 @@
|
||||||
"version" : "601.0.1"
|
"version" : "601.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftterm",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/migueldeicaza/SwiftTerm",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b1262db5b6bea699a8260a8c66999436c508ca56",
|
||||||
|
"version" : "1.11.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftui-introspect",
|
"identity" : "swiftui-introspect",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,25 @@ struct ContentView: View {
|
||||||
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
|
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
|
||||||
@Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities
|
@Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities
|
||||||
@Default(.showStandardMediaControls) var showStandardMediaControls
|
@Default(.showStandardMediaControls) var showStandardMediaControls
|
||||||
|
@Default(.externalDisplayStyle) var externalDisplayStyle
|
||||||
|
@Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
|
// When inline sneak peek is active in closed notch, use the wider inline width
|
||||||
|
// so the outer maxWidth frame doesn't clip the expanded content
|
||||||
|
let inlineSneakPeekActive = vm.notchState == .closed
|
||||||
|
&& coordinator.expandingView.show
|
||||||
|
&& (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer)
|
||||||
|
&& Defaults[.enableSneakPeek]
|
||||||
|
&& Defaults[.sneakPeekStyles] == .inline
|
||||||
|
if inlineSneakPeekActive {
|
||||||
|
let inlineWidth: CGFloat = 460
|
||||||
|
return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
if coordinator.currentView == .timer {
|
if coordinator.currentView == .timer {
|
||||||
return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets
|
return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +109,14 @@ struct ContentView: View {
|
||||||
return CGSize(width: baseSize.width, height: resolvedHeight)
|
return CGSize(width: baseSize.width, height: resolvedHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if coordinator.currentView == .terminal {
|
||||||
|
// Dynamic height: up to terminalMaxHeightFraction of screen, min 300pt
|
||||||
|
let screenHeight = NSScreen.main?.visibleFrame.height ?? 800
|
||||||
|
let maxFraction = Defaults[.terminalMaxHeightFraction]
|
||||||
|
let terminalHeight = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction))
|
||||||
|
return CGSize(width: baseSize.width, height: terminalHeight)
|
||||||
|
}
|
||||||
|
|
||||||
if coordinator.currentView == .extensionExperience {
|
if coordinator.currentView == .extensionExperience {
|
||||||
if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) {
|
if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) {
|
||||||
return CGSize(width: baseSize.width, height: preferredHeight)
|
return CGSize(width: baseSize.width, height: preferredHeight)
|
||||||
|
|
@ -127,6 +149,7 @@ struct ContentView: View {
|
||||||
@State private var isHovering: Bool = false
|
@State private var isHovering: Bool = false
|
||||||
@State private var lastHapticTime: Date = Date()
|
@State private var lastHapticTime: Date = Date()
|
||||||
@State private var hoverClickMonitor: Any?
|
@State private var hoverClickMonitor: Any?
|
||||||
|
@State private var hoverClickLocalMonitor: Any?
|
||||||
|
|
||||||
@State private var gestureProgress: CGFloat = .zero
|
@State private var gestureProgress: CGFloat = .zero
|
||||||
@State private var skipGestureActiveDirection: MusicManager.SkipDirection?
|
@State private var skipGestureActiveDirection: MusicManager.SkipDirection?
|
||||||
|
|
@ -231,8 +254,53 @@ struct ContentView: View {
|
||||||
return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius)
|
return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the current screen should render as a Dynamic Island pill
|
||||||
|
/// rather than the standard notch shape. Always false on physical notch screens.
|
||||||
|
private var isDynamicIslandMode: Bool {
|
||||||
|
shouldUseDynamicIslandMode(for: vm.screen ?? coordinator.selectedScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current screen lacks a physical notch.
|
||||||
|
private var isNonNotchScreen: Bool {
|
||||||
|
let screenName = vm.screen ?? coordinator.selectedScreen
|
||||||
|
guard let screen = NSScreen.screens.first(where: { $0.localizedName == screenName }) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return screen.safeAreaInsets.top <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the notch/island should hide off-screen when closed on a non-notch display.
|
||||||
|
/// Temporarily reveals the notch when a sneakPeek HUD (volume, brightness, music, etc.) is active.
|
||||||
|
private var shouldHideUntilHover: Bool {
|
||||||
|
hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !coordinator.sneakPeek.show
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pill shape for Dynamic Island mode with animated corner radius transitions.
|
||||||
|
private var currentPillShape: DynamicIslandPillShape {
|
||||||
|
let radius: CGFloat
|
||||||
|
if vm.notchState == .open {
|
||||||
|
radius = enableMinimalisticUI
|
||||||
|
? minimalisticCornerRadiusInsets.opened.top
|
||||||
|
: dynamicIslandPillCornerRadiusInsets.opened
|
||||||
|
} else {
|
||||||
|
// Use half the closed height for a true capsule shape
|
||||||
|
radius = max(vm.closedNotchSize.height / 2, dynamicIslandPillCornerRadiusInsets.closed.standard)
|
||||||
|
}
|
||||||
|
return DynamicIslandPillShape(cornerRadius: radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the clip/content shape per-screen: pill on non-notch screens
|
||||||
|
/// when dynamic island mode is active, standard notch shape otherwise.
|
||||||
|
private var resolvedClipShape: AnyShape {
|
||||||
|
if isDynamicIslandMode {
|
||||||
|
return AnyShape(currentPillShape)
|
||||||
|
}
|
||||||
|
return AnyShape(currentNotchShape)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let interactionsEnabled = !lockScreenManager.isLocked
|
let interactionsEnabled = !lockScreenManager.isLocked
|
||||||
|
let isIslandMode = isDynamicIslandMode
|
||||||
let notchHorizontalPadding: CGFloat = {
|
let notchHorizontalPadding: CGFloat = {
|
||||||
guard vm.notchState == .open else {
|
guard vm.notchState == .open else {
|
||||||
return activeCornerRadiusInsets.closed.bottom
|
return activeCornerRadiusInsets.closed.bottom
|
||||||
|
|
@ -249,6 +317,8 @@ struct ContentView: View {
|
||||||
return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0
|
return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0
|
||||||
}()
|
}()
|
||||||
let notchBottomPadding = currentShadowPadding + hoverAreaPadding
|
let notchBottomPadding = currentShadowPadding + hoverAreaPadding
|
||||||
|
// Extra top padding to detach pill from screen edge in Dynamic Island mode
|
||||||
|
let pillTopOffset: CGFloat = isIslandMode ? dynamicIslandTopOffset : 0
|
||||||
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
let mainLayout = NotchLayout()
|
let mainLayout = NotchLayout()
|
||||||
|
|
@ -256,7 +326,7 @@ struct ContentView: View {
|
||||||
.padding(.horizontal, notchHorizontalPadding)
|
.padding(.horizontal, notchHorizontalPadding)
|
||||||
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
|
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
|
||||||
.background(.black)
|
.background(.black)
|
||||||
.clipShape(currentNotchShape)
|
.clipShape(resolvedClipShape)
|
||||||
.compositingGroup()
|
.compositingGroup()
|
||||||
.shadow(
|
.shadow(
|
||||||
color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow])
|
color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow])
|
||||||
|
|
@ -264,7 +334,10 @@ struct ContentView: View {
|
||||||
: .clear,
|
: .clear,
|
||||||
radius: Defaults[.cornerRadiusScaling] ? 10 : 5
|
radius: Defaults[.cornerRadiusScaling] ? 10 : 5
|
||||||
)
|
)
|
||||||
.padding(.bottom, notchBottomPadding)
|
// Extra horizontal inset for Dynamic Island mode so the shadow
|
||||||
|
// is not clipped by the outer frame constraint
|
||||||
|
.padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0)
|
||||||
|
.padding(.top, pillTopOffset)
|
||||||
|
|
||||||
mainLayout
|
mainLayout
|
||||||
.conditionalModifier(!useModernCloseAnimation) { view in
|
.conditionalModifier(!useModernCloseAnimation) { view in
|
||||||
|
|
@ -288,7 +361,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.conditionalModifier(interactionsEnabled) { view in
|
.conditionalModifier(interactionsEnabled) { view in
|
||||||
view
|
view
|
||||||
.contentShape(currentNotchShape)
|
.contentShape(resolvedClipShape)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
handleHover(hovering)
|
handleHover(hovering)
|
||||||
}
|
}
|
||||||
|
|
@ -317,10 +390,19 @@ struct ContentView: View {
|
||||||
handleUpGesture(translation: translation, phase: phase)
|
handleUpGesture(translation: translation, phase: phase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Shadow bottom padding and hide-until-hover offset applied AFTER
|
||||||
|
// interaction modifiers so .contentShape / .onHover only covers
|
||||||
|
// the actual notch content, not the shadow clearance below it.
|
||||||
|
.padding(.bottom, notchBottomPadding)
|
||||||
|
.offset(y: shouldHideUntilHover && !isHovering
|
||||||
|
? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10)
|
||||||
|
: 0
|
||||||
|
)
|
||||||
.onAppear(perform: {
|
.onAppear(perform: {
|
||||||
runAfter(1) {
|
if coordinator.firstLaunch {
|
||||||
withAnimation(vm.animation) {
|
// Single open during first launch; closeHello() handles the timed close.
|
||||||
if coordinator.firstLaunch {
|
runAfter(1) {
|
||||||
|
withAnimation(vm.animation) {
|
||||||
openNotch()
|
openNotch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -410,8 +492,8 @@ struct ContentView: View {
|
||||||
|
|
||||||
}
|
}
|
||||||
.frame(
|
.frame(
|
||||||
maxWidth: dynamicNotchSize.width,
|
maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0),
|
||||||
maxHeight: dynamicNotchSize.height + currentShadowPadding,
|
maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0),
|
||||||
alignment: .top
|
alignment: .top
|
||||||
)
|
)
|
||||||
.frame(maxHeight: .infinity, alignment: .top)
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
|
@ -762,6 +844,8 @@ struct ContentView: View {
|
||||||
NotchNotesView()
|
NotchNotesView()
|
||||||
case .clipboard:
|
case .clipboard:
|
||||||
NotchNotesView()
|
NotchNotesView()
|
||||||
|
case .terminal:
|
||||||
|
NotchTerminalView()
|
||||||
case .extensionExperience:
|
case .extensionExperience:
|
||||||
if let payload = currentExtensionTabPayload() {
|
if let payload = currentExtensionTabPayload() {
|
||||||
ExtensionNotchExperienceTabView(payload: payload)
|
ExtensionNotchExperienceTabView(payload: payload)
|
||||||
|
|
@ -774,7 +858,6 @@ struct ContentView: View {
|
||||||
.transition(tabSwitchTransition)
|
.transition(tabSwitchTransition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clipped()
|
|
||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
.allowsHitTesting(vm.notchState == .open)
|
.allowsHitTesting(vm.notchState == .open)
|
||||||
.blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0)
|
.blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0)
|
||||||
|
|
@ -895,37 +978,42 @@ struct ContentView: View {
|
||||||
.fill(.black)
|
.fill(.black)
|
||||||
.frame(width: effectiveCenterWidth, height: notchContentHeight)
|
.frame(width: effectiveCenterWidth, height: notchContentHeight)
|
||||||
.overlay(
|
.overlay(
|
||||||
HStack(alignment: .top){
|
HStack(alignment: .top) {
|
||||||
if(coordinator.expandingView.show && coordinator.expandingView.type == .music) {
|
if(coordinator.expandingView.show && coordinator.expandingView.type == .music) {
|
||||||
MarqueeText(
|
MarqueeText(
|
||||||
.constant(musicManager.songTitle),
|
.constant(musicManager.songTitle),
|
||||||
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
||||||
minDuration: 0.4,
|
minDuration: 0.4,
|
||||||
frameWidth: 100
|
frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12)
|
||||||
)
|
)
|
||||||
|
.padding(.leading, 8)
|
||||||
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
||||||
Spacer(minLength: vm.closedNotchSize.width)
|
Spacer(minLength: vm.closedNotchSize.width)
|
||||||
Text(musicManager.artistName)
|
Text(musicManager.artistName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
.foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray)
|
.foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray)
|
||||||
|
.padding(.trailing, 8)
|
||||||
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
||||||
} else if(coordinator.expandingView.show && coordinator.expandingView.type == .timer) {
|
} else if(coordinator.expandingView.show && coordinator.expandingView.type == .timer) {
|
||||||
MarqueeText(
|
MarqueeText(
|
||||||
.constant(timerManager.timerName),
|
.constant(timerManager.timerName),
|
||||||
textColor: timerManager.timerColor,
|
textColor: timerManager.timerColor,
|
||||||
minDuration: 0.4,
|
minDuration: 0.4,
|
||||||
frameWidth: 100
|
frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12)
|
||||||
)
|
)
|
||||||
|
.padding(.leading, 8)
|
||||||
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
||||||
Spacer(minLength: vm.closedNotchSize.width)
|
Spacer(minLength: vm.closedNotchSize.width)
|
||||||
Text(timerManager.formattedRemainingTime())
|
Text(timerManager.formattedRemainingTime())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
.foregroundStyle(timerManager.timerColor)
|
.foregroundStyle(timerManager.timerColor)
|
||||||
|
.padding(.trailing, 8)
|
||||||
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.clipped()
|
||||||
)
|
)
|
||||||
|
|
||||||
musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth)
|
musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth)
|
||||||
|
|
@ -1457,17 +1545,34 @@ struct ContentView: View {
|
||||||
|
|
||||||
private func startHoverClickMonitor() {
|
private func startHoverClickMonitor() {
|
||||||
guard hoverClickMonitor == nil else { return }
|
guard hoverClickMonitor == nil else { return }
|
||||||
hoverClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { _ in
|
|
||||||
|
let handleClick: @Sendable () -> Void = { [weak vm, weak lockScreenManager] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard let vm, let lockScreenManager else { return }
|
||||||
guard !lockScreenManager.isLocked else { return }
|
guard !lockScreenManager.isLocked else { return }
|
||||||
guard vm.notchState == .closed else { return }
|
guard vm.notchState == .closed else { return }
|
||||||
guard isHovering else { return }
|
guard self.isHovering else { return }
|
||||||
if Defaults[.enableHaptics] {
|
if Defaults[.enableHaptics] {
|
||||||
triggerHapticIfAllowed()
|
self.triggerHapticIfAllowed()
|
||||||
}
|
}
|
||||||
openNotch()
|
self.openNotch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global monitor catches clicks outside the app window (e.g. when
|
||||||
|
// the cursor is at the very top screen edge and the click goes to
|
||||||
|
// the system rather than our panel).
|
||||||
|
hoverClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { _ in
|
||||||
|
handleClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local monitor catches clicks that DO hit our window — at the
|
||||||
|
// screen edge SwiftUI's .onTapGesture may not fire reliably, but
|
||||||
|
// the NSEvent local monitor will.
|
||||||
|
hoverClickLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { event in
|
||||||
|
handleClick()
|
||||||
|
return event
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopHoverClickMonitor() {
|
private func stopHoverClickMonitor() {
|
||||||
|
|
@ -1475,6 +1580,10 @@ struct ContentView: View {
|
||||||
NSEvent.removeMonitor(hoverClickMonitor)
|
NSEvent.removeMonitor(hoverClickMonitor)
|
||||||
self.hoverClickMonitor = nil
|
self.hoverClickMonitor = nil
|
||||||
}
|
}
|
||||||
|
if let hoverClickLocalMonitor {
|
||||||
|
NSEvent.removeMonitor(hoverClickLocalMonitor)
|
||||||
|
self.hoverClickLocalMonitor = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hover Management
|
// MARK: - Hover Management
|
||||||
|
|
@ -1551,7 +1660,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldPreventAutoClose() -> Bool {
|
private func shouldPreventAutoClose() -> Bool {
|
||||||
hasAnyActivePopovers() || vm.isAutoCloseSuppressed || SharingStateManager.shared.preventNotchClose
|
coordinator.firstLaunch || hasAnyActivePopovers() || vm.isAutoCloseSuppressed || SharingStateManager.shared.preventNotchClose
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to prevent rapid haptic feedback
|
// Helper to prevent rapid haptic feedback
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.security.mach-lookup.global-name</key>
|
||||||
|
<array>
|
||||||
|
<string>com.ebullioscopic.Atoll.xpc</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.automation.apple-events</key>
|
<key>com.apple.security.automation.apple-events</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.temporary-exception.apple-events</key>
|
<key>com.apple.security.temporary-exception.apple-events</key>
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
-> NSWindow
|
-> NSWindow
|
||||||
{
|
{
|
||||||
// Use the current required size instead of always using openNotchSize
|
// Use the current required size instead of always using openNotchSize
|
||||||
let requiredSize = calculateRequiredNotchSize()
|
let baseSize = calculateRequiredNotchSize()
|
||||||
|
let requiredSize = adjustedSizeForScreen(baseSize, screen: screen)
|
||||||
|
|
||||||
let window = DynamicIslandWindow(
|
let window = DynamicIslandWindow(
|
||||||
contentRect: NSRect(
|
contentRect: NSRect(
|
||||||
|
|
@ -296,8 +297,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// If inline sneak peek is active, use a wider width to accommodate the expanded content
|
// If inline sneak peek is active, use a wider width to accommodate the expanded content
|
||||||
if isInlineSneakPeekActive {
|
if isInlineSneakPeekActive {
|
||||||
// Calculate required width for inline sneak peek:
|
// Calculate required width for inline sneak peek:
|
||||||
// Album art (~32) + Middle section (380) + Visualizer (~32) + padding = ~450
|
// Album art (~32) + Middle section (380) + Visualizer (~32) + horizontal padding (28) + clip shape margin (12)
|
||||||
let inlineSneakPeekWidth: CGFloat = 450
|
let inlineSneakPeekWidth: CGFloat = 460
|
||||||
return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight)
|
return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,6 +311,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
} else if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
|
} else if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
|
||||||
let preferredHeight = coordinator.notesLayoutState.preferredHeight
|
let preferredHeight = coordinator.notesLayoutState.preferredHeight
|
||||||
baseSize.height = max(baseSize.height, preferredHeight)
|
baseSize.height = max(baseSize.height, preferredHeight)
|
||||||
|
} else if coordinator.currentView == .terminal {
|
||||||
|
let screenHeight = NSScreen.main?.visibleFrame.height ?? 800
|
||||||
|
let maxFraction = Defaults[.terminalMaxHeightFraction]
|
||||||
|
baseSize.height = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction))
|
||||||
}
|
}
|
||||||
|
|
||||||
let adjustedContentSize = statsAdjustedNotchSize(
|
let adjustedContentSize = statsAdjustedNotchSize(
|
||||||
|
|
@ -317,10 +322,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
isStatsTabActive: coordinator.currentView == .stats,
|
isStatsTabActive: coordinator.currentView == .stats,
|
||||||
secondRowProgress: coordinator.statsSecondRowExpansion
|
secondRowProgress: coordinator.statsSecondRowExpansion
|
||||||
)
|
)
|
||||||
return addShadowPadding(
|
var result = addShadowPadding(
|
||||||
to: adjustedContentSize,
|
to: adjustedContentSize,
|
||||||
isMinimalistic: Defaults[.enableMinimalisticUI]
|
isMinimalistic: Defaults[.enableMinimalisticUI]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusts a base notch size for a specific screen by adding Dynamic Island
|
||||||
|
/// shadow insets and top-offset only when the screen lacks a physical notch
|
||||||
|
/// and the user has chosen the Dynamic Island style.
|
||||||
|
private func adjustedSizeForScreen(_ baseSize: CGSize, screen: NSScreen) -> CGSize {
|
||||||
|
guard shouldUseDynamicIslandMode(for: screen.localizedName) else {
|
||||||
|
return baseSize
|
||||||
|
}
|
||||||
|
var adjusted = baseSize
|
||||||
|
adjusted.width += dynamicIslandShadowInset * 2
|
||||||
|
adjusted.height += dynamicIslandTopOffset
|
||||||
|
return adjusted
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) {
|
func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) {
|
||||||
|
|
@ -332,25 +352,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
if Defaults[.showOnAllDisplays] {
|
if Defaults[.showOnAllDisplays] {
|
||||||
for (screen, window) in windows {
|
for (screen, window) in windows {
|
||||||
if force || window.frame.size != size {
|
let screenSize = adjustedSizeForScreen(size, screen: screen)
|
||||||
resizeWindow(window, on: screen, to: size, animated: animated)
|
if force || window.frame.size != screenSize {
|
||||||
|
resizeWindow(window, on: screen, to: screenSize, animated: animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let window {
|
} else if let window {
|
||||||
let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } ?? NSScreen.main ?? NSScreen.screens.first
|
let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } ?? NSScreen.main ?? NSScreen.screens.first
|
||||||
guard let screen else { return }
|
guard let screen else { return }
|
||||||
if force || window.frame.size != size {
|
let screenSize = adjustedSizeForScreen(size, screen: screen)
|
||||||
resizeWindow(window, on: screen, to: size, animated: animated)
|
if force || window.frame.size != screenSize {
|
||||||
|
resizeWindow(window, on: screen, to: screenSize, animated: animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resizeWindow(_ window: NSWindow, on screen: NSScreen, to size: CGSize, animated: Bool) {
|
private func resizeWindow(_ window: NSWindow, on screen: NSScreen, to size: CGSize, animated: Bool) {
|
||||||
let screenFrame = screen.frame
|
let screenFrame = screen.frame
|
||||||
|
// Clamp width to screen width so the notch never extends beyond screen edges on scaled displays
|
||||||
|
let clampedWidth = min(size.width, screenFrame.width)
|
||||||
|
let clampedHeight = min(size.height, screenFrame.height)
|
||||||
let centerX = screenFrame.midX
|
let centerX = screenFrame.midX
|
||||||
let newX = centerX - (size.width / 2)
|
let newX = centerX - (clampedWidth / 2)
|
||||||
let newY = screenFrame.origin.y + screenFrame.height - size.height
|
let newY = screenFrame.origin.y + screenFrame.height - clampedHeight
|
||||||
let targetFrame = NSRect(x: newX, y: newY, width: size.width, height: size.height)
|
let targetFrame = NSRect(x: newX, y: newY, width: clampedWidth, height: clampedHeight)
|
||||||
|
|
||||||
window.setFrame(targetFrame, display: true)
|
window.setFrame(targetFrame, display: true)
|
||||||
}
|
}
|
||||||
|
|
@ -387,6 +412,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
// Setup SystemHUD Manager
|
// Setup SystemHUD Manager
|
||||||
SystemHUDManager.shared.setup(coordinator: coordinator)
|
SystemHUDManager.shared.setup(coordinator: coordinator)
|
||||||
|
|
||||||
|
// Setup BetterDisplay integration
|
||||||
|
BetterDisplayManager.shared.configure(coordinator: coordinator)
|
||||||
|
|
||||||
// Setup ScreenRecording Manager
|
// Setup ScreenRecording Manager
|
||||||
if Defaults[.enableScreenRecordingDetection] {
|
if Defaults[.enableScreenRecordingDetection] {
|
||||||
|
|
@ -402,8 +430,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
PrivacyIndicatorManager.shared.startMonitoring()
|
PrivacyIndicatorManager.shared.startMonitoring()
|
||||||
|
|
||||||
// Observe tab changes - use immediate resize to keep the notch pinned
|
// Observe tab changes - use immediate resize to keep the notch pinned
|
||||||
|
// Deferred to next run loop tick because @Published fires on willSet,
|
||||||
|
// so coordinator.currentView still holds the OLD value at emission time.
|
||||||
coordinator.$currentView.sink { [weak self] _ in
|
coordinator.$currentView.sink { [weak self] _ in
|
||||||
self?.updateWindowSizeForTabSwitch()
|
DispatchQueue.main.async {
|
||||||
|
self?.updateWindowSizeForTabSwitch()
|
||||||
|
}
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
coordinator.$notesLayoutState
|
coordinator.$notesLayoutState
|
||||||
|
|
@ -442,6 +474,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
self?.debouncedUpdateWindowSize()
|
self?.debouncedUpdateWindowSize()
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe terminal settings changes
|
||||||
|
Defaults.publisher(.enableTerminalFeature, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.terminalMaxHeightFraction, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
MemoryUsageMonitor.shared.startMonitoring()
|
MemoryUsageMonitor.shared.startMonitoring()
|
||||||
|
|
||||||
ReminderLiveActivityManager.shared.$activeWindowReminders
|
ReminderLiveActivityManager.shared.$activeWindowReminders
|
||||||
|
|
@ -492,11 +533,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
// Observe minimalistic UI setting changes - trigger window resize
|
// Observe minimalistic UI setting changes - trigger window resize
|
||||||
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] _ in
|
||||||
// Force sneak peek to standard mode when minimalistic UI is enabled
|
|
||||||
if change.newValue == true && Defaults[.sneakPeekStyles] != .standard {
|
|
||||||
Defaults[.sneakPeekStyles] = .standard
|
|
||||||
}
|
|
||||||
// Update window size IMMEDIATELY (no debouncing) to prevent position shift
|
// Update window size IMMEDIATELY (no debouncing) to prevent position shift
|
||||||
self?.updateWindowSizeIfNeeded()
|
self?.updateWindowSizeIfNeeded()
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
static let shared = DynamicIslandViewCoordinator()
|
static let shared = DynamicIslandViewCoordinator()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .extensionExperience]
|
private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience]
|
||||||
|
|
||||||
/// Direction of the most recent tab switch (true = forward/right, false = backward/left)
|
/// Direction of the most recent tab switch (true = forward/right, false = backward/left)
|
||||||
@Published var tabSwitchForward: Bool = true
|
@Published var tabSwitchForward: Bool = true
|
||||||
|
|
@ -219,6 +219,30 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences)
|
handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences)
|
||||||
|
|
||||||
|
// Observe all tab-affecting settings to enforce minimum notch width
|
||||||
|
Publishers.MergeMany(
|
||||||
|
Defaults.publisher(.showStandardMediaControls).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.showCalendar).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.showMirror).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.dynamicShelf).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.enableTimerFeature).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.timerDisplayMode).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.enableStatsFeature).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.enableNotes).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.enableClipboardManager).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.clipboardDisplayMode).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.enableTerminalFeature).map { _ in () }.eraseToAnyPublisher(),
|
||||||
|
Defaults.publisher(.enableMinimalisticUI).map { _ in () }.eraseToAnyPublisher()
|
||||||
|
)
|
||||||
|
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
|
||||||
|
.sink { _ in
|
||||||
|
enforceMinimumNotchWidth()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Enforce minimum width on launch for existing configurations
|
||||||
|
enforceMinimumNotchWidth()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
|
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
|
||||||
|
|
|
||||||
|
|
@ -1054,6 +1054,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld pt" : {
|
||||||
|
"comment" : "A label displaying the current font size of the terminal. The value is shown in point size.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld px" : {
|
"%lld px" : {
|
||||||
"comment" : "A label displaying the current expanded notch width value. The value is shown in pixels.",
|
"comment" : "A label displaying the current expanded notch width value. The value is shown in pixels.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -1064,6 +1068,18 @@
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"%lld tab%@ enabled · min %lld px" : {
|
||||||
|
"comment" : "A label displaying the number of enabled tabs, the recommended minimum notch width, and a button to reset the notch width to its recommended minimum value. The first argument is the count of enabled tabs. The second argument is the recommended minimum notch width as a formatted pixel count.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld tab%2$@ enabled · min %3$lld px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%lld%%" : {
|
"%lld%%" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -1591,6 +1607,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"±10 Seconds" : {
|
||||||
|
"comment" : "Display name for the \"±10 Seconds\" music skip behavior option.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"0" : {
|
"0" : {
|
||||||
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
|
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -1907,6 +1927,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Accent" : {
|
||||||
|
"comment" : "Display name for the \"Accent\" caps lock indicator tint mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Accent color" : {
|
"Accent color" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -2252,6 +2276,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Adaptive gradient" : {
|
||||||
|
"comment" : "Name of the timer icon color mode option that uses an adaptive gradient.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Add" : {
|
"Add" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -2743,6 +2771,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Adds a Guake-style dropdown terminal tab. The terminal session persists across notch open/close cycles." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : {
|
"Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -3344,6 +3375,9 @@
|
||||||
"Allow minimalistic overrides" : {
|
"Allow minimalistic overrides" : {
|
||||||
"comment" : "Label for a toggle that allows minimalistic overrides in notch experiences.",
|
"comment" : "Label for a toggle that allows minimalistic overrides in notch experiences.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Allow mouse reporting" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Allowed Features" : {
|
"Allowed Features" : {
|
||||||
"comment" : "A label describing the allowed features for an app extension.",
|
"comment" : "A label describing the allowed features for an app extension.",
|
||||||
|
|
@ -3841,6 +3875,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Apple Music" : {
|
||||||
|
"comment" : "Names of the different music streaming services.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Applies the notch-style parallax effect to the lock screen media widget album art." : {
|
"Applies the notch-style parallax effect to the lock screen media widget album art." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
|
|
@ -4239,6 +4277,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Background" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Backlight" : {
|
"Backlight" : {
|
||||||
"comment" : "A control for adjusting the keyboard backlight intensity.",
|
"comment" : "A control for adjusting the keyboard backlight intensity.",
|
||||||
|
|
@ -4252,6 +4293,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Bar" : {
|
||||||
|
"comment" : "Localized name for the \"Bar\" timer progress style.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Battery" : {
|
"Battery" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -4742,6 +4787,20 @@
|
||||||
"comment" : "A label indicating a beta version.",
|
"comment" : "A label indicating a beta version.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
|
"BetterDisplay" : {
|
||||||
|
"comment" : "The name of the BetterDisplay integration option.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"BetterDisplay Integration" : {
|
||||||
|
"comment" : "The header text for the \"BetterDisplay Integration\" section in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"BetterDisplay OSD events will be routed through Atoll's active HUD style. Make sure BetterDisplay's OSD integration is enabled in its Settings › Application › Integration." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Blue dashed box shows actual size in Atoll" : {
|
"Blue dashed box shows actual size in Atoll" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -4806,6 +4865,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Bold text as bright colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Boost your productivity with Clipboard Manager" : {
|
"Boost your productivity with Clipboard Manager" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -6234,6 +6296,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Classic notch shape that blends into the top screen edge" : {
|
||||||
|
"comment" : "Description of a notch shape that blends into the top screen edge.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Clear" : {
|
"Clear" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -6854,6 +6920,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Coming soon" : {
|
"Coming soon" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -7454,6 +7523,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Cursor" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Cursor style" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Custom" : {
|
"Custom" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -8491,6 +8566,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Digital" : {
|
||||||
|
"comment" : "The display name for the \"Digital\" reminder presentation style.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Dimensions" : {
|
"Dimensions" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
|
|
@ -8704,6 +8783,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : {
|
"Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : {
|
||||||
"comment" : "A description of how the color-coded progress bars work when not using smooth transitions.",
|
"comment" : "A description of how the color-coded progress bars work when not using smooth transitions.",
|
||||||
|
|
@ -8779,6 +8861,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Display Style" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." : {
|
"Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." : {
|
||||||
"comment" : "A footer for the \"Bluetooth Audio Devices\" section in the HUD settings.",
|
"comment" : "A footer for the \"Bluetooth Audio Devices\" section in the HUD settings.",
|
||||||
|
|
@ -9256,6 +9341,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Dynamic Island" : {
|
||||||
|
"comment" : "Description of the dynamic island style.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Dynamic Island Progress Bars" : {
|
"Dynamic Island Progress Bars" : {
|
||||||
"comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.",
|
"comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -9966,6 +10055,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Enable it in Settings → Terminal" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enable media panel blur" : {
|
"Enable media panel blur" : {
|
||||||
"comment" : "A toggle switch that enables or disables the blur effect on the media panel.",
|
"comment" : "A toggle switch that enables or disables the blur effect on the media panel.",
|
||||||
|
|
@ -10173,6 +10265,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Enable to route BetterDisplay's brightness and volume changes through Atoll's HUD instead of the system OSD." : {
|
||||||
|
"comment" : "A description of the benefit of enabling BetterDisplay integration.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable when scaling beyond 30x20px so the Atoll opens gracefully." : {
|
"Enable when scaling beyond 30x20px so the Atoll opens gracefully." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
|
|
@ -10814,6 +10910,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"External display style" : {
|
||||||
|
"comment" : "A picker that lets the user choose the style of the external display.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Favorites" : {
|
"Favorites" : {
|
||||||
"comment" : "Localized name of the \"Favorites\" tab in the clipboard.",
|
"comment" : "Localized name of the \"Favorites\" tab in the clipboard.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -11005,6 +11105,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Font size" : {
|
||||||
|
"comment" : "A label displayed above a slider that controls the font size of the terminal",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Foreground" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Free · %@" : {
|
"Free · %@" : {
|
||||||
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
|
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
|
||||||
|
|
@ -11861,6 +11968,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Green" : {
|
||||||
|
"comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Height:" : {
|
"Height:" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -12950,6 +13061,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Input" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD." : {
|
||||||
|
"comment" : "A description of the benefits of installing BetterDisplay.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Install updates automatically" : {
|
"Install updates automatically" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -13976,6 +14094,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Main screen style" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : {
|
"Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : {
|
||||||
"comment" : "A description of how to manage the microphone privacy indicator preferences.",
|
"comment" : "A description of how to manage the microphone privacy indicator preferences.",
|
||||||
|
|
@ -14330,6 +14451,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Maximum height" : {
|
||||||
|
"comment" : "A label for the terminal's maximum height setting.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Media" : {
|
"Media" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -15527,7 +15652,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Minutes" : {
|
"Minutes" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -17725,6 +17849,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Nothing Playing" : {
|
||||||
|
"comment" : "A label displayed when there is nothing playing in the music player.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Notify before" : {
|
"Notify before" : {
|
||||||
"comment" : "A label describing the slider that allows the user to set a reminder lead time.",
|
"comment" : "A label describing the slider that allows the user to set a reminder lead time.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -17736,6 +17864,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Now Playing" : {
|
||||||
|
"comment" : "Localized name for each media controller type.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Number of lines kept in the scrollback buffer. Higher values use more memory." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Off" : {
|
"Off" : {
|
||||||
"comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.",
|
"comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.",
|
||||||
|
|
@ -18066,6 +18201,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Option as Meta key" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Option as Meta sends Esc+key instead of macOS special characters. Mouse reporting forwards mouse events to terminal applications like vim or tmux." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Output devices" : {
|
"Output devices" : {
|
||||||
"comment" : "Section title for the list of available audio output devices in the media output selector.",
|
"comment" : "Section title for the list of available audio output devices in the media output selector.",
|
||||||
|
|
@ -18103,6 +18244,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Panel" : {
|
||||||
|
"comment" : "Display name for the \"Panel\" clipboard display mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Panel mode shows clipboard in a floating window near the notch." : {
|
"Panel mode shows clipboard in a floating window near the notch." : {
|
||||||
"comment" : "A description of the \"Panel\" clipboard display mode.",
|
"comment" : "A description of the \"Panel\" clipboard display mode.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -18232,6 +18377,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island" : {
|
||||||
|
"comment" : "Description of the Dynamic Island style for external displays.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Pin items from history to save them here" : {
|
"Pin items from history to save them here" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
|
|
@ -19074,6 +19223,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Refresh detection" : {
|
||||||
|
"comment" : "A button label that refreshes the detection status of BetterDisplay.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Release name" : {
|
"Release name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -19619,6 +19772,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Restart shell" : {
|
||||||
|
"comment" : "A button label that says \"Restart shell\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Restart Shell" : {
|
||||||
|
"comment" : "A button that restarts the shell process. Any unsaved work in the terminal will be lost.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Restarts the shell process. Any unsaved work in the terminal will be lost." : {
|
||||||
|
"comment" : "A footer description for the \"Restart Shell\" button in the Terminal settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Restore" : {
|
"Restore" : {
|
||||||
"comment" : "The text for a button that restores default settings.",
|
"comment" : "The text for a button that restores default settings.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -19697,6 +19862,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Ring" : {
|
||||||
|
"comment" : "Localized name for the \"Ring\" timer progress style.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Rotation:" : {
|
"Rotation:" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -20009,6 +20178,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Scrollback" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scrollback lines" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Search clipboard..." : {
|
"Search clipboard..." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -20517,6 +20692,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Separate Tab" : {
|
||||||
|
"comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : {
|
"Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : {
|
||||||
"comment" : "A description of the \"Separate Tab\" clipboard display mode.",
|
"comment" : "A description of the \"Separate Tab\" clipboard display mode.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -20781,6 +20960,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Shell" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Shell path" : {
|
||||||
|
"comment" : "A setting option for the path to the shell to use in the terminal",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Shortcuts" : {
|
"Shortcuts" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -21571,6 +21757,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Shows screen assistant as a dropdown attached to the AI button" : {
|
||||||
|
"comment" : "Description of the \"popover\" display mode for the screen assistant.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Shows screen assistant in a floating panel near the notch" : {
|
||||||
|
"comment" : "Description of the \"panel\" display mode for the screen assistant.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Shows the model's reasoning process before the final answer" : {
|
"Shows the model's reasoning process before the final answer" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -21633,6 +21827,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Skip forward or backward by ten seconds" : {
|
||||||
|
"comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Slider color" : {
|
"Slider color" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -21969,6 +22167,7 @@
|
||||||
},
|
},
|
||||||
"Sneak peek style is locked to Standard in minimalistic mode" : {
|
"Sneak peek style is locked to Standard in minimalistic mode" : {
|
||||||
"comment" : "A message displayed when the sneak peek style is locked to standard in minimalistic UI mode.",
|
"comment" : "A message displayed when the sneak peek style is locked to standard in minimalistic UI mode.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
|
|
@ -22200,6 +22399,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Spotify" : {
|
||||||
|
"comment" : "Name of a media controller type for Spotify.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Square" : {
|
"Square" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -22302,12 +22505,20 @@
|
||||||
},
|
},
|
||||||
"Standard" : {
|
"Standard" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Standard Notch" : {
|
||||||
|
"comment" : "Text for the \"Standard Notch\" option in the external display style picker.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Standard notch media controls are hidden while this toggle is off." : {
|
"Standard notch media controls are hidden while this toggle is off." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Standard notch media controls are hidden. Re-enable the toggle above to restore them." : {
|
"Standard notch media controls are hidden. Re-enable the toggle above to restore them." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Standard previous/next track controls" : {
|
||||||
|
"comment" : "Description of a music skip behavior option that uses standard previous/next track controls.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Start" : {
|
"Start" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -23052,6 +23263,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Terminal" : {
|
||||||
|
"comment" : "The title of the settings view related to the terminal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Terminal is disabled" : {
|
||||||
|
"comment" : "A message displayed when the terminal feature is disabled.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Terminal opacity" : {
|
||||||
|
"comment" : "A label displayed above a slider that controls the opacity of the terminal view",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Text" : {
|
"Text" : {
|
||||||
"comment" : "Name of a clipboard item type that contains plain text.",
|
"comment" : "Name of a clipboard item type that contains plain text.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -23657,6 +23880,10 @@
|
||||||
},
|
},
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"Track Skip" : {
|
||||||
|
"comment" : "Display name for the \"Track Skip\" music skip behavior.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Transform" : {
|
"Transform" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -25210,6 +25437,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"When bold-as-bright is off, bold text uses a heavier font weight instead of bright ANSI colors." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : {
|
"When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -25221,6 +25451,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"When enabled, Atoll listens for OSD notifications from BetterDisplay and displays them using your selected HUD style above. This works alongside the existing media key interception — BetterDisplay handles external display controls while Atoll provides the visual feedback." : {
|
||||||
|
"comment" : "A footer description for the \"BetterDisplay Integration\" section in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible." : {
|
"When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible." : {
|
||||||
"comment" : "A help text for the setting that allows the focus indicator to collapse instead of staying visible.",
|
"comment" : "A help text for the setting that allows the focus indicator to collapse instead of staying visible.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -25243,6 +25477,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it." : {
|
||||||
|
"comment" : "A description of the feature that hides the notch on non-notch displays.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
|
|
@ -25567,6 +25805,10 @@
|
||||||
"comment" : "A footer note explaining the app's funding.",
|
"comment" : "A footer note explaining the app's funding.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Youtube Music" : {
|
||||||
|
"comment" : "Name for the \"Youtube Music\" media controller type in settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"YouTube Music requires this third-party app to be installed: " : {
|
"YouTube Music requires this third-party app to be installed: " : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
.accessibilityLabel(accessibilityDescription)
|
.accessibilityLabel(accessibilityDescription)
|
||||||
.onAppear(perform: handleInitialState)
|
.onAppear(perform: handleInitialState)
|
||||||
.onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange)
|
.onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange)
|
||||||
|
.onChange(of: manager.focusToastTrigger, handleFocusModeSwitchToast)
|
||||||
.onDisappear(perform: cancelPendingTasks)
|
.onDisappear(perform: cancelPendingTasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,19 +334,57 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
|
|
||||||
private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) {
|
private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) {
|
||||||
cancelPendingTasks()
|
cancelPendingTasks()
|
||||||
|
|
||||||
if isActive {
|
if isActive {
|
||||||
withAnimation(.smooth(duration: 0.2)) {
|
// Force a clean collapsed baseline before animating ON.
|
||||||
|
// This avoids cases where prior OFF state leaves the view mid-transition.
|
||||||
|
withAnimation(.none) {
|
||||||
showInactiveIcon = false
|
showInactiveIcon = false
|
||||||
|
isExpanded = false
|
||||||
|
iconScale = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run the expansion on the next runloop tick so the reset takes effect first.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||||
|
iconScale = 1.0
|
||||||
|
isExpanded = true
|
||||||
|
}
|
||||||
|
if focusToastMode {
|
||||||
|
scheduleTransientCollapse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
triggerInactiveAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFocusModeSwitchToast(_ oldValue: UUID, _ newValue: UUID) {
|
||||||
|
// Only show a toast when Focus is currently active.
|
||||||
|
guard manager.isDoNotDisturbActive else { return }
|
||||||
|
|
||||||
|
// Only relevant for the non-persistent (brief toast) mode.
|
||||||
|
guard focusToastMode else { return }
|
||||||
|
|
||||||
|
// Cancel any pending collapse so the new mode can animate in cleanly.
|
||||||
|
cancelPendingTasks()
|
||||||
|
|
||||||
|
// Force a clean collapsed baseline before animating ON for the *new* mode.
|
||||||
|
// Without this, a mode switch can appear to start then cancel.
|
||||||
|
withAnimation(.none) {
|
||||||
|
showInactiveIcon = false
|
||||||
|
isExpanded = false
|
||||||
|
iconScale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the expansion on the next runloop tick so the reset takes effect first.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard manager.isDoNotDisturbActive else { return }
|
||||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||||
iconScale = 1.0
|
iconScale = 1.0
|
||||||
isExpanded = true
|
isExpanded = true
|
||||||
}
|
}
|
||||||
if focusToastMode {
|
scheduleTransientCollapse()
|
||||||
scheduleTransientCollapse()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
triggerInactiveAnimation()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ struct InlineHUD: View {
|
||||||
.frame(width: 20, height: 15, alignment: .leading)
|
.frame(width: 20, height: 15, alignment: .leading)
|
||||||
}
|
}
|
||||||
case .brightness:
|
case .brightness:
|
||||||
Image(systemName: BrightnessSymbol(value))
|
Image(systemName: !icon.isEmpty ? icon : BrightnessSymbol(value))
|
||||||
.contentTransition(.interpolate)
|
.contentTransition(.interpolate)
|
||||||
.frame(width: 20, height: 15, alignment: .center)
|
.frame(width: 20, height: 15, alignment: .center)
|
||||||
case .backlight:
|
case .backlight:
|
||||||
|
|
@ -237,7 +237,7 @@ struct InlineHUD: View {
|
||||||
LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20))
|
LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20))
|
||||||
.frame(width: 20, height: 20, alignment: .center)
|
.frame(width: 20, height: 20, alignment: .center)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: icon.isEmpty ? "bluetooth" : icon)
|
Image(systemName: icon.isEmpty ? "dot.radiowaves.left.and.right" : icon)
|
||||||
.symbolRenderingMode(.hierarchical)
|
.symbolRenderingMode(.hierarchical)
|
||||||
.contentTransition(.interpolate)
|
.contentTransition(.interpolate)
|
||||||
.frame(width: 20, height: 15, alignment: .center)
|
.frame(width: 20, height: 15, alignment: .center)
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if isActive {
|
if isActive && musicManager.hasActiveSession {
|
||||||
panelContent
|
panelContent
|
||||||
} else {
|
} else {
|
||||||
Color.clear
|
Color.clear
|
||||||
|
|
@ -365,7 +365,7 @@ struct LockScreenMusicPanel: View {
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
TimelineView(
|
TimelineView(
|
||||||
.animation(
|
.animation(
|
||||||
minimumInterval: 0.1,
|
minimumInterval: 1.0,
|
||||||
paused: isProgressTimelinePaused
|
paused: isProgressTimelinePaused
|
||||||
)
|
)
|
||||||
) { timeline in
|
) { timeline in
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,11 @@ struct DynamicIslandHeader: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if vm.notchState == .open && !Defaults[.enableMinimalisticUI] {
|
if vm.notchState == .open && !Defaults[.enableMinimalisticUI] {
|
||||||
|
let spacerWidth = min(vm.closedNotchSize.width, 300)
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(NSScreen.screens
|
.fill(NSScreen.screens
|
||||||
.first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear)
|
.first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear)
|
||||||
.frame(width: vm.closedNotchSize.width)
|
.frame(width: spacerWidth)
|
||||||
.mask {
|
.mask {
|
||||||
NotchShape()
|
NotchShape()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
DynamicIsland/components/Notch/DynamicIslandPillShape.swift
Normal file
50
DynamicIsland/components/Notch/DynamicIslandPillShape.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Atoll (DynamicIsland)
|
||||||
|
* Copyright (C) 2024-2026 Atoll Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A pill-shaped (capsule-like) clip shape used for the Dynamic Island mode
|
||||||
|
/// on external and non-notched displays. Uses `.continuous` rounded corners
|
||||||
|
/// for an Apple-style squircle appearance, inspired by DynamicNotchKit's
|
||||||
|
/// floating style.
|
||||||
|
struct DynamicIslandPillShape: Shape {
|
||||||
|
var cornerRadius: CGFloat
|
||||||
|
|
||||||
|
var animatableData: CGFloat {
|
||||||
|
get { cornerRadius }
|
||||||
|
set { cornerRadius = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
let resolvedRadius = min(cornerRadius, min(rect.width, rect.height) / 2)
|
||||||
|
return Path(roundedRect: rect, cornerRadius: resolvedRadius, style: .continuous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
DynamicIslandPillShape(cornerRadius: 16)
|
||||||
|
.fill(.black)
|
||||||
|
.frame(width: 185, height: 32)
|
||||||
|
|
||||||
|
DynamicIslandPillShape(cornerRadius: 24)
|
||||||
|
.fill(.black)
|
||||||
|
.frame(width: 640, height: 200)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
|
@ -45,72 +45,98 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
private let skipMagnitude: CGFloat = 8
|
private let skipMagnitude: CGFloat = 8
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
if !musicManager.hasActiveSession {
|
||||||
// Header area with album art (matching DynamicIslandHeader height of 24pt)
|
// Nothing playing state
|
||||||
GeometryReader { headerGeo in
|
VStack(spacing: 0) {
|
||||||
let albumArtWidth: CGFloat = 50
|
Spacer(minLength: 0)
|
||||||
let spacing: CGFloat = 10
|
|
||||||
let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0
|
VStack(spacing: 8) {
|
||||||
let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0))
|
Image(systemName: "music.note.slash")
|
||||||
HStack(alignment: .center, spacing: spacing) {
|
.font(.system(size: 24, weight: .light))
|
||||||
MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
.foregroundColor(.gray)
|
||||||
.frame(width: albumArtWidth, height: albumArtWidth)
|
Text("Nothing Playing")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
timerCountdownSection
|
||||||
|
|
||||||
|
reminderList
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: calculateDynamicHeight())
|
||||||
|
.animation(.smooth(duration: 0.3), value: dynamicHeightSignature)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header area with album art (matching DynamicIslandHeader height of 24pt)
|
||||||
|
GeometryReader { headerGeo in
|
||||||
|
let albumArtWidth: CGFloat = 50
|
||||||
|
let spacing: CGFloat = 10
|
||||||
|
let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0
|
||||||
|
let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0))
|
||||||
|
HStack(alignment: .center, spacing: spacing) {
|
||||||
|
MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
||||||
|
.frame(width: albumArtWidth, height: albumArtWidth)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
if !musicManager.songTitle.isEmpty {
|
||||||
|
MarqueeText(
|
||||||
|
$musicManager.songTitle,
|
||||||
|
font: .system(size: 12, weight: .semibold),
|
||||||
|
nsFont: .subheadline,
|
||||||
|
textColor: .white,
|
||||||
|
frameWidth: textWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(musicManager.artistName)
|
||||||
|
.font(.system(size: 10, weight: .regular))
|
||||||
|
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
if !musicManager.songTitle.isEmpty {
|
|
||||||
MarqueeText(
|
|
||||||
$musicManager.songTitle,
|
|
||||||
font: .system(size: 12, weight: .semibold),
|
|
||||||
nsFont: .subheadline,
|
|
||||||
textColor: .white,
|
|
||||||
frameWidth: textWidth
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.frame(width: textWidth, alignment: .leading)
|
||||||
|
|
||||||
Text(musicManager.artistName)
|
if useMusicVisualizer {
|
||||||
.font(.system(size: 10, weight: .regular))
|
visualizer
|
||||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
.frame(width: visualizerWidth)
|
||||||
.lineLimit(1)
|
}
|
||||||
|
|
||||||
}
|
|
||||||
.frame(width: textWidth, alignment: .leading)
|
|
||||||
|
|
||||||
if useMusicVisualizer {
|
|
||||||
visualizer
|
|
||||||
.frame(width: visualizerWidth)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.frame(height: 50)
|
||||||
.frame(height: 50)
|
|
||||||
|
// Compact progress bar
|
||||||
// Compact progress bar
|
progressBar
|
||||||
progressBar
|
.padding(.top, 6)
|
||||||
.padding(.top, 6)
|
|
||||||
|
// Compact playback controls
|
||||||
// Compact playback controls
|
if shouldShowControlHUDRow {
|
||||||
if shouldShowControlHUDRow {
|
controlHUDRow
|
||||||
controlHUDRow
|
.padding(.top, 4)
|
||||||
.padding(.top, 4)
|
} else {
|
||||||
} else {
|
playbackControls
|
||||||
playbackControls
|
.padding(.top, 4)
|
||||||
.padding(.top, 4)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if enableLyrics {
|
if enableLyrics {
|
||||||
lyricsView
|
lyricsView
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
timerCountdownSection
|
||||||
|
|
||||||
|
reminderList
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
timerCountdownSection
|
.padding(.top, -15)
|
||||||
|
.padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||||||
reminderList
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: calculateDynamicHeight(), alignment: .top)
|
||||||
|
.animation(.smooth(duration: 0.3), value: dynamicHeightSignature)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, -15)
|
|
||||||
.padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: calculateDynamicHeight(), alignment: .top)
|
|
||||||
.animation(.smooth(duration: 0.3), value: dynamicHeightSignature)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TypingLyricView
|
// MARK: - TypingLyricView
|
||||||
|
|
@ -624,14 +650,20 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
// MARK: - Progress Bar (Full Width)
|
// MARK: - Progress Bar (Full Width)
|
||||||
|
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@State private var sliderValue: Double = 0
|
@State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition()
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
@State private var lastDragged: Date = .distantPast
|
||||||
|
|
||||||
|
/// Whether the progress timeline should be paused (no ticks).
|
||||||
|
private var isProgressTimelinePaused: Bool {
|
||||||
|
!musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0
|
||||||
|
}
|
||||||
|
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
TimelineView(
|
TimelineView(
|
||||||
.animation(
|
.animation(
|
||||||
minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
|
minimumInterval: 1.0,
|
||||||
|
paused: isProgressTimelinePaused
|
||||||
)
|
)
|
||||||
) { timeline in
|
) { timeline in
|
||||||
MusicSliderView(
|
MusicSliderView(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ struct MusicPlayerView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5)
|
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
||||||
|
.padding(.all, 5)
|
||||||
MusicControlsView()
|
MusicControlsView()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.drawingGroup()
|
.drawingGroup()
|
||||||
|
|
@ -132,7 +133,7 @@ struct MusicControlsView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@State private var sliderValue: Double = 0
|
@State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition()
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
@State private var lastDragged: Date = .distantPast
|
||||||
@State private var hudValue: Double = 0
|
@State private var hudValue: Double = 0
|
||||||
|
|
@ -215,8 +216,13 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the progress timeline should be paused (no ticks).
|
||||||
|
private var isProgressTimelinePaused: Bool {
|
||||||
|
!musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0
|
||||||
|
}
|
||||||
|
|
||||||
private var musicSlider: some View {
|
private var musicSlider: some View {
|
||||||
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
TimelineView(.animation(minimumInterval: 1.0, paused: isProgressTimelinePaused)) { timeline in
|
||||||
MusicSliderView(
|
MusicSliderView(
|
||||||
sliderValue: $sliderValue,
|
sliderValue: $sliderValue,
|
||||||
duration: $musicManager.songDuration,
|
duration: $musicManager.songDuration,
|
||||||
|
|
@ -521,8 +527,14 @@ struct NotchHomeView: View {
|
||||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
|
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
|
||||||
|
@ObservedObject private var musicManager = MusicManager.shared
|
||||||
@Default(.showStandardMediaControls) private var showStandardMediaControls
|
@Default(.showStandardMediaControls) private var showStandardMediaControls
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
|
|
||||||
|
/// Whether the music player should actively display (enabled AND has real content).
|
||||||
|
private var shouldShowMusicPlayer: Bool {
|
||||||
|
showStandardMediaControls && musicManager.hasActiveSession
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
|
|
@ -546,14 +558,14 @@ struct NotchHomeView: View {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: Show full music player with optional calendar and webcam
|
// Normal mode: Show full music player with optional calendar and webcam
|
||||||
if showStandardMediaControls {
|
if shouldShowMusicPlayer {
|
||||||
MusicPlayerView(albumArtNamespace: albumArtNamespace)
|
MusicPlayerView(albumArtNamespace: albumArtNamespace)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Defaults[.showCalendar] {
|
if Defaults[.showCalendar] {
|
||||||
Group {
|
Group {
|
||||||
if showStandardMediaControls {
|
if shouldShowMusicPlayer {
|
||||||
CalendarView()
|
CalendarView()
|
||||||
} else {
|
} else {
|
||||||
StandaloneCalendarView()
|
StandaloneCalendarView()
|
||||||
|
|
@ -633,6 +645,13 @@ struct MusicSliderView: View {
|
||||||
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
|
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
|
||||||
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
|
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
|
||||||
}
|
}
|
||||||
|
.onChange(of: isPlaying) { _, playing in
|
||||||
|
// Snap slider to the exact position when music pauses so
|
||||||
|
// the in-flight animation doesn't coast past the true value.
|
||||||
|
if !playing {
|
||||||
|
sliderValue = MusicManager.shared.estimatedPlaybackPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: isLiveStream) { isLive in
|
.onChange(of: isLiveStream) { isLive in
|
||||||
if isLive {
|
if isLive {
|
||||||
sliderValue = 0
|
sliderValue = 0
|
||||||
|
|
@ -705,6 +724,15 @@ struct MusicSliderView: View {
|
||||||
restingTrackHeight: restingTrackHeight,
|
restingTrackHeight: restingTrackHeight,
|
||||||
draggingTrackHeight: draggingTrackHeight
|
draggingTrackHeight: draggingTrackHeight
|
||||||
)
|
)
|
||||||
|
// Smoothly interpolate the filled track between 1-second ticks using
|
||||||
|
// Core Animation — runs on the GPU with zero CPU polling cost.
|
||||||
|
// Disabled while dragging or paused so the bar responds instantly.
|
||||||
|
.animation(
|
||||||
|
!dragging && isPlaying && !isLiveStream
|
||||||
|
? .linear(duration: 1.0)
|
||||||
|
: nil,
|
||||||
|
value: sliderValue
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sliderTint: Color {
|
private var sliderTint: Color {
|
||||||
|
|
|
||||||
164
DynamicIsland/components/Notch/NotchTerminalView.swift
Normal file
164
DynamicIsland/components/Notch/NotchTerminalView.swift
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/*
|
||||||
|
* Atoll (DynamicIsland)
|
||||||
|
* Copyright (C) 2024-2026 Atoll Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftTerm
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
// MARK: - NSViewRepresentable wrapper for SwiftTerm
|
||||||
|
|
||||||
|
/// Bridges SwiftTerm's `LocalProcessTerminalView` (NSView) into SwiftUI.
|
||||||
|
///
|
||||||
|
/// Returns `TerminalManager.containerView` (a stable NSView) so that
|
||||||
|
/// SwiftUI's view lifecycle never tears down the actual terminal. The
|
||||||
|
/// real `LocalProcessTerminalView` lives as a subview of that container
|
||||||
|
/// and survives notch close/open and tab-switch cycles.
|
||||||
|
struct TerminalNSViewRepresentable: NSViewRepresentable {
|
||||||
|
@ObservedObject var terminalManager = TerminalManager.shared
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
context.coordinator.terminalManager = terminalManager
|
||||||
|
terminalManager.ensureTerminalView(delegate: context.coordinator)
|
||||||
|
if !terminalManager.isProcessRunning {
|
||||||
|
terminalManager.startShellProcess()
|
||||||
|
}
|
||||||
|
return terminalManager.containerView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
|
// Re-mount terminal if it was restarted (generation bumped).
|
||||||
|
terminalManager.ensureTerminalView(delegate: context.coordinator)
|
||||||
|
if !terminalManager.isProcessRunning {
|
||||||
|
terminalManager.startShellProcess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
|
||||||
|
var terminalManager: TerminalManager?
|
||||||
|
|
||||||
|
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {
|
||||||
|
// Terminal resized — no action needed; SwiftTerm handles reflow.
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
terminalManager?.updateTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
||||||
|
// Could be used for breadcrumbs in the future.
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||||
|
Task { @MainActor in
|
||||||
|
terminalManager?.processDidTerminate(exitCode: exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notch Tab View
|
||||||
|
|
||||||
|
/// Guake-style dropdown terminal tab for the notch.
|
||||||
|
/// Dynamically sizes up to half the screen height; content scrolls when the
|
||||||
|
/// terminal buffer exceeds the visible area (handled internally by SwiftTerm).
|
||||||
|
struct NotchTerminalView: View {
|
||||||
|
@ObservedObject var terminalManager = TerminalManager.shared
|
||||||
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
|
@Default(.enableTerminalFeature) var enableTerminalFeature
|
||||||
|
@State private var suppressionToken = UUID()
|
||||||
|
@State private var isSuppressing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if enableTerminalFeature {
|
||||||
|
// Terminal header bar
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "apple.terminal")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
|
||||||
|
Text(terminalManager.terminalTitle)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Restart button
|
||||||
|
Button {
|
||||||
|
terminalManager.restartShell()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Restart shell")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
|
// Terminal content — the containerView is stable across
|
||||||
|
// notch close/open cycles; updateNSView handles restart.
|
||||||
|
TerminalNSViewRepresentable(terminalManager: terminalManager)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.onHover { hovering in
|
||||||
|
updateSuppression(for: hovering)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Feature disabled placeholder
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "apple.terminal")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Terminal is disabled")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Enable it in Settings → Terminal")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
updateSuppression(for: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSuppression(for hovering: Bool) {
|
||||||
|
guard hovering != isSuppressing else { return }
|
||||||
|
isSuppressing = hovering
|
||||||
|
vm.setScrollGestureSuppression(hovering, token: suppressionToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -209,6 +209,7 @@ func applyProfileSettings(_ profiles: Set<String>) {
|
||||||
if isDeveloper {
|
if isDeveloper {
|
||||||
Defaults[.enableColorPickerFeature] = true
|
Defaults[.enableColorPickerFeature] = true
|
||||||
Defaults[.enableStatsFeature] = true
|
Defaults[.enableStatsFeature] = true
|
||||||
|
Defaults[.enableTerminalFeature] = true
|
||||||
Defaults[.enableTimerFeature] = true
|
Defaults[.enableTimerFeature] = true
|
||||||
Defaults[.enableScreenAssistant] = true
|
Defaults[.enableScreenAssistant] = true
|
||||||
Defaults[.showMirror] = false
|
Defaults[.showMirror] = false
|
||||||
|
|
@ -262,9 +263,35 @@ func applyProfileSettings(_ profiles: Set<String>) {
|
||||||
Defaults[.menubarIcon] = true
|
Defaults[.menubarIcon] = true
|
||||||
Defaults[.enableHaptics] = true
|
Defaults[.enableHaptics] = true
|
||||||
|
|
||||||
|
// Lyrics disabled by default for all profiles
|
||||||
|
Defaults[.enableLyrics] = false
|
||||||
|
|
||||||
|
// Weather widget defaults to inline style
|
||||||
|
Defaults[.lockScreenWeatherWidgetStyle] = .inline
|
||||||
|
|
||||||
|
// Auto-detect notch: Dynamic Island for non-notch Macs, standard notch otherwise
|
||||||
|
if mainScreenHasNotch() {
|
||||||
|
Defaults[.externalDisplayStyle] = .notch
|
||||||
|
} else {
|
||||||
|
Defaults[.externalDisplayStyle] = .dynamicIsland
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock screen glass: custom liquid glass v11 on macOS 26+
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
Defaults[.lockScreenGlassStyle] = .liquid
|
||||||
|
Defaults[.lockScreenGlassCustomizationMode] = .customLiquid
|
||||||
|
Defaults[.lockScreenMusicLiquidGlassVariant] = .v11
|
||||||
|
}
|
||||||
|
|
||||||
print("✅ Applied profile settings for: \(profiles.joined(separator: ", "))")
|
print("✅ Applied profile settings for: \(profiles.joined(separator: ", "))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the main screen has a physical notch (safe area insets > 0).
|
||||||
|
private func mainScreenHasNotch() -> Bool {
|
||||||
|
guard let screen = NSScreen.main else { return false }
|
||||||
|
return screen.safeAreaInsets.top > 0
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ProfileSelectionView(onContinue: { profiles in
|
ProfileSelectionView(onContinue: { profiles in
|
||||||
print("Selected profiles: \(profiles)")
|
print("Selected profiles: \(profiles)")
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,34 @@ import SwiftUI
|
||||||
import SwiftUIIntrospect
|
import SwiftUIIntrospect
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Groups for organizing settings tabs in the sidebar.
|
||||||
|
private enum SettingsTabGroup: String, CaseIterable, Identifiable {
|
||||||
|
case core
|
||||||
|
case mediaAndDisplay
|
||||||
|
case system
|
||||||
|
case productivity
|
||||||
|
case utilities
|
||||||
|
case developer
|
||||||
|
case integrations
|
||||||
|
case info
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Display title for the section header. `nil` means no visible header.
|
||||||
|
var title: String? {
|
||||||
|
switch self {
|
||||||
|
case .core: return nil
|
||||||
|
case .mediaAndDisplay: return "Media & Display"
|
||||||
|
case .system: return "System"
|
||||||
|
case .productivity: return "Productivity"
|
||||||
|
case .utilities: return "Utilities"
|
||||||
|
case .developer: return "Developer"
|
||||||
|
case .integrations: return "Integrations"
|
||||||
|
case .info: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum SettingsTab: String, CaseIterable, Identifiable {
|
private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case general
|
case general
|
||||||
case liveActivities
|
case liveActivities
|
||||||
|
|
@ -37,10 +65,26 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case shelf
|
case shelf
|
||||||
case shortcuts
|
case shortcuts
|
||||||
case notes
|
case notes
|
||||||
|
case terminal
|
||||||
case about
|
case about
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Which sidebar group this tab belongs to.
|
||||||
|
var group: SettingsTabGroup {
|
||||||
|
switch self {
|
||||||
|
case .general, .appearance: return .core
|
||||||
|
case .media, .liveActivities, .lockScreen, .devices: return .mediaAndDisplay
|
||||||
|
case .hudAndOSD, .battery: return .system
|
||||||
|
case .timer, .calendar, .notes: return .productivity
|
||||||
|
case .clipboard, .screenAssistant, .colorPicker, .shelf,
|
||||||
|
.downloads, .shortcuts: return .utilities
|
||||||
|
case .stats, .terminal: return .developer
|
||||||
|
case .extensions: return .integrations
|
||||||
|
case .about: return .info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: return "General"
|
case .general: return "General"
|
||||||
|
|
@ -62,6 +106,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .shelf: return "Shelf"
|
case .shelf: return "Shelf"
|
||||||
case .shortcuts: return "Shortcuts"
|
case .shortcuts: return "Shortcuts"
|
||||||
case .notes: return "Notes"
|
case .notes: return "Notes"
|
||||||
|
case .terminal: return "Terminal"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +132,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .shelf: return "books.vertical"
|
case .shelf: return "books.vertical"
|
||||||
case .shortcuts: return "keyboard"
|
case .shortcuts: return "keyboard"
|
||||||
case .notes: return "note.text"
|
case .notes: return "note.text"
|
||||||
|
case .terminal: return "apple.terminal"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +158,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .shelf: return .brown
|
case .shelf: return .brown
|
||||||
case .shortcuts: return .orange
|
case .shortcuts: return .orange
|
||||||
case .notes: return Color(red: 0.979, green: 0.716, blue: 0.153, opacity: 1.000)
|
case .notes: return Color(red: 0.979, green: 0.716, blue: 0.153, opacity: 1.000)
|
||||||
|
case .terminal: return Color(red: 0.2, green: 0.8, blue: 0.4)
|
||||||
case .about: return .secondary
|
case .about: return .secondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -271,36 +318,19 @@ struct SettingsView: View {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
List(filteredTabs, selection: selectionBinding) { tab in
|
List(selection: selectionBinding) {
|
||||||
NavigationLink(value: tab) {
|
ForEach(groupedFilteredTabs, id: \.group) { section in
|
||||||
HStack(spacing: 10) {
|
Section {
|
||||||
sidebarIcon(for: tab)
|
ForEach(section.tabs) { tab in
|
||||||
Text(tab.title)
|
NavigationLink(value: tab) {
|
||||||
if tab == .downloads || tab == .hudAndOSD {
|
sidebarRow(for: tab)
|
||||||
Spacer()
|
}
|
||||||
Text("BETA")
|
}
|
||||||
.font(.system(size: 9, weight: .bold))
|
} header: {
|
||||||
.foregroundStyle(.white)
|
if let title = section.group.title {
|
||||||
.padding(.horizontal, 6)
|
Text(title)
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.blue)
|
|
||||||
)
|
|
||||||
} else if tab == .extensions {
|
|
||||||
Spacer()
|
|
||||||
Text("ALPHA")
|
|
||||||
.font(.system(size: 9, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.orange)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(SidebarListStyle())
|
.listStyle(SidebarListStyle())
|
||||||
|
|
@ -401,33 +431,92 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sidebarRow(for tab: SettingsTab) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
sidebarIcon(for: tab)
|
||||||
|
Text(tab.title)
|
||||||
|
if tab == .downloads {
|
||||||
|
Spacer()
|
||||||
|
Text("BETA")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.blue)
|
||||||
|
)
|
||||||
|
} else if tab == .extensions {
|
||||||
|
Spacer()
|
||||||
|
Text("BETA")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.blue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
private var availableTabs: [SettingsTab] {
|
private var availableTabs: [SettingsTab] {
|
||||||
|
// Ordered to match group layout: core → media & display → system →
|
||||||
|
// productivity → utilities → developer → integrations → info.
|
||||||
let ordered: [SettingsTab] = [
|
let ordered: [SettingsTab] = [
|
||||||
|
// Core
|
||||||
.general,
|
.general,
|
||||||
.liveActivities,
|
|
||||||
.appearance,
|
.appearance,
|
||||||
.lockScreen,
|
// Media & Display
|
||||||
.media,
|
.media,
|
||||||
|
.liveActivities,
|
||||||
|
.lockScreen,
|
||||||
.devices,
|
.devices,
|
||||||
.timer,
|
// System
|
||||||
.calendar,
|
|
||||||
.hudAndOSD,
|
.hudAndOSD,
|
||||||
.battery,
|
.battery,
|
||||||
.stats,
|
// Productivity
|
||||||
|
.timer,
|
||||||
|
.calendar,
|
||||||
.notes,
|
.notes,
|
||||||
|
// Utilities
|
||||||
.clipboard,
|
.clipboard,
|
||||||
.screenAssistant,
|
.screenAssistant,
|
||||||
.colorPicker,
|
.colorPicker,
|
||||||
.downloads,
|
|
||||||
.shelf,
|
.shelf,
|
||||||
|
.downloads,
|
||||||
.shortcuts,
|
.shortcuts,
|
||||||
|
// Developer
|
||||||
|
.stats,
|
||||||
|
.terminal,
|
||||||
|
// Integrations
|
||||||
.extensions,
|
.extensions,
|
||||||
|
// Info
|
||||||
.about
|
.about
|
||||||
]
|
]
|
||||||
|
|
||||||
return ordered.filter { isTabVisible($0) }
|
return ordered.filter { isTabVisible($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Groups the filtered tabs into sidebar sections, preserving both
|
||||||
|
/// the group order and the per-group tab order from `availableTabs`.
|
||||||
|
private var groupedFilteredTabs: [(group: SettingsTabGroup, tabs: [SettingsTab])] {
|
||||||
|
let visible = filteredTabs
|
||||||
|
var result: [(group: SettingsTabGroup, tabs: [SettingsTab])] = []
|
||||||
|
|
||||||
|
for group in SettingsTabGroup.allCases {
|
||||||
|
let tabs = visible.filter { $0.group == group }
|
||||||
|
if !tabs.isEmpty {
|
||||||
|
result.append((group: group, tabs: tabs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private func tabsMatchingSearch(_ query: String) -> [SettingsTab] {
|
private func tabsMatchingSearch(_ query: String) -> [SettingsTab] {
|
||||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return availableTabs }
|
guard !trimmed.isEmpty else { return availableTabs }
|
||||||
|
|
@ -606,6 +695,8 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"], highlightID: SettingsTab.general.highlightID(for: "Extend hover area")),
|
SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"], highlightID: SettingsTab.general.highlightID(for: "Extend hover area")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"], highlightID: SettingsTab.general.highlightID(for: "Enable haptics")),
|
SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"], highlightID: SettingsTab.general.highlightID(for: "Enable haptics")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"], highlightID: SettingsTab.general.highlightID(for: "Open notch on hover")),
|
SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"], highlightID: SettingsTab.general.highlightID(for: "Open notch on hover")),
|
||||||
|
SettingsSearchEntry(tab: .general, title: "External display style", keywords: ["dynamic island", "pill", "external display", "non-notch", "floating", "capsule"], highlightID: SettingsTab.general.highlightID(for: "External display style")),
|
||||||
|
SettingsSearchEntry(tab: .general, title: "Hide until hovered", keywords: ["hide", "hover", "external", "non-notch", "auto hide", "slide"], highlightID: SettingsTab.general.highlightID(for: "Hide until hovered")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Notch display height", keywords: ["display height", "menu bar size"], highlightID: SettingsTab.general.highlightID(for: "Notch display height")),
|
SettingsSearchEntry(tab: .general, title: "Notch display height", keywords: ["display height", "menu bar size"], highlightID: SettingsTab.general.highlightID(for: "Notch display height")),
|
||||||
|
|
||||||
// Live Activities
|
// Live Activities
|
||||||
|
|
@ -648,6 +739,7 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
|
SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
|
||||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
|
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
|
||||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
|
SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
|
||||||
|
SettingsSearchEntry(tab: .hudAndOSD, title: "BetterDisplay integration", keywords: ["betterdisplay", "external", "display", "brightness", "integration", "third party"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "BetterDisplay integration")),
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
||||||
|
|
@ -686,6 +778,7 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")),
|
SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")),
|
||||||
|
|
||||||
// Appearance
|
// Appearance
|
||||||
|
SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")),
|
||||||
SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"], highlightID: SettingsTab.appearance.highlightID(for: "Settings icon in notch")),
|
SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"], highlightID: SettingsTab.appearance.highlightID(for: "Settings icon in notch")),
|
||||||
SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"], highlightID: SettingsTab.appearance.highlightID(for: "Enable window shadow")),
|
SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"], highlightID: SettingsTab.appearance.highlightID(for: "Enable window shadow")),
|
||||||
SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"], highlightID: SettingsTab.appearance.highlightID(for: "Corner radius scaling")),
|
SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"], highlightID: SettingsTab.appearance.highlightID(for: "Corner radius scaling")),
|
||||||
|
|
@ -758,6 +851,7 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .stats, title: "Enable system stats monitoring", keywords: ["stats", "monitoring"], highlightID: SettingsTab.stats.highlightID(for: "Enable system stats monitoring")),
|
SettingsSearchEntry(tab: .stats, title: "Enable system stats monitoring", keywords: ["stats", "monitoring"], highlightID: SettingsTab.stats.highlightID(for: "Enable system stats monitoring")),
|
||||||
SettingsSearchEntry(tab: .stats, title: "Stop monitoring after closing the notch", keywords: ["stats", "auto stop"], highlightID: SettingsTab.stats.highlightID(for: "Stop monitoring after closing the notch")),
|
SettingsSearchEntry(tab: .stats, title: "Stop monitoring after closing the notch", keywords: ["stats", "auto stop"], highlightID: SettingsTab.stats.highlightID(for: "Stop monitoring after closing the notch")),
|
||||||
SettingsSearchEntry(tab: .stats, title: "CPU Usage", keywords: ["cpu", "graph"], highlightID: SettingsTab.stats.highlightID(for: "CPU Usage")),
|
SettingsSearchEntry(tab: .stats, title: "CPU Usage", keywords: ["cpu", "graph"], highlightID: SettingsTab.stats.highlightID(for: "CPU Usage")),
|
||||||
|
SettingsSearchEntry(tab: .stats, title: "Temperature unit", keywords: ["cpu", "temperature", "celsius", "fahrenheit"], highlightID: SettingsTab.stats.highlightID(for: "Temperature unit")),
|
||||||
SettingsSearchEntry(tab: .stats, title: "Memory Usage", keywords: ["memory", "ram"], highlightID: SettingsTab.stats.highlightID(for: "Memory Usage")),
|
SettingsSearchEntry(tab: .stats, title: "Memory Usage", keywords: ["memory", "ram"], highlightID: SettingsTab.stats.highlightID(for: "Memory Usage")),
|
||||||
SettingsSearchEntry(tab: .stats, title: "GPU Usage", keywords: ["gpu", "graphics"], highlightID: SettingsTab.stats.highlightID(for: "GPU Usage")),
|
SettingsSearchEntry(tab: .stats, title: "GPU Usage", keywords: ["gpu", "graphics"], highlightID: SettingsTab.stats.highlightID(for: "GPU Usage")),
|
||||||
SettingsSearchEntry(tab: .stats, title: "Network Activity", keywords: ["network", "graph"], highlightID: SettingsTab.stats.highlightID(for: "Network Activity")),
|
SettingsSearchEntry(tab: .stats, title: "Network Activity", keywords: ["network", "graph"], highlightID: SettingsTab.stats.highlightID(for: "Network Activity")),
|
||||||
|
|
@ -778,13 +872,28 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show Color Picker Icon")),
|
SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show Color Picker Icon")),
|
||||||
SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"], highlightID: SettingsTab.colorPicker.highlightID(for: "Display Mode")),
|
SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"], highlightID: SettingsTab.colorPicker.highlightID(for: "Display Mode")),
|
||||||
SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"], highlightID: SettingsTab.colorPicker.highlightID(for: "History Size")),
|
SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"], highlightID: SettingsTab.colorPicker.highlightID(for: "History Size")),
|
||||||
SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats"))
|
SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats")),
|
||||||
|
|
||||||
|
// Terminal
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Enable terminal", keywords: ["terminal", "guake", "shell"], highlightID: SettingsTab.terminal.highlightID(for: "Enable terminal")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Shell path", keywords: ["shell", "zsh", "bash", "terminal"], highlightID: SettingsTab.terminal.highlightID(for: "Shell path")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Background color", keywords: ["terminal", "background", "color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Background color")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Foreground color", keywords: ["terminal", "foreground", "text color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Foreground color")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Cursor color", keywords: ["terminal", "cursor", "caret", "color"], highlightID: SettingsTab.terminal.highlightID(for: "Cursor color")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Bold as bright", keywords: ["terminal", "bold", "bright", "colors"], highlightID: SettingsTab.terminal.highlightID(for: "Bold as bright")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Cursor style", keywords: ["terminal", "cursor", "block", "underline", "bar", "blink"], highlightID: SettingsTab.terminal.highlightID(for: "Cursor style")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Scrollback lines", keywords: ["terminal", "scrollback", "buffer", "history"], highlightID: SettingsTab.terminal.highlightID(for: "Scrollback lines")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Option as Meta", keywords: ["terminal", "option", "meta", "alt", "key"], highlightID: SettingsTab.terminal.highlightID(for: "Option as Meta")),
|
||||||
|
SettingsSearchEntry(tab: .terminal, title: "Mouse reporting", keywords: ["terminal", "mouse", "reporting", "vim", "tmux"], highlightID: SettingsTab.terminal.highlightID(for: "Mouse reporting")),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isTabVisible(_ tab: SettingsTab) -> Bool {
|
private func isTabVisible(_ tab: SettingsTab) -> Bool {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes:
|
case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes, .terminal:
|
||||||
return !enableMinimalisticUI
|
return !enableMinimalisticUI
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|
@ -870,6 +979,10 @@ struct SettingsView: View {
|
||||||
SettingsForm(tab: .notes) {
|
SettingsForm(tab: .notes) {
|
||||||
NotesSettingsView()
|
NotesSettingsView()
|
||||||
}
|
}
|
||||||
|
case .terminal:
|
||||||
|
SettingsForm(tab: .terminal) {
|
||||||
|
TerminalSettings()
|
||||||
|
}
|
||||||
case .about:
|
case .about:
|
||||||
if let controller = updaterController {
|
if let controller = updaterController {
|
||||||
SettingsForm(tab: .about) {
|
SettingsForm(tab: .about) {
|
||||||
|
|
@ -905,6 +1018,8 @@ struct GeneralSettings: View {
|
||||||
@Default(.musicGestureBehavior) var musicGestureBehavior
|
@Default(.musicGestureBehavior) var musicGestureBehavior
|
||||||
@Default(.reverseSwipeGestures) var reverseSwipeGestures
|
@Default(.reverseSwipeGestures) var reverseSwipeGestures
|
||||||
@Default(.reverseScrollGestures) var reverseScrollGestures
|
@Default(.reverseScrollGestures) var reverseScrollGestures
|
||||||
|
@Default(.externalDisplayStyle) var externalDisplayStyle
|
||||||
|
@Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.general.highlightID(for: title)
|
SettingsTab.general.highlightID(for: title)
|
||||||
|
|
@ -1118,6 +1233,24 @@ struct GeneralSettings: View {
|
||||||
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Picker("External display style", selection: $externalDisplayStyle) {
|
||||||
|
ForEach(ExternalDisplayStyle.allCases) { style in
|
||||||
|
Text(style.localizedName)
|
||||||
|
.tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: externalDisplayStyle) {
|
||||||
|
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("External display style"))
|
||||||
|
Text(externalDisplayStyle.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Defaults.Toggle("Hide until hovered on non-notch displays", key: .hideNonNotchUntilHover)
|
||||||
|
.settingsHighlight(id: highlightID("Hide until hovered"))
|
||||||
|
Text("When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Notch behavior")
|
Text("Notch behavior")
|
||||||
}
|
}
|
||||||
|
|
@ -1361,8 +1494,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
@Default(.enableVolumeHUD) var enableVolumeHUD
|
@Default(.enableVolumeHUD) var enableVolumeHUD
|
||||||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||||
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
// Vertical HUD Props
|
|
||||||
@Default(.verticalHUDShowValue) var verticalHUDShowValue
|
@Default(.verticalHUDShowValue) var verticalHUDShowValue
|
||||||
@Default(.verticalHUDInteractive) var verticalHUDInteractive
|
@Default(.verticalHUDInteractive) var verticalHUDInteractive
|
||||||
@Default(.verticalHUDHeight) var verticalHUDHeight
|
@Default(.verticalHUDHeight) var verticalHUDHeight
|
||||||
|
|
@ -1598,7 +1730,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
case .vertical:
|
case .vertical:
|
||||||
Form {
|
Form {
|
||||||
if !accessibilityPermission.isAuthorized {
|
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
|
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
|
||||||
|
|
@ -1614,11 +1746,13 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if accessibilityPermission.isAuthorized {
|
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
|
.disabled(enableBetterDisplayIntegration)
|
||||||
|
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -1713,7 +1847,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
|
|
||||||
case .circular:
|
case .circular:
|
||||||
Form {
|
Form {
|
||||||
if !accessibilityPermission.isAuthorized {
|
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
|
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
|
||||||
|
|
@ -1729,11 +1863,13 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if accessibilityPermission.isAuthorized {
|
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
|
.disabled(enableBetterDisplayIntegration)
|
||||||
|
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -1772,6 +1908,9 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BetterDisplay Integration (shared across all HUD variants)
|
||||||
|
BetterDisplayIntegrationSection()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.background(paneBackgroundColor)
|
.background(paneBackgroundColor)
|
||||||
|
|
@ -1785,6 +1924,98 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - BetterDisplay Integration Settings Section
|
||||||
|
|
||||||
|
private struct BetterDisplayIntegrationSection: View {
|
||||||
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
|
@ObservedObject private var betterDisplayManager = BetterDisplayManager.shared
|
||||||
|
|
||||||
|
private func highlightID(_ title: String) -> String {
|
||||||
|
SettingsTab.hudAndOSD.highlightID(for: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
if betterDisplayManager.isRunning {
|
||||||
|
return "Running"
|
||||||
|
} else if betterDisplayManager.isDetected {
|
||||||
|
return "Not running"
|
||||||
|
} else {
|
||||||
|
return "Not detected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
if betterDisplayManager.isRunning {
|
||||||
|
return .green
|
||||||
|
} else if betterDisplayManager.isDetected {
|
||||||
|
return .orange
|
||||||
|
} else {
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("BetterDisplay")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
Text(statusText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: $enableBetterDisplayIntegration)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(!betterDisplayManager.isDetected)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("BetterDisplay integration"))
|
||||||
|
|
||||||
|
if !betterDisplayManager.isDetected {
|
||||||
|
Text("Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if !betterDisplayManager.isRunning {
|
||||||
|
Text("BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if enableBetterDisplayIntegration {
|
||||||
|
Text("BetterDisplay OSD events will be routed through Atoll's active HUD style. Make sure BetterDisplay's OSD integration is enabled in its Settings › Application › Integration.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Enable to route BetterDisplay's brightness and volume changes through Atoll's HUD instead of the system OSD.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if betterDisplayManager.isDetected {
|
||||||
|
Button {
|
||||||
|
betterDisplayManager.refreshDetectionStatus()
|
||||||
|
} label: {
|
||||||
|
Label("Refresh detection", systemImage: "arrow.clockwise")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "display.2")
|
||||||
|
Text("BetterDisplay Integration")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("When enabled, Atoll listens for OSD notifications from BetterDisplay and displays them using your selected HUD style above. This works alongside the existing media key interception — BetterDisplay handles external display controls while Atoll provides the visual feedback.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct HUDSelectionCard<Preview: View>: View {
|
private struct HUDSelectionCard<Preview: View>: View {
|
||||||
let title: String
|
let title: String
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
|
@ -1926,6 +2157,7 @@ struct HUD: View {
|
||||||
@Default(.enableVolumeHUD) var enableVolumeHUD
|
@Default(.enableVolumeHUD) var enableVolumeHUD
|
||||||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||||
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||||
|
|
@ -1944,7 +2176,7 @@ struct HUD: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if !hasAccessibilityPermission {
|
if !hasAccessibilityPermission && !enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
||||||
|
|
@ -1958,11 +2190,13 @@ struct HUD: View {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission {
|
if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableBetterDisplayIntegration) {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
|
.disabled(enableBetterDisplayIntegration)
|
||||||
|
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active \u{2014} BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -2208,21 +2442,9 @@ struct Media: View {
|
||||||
Text(style.rawValue).tag(style)
|
Text(style.rawValue).tag(style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!enableSneakPeek || enableMinimalisticUI)
|
.disabled(!enableSneakPeek)
|
||||||
.onChange(of: enableMinimalisticUI) { _, isMinimalistic in
|
|
||||||
// Force standard sneak peek style when minimalistic UI is enabled
|
|
||||||
if isMinimalistic {
|
|
||||||
sneakPeekStyles = .standard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.settingsHighlight(id: highlightID("Sneak Peek Style"))
|
.settingsHighlight(id: highlightID("Sneak Peek Style"))
|
||||||
|
|
||||||
if enableMinimalisticUI {
|
|
||||||
Text("Sneak peek style is locked to Standard in minimalistic mode")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Stepper(value: $waitInterval, in: 0...10, step: 1) {
|
Stepper(value: $waitInterval, in: 0...10, step: 1) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -3261,6 +3483,7 @@ struct Appearance: View {
|
||||||
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
|
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
|
||||||
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
||||||
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
||||||
|
@Default(.externalDisplayStyle) private var externalDisplayStyle
|
||||||
@State private var selectedListVisualizer: CustomVisualizer? = nil
|
@State private var selectedListVisualizer: CustomVisualizer? = nil
|
||||||
|
|
||||||
@State private var isIconImporterPresented = false
|
@State private var isIconImporterPresented = false
|
||||||
|
|
@ -3272,8 +3495,20 @@ struct Appearance: View {
|
||||||
@State private var url: String = ""
|
@State private var url: String = ""
|
||||||
@State private var speed: CGFloat = 1.0
|
@State private var speed: CGFloat = 1.0
|
||||||
|
|
||||||
private let notchWidthRange: ClosedRange<Double> = 640...900
|
/// Whether the main screen has a physical notch.
|
||||||
private let defaultOpenNotchWidth: CGFloat = 640
|
private var mainScreenHasPhysicalNotch: Bool {
|
||||||
|
guard let screen = NSScreen.main else { return false }
|
||||||
|
return screen.safeAreaInsets.top > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notchWidthRange: ClosedRange<Double> {
|
||||||
|
let minW = Double(currentRecommendedMinimumNotchWidth())
|
||||||
|
let maxW = min(900, Double(maxAllowedNotchWidth()))
|
||||||
|
return minW...max(minW, maxW)
|
||||||
|
}
|
||||||
|
private var defaultOpenNotchWidth: CGFloat {
|
||||||
|
currentRecommendedMinimumNotchWidth()
|
||||||
|
}
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.appearance.highlightID(for: title)
|
SettingsTab.appearance.highlightID(for: title)
|
||||||
|
|
@ -3326,6 +3561,27 @@ struct Appearance: View {
|
||||||
Text("General")
|
Text("General")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show display style picker only on non-notch Macs (main screen has no physical notch)
|
||||||
|
if !mainScreenHasPhysicalNotch {
|
||||||
|
Section {
|
||||||
|
Picker("Main screen style", selection: $externalDisplayStyle) {
|
||||||
|
ForEach(ExternalDisplayStyle.allCases) { style in
|
||||||
|
Text(style.localizedName)
|
||||||
|
.tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: externalDisplayStyle) {
|
||||||
|
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Main screen style"))
|
||||||
|
Text(externalDisplayStyle.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} header: {
|
||||||
|
Text("Display Style")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notchWidthControls()
|
notchWidthControls()
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|
@ -3823,10 +4079,14 @@ struct Appearance: View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func notchWidthControls() -> some View {
|
private func notchWidthControls() -> some View {
|
||||||
Section {
|
Section {
|
||||||
|
let recommendedMin = currentRecommendedMinimumNotchWidth()
|
||||||
|
let tabCount = enabledStandardTabCount()
|
||||||
|
let dynamicRange = Double(recommendedMin)...900
|
||||||
|
|
||||||
let widthBinding = Binding<Double>(
|
let widthBinding = Binding<Double>(
|
||||||
get: { Double(openNotchWidth) },
|
get: { Double(openNotchWidth) },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
let clamped = min(max(newValue, notchWidthRange.lowerBound), notchWidthRange.upperBound)
|
let clamped = min(max(newValue, dynamicRange.lowerBound), dynamicRange.upperBound)
|
||||||
let value = CGFloat(clamped)
|
let value = CGFloat(clamped)
|
||||||
if openNotchWidth != value {
|
if openNotchWidth != value {
|
||||||
openNotchWidth = value
|
openNotchWidth = value
|
||||||
|
|
@ -3837,7 +4097,7 @@ struct Appearance: View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Slider(
|
Slider(
|
||||||
value: widthBinding,
|
value: widthBinding,
|
||||||
in: notchWidthRange,
|
in: dynamicRange,
|
||||||
step: 10
|
step: 10
|
||||||
) {
|
) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -3851,22 +4111,28 @@ struct Appearance: View {
|
||||||
.settingsHighlight(id: highlightID("Expanded notch width"))
|
.settingsHighlight(id: highlightID("Expanded notch width"))
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
|
Text("\(tabCount) tab\(tabCount == 1 ? "" : "s") enabled · min \(Int(recommendedMin)) px")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Reset Width") {
|
Button("Reset Width") {
|
||||||
openNotchWidth = defaultOpenNotchWidth
|
openNotchWidth = recommendedMin
|
||||||
}
|
}
|
||||||
.disabled(abs(openNotchWidth - defaultOpenNotchWidth) < 0.5)
|
.disabled(abs(openNotchWidth - recommendedMin) < 0.5)
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
let description = enableMinimalisticUI
|
let description = enableMinimalisticUI
|
||||||
? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value."
|
? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value."
|
||||||
: "Extend the notch span so the clipboard, colour picker, and other trailing icons remain visible on scaled displays (e.g. More Space)."
|
: "Recommended minimum width adjusts automatically based on the number of enabled tabs."
|
||||||
|
|
||||||
Text(description)
|
Text(description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
enforceMinimumNotchWidth()
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Notch Width")
|
Text("Notch Width")
|
||||||
|
|
@ -5916,6 +6182,7 @@ struct StatsSettings: View {
|
||||||
@Default(.showGpuGraph) var showGpuGraph
|
@Default(.showGpuGraph) var showGpuGraph
|
||||||
@Default(.showNetworkGraph) var showNetworkGraph
|
@Default(.showNetworkGraph) var showNetworkGraph
|
||||||
@Default(.showDiskGraph) var showDiskGraph
|
@Default(.showDiskGraph) var showDiskGraph
|
||||||
|
@Default(.cpuTemperatureUnit) var cpuTemperatureUnit
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.stats.highlightID(for: title)
|
SettingsTab.stats.highlightID(for: title)
|
||||||
|
|
@ -6005,6 +6272,16 @@ struct StatsSettings: View {
|
||||||
Section {
|
Section {
|
||||||
Defaults.Toggle("CPU Usage", key: .showCpuGraph)
|
Defaults.Toggle("CPU Usage", key: .showCpuGraph)
|
||||||
.settingsHighlight(id: highlightID("CPU Usage"))
|
.settingsHighlight(id: highlightID("CPU Usage"))
|
||||||
|
|
||||||
|
if showCpuGraph {
|
||||||
|
Picker("Temperature unit", selection: $cpuTemperatureUnit) {
|
||||||
|
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
|
||||||
|
Text(unit.rawValue).tag(unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.settingsHighlight(id: highlightID("Temperature unit"))
|
||||||
|
}
|
||||||
Defaults.Toggle("Memory Usage", key: .showMemoryGraph)
|
Defaults.Toggle("Memory Usage", key: .showMemoryGraph)
|
||||||
.settingsHighlight(id: highlightID("Memory Usage"))
|
.settingsHighlight(id: highlightID("Memory Usage"))
|
||||||
Defaults.Toggle("GPU Usage", key: .showGpuGraph)
|
Defaults.Toggle("GPU Usage", key: .showGpuGraph)
|
||||||
|
|
@ -6608,6 +6885,7 @@ struct CustomOSDSettings: View {
|
||||||
@Default(.enableOSDVolume) var enableOSDVolume
|
@Default(.enableOSDVolume) var enableOSDVolume
|
||||||
@Default(.enableOSDBrightness) var enableOSDBrightness
|
@Default(.enableOSDBrightness) var enableOSDBrightness
|
||||||
@Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight
|
@Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight
|
||||||
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
@Default(.osdMaterial) var osdMaterial
|
@Default(.osdMaterial) var osdMaterial
|
||||||
@Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode
|
@Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode
|
||||||
@Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant
|
@Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant
|
||||||
|
|
@ -6650,7 +6928,7 @@ struct CustomOSDSettings: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if !hasAccessibilityPermission {
|
if !hasAccessibilityPermission && !enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission is needed to intercept system controls for the Custom OSD.",
|
message: "Accessibility permission is needed to intercept system controls for the Custom OSD.",
|
||||||
|
|
@ -6662,7 +6940,7 @@ struct CustomOSDSettings: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasAccessibilityPermission {
|
if hasAccessibilityPermission || enableBetterDisplayIntegration {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
||||||
.settingsHighlight(id: highlightID("Volume OSD"))
|
.settingsHighlight(id: highlightID("Volume OSD"))
|
||||||
|
|
@ -6670,6 +6948,8 @@ struct CustomOSDSettings: View {
|
||||||
.settingsHighlight(id: highlightID("Brightness OSD"))
|
.settingsHighlight(id: highlightID("Brightness OSD"))
|
||||||
Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight)
|
Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight)
|
||||||
.settingsHighlight(id: highlightID("Keyboard Backlight OSD"))
|
.settingsHighlight(id: highlightID("Keyboard Backlight OSD"))
|
||||||
|
.disabled(enableBetterDisplayIntegration)
|
||||||
|
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active \u{2014} BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -6899,3 +7179,233 @@ struct NotesSettingsView: View {
|
||||||
.navigationTitle("Notes")
|
.navigationTitle("Notes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal Settings
|
||||||
|
|
||||||
|
struct TerminalSettings: View {
|
||||||
|
@ObservedObject var terminalManager = TerminalManager.shared
|
||||||
|
@Default(.enableTerminalFeature) var enableTerminalFeature
|
||||||
|
@Default(.terminalShellPath) var terminalShellPath
|
||||||
|
@Default(.terminalFontSize) var terminalFontSize
|
||||||
|
@Default(.terminalOpacity) var terminalOpacity
|
||||||
|
@Default(.terminalMaxHeightFraction) var terminalMaxHeightFraction
|
||||||
|
@Default(.terminalCursorStyle) var terminalCursorStyle
|
||||||
|
@Default(.terminalScrollbackLines) var terminalScrollbackLines
|
||||||
|
@Default(.terminalOptionAsMeta) var terminalOptionAsMeta
|
||||||
|
@Default(.terminalMouseReporting) var terminalMouseReporting
|
||||||
|
@Default(.terminalBoldAsBright) var terminalBoldAsBright
|
||||||
|
@Default(.terminalBackgroundColor) var terminalBackgroundColor
|
||||||
|
@Default(.terminalForegroundColor) var terminalForegroundColor
|
||||||
|
@Default(.terminalCursorColor) var terminalCursorColor
|
||||||
|
|
||||||
|
private func highlightID(_ title: String) -> String {
|
||||||
|
SettingsTab.terminal.highlightID(for: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedMaxHeight: String {
|
||||||
|
"\(Int(terminalMaxHeightFraction * 100))% of screen"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cursorStyleBinding: Binding<TerminalCursorStyleOption> {
|
||||||
|
Binding(
|
||||||
|
get: { TerminalCursorStyleOption(rawValue: terminalCursorStyle) ?? .blinkBlock },
|
||||||
|
set: { terminalCursorStyle = $0.rawValue }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Defaults.Toggle("Enable terminal", key: .enableTerminalFeature)
|
||||||
|
.settingsHighlight(id: highlightID("Enable terminal"))
|
||||||
|
} header: {
|
||||||
|
Text("General")
|
||||||
|
} footer: {
|
||||||
|
Text("Adds a Guake-style dropdown terminal tab. The terminal session persists across notch open/close cycles.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableTerminalFeature {
|
||||||
|
// MARK: Shell
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Shell path")
|
||||||
|
Spacer()
|
||||||
|
TextField("", text: $terminalShellPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 200)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Shell path"))
|
||||||
|
} header: {
|
||||||
|
Text("Shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Appearance
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Font size")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(terminalFontSize)) pt")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(value: $terminalFontSize, in: 8...24, step: 1)
|
||||||
|
.onChange(of: terminalFontSize) { _, newValue in
|
||||||
|
terminalManager.applyFontSize(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Font size"))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Terminal opacity")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(terminalOpacity * 100))%")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(value: $terminalOpacity, in: 0.3...1.0, step: 0.05)
|
||||||
|
.onChange(of: terminalOpacity) { _, newValue in
|
||||||
|
terminalManager.applyOpacity(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Terminal opacity"))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Maximum height")
|
||||||
|
Spacer()
|
||||||
|
Text(formattedMaxHeight)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(value: $terminalMaxHeightFraction, in: 0.2...0.5, step: 0.05)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Maximum height"))
|
||||||
|
} header: {
|
||||||
|
Text("Appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Colors
|
||||||
|
Section {
|
||||||
|
ColorPicker("Background", selection: $terminalBackgroundColor, supportsOpacity: false)
|
||||||
|
.onChange(of: terminalBackgroundColor) { _, newValue in
|
||||||
|
terminalManager.applyBackgroundColor(newValue)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Background color"))
|
||||||
|
|
||||||
|
ColorPicker("Foreground", selection: $terminalForegroundColor, supportsOpacity: false)
|
||||||
|
.onChange(of: terminalForegroundColor) { _, newValue in
|
||||||
|
terminalManager.applyForegroundColor(newValue)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Foreground color"))
|
||||||
|
|
||||||
|
ColorPicker("Cursor", selection: $terminalCursorColor, supportsOpacity: false)
|
||||||
|
.onChange(of: terminalCursorColor) { _, newValue in
|
||||||
|
terminalManager.applyCursorColor(newValue)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Cursor color"))
|
||||||
|
|
||||||
|
Toggle("Bold text as bright colors", isOn: $terminalBoldAsBright)
|
||||||
|
.onChange(of: terminalBoldAsBright) { _, newValue in
|
||||||
|
terminalManager.applyBoldAsBright(newValue)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Bold as bright"))
|
||||||
|
} header: {
|
||||||
|
Text("Colors")
|
||||||
|
} footer: {
|
||||||
|
Text("When bold-as-bright is off, bold text uses a heavier font weight instead of bright ANSI colors.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Cursor
|
||||||
|
Section {
|
||||||
|
Picker("Cursor style", selection: cursorStyleBinding) {
|
||||||
|
ForEach(TerminalCursorStyleOption.allCases, id: \.self) { style in
|
||||||
|
Text(style.displayName).tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: terminalCursorStyle) { _, newValue in
|
||||||
|
if let style = TerminalCursorStyleOption(rawValue: newValue) {
|
||||||
|
terminalManager.applyCursorStyle(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Cursor style"))
|
||||||
|
} header: {
|
||||||
|
Text("Cursor")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Scrollback
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Scrollback lines")
|
||||||
|
Spacer()
|
||||||
|
Text("\(terminalScrollbackLines)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(terminalScrollbackLines) },
|
||||||
|
set: { terminalScrollbackLines = Int($0) }
|
||||||
|
),
|
||||||
|
in: 100...10000,
|
||||||
|
step: 100
|
||||||
|
)
|
||||||
|
.onChange(of: terminalScrollbackLines) { _, newValue in
|
||||||
|
terminalManager.applyScrollback(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Scrollback lines"))
|
||||||
|
} header: {
|
||||||
|
Text("Scrollback")
|
||||||
|
} footer: {
|
||||||
|
Text("Number of lines kept in the scrollback buffer. Higher values use more memory.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Input
|
||||||
|
Section {
|
||||||
|
Toggle("Option as Meta key", isOn: $terminalOptionAsMeta)
|
||||||
|
.onChange(of: terminalOptionAsMeta) { _, newValue in
|
||||||
|
terminalManager.applyOptionAsMeta(newValue)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Option as Meta"))
|
||||||
|
|
||||||
|
Toggle("Allow mouse reporting", isOn: $terminalMouseReporting)
|
||||||
|
.onChange(of: terminalMouseReporting) { _, newValue in
|
||||||
|
terminalManager.applyMouseReporting(newValue)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Mouse reporting"))
|
||||||
|
} header: {
|
||||||
|
Text("Input")
|
||||||
|
} footer: {
|
||||||
|
Text("Option as Meta sends Esc+key instead of macOS special characters. Mouse reporting forwards mouse events to terminal applications like vim or tmux.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Actions
|
||||||
|
Section {
|
||||||
|
Button("Restart Shell") {
|
||||||
|
terminalManager.restartShell()
|
||||||
|
}
|
||||||
|
.disabled(!terminalManager.isProcessRunning)
|
||||||
|
} header: {
|
||||||
|
Text("Actions")
|
||||||
|
} footer: {
|
||||||
|
Text("Restarts the shell process. Any unsaved work in the terminal will be lost.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StatsCard<Content: View>: View {
|
struct StatsCard<Content: View>: View {
|
||||||
|
|
@ -163,7 +164,10 @@ private struct CPUTemperatureGauge: View {
|
||||||
let tint: Color
|
let tint: Color
|
||||||
var size: CGFloat = 84
|
var size: CGFloat = 84
|
||||||
var lineWidth: CGFloat = 9
|
var lineWidth: CGFloat = 9
|
||||||
private let maxTemperature: Double = 110
|
@Default(.cpuTemperatureUnit) private var temperatureUnit
|
||||||
|
private var maxTemperature: Double {
|
||||||
|
temperatureUnit == .celsius ? 110 : 230
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CircularGaugeView(
|
CircularGaugeView(
|
||||||
|
|
@ -177,14 +181,19 @@ private struct CPUTemperatureGauge: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var displayTemperature: Double? {
|
||||||
|
guard let temperature else { return nil }
|
||||||
|
return temperatureUnit == .celsius ? temperature : temperature * 9.0 / 5.0 + 32.0
|
||||||
|
}
|
||||||
|
|
||||||
private var normalizedValue: Double {
|
private var normalizedValue: Double {
|
||||||
guard let temperature, maxTemperature > 0 else { return 0 }
|
guard let displayTemperature, maxTemperature > 0 else { return 0 }
|
||||||
return min(max(temperature / maxTemperature, 0), 1)
|
return min(max(displayTemperature / maxTemperature, 0), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var centerPrimary: String {
|
private var centerPrimary: String {
|
||||||
guard let temperature else { return "—" }
|
guard let displayTemperature else { return "—" }
|
||||||
return String(format: "%.0f°", temperature)
|
return String(format: "%.0f%@", displayTemperature, temperatureUnit.symbol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ struct TabSelectionView: View {
|
||||||
let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard"
|
let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard"
|
||||||
tabsArray.append(TabModel(label: label, icon: icon, view: .notes))
|
tabsArray.append(TabModel(label: label, icon: icon, view: .notes))
|
||||||
}
|
}
|
||||||
|
if Defaults[.enableTerminalFeature] {
|
||||||
|
tabsArray.append(TabModel(label: "Terminal", icon: "apple.terminal", view: .terminal))
|
||||||
|
}
|
||||||
if extensionTabsEnabled {
|
if extensionTabsEnabled {
|
||||||
for payload in extensionTabPayloads {
|
for payload in extensionTabPayloads {
|
||||||
guard let tab = payload.descriptor.tab else { continue }
|
guard let tab = payload.descriptor.tab else { continue }
|
||||||
|
|
@ -107,12 +110,10 @@ struct TabSelectionView: View {
|
||||||
let isSelected = isSelected(tab)
|
let isSelected = isSelected(tab)
|
||||||
let activeAccent = tab.accentColor ?? .white
|
let activeAccent = tab.accentColor ?? .white
|
||||||
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
|
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
|
||||||
withAnimation(.smooth(duration: 0.3)) {
|
if tab.view == .extensionExperience {
|
||||||
if tab.view == .extensionExperience {
|
coordinator.selectedExtensionExperienceID = tab.experienceID
|
||||||
coordinator.selectedExtensionExperienceID = tab.experienceID
|
|
||||||
}
|
|
||||||
coordinator.currentView = tab.view
|
|
||||||
}
|
}
|
||||||
|
coordinator.currentView = tab.view
|
||||||
}
|
}
|
||||||
.frame(height: 26)
|
.frame(height: 26)
|
||||||
.foregroundStyle(isSelected ? activeAccent : .gray)
|
.foregroundStyle(isSelected ? activeAccent : .gray)
|
||||||
|
|
@ -131,6 +132,7 @@ struct TabSelectionView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.smooth(duration: 0.3), value: coordinator.currentView)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.onAppear {
|
.onAppear {
|
||||||
ensureValidSelection(with: tabs)
|
ensureValidSelection(with: tabs)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,36 @@ public enum Style {
|
||||||
case floating
|
case floating
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Controls how Atoll renders on external and non-notched displays.
|
||||||
|
/// - `notch`: Standard notch shape (concave top corners blending into the screen edge).
|
||||||
|
/// - `dynamicIsland`: Pill-shaped island with continuously rounded corners,
|
||||||
|
/// inspired by DynamicNotchKit's floating style. Only applies to screens
|
||||||
|
/// that do NOT have a physical notch.
|
||||||
|
enum ExternalDisplayStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
|
||||||
|
case notch = "Standard Notch"
|
||||||
|
case dynamicIsland = "Dynamic Island"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var localizedName: String {
|
||||||
|
switch self {
|
||||||
|
case .notch:
|
||||||
|
return String(localized: "Standard Notch")
|
||||||
|
case .dynamicIsland:
|
||||||
|
return String(localized: "Dynamic Island")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .notch:
|
||||||
|
return String(localized: "Classic notch shape that blends into the top screen edge")
|
||||||
|
case .dynamicIsland:
|
||||||
|
return String(localized: "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum ContentType: Int, Codable, Hashable, Equatable {
|
public enum ContentType: Int, Codable, Hashable, Equatable {
|
||||||
case normal
|
case normal
|
||||||
case menu
|
case menu
|
||||||
|
|
@ -48,6 +78,7 @@ public enum NotchViews {
|
||||||
case colorPicker
|
case colorPicker
|
||||||
case notes
|
case notes
|
||||||
case clipboard
|
case clipboard
|
||||||
|
case terminal
|
||||||
case extensionExperience
|
case extensionExperience
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,11 @@ private struct ScrollMonitor: NSViewRepresentable {
|
||||||
private var active = false
|
private var active = false
|
||||||
private let noiseThreshold: CGFloat = 0.2
|
private let noiseThreshold: CGFloat = 0.2
|
||||||
private weak var observedView: NSView?
|
private weak var observedView: NSView?
|
||||||
private let hoverInset: CGFloat = 14
|
/// Small vertical inset so scroll gestures fire when the cursor
|
||||||
|
/// "kisses" the very top of the screen inside the physical notch
|
||||||
|
/// area. Kept at zero horizontally to avoid unwanted hover opens
|
||||||
|
/// from the sides.
|
||||||
|
private let verticalEdgeInset: CGFloat = 4
|
||||||
private var lastEventTimestamp: TimeInterval = 0
|
private var lastEventTimestamp: TimeInterval = 0
|
||||||
|
|
||||||
init(direction: PanDirection, threshold: CGFloat, action: @escaping (CGFloat, NSEvent.Phase) -> Void) {
|
init(direction: PanDirection, threshold: CGFloat, action: @escaping (CGFloat, NSEvent.Phase) -> Void) {
|
||||||
|
|
@ -154,7 +158,12 @@ private struct ScrollMonitor: NSViewRepresentable {
|
||||||
|
|
||||||
let windowPoint = window.convertPoint(fromScreen: screenPoint)
|
let windowPoint = window.convertPoint(fromScreen: screenPoint)
|
||||||
let localPoint = view.convert(windowPoint, from: nil)
|
let localPoint = view.convert(windowPoint, from: nil)
|
||||||
let hitArea = view.bounds.insetBy(dx: -hoverInset, dy: -hoverInset)
|
|
||||||
|
// Extend the hit area vertically by a few points so the cursor
|
||||||
|
// at the very top screen edge (kissing the notch) still triggers
|
||||||
|
// scroll gestures. No horizontal extension to prevent false opens
|
||||||
|
// from the sides of the notch.
|
||||||
|
let hitArea = view.bounds.insetBy(dx: 0, dy: -verticalEdgeInset)
|
||||||
return hitArea.contains(localPoint)
|
return hitArea.contains(localPoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,7 @@ final class MediaChecker: Sendable {
|
||||||
|
|
||||||
func checkDeprecationStatus() async throws -> Bool {
|
func checkDeprecationStatus() async throws -> Bool {
|
||||||
guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
|
guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
|
||||||
let nowPlayingTestClientPath = Bundle.main.url(forResource: "MediaRemoteAdapterTestClient", withExtension: nil)?.path,
|
let frameworkPath =
|
||||||
//let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
|
|
||||||
//let frameworkPath = Optional("/System/Library/PrivateFrameworks/MediaRemoteAdapter.framework")
|
|
||||||
let frameworkPath =
|
|
||||||
Bundle.main.resourceURL?
|
Bundle.main.resourceURL?
|
||||||
.appendingPathComponent("MediaRemoteAdapter.framework")
|
.appendingPathComponent("MediaRemoteAdapter.framework")
|
||||||
.path
|
.path
|
||||||
|
|
@ -43,6 +40,11 @@ final class MediaChecker: Sendable {
|
||||||
throw MediaCheckerError.missingResources
|
throw MediaCheckerError.missingResources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nowPlayingTestClientPath =
|
||||||
|
Bundle.main.bundleURL
|
||||||
|
.appendingPathComponent("Contents/Helpers/MediaRemoteAdapterTestClient")
|
||||||
|
.path
|
||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"]
|
process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"]
|
||||||
|
|
|
||||||
540
DynamicIsland/managers/BetterDisplayManager.swift
Normal file
540
DynamicIsland/managers/BetterDisplayManager.swift
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
/*
|
||||||
|
* Atoll (DynamicIsland)
|
||||||
|
* Copyright (C) 2024-2026 Atoll Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@preconcurrency import Foundation
|
||||||
|
import AppKit
|
||||||
|
import Defaults
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - BetterDisplay OSD Notification Model
|
||||||
|
|
||||||
|
/// Matches the JSON structure dispatched by BetterDisplay's OSD notification system.
|
||||||
|
/// See: https://github.com/waydabber/BetterDisplay/wiki/Integration-features,-CLI#osd-notification-dispatch-integration
|
||||||
|
struct BetterDisplayOSDNotification: Codable {
|
||||||
|
var displayID: Int?
|
||||||
|
var systemIconID: Int?
|
||||||
|
var customSymbol: String?
|
||||||
|
var text: String?
|
||||||
|
var lock: Bool?
|
||||||
|
var controlTarget: String?
|
||||||
|
var value: Double?
|
||||||
|
var maxValue: Double?
|
||||||
|
var symbolFadeAfter: Int?
|
||||||
|
var symbolSizeMultiplier: Double?
|
||||||
|
var textFadeAfter: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BetterDisplay Request / Response Models
|
||||||
|
|
||||||
|
/// JSON structure for sending commands to BetterDisplay via distributed notifications.
|
||||||
|
struct BetterDisplayRequestData: Codable {
|
||||||
|
var uuid: String?
|
||||||
|
var commands: [String] = []
|
||||||
|
var parameters: [String: String?] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON structure for receiving responses from BetterDisplay.
|
||||||
|
struct BetterDisplayResponseData: Codable {
|
||||||
|
var uuid: String?
|
||||||
|
var result: Bool?
|
||||||
|
var payload: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BetterDisplay Control Target Classification
|
||||||
|
|
||||||
|
enum BetterDisplayControlCategory {
|
||||||
|
case brightness
|
||||||
|
case volume
|
||||||
|
case other
|
||||||
|
}
|
||||||
|
|
||||||
|
private let brightnessControlTargets: Set<String> = [
|
||||||
|
"combinedBrightness",
|
||||||
|
"hardwareBrightness",
|
||||||
|
"softwareBrightness",
|
||||||
|
]
|
||||||
|
|
||||||
|
private let volumeControlTargets: Set<String> = [
|
||||||
|
"volume",
|
||||||
|
"mute",
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - BetterDisplay Manager
|
||||||
|
|
||||||
|
/// Manages integration with the BetterDisplay app (waydabber.BetterDisplay).
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// - Detect whether BetterDisplay is installed
|
||||||
|
/// - Observe OSD notifications from BetterDisplay and route them to Atoll's HUD pipeline
|
||||||
|
/// - Provide request/response primitives for controlling display properties
|
||||||
|
@MainActor
|
||||||
|
final class BetterDisplayManager: ObservableObject {
|
||||||
|
static let shared = BetterDisplayManager()
|
||||||
|
|
||||||
|
/// The bundle identifier of BetterDisplay.
|
||||||
|
nonisolated static let bundleID = "pro.betterdisplay.BetterDisplay"
|
||||||
|
|
||||||
|
// Notification names
|
||||||
|
private static let osdNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.osd")
|
||||||
|
private static let requestNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.request")
|
||||||
|
private static let responseNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.response")
|
||||||
|
private static let launchedNotificationName = NSNotification.Name("pro.betterdisplay.BetterDisplay.launched")
|
||||||
|
private static let terminatedNotificationName = NSNotification.Name("pro.betterdisplay.BetterDisplay.terminated")
|
||||||
|
|
||||||
|
// MARK: Published state
|
||||||
|
|
||||||
|
/// Whether BetterDisplay is currently detected (installed) on this machine.
|
||||||
|
@Published private(set) var isDetected: Bool = false
|
||||||
|
|
||||||
|
/// Whether BetterDisplay is currently running and ready for communication.
|
||||||
|
@Published private(set) var isRunning: Bool = false
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private var osdObserver: NSObjectProtocol?
|
||||||
|
private var responseObserver: NSObjectProtocol?
|
||||||
|
private var workspaceObserver: NSObjectProtocol?
|
||||||
|
private var workspaceTermObserver: NSObjectProtocol?
|
||||||
|
private var launchedObserver: NSObjectProtocol?
|
||||||
|
private var terminatedObserver: NSObjectProtocol?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private weak var coordinator: DynamicIslandViewCoordinator?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
isDetected = Self.checkInstallation()
|
||||||
|
isRunning = Self.checkRunning()
|
||||||
|
setupWorkspaceObserver()
|
||||||
|
setupLifecycleObservers()
|
||||||
|
setupSettingsObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Configure with the view coordinator for HUD dispatch.
|
||||||
|
func configure(coordinator: DynamicIslandViewCoordinator) {
|
||||||
|
self.coordinator = coordinator
|
||||||
|
|
||||||
|
// Start listening if integration is enabled and BetterDisplay is running
|
||||||
|
if Defaults[.enableBetterDisplayIntegration] && isRunning {
|
||||||
|
startListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh detection status (e.g. after app install/uninstall).
|
||||||
|
func refreshDetectionStatus() {
|
||||||
|
let wasDetected = isDetected
|
||||||
|
let wasRunning = isRunning
|
||||||
|
isDetected = Self.checkInstallation()
|
||||||
|
isRunning = Self.checkRunning()
|
||||||
|
if wasRunning && !isRunning {
|
||||||
|
stopListening()
|
||||||
|
} else if !wasRunning && isRunning && Defaults[.enableBetterDisplayIntegration] {
|
||||||
|
startListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection
|
||||||
|
|
||||||
|
/// Check if BetterDisplay is installed by looking for its bundle ID.
|
||||||
|
static func checkInstallation() -> Bool {
|
||||||
|
// Check running apps first (fast path)
|
||||||
|
if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check if the app is installed via URL scheme or bundle lookup
|
||||||
|
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
|
||||||
|
return FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if BetterDisplay is currently running.
|
||||||
|
static func checkRunning() -> Bool {
|
||||||
|
NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OSD Listening
|
||||||
|
|
||||||
|
private func startListening() {
|
||||||
|
guard osdObserver == nil else { return }
|
||||||
|
|
||||||
|
osdObserver = DistributedNotificationCenter.default().addObserver(
|
||||||
|
forName: Self.osdNotificationName,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleOSDNotification(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("✅ BetterDisplay OSD listener started")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopListening() {
|
||||||
|
if let observer = osdObserver {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(observer)
|
||||||
|
osdObserver = nil
|
||||||
|
NSLog("⏹ BetterDisplay OSD listener stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OSD Handling
|
||||||
|
|
||||||
|
private func handleOSDNotification(_ notification: Notification) {
|
||||||
|
guard Defaults[.enableBetterDisplayIntegration], isRunning else { return }
|
||||||
|
|
||||||
|
guard let notificationString = notification.object as? String else {
|
||||||
|
NSLog("⚠️ BetterDisplay OSD: unexpected notification format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("📺 BetterDisplay OSD raw payload: \(notificationString)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let osd = try JSONDecoder().decode(
|
||||||
|
BetterDisplayOSDNotification.self,
|
||||||
|
from: Data(notificationString.utf8)
|
||||||
|
)
|
||||||
|
routeOSDToHUD(osd)
|
||||||
|
} catch {
|
||||||
|
NSLog("⚠️ BetterDisplay OSD decode error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a decoded BetterDisplay OSD notification to the active Atoll HUD variant.
|
||||||
|
private func routeOSDToHUD(_ osd: BetterDisplayOSDNotification) {
|
||||||
|
let category = classifyControlTarget(osd.controlTarget, systemIconID: osd.systemIconID)
|
||||||
|
let normalizedValue = normalizeValue(osd.value, maxValue: osd.maxValue)
|
||||||
|
let targetScreen = resolveScreen(for: osd.displayID)
|
||||||
|
let isExternalDisplay = isExternal(displayID: osd.displayID)
|
||||||
|
|
||||||
|
NSLog("📺 BetterDisplay OSD: target=\(osd.controlTarget ?? "nil") displayID=\(osd.displayID.map(String.init) ?? "nil") resolvedScreen=\(targetScreen?.localizedName ?? "nil") isExternal=\(isExternalDisplay) value=\(osd.value ?? -1)")
|
||||||
|
|
||||||
|
switch category {
|
||||||
|
case .brightness:
|
||||||
|
let icon = isExternalDisplay ? "display" : nil
|
||||||
|
dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen)
|
||||||
|
|
||||||
|
case .volume:
|
||||||
|
let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4
|
||||||
|
dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted, onScreen: targetScreen)
|
||||||
|
|
||||||
|
case .other:
|
||||||
|
// For unsupported control targets (contrast, gamma, temperature, etc.),
|
||||||
|
// show a generic brightness-style HUD if the user has brightness HUD enabled
|
||||||
|
let icon = isExternalDisplay ? "display" : osd.customSymbol
|
||||||
|
dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD Dispatch (mirrors SystemChangesObserver logic)
|
||||||
|
|
||||||
|
private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool, onScreen targetScreen: NSScreen? = nil) {
|
||||||
|
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return }
|
||||||
|
|
||||||
|
if Defaults[.enableCircularHUD] {
|
||||||
|
CircularHUDWindowManager.shared.show(type: .volume, value: value, onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableVerticalHUD] {
|
||||||
|
VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "", onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] {
|
||||||
|
CustomOSDWindowManager.shared.showVolume(value: value, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .volume,
|
||||||
|
value: value,
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dispatchBrightnessHUD(value: CGFloat, customSymbol: String? = nil, onScreen targetScreen: NSScreen? = nil) {
|
||||||
|
let icon = customSymbol ?? ""
|
||||||
|
if Defaults[.enableCircularHUD] {
|
||||||
|
CircularHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableVerticalHUD] {
|
||||||
|
VerticalHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] {
|
||||||
|
CustomOSDWindowManager.shared.showBrightness(value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .brightness,
|
||||||
|
value: value,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Resolve a BetterDisplay `displayID` (CGDirectDisplayID) to the matching NSScreen, if any.
|
||||||
|
private func resolveScreen(for displayID: Int?) -> NSScreen? {
|
||||||
|
guard let displayID else {
|
||||||
|
NSLog("📺 BetterDisplay resolveScreen: displayID is nil, falling back to all screens")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let target = UInt32(displayID)
|
||||||
|
let availableScreens = NSScreen.screens.map { screen -> (String, UInt32) in
|
||||||
|
let num = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
|
||||||
|
return (screen.localizedName, num)
|
||||||
|
}
|
||||||
|
NSLog("📺 BetterDisplay resolveScreen: looking for displayID=\(displayID) (UInt32=\(target)) among screens: \(availableScreens)")
|
||||||
|
let matched = NSScreen.screens.first { screen in
|
||||||
|
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return screenNumber.uint32Value == target
|
||||||
|
}
|
||||||
|
if matched == nil {
|
||||||
|
NSLog("📺 BetterDisplay resolveScreen: no match found for displayID=\(displayID), HUD will show on all screens")
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the given displayID refers to an external (non-built-in) display.
|
||||||
|
private func isExternal(displayID: Int?) -> Bool {
|
||||||
|
guard let displayID else { return false }
|
||||||
|
return CGDisplayIsBuiltin(UInt32(displayID)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory {
|
||||||
|
if let target {
|
||||||
|
if brightnessControlTargets.contains(target) { return .brightness }
|
||||||
|
if volumeControlTargets.contains(target) { return .volume }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to systemIconID
|
||||||
|
switch systemIconID {
|
||||||
|
case 1: return .brightness // brightness icon
|
||||||
|
case 3: return .volume // volume icon
|
||||||
|
case 4: return .volume // mute icon
|
||||||
|
default: return .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a BetterDisplay value (0...maxValue) to 0...1 range.
|
||||||
|
private func normalizeValue(_ value: Double?, maxValue: Double?) -> CGFloat {
|
||||||
|
guard let value else { return 0 }
|
||||||
|
let maxVal = maxValue ?? 1.0
|
||||||
|
guard maxVal > 0 else { return 0 }
|
||||||
|
return CGFloat(Swift.min(Swift.max(value / maxVal, 0), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request API
|
||||||
|
|
||||||
|
/// Send a command to BetterDisplay and optionally receive a response.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - commands: e.g. ["set"], ["get"]
|
||||||
|
/// - parameters: e.g. ["brightness": "0.8"]
|
||||||
|
/// - completion: Called with the response on the main queue, or nil on timeout.
|
||||||
|
func sendRequest(
|
||||||
|
commands: [String],
|
||||||
|
parameters: [String: String?],
|
||||||
|
completion: (@MainActor @Sendable (BetterDisplayResponseData?) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
guard isRunning else {
|
||||||
|
NSLog("⚠️ BetterDisplay sendRequest skipped — app is not running")
|
||||||
|
completion?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let uuid = UUID().uuidString
|
||||||
|
let request = BetterDisplayRequestData(uuid: uuid, commands: commands, parameters: parameters)
|
||||||
|
|
||||||
|
// If we need a response, set up a temporary observer
|
||||||
|
if let completion {
|
||||||
|
// Use a class wrapper so the observer closure can safely capture & cancel
|
||||||
|
final class ResponseState: @unchecked Sendable {
|
||||||
|
var observer: NSObjectProtocol?
|
||||||
|
var timeoutItem: DispatchWorkItem?
|
||||||
|
}
|
||||||
|
let state = ResponseState()
|
||||||
|
|
||||||
|
let timeoutItem = DispatchWorkItem {
|
||||||
|
if let obs = state.observer {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(obs)
|
||||||
|
state.observer = nil
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.timeoutItem = timeoutItem
|
||||||
|
|
||||||
|
state.observer = DistributedNotificationCenter.default().addObserver(
|
||||||
|
forName: Self.responseNotificationName,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { notification in
|
||||||
|
guard let responseString = notification.object as? String,
|
||||||
|
let response = try? JSONDecoder().decode(
|
||||||
|
BetterDisplayResponseData.self,
|
||||||
|
from: Data(responseString.utf8)
|
||||||
|
),
|
||||||
|
response.uuid == uuid
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
state.timeoutItem?.cancel()
|
||||||
|
if let obs = state.observer {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(obs)
|
||||||
|
state.observer = nil
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
do {
|
||||||
|
let encoded = try JSONEncoder().encode(request)
|
||||||
|
if let encodedString = String(data: encoded, encoding: .utf8) {
|
||||||
|
DistributedNotificationCenter.default().postNotificationName(
|
||||||
|
Self.requestNotificationName,
|
||||||
|
object: encodedString,
|
||||||
|
userInfo: nil,
|
||||||
|
deliverImmediately: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("⚠️ BetterDisplay request encode error: \(error.localizedDescription)")
|
||||||
|
completion?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workspace Observer (detect install/uninstall)
|
||||||
|
|
||||||
|
private func setupWorkspaceObserver() {
|
||||||
|
let betterDisplayBundleID = Self.bundleID
|
||||||
|
|
||||||
|
workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didLaunchApplicationNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||||
|
app.bundleIdentifier == betterDisplayBundleID
|
||||||
|
else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.refreshDetectionStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also observe app termination — handles crashes and force-quits
|
||||||
|
// (orderly quits are caught by the lifecycle observer below)
|
||||||
|
workspaceTermObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didTerminateApplicationNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||||
|
app.bundleIdentifier == betterDisplayBundleID
|
||||||
|
else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
NSLog("🔴 BetterDisplay terminated (workspace notification)")
|
||||||
|
self?.isRunning = false
|
||||||
|
self?.stopListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle Observers (BetterDisplay launched/terminated notifications)
|
||||||
|
|
||||||
|
/// Listen for distributed notifications sent by BetterDisplay itself
|
||||||
|
/// to know when it becomes ready and when it shuts down cleanly.
|
||||||
|
private func setupLifecycleObservers() {
|
||||||
|
launchedObserver = DistributedNotificationCenter.default().addObserver(
|
||||||
|
forName: Self.launchedNotificationName,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
NSLog("🟢 BetterDisplay launched notification received")
|
||||||
|
self?.isDetected = true
|
||||||
|
self?.isRunning = true
|
||||||
|
if Defaults[.enableBetterDisplayIntegration] {
|
||||||
|
self?.startListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
terminatedObserver = DistributedNotificationCenter.default().addObserver(
|
||||||
|
forName: Self.terminatedNotificationName,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
NSLog("🔴 BetterDisplay terminated notification received")
|
||||||
|
self?.isRunning = false
|
||||||
|
self?.stopListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Observer
|
||||||
|
|
||||||
|
private func setupSettingsObserver() {
|
||||||
|
Defaults.publisher(.enableBetterDisplayIntegration, options: [])
|
||||||
|
.sink { [weak self] change in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
if change.newValue && self.isRunning {
|
||||||
|
self.startListening()
|
||||||
|
} else {
|
||||||
|
self.stopListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let osdObserver {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(osdObserver)
|
||||||
|
}
|
||||||
|
if let launchedObserver {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(launchedObserver)
|
||||||
|
}
|
||||||
|
if let terminatedObserver {
|
||||||
|
DistributedNotificationCenter.default().removeObserver(terminatedObserver)
|
||||||
|
}
|
||||||
|
if let workspaceObserver {
|
||||||
|
NSWorkspace.shared.notificationCenter.removeObserver(workspaceObserver)
|
||||||
|
}
|
||||||
|
if let workspaceTermObserver {
|
||||||
|
NSWorkspace.shared.notificationCenter.removeObserver(workspaceTermObserver)
|
||||||
|
}
|
||||||
|
cancellables.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,13 +66,13 @@ final class CircularHUDWindowManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(type: SneakContentType, value: CGFloat, icon: String = "") {
|
func show(type: SneakContentType, value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) {
|
||||||
guard Defaults[.enableCircularHUD] else { return }
|
guard Defaults[.enableCircularHUD] else { return }
|
||||||
|
|
||||||
let screens = NSScreen.screens
|
let screens = targetScreen.map { [$0] } ?? NSScreen.screens
|
||||||
guard !screens.isEmpty else { return }
|
guard !screens.isEmpty else { return }
|
||||||
|
|
||||||
// Show on all screens
|
// Show on target screen(s)
|
||||||
for screen in screens {
|
for screen in screens {
|
||||||
let windowContext = ensureWindow(for: type, screen: screen)
|
let windowContext = ensureWindow(for: type, screen: screen)
|
||||||
updateContent(window: windowContext, type: type, value: value, icon: icon)
|
updateContent(window: windowContext, type: type, value: value, icon: icon)
|
||||||
|
|
|
||||||
|
|
@ -46,14 +46,14 @@ final class CustomOSDWindowManager {
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
func showVolume(value: CGFloat, isMuted: Bool = false, icon: String = "") {
|
func showVolume(value: CGFloat, isMuted: Bool = false, icon: String = "", onScreen targetScreen: NSScreen? = nil) {
|
||||||
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
||||||
show(type: .volume, value: value, icon: icon)
|
show(type: .volume, value: value, icon: icon, onScreen: targetScreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showBrightness(value: CGFloat) {
|
func showBrightness(value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) {
|
||||||
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
||||||
show(type: .brightness, value: value, icon: "")
|
show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showBacklight(value: CGFloat) {
|
func showBacklight(value: CGFloat) {
|
||||||
|
|
@ -67,14 +67,14 @@ final class CustomOSDWindowManager {
|
||||||
|
|
||||||
// MARK: - Private Implementation
|
// MARK: - Private Implementation
|
||||||
|
|
||||||
private func show(type: SneakContentType, value: CGFloat, icon: String) {
|
private func show(type: SneakContentType, value: CGFloat, icon: String, onScreen targetScreen: NSScreen? = nil) {
|
||||||
let screens = NSScreen.screens
|
let screens = targetScreen.map { [$0] } ?? NSScreen.screens
|
||||||
guard !screens.isEmpty else { return }
|
guard !screens.isEmpty else { return }
|
||||||
|
|
||||||
// Close other windows first
|
// Close other windows first
|
||||||
hideAllWindowsExcept(type: type)
|
hideAllWindowsExcept(type: type)
|
||||||
|
|
||||||
// Show on all screens
|
// Show on target screen(s)
|
||||||
for screen in screens {
|
for screen in screens {
|
||||||
let window = ensureWindow(for: type, screen: screen)
|
let window = ensureWindow(for: type, screen: screen)
|
||||||
updateContent(window: window, type: type, value: value, icon: icon)
|
updateContent(window: window, type: type, value: value, icon: icon)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
@Published var currentFocusModeName: String = ""
|
@Published var currentFocusModeName: String = ""
|
||||||
@Published var currentFocusModeIdentifier: String = ""
|
@Published var currentFocusModeIdentifier: String = ""
|
||||||
|
|
||||||
|
// Used by the brief-toast UI to show an ON toast when the active mode switches while Focus stays enabled.
|
||||||
|
@Published var focusToastTrigger: UUID = UUID()
|
||||||
|
|
||||||
private let notificationCenter = DistributedNotificationCenter.default()
|
private let notificationCenter = DistributedNotificationCenter.default()
|
||||||
private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated)
|
private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated)
|
||||||
private let pollingQueue = DispatchQueue(label: "com.dynamicisland.focus.polling", qos: .utility)
|
private let pollingQueue = DispatchQueue(label: "com.dynamicisland.focus.polling", qos: .utility)
|
||||||
|
|
@ -82,9 +85,8 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
suspensionBehavior: .deliverImmediately
|
suspensionBehavior: .deliverImmediately
|
||||||
)
|
)
|
||||||
|
|
||||||
startAssertionsPolling()
|
|
||||||
applyMonitoringModeChange(Defaults[.focusMonitoringMode])
|
|
||||||
isMonitoring = true
|
isMonitoring = true
|
||||||
|
applyMonitoringModeChange(Defaults[.focusMonitoringMode])
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopMonitoring() {
|
func stopMonitoring() {
|
||||||
|
|
@ -128,30 +130,69 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName)
|
let isGenericFocusIdentifier = (trimmedIdentifier?.lowercased() == "com.apple.focus")
|
||||||
|
let usableIdentifier = (trimmedIdentifier?.isEmpty == false && !isGenericFocusIdentifier) ? trimmedIdentifier : nil
|
||||||
let finalIdentifier: String
|
let usableName = (trimmedName?.isEmpty == false) ? trimmedName : nil
|
||||||
if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
|
||||||
finalIdentifier = identifier
|
|
||||||
} else {
|
|
||||||
finalIdentifier = resolvedMode.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalName: String
|
|
||||||
if let name = trimmedName, !name.isEmpty {
|
|
||||||
finalName = name
|
|
||||||
} else if !resolvedMode.displayName.isEmpty {
|
|
||||||
finalName = resolvedMode.displayName
|
|
||||||
} else if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
|
||||||
finalName = identifier
|
|
||||||
} else {
|
|
||||||
finalName = "Focus"
|
|
||||||
}
|
|
||||||
|
|
||||||
let previousIdentifier = self.currentFocusModeIdentifier
|
let previousIdentifier = self.currentFocusModeIdentifier
|
||||||
let previousName = self.currentFocusModeName
|
let previousName = self.currentFocusModeName
|
||||||
let previousActive = self.isDoNotDisturbActive
|
let previousActive = self.isDoNotDisturbActive
|
||||||
|
|
||||||
|
// When neither identifier nor name is available, only update the active
|
||||||
|
// state without overwriting existing mode metadata. This prevents the
|
||||||
|
// assertions-poll (which often lacks metadata) from reverting a correct
|
||||||
|
// mode set by the log-stream or notification handler.
|
||||||
|
if usableIdentifier == nil && usableName == nil {
|
||||||
|
if let isActive = isActive, isActive != previousActive {
|
||||||
|
withAnimation(.smooth(duration: 0.25)) {
|
||||||
|
self.isDoNotDisturbActive = isActive
|
||||||
|
}
|
||||||
|
// First activation with no prior mode: default to DND.
|
||||||
|
if isActive && previousIdentifier.isEmpty {
|
||||||
|
self.currentFocusModeIdentifier = FocusModeType.doNotDisturb.rawValue
|
||||||
|
self.currentFocusModeName = FocusModeType.doNotDisturb.displayName
|
||||||
|
}
|
||||||
|
debugPrint("[DoNotDisturbManager] Focus active-only update -> source: \(source) | isActive: \(isActive)")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedMode = FocusModeType.resolve(identifier: usableIdentifier, name: usableName)
|
||||||
|
|
||||||
|
let finalIdentifier: String = usableIdentifier ?? resolvedMode.rawValue
|
||||||
|
|
||||||
|
// Compute display name
|
||||||
|
let finalName: String
|
||||||
|
if resolvedMode == .custom, !FullDiskAccessAuthorization.hasPermission() {
|
||||||
|
finalName = "Focus"
|
||||||
|
} else if resolvedMode == .custom, FullDiskAccessAuthorization.hasPermission() {
|
||||||
|
// With FDA, prefer the real name from ModeConfigurations.json (fixes slug names like graduationcap.fill).
|
||||||
|
let lookedUp = FocusMetadataReader.shared.getDisplayName(for: trimmedName ?? "", identifier: finalIdentifier)
|
||||||
|
finalName = lookedUp.isEmpty ? "Focus" : lookedUp
|
||||||
|
} else if let name = trimmedName, !name.isEmpty {
|
||||||
|
let lower = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
switch lower {
|
||||||
|
case "work":
|
||||||
|
finalName = "Work"
|
||||||
|
case "personal", "personal-time":
|
||||||
|
finalName = "Personal"
|
||||||
|
case "reduce-interruptions":
|
||||||
|
finalName = "Reduce Interruptions"
|
||||||
|
case "sleep", "sleep-mode":
|
||||||
|
finalName = "Sleep"
|
||||||
|
case "driving":
|
||||||
|
finalName = "Driving"
|
||||||
|
case "default", "dnd", "do-not-disturb", "do not disturb", "donotdisturb":
|
||||||
|
finalName = "Do Not Disturb"
|
||||||
|
default:
|
||||||
|
finalName = name
|
||||||
|
}
|
||||||
|
} else if !resolvedMode.displayName.isEmpty {
|
||||||
|
finalName = resolvedMode.displayName
|
||||||
|
} else {
|
||||||
|
finalName = "Focus"
|
||||||
|
}
|
||||||
|
|
||||||
let identifierChanged = finalIdentifier != previousIdentifier
|
let identifierChanged = finalIdentifier != previousIdentifier
|
||||||
let nameChanged = finalName != previousName
|
let nameChanged = finalName != previousName
|
||||||
let shouldToggleActive = isActive.map { $0 != previousActive } ?? false
|
let shouldToggleActive = isActive.map { $0 != previousActive } ?? false
|
||||||
|
|
@ -161,10 +202,12 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
if nameChanged {
|
if nameChanged {
|
||||||
self.currentFocusModeName = finalName
|
self.currentFocusModeName = finalName.localizedCaseInsensitiveContains("Reduce Interruptions") ? "Reduce Interr." : finalName
|
||||||
.localizedCaseInsensitiveContains(
|
}
|
||||||
"Reduce Interruptions"
|
|
||||||
) ? "Reduce Interr." : finalName
|
// If Focus remains active and the mode switches (e.g., DND -> Sleep), trigger an ON toast for the new mode.
|
||||||
|
if isActive == nil, previousActive == true, identifierChanged {
|
||||||
|
self.focusToastTrigger = UUID()
|
||||||
}
|
}
|
||||||
|
|
||||||
if identifierChanged || nameChanged || shouldToggleActive {
|
if identifierChanged || nameChanged || shouldToggleActive {
|
||||||
|
|
@ -176,12 +219,16 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
withAnimation(.smooth(duration: 0.25)) {
|
withAnimation(.smooth(duration: 0.25)) {
|
||||||
self.isDoNotDisturbActive = isActive
|
self.isDoNotDisturbActive = isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If Focus turned OFF, clear cached mode metadata after the OFF toast can use it.
|
||||||
|
if isActive == false {
|
||||||
|
self.currentFocusModeIdentifier = previousIdentifier
|
||||||
|
self.currentFocusModeName = previousName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLogMetadataUpdate(identifier: String?, name: String?) {
|
private func handleLogMetadataUpdate(identifier: String?, name: String?) {
|
||||||
guard Defaults[.focusMonitoringMode] == .useDevTools else { return }
|
|
||||||
|
|
||||||
metadataExtractionQueue.async { [weak self] in
|
metadataExtractionQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
@ -336,9 +383,11 @@ private extension DoNotDisturbManager {
|
||||||
guard isMonitoring else { return }
|
guard isMonitoring else { return }
|
||||||
|
|
||||||
if mode == .useDevTools {
|
if mode == .useDevTools {
|
||||||
|
stopAssertionsPolling()
|
||||||
focusLogStream.start()
|
focusLogStream.start()
|
||||||
} else {
|
} else {
|
||||||
focusLogStream.stop()
|
focusLogStream.stop()
|
||||||
|
startAssertionsPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,11 +590,24 @@ extension FocusModeType {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Exact rawValue match (e.g., "com.apple.focus.work", "com.apple.donotdisturb.mode").
|
||||||
if let direct = FocusModeType(rawValue: normalized) ?? FocusModeType(rawValue: normalizedLowercased) {
|
if let direct = FocusModeType(rawValue: normalized) ?? FocusModeType(rawValue: normalizedLowercased) {
|
||||||
self = direct
|
self = direct
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. macOS 26.3 custom Focus modes use `com.apple.donotdisturb.mode.<symbol>`.
|
||||||
|
// Must be checked BEFORE the generic prefix match, otherwise the prefix
|
||||||
|
// `com.apple.donotdisturb.mode` would incorrectly resolve to .doNotDisturb.
|
||||||
|
if normalizedLowercased.hasPrefix("com.apple.donotdisturb.mode.") {
|
||||||
|
let suffix = String(normalizedLowercased.dropFirst("com.apple.donotdisturb.mode.".count))
|
||||||
|
if !suffix.isEmpty && suffix != "default" {
|
||||||
|
self = .custom
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Prefix match for known built-in modes (e.g., com.apple.focus.personal-time -> .personal).
|
||||||
if let resolved = FocusModeType.allCases.first(where: {
|
if let resolved = FocusModeType.allCases.first(where: {
|
||||||
guard !$0.rawValue.isEmpty else { return false }
|
guard !$0.rawValue.isEmpty else { return false }
|
||||||
return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue)
|
return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue)
|
||||||
|
|
@ -554,6 +616,7 @@ extension FocusModeType {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Anything else under com.apple.focus is custom.
|
||||||
if normalizedLowercased.hasPrefix("com.apple.focus") {
|
if normalizedLowercased.hasPrefix("com.apple.focus") {
|
||||||
self = .custom
|
self = .custom
|
||||||
return
|
return
|
||||||
|
|
@ -570,6 +633,22 @@ extension FocusModeType {
|
||||||
}) {
|
}) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also try matching the name against known raw identifiers (e.g., a log line may emit "work" or "sleep" as the name).
|
||||||
|
let lower = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
switch lower {
|
||||||
|
case "work": return .work
|
||||||
|
case "personal", "personal-time": return .personal
|
||||||
|
case "sleep", "sleep-mode": return .sleep
|
||||||
|
case "driving": return .driving
|
||||||
|
case "fitness": return .fitness
|
||||||
|
case "gaming": return .gaming
|
||||||
|
case "mindfulness": return .mindfulness
|
||||||
|
case "reading": return .reading
|
||||||
|
case "reduce-interruptions", "reduce interruptions": return .reduceInterruptions
|
||||||
|
case "do not disturb", "dnd", "donotdisturb", "do-not-disturb", "default": return .doNotDisturb
|
||||||
|
default: break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let identifier, !identifier.isEmpty {
|
if let identifier, !identifier.isEmpty {
|
||||||
|
|
@ -580,13 +659,17 @@ extension FocusModeType {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCustomIconFromFile() -> String {
|
func getCustomIconFromFile() -> String {
|
||||||
return FocusMetadataReader.shared
|
return FocusMetadataReader.shared.getIcon(
|
||||||
.getIcon(for: DoNotDisturbManager.shared.currentFocusModeName)
|
for: DoNotDisturbManager.shared.currentFocusModeName,
|
||||||
|
identifier: DoNotDisturbManager.shared.currentFocusModeIdentifier
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCustomAccentColorFromFile() -> Color {
|
func getCustomAccentColorFromFile() -> Color {
|
||||||
return FocusMetadataReader.shared
|
return FocusMetadataReader.shared.getAccentColor(
|
||||||
.getAccentColor(for: DoNotDisturbManager.shared.currentFocusModeName)
|
for: DoNotDisturbManager.shared.currentFocusModeName,
|
||||||
|
identifier: DoNotDisturbManager.shared.currentFocusModeIdentifier
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -815,6 +898,7 @@ private final class FocusLogStream {
|
||||||
private var pipe: Pipe?
|
private var pipe: Pipe?
|
||||||
private var buffer = Data()
|
private var buffer = Data()
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
|
private var didTerminate = false
|
||||||
|
|
||||||
private let metadataLock = NSLock()
|
private let metadataLock = NSLock()
|
||||||
private var lastIdentifier: String?
|
private var lastIdentifier: String?
|
||||||
|
|
@ -831,12 +915,13 @@ private final class FocusLogStream {
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/log")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/log")
|
||||||
process.arguments = [
|
process.arguments = [
|
||||||
"stream",
|
"stream",
|
||||||
|
"--no-backtrace",
|
||||||
"--style",
|
"--style",
|
||||||
"compact",
|
"compact",
|
||||||
"--level",
|
"--level",
|
||||||
"info",
|
"info",
|
||||||
"--predicate",
|
"--predicate",
|
||||||
"subsystem == \"com.apple.focus\""
|
"process == \"duetexpertd\" AND eventMessage CONTAINS \"semanticModeIdentifier\""
|
||||||
]
|
]
|
||||||
|
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
|
|
@ -870,7 +955,8 @@ private final class FocusLogStream {
|
||||||
self.process = process
|
self.process = process
|
||||||
self.pipe = pipe
|
self.pipe = pipe
|
||||||
self.isRunning = true
|
self.isRunning = true
|
||||||
debugPrint("[FocusLogStream] Started unified log tail for com.apple.focus")
|
self.didTerminate = false
|
||||||
|
debugPrint("[FocusLogStream] Started unified log tail for duetexpertd/donotdisturbd Focus metadata")
|
||||||
} catch {
|
} catch {
|
||||||
debugPrint("[FocusLogStream] Failed to start log stream: \(error)")
|
debugPrint("[FocusLogStream] Failed to start log stream: \(error)")
|
||||||
pipe.fileHandleForReading.readabilityHandler = nil
|
pipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
|
@ -937,7 +1023,12 @@ private final class FocusLogStream {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed.contains("active mode assertion: (null)") || trimmed.contains("active activity: (null)") {
|
if trimmed.lowercased().contains("error") && trimmed.lowercased().contains("predicate") {
|
||||||
|
debugPrint("[FocusLogStream] log stream error: \(trimmed)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear only when logs explicitly indicate no active mode (helps avoid wiping state during transitions).
|
||||||
|
if trimmed.contains("active mode assertion: (null)") || trimmed.contains("activeModeIdentifier: (null)") {
|
||||||
clearMetadata()
|
clearMetadata()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -945,11 +1036,26 @@ private final class FocusLogStream {
|
||||||
var updatedIdentifier: String?
|
var updatedIdentifier: String?
|
||||||
var updatedName: String?
|
var updatedName: String?
|
||||||
|
|
||||||
if let identifier = FocusMetadataDecoder.extractIdentifier(from: trimmed), !identifier.isEmpty {
|
// Special-case parsing for donotdisturbd logs which include a full DNDMode description.
|
||||||
|
// Example: <DNDMode: ... name: Lock In; modeIdentifier: com.apple.donotdisturb.mode.graduationcap.fill; ...>
|
||||||
|
if trimmed.contains("<DNDMode:") {
|
||||||
|
func extractField(_ key: String) -> String? {
|
||||||
|
guard let r = trimmed.range(of: key) else { return nil }
|
||||||
|
let suffix = trimmed[r.upperBound...]
|
||||||
|
guard let end = suffix.range(of: ";") else { return nil }
|
||||||
|
let value = suffix[..<end.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
|
||||||
|
if let v = extractField("modeIdentifier:") { updatedIdentifier = v }
|
||||||
|
if let v = extractField("name:") { updatedName = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedIdentifier == nil, let identifier = FocusMetadataDecoder.extractIdentifier(from: trimmed), !identifier.isEmpty {
|
||||||
updatedIdentifier = identifier
|
updatedIdentifier = identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
if let name = FocusMetadataDecoder.extractName(from: trimmed), !name.isEmpty {
|
if updatedName == nil, let name = FocusMetadataDecoder.extractName(from: trimmed), !name.isEmpty {
|
||||||
updatedName = name
|
updatedName = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -983,6 +1089,8 @@ private final class FocusLogStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleTermination(terminateProcess: Bool = false) {
|
private func handleTermination(terminateProcess: Bool = false) {
|
||||||
|
if didTerminate { return }
|
||||||
|
didTerminate = true
|
||||||
if terminateProcess, let process, process.isRunning {
|
if terminateProcess, let process, process.isRunning {
|
||||||
process.terminate()
|
process.terminate()
|
||||||
}
|
}
|
||||||
|
|
@ -995,7 +1103,7 @@ private final class FocusLogStream {
|
||||||
buffer.removeAll(keepingCapacity: false)
|
buffer.removeAll(keepingCapacity: false)
|
||||||
isRunning = false
|
isRunning = false
|
||||||
clearMetadata()
|
clearMetadata()
|
||||||
debugPrint("[FocusLogStream] Stopped unified log tail for com.apple.focus")
|
debugPrint("[FocusLogStream] Stopped unified log tail for duetexpertd/donotdisturbd Focus metadata")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifyMetadataUpdate(identifier: String?, name: String?) {
|
private func notifyMetadataUpdate(identifier: String?, name: String?) {
|
||||||
|
|
@ -1006,14 +1114,15 @@ private final class FocusLogStream {
|
||||||
|
|
||||||
private enum FocusNotificationParsing {
|
private enum FocusNotificationParsing {
|
||||||
static let identifierPattern: NSRegularExpression? = {
|
static let identifierPattern: NSRegularExpression? = {
|
||||||
let pattern = "com\\.apple\\.(?:focus|donotdisturb)[A-Za-z0-9_.-]*"
|
let pattern = "com\\.apple\\.(?:focus|donotdisturb|sleep)[A-Za-z0-9_.-]*"
|
||||||
return try? NSRegularExpression(pattern: pattern, options: [])
|
return try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static let identifierDetailPatterns: [NSRegularExpression] = {
|
static let identifierDetailPatterns: [NSRegularExpression] = {
|
||||||
let patterns = [
|
let patterns = [
|
||||||
"modeIdentifier:\\s*'([^'\\s]+)'",
|
"modeIdentifier:\\s*'([^'\\s]+)'",
|
||||||
"activityIdentifier:\\s*([A-Za-z0-9._-]+)"
|
"activityIdentifier:\\s*([A-Za-z0-9._-]+)",
|
||||||
|
"semanticModeIdentifier:\\s*([A-Za-z0-9._-]+)"
|
||||||
]
|
]
|
||||||
return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) }
|
return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) }
|
||||||
}()
|
}()
|
||||||
|
|
@ -1083,65 +1192,82 @@ private enum FocusMetadataDecoder {
|
||||||
|
|
||||||
private final class FocusMetadataReader {
|
private final class FocusMetadataReader {
|
||||||
private let pathToDatabase:URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/DoNotDisturb/DB/ModeConfigurations.json")
|
private let pathToDatabase:URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/DoNotDisturb/DB/ModeConfigurations.json")
|
||||||
|
|
||||||
struct DNDConfigRoot: Codable {
|
struct DNDConfigRoot: Codable {
|
||||||
let data: [DNDDataEntry]
|
let data: [DNDDataEntry]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DNDDataEntry: Codable {
|
struct DNDDataEntry: Codable {
|
||||||
let modeConfigurations: [String: DNDModeWrapper]
|
let modeConfigurations: [String: DNDModeWrapper]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DNDModeWrapper: Codable {
|
struct DNDModeWrapper: Codable {
|
||||||
let mode: DNDMode
|
let mode: DNDMode
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DNDMode: Codable {
|
struct DNDMode: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
|
let modeIdentifier: String
|
||||||
let symbolImageName: String?
|
let symbolImageName: String?
|
||||||
let tintColorName: String?
|
let tintColorName: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(){}
|
private init(){}
|
||||||
|
|
||||||
static let shared = FocusMetadataReader()
|
static let shared = FocusMetadataReader()
|
||||||
|
|
||||||
private func getModeConfig(for focusName: String) -> DNDMode? {
|
private func getModeConfig(for focusName: String, identifier: String? = nil) -> DNDMode? {
|
||||||
guard FullDiskAccessAuthorization.hasPermission() else { return nil }
|
guard FullDiskAccessAuthorization.hasPermission() else { return nil }
|
||||||
|
|
||||||
|
let trimmedName = focusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: pathToDatabase)
|
let data = try Data(contentsOf: pathToDatabase)
|
||||||
let root = try JSONDecoder().decode(DNDConfigRoot.self, from: data)
|
let root = try JSONDecoder().decode(DNDConfigRoot.self, from: data)
|
||||||
|
|
||||||
for entry in root.data {
|
for entry in root.data {
|
||||||
for wrapper in entry.modeConfigurations.values {
|
for wrapper in entry.modeConfigurations.values {
|
||||||
if wrapper.mode.name
|
let mode = wrapper.mode
|
||||||
.localizedCaseInsensitiveCompare(focusName) == .orderedSame {
|
|
||||||
return wrapper.mode
|
if let id = trimmedIdentifier, !id.isEmpty,
|
||||||
|
mode.modeIdentifier.compare(id, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trimmedName.isEmpty,
|
||||||
|
mode.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame {
|
||||||
|
return mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("JSON Error: \(error)")
|
print("ModeConfigurations.json decode error: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDisplayName(for focus: String, identifier: String? = nil) -> String {
|
||||||
|
guard let mode = getModeConfig(for: focus, identifier: identifier) else { return "" }
|
||||||
|
return mode.name
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the icon for the current focus from disk. If the focus is not found return the placeholder `app.badge`
|
/// Fetch the icon for the current focus from disk. If the focus is not found return the placeholder `app.badge`
|
||||||
/// - Returns A string representing the sfSymbol of the current focus
|
/// - Returns A string representing the sfSymbol of the current focus
|
||||||
func getIcon(for focus: String) -> String {
|
func getIcon(for focus: String, identifier: String? = nil) -> String {
|
||||||
guard let mode = getModeConfig(for: focus) else { return "app.badge" }
|
guard let mode = getModeConfig(for: focus, identifier: identifier) else { return "app.badge" }
|
||||||
return mode.symbolImageName ?? "app.badge"
|
return mode.symbolImageName ?? "app.badge"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the accent color for the current focus from disk. If the focus is not found return the placeholder `Color.indigo`
|
/// Fetch the accent color for the current focus from disk. If the focus is not found return the placeholder `Color.indigo`
|
||||||
/// - Returns A Color representing the accent color for the current focus
|
/// - Returns A Color representing the accent color for the current focus
|
||||||
func getAccentColor(for focus: String) -> Color {
|
func getAccentColor(for focus: String, identifier: String? = nil) -> Color {
|
||||||
guard let mode = getModeConfig(for: focus),
|
guard let mode = getModeConfig(for: focus, identifier: identifier),
|
||||||
let colorName = mode.tintColorName else { return .indigo }
|
let colorName = mode.tintColorName else { return .indigo }
|
||||||
|
|
||||||
return Color.stringToColor(for: colorName)
|
return Color.stringToColor(for: colorName)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,28 @@ class MusicManager: ObservableObject {
|
||||||
@Published var isPlaying = false
|
@Published var isPlaying = false
|
||||||
@Published var album: String = "Self Love"
|
@Published var album: String = "Self Love"
|
||||||
@Published var isPlayerIdle: Bool = true
|
@Published var isPlayerIdle: Bool = true
|
||||||
|
|
||||||
|
/// Whether there is an active music session with real metadata.
|
||||||
|
/// Returns `false` only when the metadata is still placeholder/fallback defaults
|
||||||
|
/// (i.e. nothing has been played since app launch, or the controller returned
|
||||||
|
/// unknown/not-playing placeholders). Paused music with real metadata is still
|
||||||
|
/// considered an active session.
|
||||||
|
private static let placeholderTitles: Set<String> = [
|
||||||
|
"i'm handsome", "unknown", "not playing"
|
||||||
|
]
|
||||||
|
private static let placeholderArtists: Set<String> = [
|
||||||
|
"me", "unknown"
|
||||||
|
]
|
||||||
|
|
||||||
|
var hasActiveSession: Bool {
|
||||||
|
if isPlaying { return true }
|
||||||
|
let trimmedTitle = songTitle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let trimmedArtist = artistName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let hasRealTitle = !trimmedTitle.isEmpty && !Self.placeholderTitles.contains(trimmedTitle)
|
||||||
|
let hasRealArtist = !trimmedArtist.isEmpty && !Self.placeholderArtists.contains(trimmedArtist)
|
||||||
|
return hasRealTitle || hasRealArtist
|
||||||
|
}
|
||||||
|
|
||||||
@Published var animations: DynamicIslandAnimations = .init()
|
@Published var animations: DynamicIslandAnimations = .init()
|
||||||
@Published var avgColor: NSColor = .white
|
@Published var avgColor: NSColor = .white
|
||||||
@Published var bundleIdentifier: String? = nil
|
@Published var bundleIdentifier: String? = nil
|
||||||
|
|
|
||||||
|
|
@ -108,72 +108,86 @@ class SystemHUDManager {
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
// Observe individual HUD toggles
|
// Observe individual HUD toggles
|
||||||
Defaults.publisher(.enableVolumeHUD, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableVolumeHUD, options: []).sink { [weak self] _ in
|
||||||
guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else {
|
guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let flags = self.resolvedControlFlags()
|
||||||
self.changesObserver?.update(
|
self.changesObserver?.update(
|
||||||
volumeEnabled: change.newValue,
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: Defaults[.enableBrightnessHUD],
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: Defaults[.enableKeyboardBacklightHUD]
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] _ in
|
||||||
guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else {
|
guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let flags = self.resolvedControlFlags()
|
||||||
self.changesObserver?.update(
|
self.changesObserver?.update(
|
||||||
volumeEnabled: Defaults[.enableVolumeHUD],
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: change.newValue,
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: Defaults[.enableKeyboardBacklightHUD]
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
Defaults.publisher(.enableKeyboardBacklightHUD, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableKeyboardBacklightHUD, options: []).sink { [weak self] _ in
|
||||||
guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else {
|
guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let flags = self.resolvedControlFlags()
|
||||||
self.changesObserver?.update(
|
self.changesObserver?.update(
|
||||||
volumeEnabled: Defaults[.enableVolumeHUD],
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: Defaults[.enableBrightnessHUD],
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: change.newValue
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
// Observe individual OSD toggles
|
// Observe individual OSD toggles
|
||||||
Defaults.publisher(.enableOSDVolume, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableOSDVolume, options: []).sink { [weak self] _ in
|
||||||
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let flags = self.resolvedControlFlags()
|
||||||
self.changesObserver?.update(
|
self.changesObserver?.update(
|
||||||
volumeEnabled: change.newValue,
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: Defaults[.enableOSDBrightness],
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: Defaults[.enableOSDKeyboardBacklight]
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
Defaults.publisher(.enableOSDBrightness, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableOSDBrightness, options: []).sink { [weak self] _ in
|
||||||
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let flags = self.resolvedControlFlags()
|
||||||
self.changesObserver?.update(
|
self.changesObserver?.update(
|
||||||
volumeEnabled: Defaults[.enableOSDVolume],
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: change.newValue,
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: Defaults[.enableOSDKeyboardBacklight]
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
Defaults.publisher(.enableOSDKeyboardBacklight, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableOSDKeyboardBacklight, options: []).sink { [weak self] _ in
|
||||||
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let flags = self.resolvedControlFlags()
|
||||||
self.changesObserver?.update(
|
self.changesObserver?.update(
|
||||||
volumeEnabled: Defaults[.enableOSDVolume],
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: Defaults[.enableOSDBrightness],
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: change.newValue
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// When BetterDisplay integration toggled, restart observer to update Cmd+Brightness key interception
|
||||||
|
Defaults.publisher(.enableBetterDisplayIntegration, options: []).sink { [weak self] _ in
|
||||||
|
guard let self = self, self.isSetupComplete else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.startSystemObserver()
|
||||||
|
}
|
||||||
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
@ -206,22 +220,13 @@ class SystemHUDManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
/// Resolves the effective control flags, applying BetterDisplay overrides.
|
||||||
private func startSystemObserver() async {
|
private func resolvedControlFlags() -> (volume: Bool, brightness: Bool, backlight: Bool) {
|
||||||
guard let coordinator = coordinator, !isSystemOperationInProgress else { return }
|
var volumeEnabled: Bool
|
||||||
|
var brightnessEnabled: Bool
|
||||||
isSystemOperationInProgress = true
|
var keyboardBacklightEnabled: Bool
|
||||||
await stopSystemObserver() // Stop any existing observer
|
|
||||||
|
|
||||||
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
|
||||||
|
|
||||||
// Determine which controls to enable based on HUD or OSD mode
|
|
||||||
let volumeEnabled: Bool
|
|
||||||
let brightnessEnabled: Bool
|
|
||||||
let keyboardBacklightEnabled: Bool
|
|
||||||
|
|
||||||
if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] {
|
if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] {
|
||||||
// Respect per-control toggles so the system HUD can handle disabled events
|
|
||||||
volumeEnabled = Defaults[.enableVolumeHUD]
|
volumeEnabled = Defaults[.enableVolumeHUD]
|
||||||
brightnessEnabled = Defaults[.enableBrightnessHUD]
|
brightnessEnabled = Defaults[.enableBrightnessHUD]
|
||||||
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
||||||
|
|
@ -234,17 +239,41 @@ class SystemHUDManager {
|
||||||
brightnessEnabled = Defaults[.enableBrightnessHUD]
|
brightnessEnabled = Defaults[.enableBrightnessHUD]
|
||||||
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When BetterDisplay integration is on, stop intercepting brightness and
|
||||||
|
// Cmd+Brightness keys so BetterDisplay receives them and sends OSD notifications.
|
||||||
|
if Defaults[.enableBetterDisplayIntegration] {
|
||||||
|
brightnessEnabled = false
|
||||||
|
keyboardBacklightEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (volumeEnabled, brightnessEnabled, keyboardBacklightEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func startSystemObserver() async {
|
||||||
|
guard let coordinator = coordinator, !isSystemOperationInProgress else { return }
|
||||||
|
|
||||||
|
isSystemOperationInProgress = true
|
||||||
|
|
||||||
|
// Stop any existing observer directly (cannot call stopSystemObserver()
|
||||||
|
// because isSystemOperationInProgress is already true).
|
||||||
|
changesObserver?.stopObserving()
|
||||||
|
changesObserver = nil
|
||||||
|
|
||||||
|
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
||||||
|
|
||||||
|
let flags = resolvedControlFlags()
|
||||||
changesObserver?.startObserving(
|
changesObserver?.startObserving(
|
||||||
volumeEnabled: volumeEnabled,
|
volumeEnabled: flags.volume,
|
||||||
brightnessEnabled: brightnessEnabled,
|
brightnessEnabled: flags.brightness,
|
||||||
keyboardBacklightEnabled: keyboardBacklightEnabled
|
keyboardBacklightEnabled: flags.backlight
|
||||||
)
|
)
|
||||||
|
|
||||||
// Force disable system HUD to ensure no duplicates
|
// Force disable system HUD to ensure no duplicates
|
||||||
SystemOSDManager.disableSystemHUD()
|
SystemOSDManager.disableSystemHUD()
|
||||||
|
|
||||||
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]))")
|
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]), BD: \(Defaults[.enableBetterDisplayIntegration]))")
|
||||||
isSystemOperationInProgress = false
|
isSystemOperationInProgress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
329
DynamicIsland/managers/TerminalManager.swift
Normal file
329
DynamicIsland/managers/TerminalManager.swift
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
/*
|
||||||
|
* Atoll (DynamicIsland)
|
||||||
|
* Copyright (C) 2024-2026 Atoll Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftTerm
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
// MARK: - Stable Container
|
||||||
|
|
||||||
|
/// Container NSView that shields its terminal child from zero-sized frames.
|
||||||
|
///
|
||||||
|
/// When the notch closes, SwiftUI removes the `NSViewRepresentable` from the
|
||||||
|
/// hierarchy — but later re-adds the same `containerView` on reopen. During
|
||||||
|
/// that insertion, SwiftUI momentarily sets the frame to `.zero`. Without
|
||||||
|
/// protection, AppKit's autoresizing mask propagates the zero frame to the
|
||||||
|
/// terminal child, causing SwiftTerm's `processSizeChange` to resize the
|
||||||
|
/// emulator to 2 cols × 1 row (the enforced minimum), which destroys the
|
||||||
|
/// scrollback buffer.
|
||||||
|
///
|
||||||
|
/// We bypass `super.resizeSubviews(withOldSize:)` entirely and manually set
|
||||||
|
/// children to fill the container's bounds. This avoids the autoresizing
|
||||||
|
/// calculation which produces corrupt geometry when `oldSize` was `.zero`
|
||||||
|
/// (recorded during the transient removal) but the child kept its previous
|
||||||
|
/// large frame.
|
||||||
|
final class StableTerminalContainerView: NSView {
|
||||||
|
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
||||||
|
let size = bounds.size
|
||||||
|
// Block degenerate sizes — the terminal would collapse to 2×1
|
||||||
|
guard size.width >= 10, size.height >= 10 else { return }
|
||||||
|
// Manually fill children to bounds — bypasses autoresizing math
|
||||||
|
// that breaks when oldSize was zero but child kept its old frame.
|
||||||
|
for child in subviews {
|
||||||
|
child.frame = bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
guard window != nil else { return }
|
||||||
|
// When re-inserted into a window (notch reopened), force a full
|
||||||
|
// redraw so SwiftTerm re-renders the terminal buffer contents.
|
||||||
|
for child in subviews {
|
||||||
|
child.needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal Manager
|
||||||
|
|
||||||
|
/// Manages the Guake-style dropdown terminal session lifecycle.
|
||||||
|
/// The terminal is lazily created when the user first switches to the terminal tab,
|
||||||
|
/// and the process is kept alive across notch open/close cycles.
|
||||||
|
///
|
||||||
|
/// Uses a stable `StableTerminalContainerView` as the host so that SwiftUI's
|
||||||
|
/// `NSViewRepresentable` lifecycle (make/update/dismantle) never tears down
|
||||||
|
/// the actual terminal. The `LocalProcessTerminalView` is added as a subview
|
||||||
|
/// of the container and survives notch close/open cycles.
|
||||||
|
@MainActor
|
||||||
|
class TerminalManager: ObservableObject {
|
||||||
|
static let shared = TerminalManager()
|
||||||
|
|
||||||
|
/// Whether a shell process is currently running.
|
||||||
|
@Published var isProcessRunning: Bool = false
|
||||||
|
|
||||||
|
/// The current terminal title reported by the shell.
|
||||||
|
@Published var terminalTitle: String = "Terminal"
|
||||||
|
|
||||||
|
/// Stable container returned to SwiftUI — never deallocated.
|
||||||
|
/// Uses `StableTerminalContainerView` to prevent zero-frame transients
|
||||||
|
/// from destroying the scrollback buffer.
|
||||||
|
let containerView: StableTerminalContainerView = {
|
||||||
|
let v = StableTerminalContainerView(frame: .zero)
|
||||||
|
v.autoresizingMask = [.width, .height]
|
||||||
|
v.wantsLayer = true
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The actual terminal view (child of `containerView`).
|
||||||
|
private(set) var terminalView: LocalProcessTerminalView?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
/// Ensures the terminal view exists inside the container and returns the container.
|
||||||
|
/// Call this from the `NSViewRepresentable` wrapper.
|
||||||
|
func ensureTerminalView(delegate: LocalProcessTerminalViewDelegate) {
|
||||||
|
if let existing = terminalView, existing.superview === containerView {
|
||||||
|
// Already mounted — just re-wire the delegate in case the coordinator changed.
|
||||||
|
existing.processDelegate = delegate
|
||||||
|
// Force a redraw — the view may have been off-screen (notch closed)
|
||||||
|
// and needs to re-render the terminal buffer.
|
||||||
|
existing.needsDisplay = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the container's current bounds if valid, otherwise a reasonable
|
||||||
|
// default. SwiftTerm's init calls setupOptions() which reads the
|
||||||
|
// frame — a zero frame creates a 2×1 terminal that is corrected
|
||||||
|
// once the container gets its proper layout size.
|
||||||
|
let initialFrame = containerView.bounds.size.width >= 10
|
||||||
|
? containerView.bounds
|
||||||
|
: CGRect(x: 0, y: 0, width: 400, height: 300)
|
||||||
|
|
||||||
|
let view = LocalProcessTerminalView(frame: initialFrame)
|
||||||
|
view.autoresizingMask = [.width, .height]
|
||||||
|
|
||||||
|
// Apply all settings from Defaults
|
||||||
|
applyAllSettings(to: view)
|
||||||
|
|
||||||
|
view.processDelegate = delegate
|
||||||
|
|
||||||
|
// Mount inside the stable container
|
||||||
|
containerView.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
containerView.addSubview(view)
|
||||||
|
terminalView = view
|
||||||
|
|
||||||
|
// If the container already has a valid size, snap the child to it.
|
||||||
|
let containerSize = containerView.bounds.size
|
||||||
|
if containerSize.width >= 10, containerSize.height >= 10 {
|
||||||
|
view.frame = containerView.bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the shell process if not already running.
|
||||||
|
func startShellProcess() {
|
||||||
|
guard let view = terminalView, !isProcessRunning else { return }
|
||||||
|
|
||||||
|
let shell = Defaults[.terminalShellPath]
|
||||||
|
let execName = "-" + (shell as NSString).lastPathComponent // login shell convention
|
||||||
|
|
||||||
|
view.startProcess(
|
||||||
|
executable: shell,
|
||||||
|
args: [],
|
||||||
|
environment: buildEnvironment(),
|
||||||
|
execName: execName
|
||||||
|
)
|
||||||
|
isProcessRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the shell process terminates.
|
||||||
|
func processDidTerminate(exitCode: Int32?) {
|
||||||
|
isProcessRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restarts the shell by tearing down the old terminal and creating a fresh one.
|
||||||
|
///
|
||||||
|
/// Instead of bumping a generation counter (which forced SwiftUI to destroy
|
||||||
|
/// and recreate the `NSViewRepresentable` — recycling the same `containerView`
|
||||||
|
/// across identities which broke layout), we simply nil out `terminalView`
|
||||||
|
/// and change `@Published` state. SwiftUI's `updateNSView` will fire,
|
||||||
|
/// see the nil terminal, and call `ensureTerminalView` to mount a new one.
|
||||||
|
func restartShell() {
|
||||||
|
// Terminate the running process gracefully
|
||||||
|
terminalView?.terminate()
|
||||||
|
// Remove old terminal from container
|
||||||
|
terminalView?.removeFromSuperview()
|
||||||
|
terminalView = nil
|
||||||
|
isProcessRunning = false
|
||||||
|
terminalTitle = "Terminal"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the terminal title from the shell escape sequence.
|
||||||
|
func updateTitle(_ title: String) {
|
||||||
|
terminalTitle = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Application
|
||||||
|
|
||||||
|
/// Applies all persisted settings to a terminal view at creation time.
|
||||||
|
private func applyAllSettings(to view: LocalProcessTerminalView) {
|
||||||
|
// Font
|
||||||
|
let fontSize = CGFloat(Defaults[.terminalFontSize])
|
||||||
|
if let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) as NSFont? {
|
||||||
|
view.font = font
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opacity
|
||||||
|
view.layer?.opacity = Float(Defaults[.terminalOpacity])
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
view.nativeBackgroundColor = NSColor(Defaults[.terminalBackgroundColor])
|
||||||
|
view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor])
|
||||||
|
view.caretColor = NSColor(Defaults[.terminalCursorColor])
|
||||||
|
|
||||||
|
// Cursor style
|
||||||
|
let cursorStyle = TerminalCursorStyleOption(rawValue: Defaults[.terminalCursorStyle])
|
||||||
|
?? .blinkBlock
|
||||||
|
view.getTerminal().options.cursorStyle = cursorStyle.swiftTermStyle
|
||||||
|
|
||||||
|
// Scrollback
|
||||||
|
let scrollback = Defaults[.terminalScrollbackLines]
|
||||||
|
view.getTerminal().buffer.changeHistorySize(scrollback)
|
||||||
|
view.getTerminal().options.scrollback = scrollback
|
||||||
|
|
||||||
|
// Input behavior
|
||||||
|
view.optionAsMetaKey = Defaults[.terminalOptionAsMeta]
|
||||||
|
view.allowMouseReporting = Defaults[.terminalMouseReporting]
|
||||||
|
|
||||||
|
// Rendering
|
||||||
|
view.useBrightColors = Defaults[.terminalBoldAsBright]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates font size on the live terminal view.
|
||||||
|
func applyFontSize(_ size: Double) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
if let font = NSFont.monospacedSystemFont(ofSize: CGFloat(size), weight: .regular) as NSFont? {
|
||||||
|
view.font = font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates opacity on the live terminal view.
|
||||||
|
func applyOpacity(_ opacity: Double) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.layer?.opacity = Float(opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates cursor style on the live terminal view.
|
||||||
|
func applyCursorStyle(_ style: TerminalCursorStyleOption) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.getTerminal().options.cursorStyle = style.swiftTermStyle
|
||||||
|
view.setNeedsDisplay(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates scrollback buffer size on the live terminal view.
|
||||||
|
func applyScrollback(_ lines: Int) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.getTerminal().buffer.changeHistorySize(lines)
|
||||||
|
view.getTerminal().options.scrollback = lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates option-as-meta on the live terminal view.
|
||||||
|
func applyOptionAsMeta(_ enabled: Bool) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.optionAsMetaKey = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates mouse reporting on the live terminal view.
|
||||||
|
func applyMouseReporting(_ enabled: Bool) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.allowMouseReporting = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates bold-as-bright on the live terminal view.
|
||||||
|
func applyBoldAsBright(_ enabled: Bool) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.useBrightColors = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates background color on the live terminal view.
|
||||||
|
func applyBackgroundColor(_ color: SwiftUI.Color) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.nativeBackgroundColor = NSColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates foreground color on the live terminal view.
|
||||||
|
func applyForegroundColor(_ color: SwiftUI.Color) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.nativeForegroundColor = NSColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates cursor color on the live terminal view.
|
||||||
|
func applyCursorColor(_ color: SwiftUI.Color) {
|
||||||
|
guard let view = terminalView else { return }
|
||||||
|
view.caretColor = NSColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Environment
|
||||||
|
|
||||||
|
/// Builds the environment for the child shell process.
|
||||||
|
private func buildEnvironment() -> [String] {
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["TERM"] = "xterm-256color"
|
||||||
|
env["LANG"] = env["LANG"] ?? "en_US.UTF-8"
|
||||||
|
// Remove TERM_PROGRAM if set by a parent terminal
|
||||||
|
env.removeValue(forKey: "TERM_PROGRAM")
|
||||||
|
return env.map { "\($0.key)=\($0.value)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cursor Style Bridge
|
||||||
|
|
||||||
|
/// Codable-friendly cursor style enum that bridges to SwiftTerm's `CursorStyle`.
|
||||||
|
enum TerminalCursorStyleOption: String, CaseIterable, Defaults.Serializable {
|
||||||
|
case blinkBlock = "blinkBlock"
|
||||||
|
case steadyBlock = "steadyBlock"
|
||||||
|
case blinkUnderline = "blinkUnderline"
|
||||||
|
case steadyUnderline = "steadyUnderline"
|
||||||
|
case blinkBar = "blinkBar"
|
||||||
|
case steadyBar = "steadyBar"
|
||||||
|
|
||||||
|
var swiftTermStyle: CursorStyle {
|
||||||
|
switch self {
|
||||||
|
case .blinkBlock: return .blinkBlock
|
||||||
|
case .steadyBlock: return .steadyBlock
|
||||||
|
case .blinkUnderline: return .blinkUnderline
|
||||||
|
case .steadyUnderline: return .steadyUnderline
|
||||||
|
case .blinkBar: return .blinkBar
|
||||||
|
case .steadyBar: return .steadyBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .blinkBlock: return "Block (blinking)"
|
||||||
|
case .steadyBlock: return "Block (steady)"
|
||||||
|
case .blinkUnderline: return "Underline (blinking)"
|
||||||
|
case .steadyUnderline: return "Underline (steady)"
|
||||||
|
case .blinkBar: return "Bar (blinking)"
|
||||||
|
case .steadyBar: return "Bar (steady)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -136,13 +136,13 @@ final class VerticalHUDWindowManager {
|
||||||
windows.removeAll()
|
windows.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(type: SneakContentType, value: CGFloat, icon: String = "") {
|
func show(type: SneakContentType, value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) {
|
||||||
guard Defaults[.enableVerticalHUD] else { return }
|
guard Defaults[.enableVerticalHUD] else { return }
|
||||||
|
|
||||||
let screens = NSScreen.screens
|
let screens = targetScreen.map { [$0] } ?? NSScreen.screens
|
||||||
guard !screens.isEmpty else { return }
|
guard !screens.isEmpty else { return }
|
||||||
|
|
||||||
// Show on all screens
|
// Show on target screen(s)
|
||||||
for screen in screens {
|
for screen in screens {
|
||||||
let windowContext = ensureWindow(for: type, screen: screen)
|
let windowContext = ensureWindow(for: type, screen: screen)
|
||||||
updateContent(window: windowContext, type: type, value: value, icon: icon)
|
updateContent(window: windowContext, type: type, value: value, icon: icon)
|
||||||
|
|
|
||||||
|
|
@ -691,7 +691,7 @@ extension Defaults.Keys {
|
||||||
static let menubarIcon = Key<Bool>("menubarIcon", default: true)
|
static let menubarIcon = Key<Bool>("menubarIcon", default: true)
|
||||||
static let showOnAllDisplays = Key<Bool>("showOnAllDisplays", default: false)
|
static let showOnAllDisplays = Key<Bool>("showOnAllDisplays", default: false)
|
||||||
static let automaticallySwitchDisplay = Key<Bool>("automaticallySwitchDisplay", default: true)
|
static let automaticallySwitchDisplay = Key<Bool>("automaticallySwitchDisplay", default: true)
|
||||||
static let releaseName = Key<String>("releaseName", default: "Bora Bora")
|
static let releaseName = Key<String>("releaseName", default: "Seychelles")
|
||||||
static let hideDynamicIslandFromScreenCapture = Key<Bool>("hideDynamicIslandFromScreenCapture", default: false)
|
static let hideDynamicIslandFromScreenCapture = Key<Bool>("hideDynamicIslandFromScreenCapture", default: false)
|
||||||
|
|
||||||
// MARK: Behavior
|
// MARK: Behavior
|
||||||
|
|
@ -699,6 +699,11 @@ extension Defaults.Keys {
|
||||||
static let enableHaptics = Key<Bool>("enableHaptics", default: true)
|
static let enableHaptics = Key<Bool>("enableHaptics", default: true)
|
||||||
static let openNotchOnHover = Key<Bool>("openNotchOnHover", default: true)
|
static let openNotchOnHover = Key<Bool>("openNotchOnHover", default: true)
|
||||||
static let extendHoverArea = Key<Bool>("extendHoverArea", default: false)
|
static let extendHoverArea = Key<Bool>("extendHoverArea", default: false)
|
||||||
|
static let externalDisplayStyle = Key<ExternalDisplayStyle>(
|
||||||
|
"externalDisplayStyle",
|
||||||
|
default: .notch
|
||||||
|
)
|
||||||
|
static let hideNonNotchUntilHover = Key<Bool>("hideNonNotchUntilHover", default: false)
|
||||||
static let notchHeightMode = Key<WindowHeightMode>(
|
static let notchHeightMode = Key<WindowHeightMode>(
|
||||||
"notchHeightMode",
|
"notchHeightMode",
|
||||||
default: WindowHeightMode.matchRealNotchSize
|
default: WindowHeightMode.matchRealNotchSize
|
||||||
|
|
@ -781,7 +786,7 @@ extension Defaults.Keys {
|
||||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||||
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
||||||
static let lockScreenWeatherShowsSunrise = Key<Bool>("lockScreenWeatherShowsSunrise", default: true)
|
static let lockScreenWeatherShowsSunrise = Key<Bool>("lockScreenWeatherShowsSunrise", default: true)
|
||||||
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
|
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .inline)
|
||||||
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
|
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
|
||||||
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: true)
|
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: true)
|
||||||
static let lockScreenWeatherAQIScale = Key<LockScreenWeatherAirQualityScale>("lockScreenWeatherAQIScale", default: .us)
|
static let lockScreenWeatherAQIScale = Key<LockScreenWeatherAirQualityScale>("lockScreenWeatherAQIScale", default: .us)
|
||||||
|
|
@ -789,10 +794,10 @@ extension Defaults.Keys {
|
||||||
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
|
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
|
||||||
static let lockScreenWeatherVerticalOffset = Key<Double>("lockScreenWeatherVerticalOffset", default: 0)
|
static let lockScreenWeatherVerticalOffset = Key<Double>("lockScreenWeatherVerticalOffset", default: 0)
|
||||||
static let lockScreenMusicVerticalOffset = Key<Double>("lockScreenMusicVerticalOffset", default: 0)
|
static let lockScreenMusicVerticalOffset = Key<Double>("lockScreenMusicVerticalOffset", default: 0)
|
||||||
static let lockScreenMusicPanelWidth = Key<Double>("lockScreenMusicPanelWidth", default: 420)
|
static let lockScreenMusicPanelWidth = Key<Double>("lockScreenMusicPanelWidth", default: 350)
|
||||||
static let lockScreenMusicAlbumParallaxEnabled = Key<Bool>("lockScreenMusicAlbumParallaxEnabled", default: false)
|
static let lockScreenMusicAlbumParallaxEnabled = Key<Bool>("lockScreenMusicAlbumParallaxEnabled", default: false)
|
||||||
static let lockScreenTimerVerticalOffset = Key<Double>("lockScreenTimerVerticalOffset", default: 0)
|
static let lockScreenTimerVerticalOffset = Key<Double>("lockScreenTimerVerticalOffset", default: 0)
|
||||||
static let lockScreenTimerWidgetWidth = Key<Double>("lockScreenTimerWidgetWidth", default: 420)
|
static let lockScreenTimerWidgetWidth = Key<Double>("lockScreenTimerWidgetWidth", default: 350)
|
||||||
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||||
static let lockScreenGlassCustomizationMode = Key<LockScreenGlassCustomizationMode>(
|
static let lockScreenGlassCustomizationMode = Key<LockScreenGlassCustomizationMode>(
|
||||||
"lockScreenGlassCustomizationMode",
|
"lockScreenGlassCustomizationMode",
|
||||||
|
|
@ -898,13 +903,29 @@ extension Defaults.Keys {
|
||||||
// MARK: Stats Feature
|
// MARK: Stats Feature
|
||||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||||
static let autoStartStatsMonitoring = Key<Bool>("autoStartStatsMonitoring", default: true)
|
static let autoStartStatsMonitoring = Key<Bool>("autoStartStatsMonitoring", default: true)
|
||||||
static let statsStopWhenNotchCloses = Key<Bool>("statsStopWhenNotchCloses", default: false)
|
static let statsStopWhenNotchCloses = Key<Bool>("statsStopWhenNotchCloses", default: true)
|
||||||
static let statsUpdateInterval = Key<Double>("statsUpdateInterval", default: 1.0)
|
static let statsUpdateInterval = Key<Double>("statsUpdateInterval", default: 1.0)
|
||||||
static let showCpuGraph = Key<Bool>("showCpuGraph", default: true)
|
static let showCpuGraph = Key<Bool>("showCpuGraph", default: true)
|
||||||
static let showMemoryGraph = Key<Bool>("showMemoryGraph", default: true)
|
static let showMemoryGraph = Key<Bool>("showMemoryGraph", default: true)
|
||||||
static let showGpuGraph = Key<Bool>("showGpuGraph", default: true)
|
static let showGpuGraph = Key<Bool>("showGpuGraph", default: true)
|
||||||
static let showNetworkGraph = Key<Bool>("showNetworkGraph", default: false)
|
static let showNetworkGraph = Key<Bool>("showNetworkGraph", default: false)
|
||||||
static let showDiskGraph = Key<Bool>("showDiskGraph", default: false)
|
static let showDiskGraph = Key<Bool>("showDiskGraph", default: false)
|
||||||
|
static let cpuTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("cpuTemperatureUnit", default: .celsius)
|
||||||
|
|
||||||
|
// MARK: Terminal Feature
|
||||||
|
static let enableTerminalFeature = Key<Bool>("enableTerminalFeature", default: false)
|
||||||
|
static let terminalShellPath = Key<String>("terminalShellPath", default: "/bin/zsh")
|
||||||
|
static let terminalFontSize = Key<Double>("terminalFontSize", default: 12.0)
|
||||||
|
static let terminalOpacity = Key<Double>("terminalOpacity", default: 1.0)
|
||||||
|
static let terminalMaxHeightFraction = Key<Double>("terminalMaxHeightFraction", default: 0.4)
|
||||||
|
static let terminalCursorStyle = Key<String>("terminalCursorStyle", default: "blinkBlock")
|
||||||
|
static let terminalScrollbackLines = Key<Int>("terminalScrollbackLines", default: 1000)
|
||||||
|
static let terminalOptionAsMeta = Key<Bool>("terminalOptionAsMeta", default: true)
|
||||||
|
static let terminalMouseReporting = Key<Bool>("terminalMouseReporting", default: true)
|
||||||
|
static let terminalBoldAsBright = Key<Bool>("terminalBoldAsBright", default: true)
|
||||||
|
static let terminalBackgroundColor = Key<Color>("terminalBackgroundColor", default: .black)
|
||||||
|
static let terminalForegroundColor = Key<Color>("terminalForegroundColor", default: .white)
|
||||||
|
static let terminalCursorColor = Key<Color>("terminalCursorColor", default: Color(.selectedControlColor))
|
||||||
|
|
||||||
// MARK: Timer Feature
|
// MARK: Timer Feature
|
||||||
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
||||||
|
|
@ -1004,6 +1025,9 @@ extension Defaults.Keys {
|
||||||
static let circularHUDSize = Key<CGFloat>("circularHUDSize", default: 65)
|
static let circularHUDSize = Key<CGFloat>("circularHUDSize", default: 65)
|
||||||
static let circularHUDStrokeWidth = Key<CGFloat>("circularHUDStrokeWidth", default: 4)
|
static let circularHUDStrokeWidth = Key<CGFloat>("circularHUDStrokeWidth", default: 4)
|
||||||
static let circularHUDUseAccentColor = Key<Bool>("circularHUDUseAccentColor", default: true)
|
static let circularHUDUseAccentColor = Key<Bool>("circularHUDUseAccentColor", default: true)
|
||||||
|
|
||||||
|
// MARK: BetterDisplay Integration
|
||||||
|
static let enableBetterDisplayIntegration = Key<Bool>("enableBetterDisplayIntegration", default: false)
|
||||||
|
|
||||||
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
|
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
|
||||||
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
|
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
|
||||||
|
|
@ -1046,7 +1070,7 @@ extension Defaults.Keys {
|
||||||
static let capsLockIndicatorUseGreenColor = Key<Bool>("capsLockIndicatorUseGreenColor", default: false) // Legacy toggle
|
static let capsLockIndicatorUseGreenColor = Key<Bool>("capsLockIndicatorUseGreenColor", default: false) // Legacy toggle
|
||||||
static let capsLockIndicatorTintMode = Key<CapsLockIndicatorTintMode>("capsLockIndicatorTintMode", default: .white)
|
static let capsLockIndicatorTintMode = Key<CapsLockIndicatorTintMode>("capsLockIndicatorTintMode", default: .white)
|
||||||
static let didMigrateCapsLockTintMode = Key<Bool>("didMigrateCapsLockTintMode", default: false)
|
static let didMigrateCapsLockTintMode = Key<Bool>("didMigrateCapsLockTintMode", default: false)
|
||||||
static let showCapsLockLabel = Key<Bool>("showCapsLockLabel", default: true)
|
static let showCapsLockLabel = Key<Bool>("showCapsLockLabel", default: false)
|
||||||
|
|
||||||
// MARK: ImageService
|
// MARK: ImageService
|
||||||
static let didClearLegacyURLCacheV1 = Key<Bool>("didClearLegacyURLCacheV1", default: false)
|
static let didClearLegacyURLCacheV1 = Key<Bool>("didClearLegacyURLCacheV1", default: false)
|
||||||
|
|
@ -1055,7 +1079,7 @@ extension Defaults.Keys {
|
||||||
static let enableMinimalisticUI = Key<Bool>("enableMinimalisticUI", default: false)
|
static let enableMinimalisticUI = Key<Bool>("enableMinimalisticUI", default: false)
|
||||||
|
|
||||||
// MARK: Lyrics Feature
|
// MARK: Lyrics Feature
|
||||||
static let enableLyrics = Key<Bool>("enableLyrics", default: true)
|
static let enableLyrics = Key<Bool>("enableLyrics", default: false)
|
||||||
|
|
||||||
// MARK: Notes Feature
|
// MARK: Notes Feature
|
||||||
static let enableNotes = Key<Bool>("enableNotes", default: false)
|
static let enableNotes = Key<Bool>("enableNotes", default: false)
|
||||||
|
|
|
||||||
|
|
@ -395,11 +395,16 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var helloCloseScheduled = false
|
||||||
|
|
||||||
func closeHello() {
|
func closeHello() {
|
||||||
|
guard !helloCloseScheduled else { return }
|
||||||
|
helloCloseScheduled = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
||||||
self?.coordinator.firstLaunch = false
|
guard let self else { return }
|
||||||
withAnimation(self?.animationLibrary.animation) {
|
self.coordinator.firstLaunch = false
|
||||||
self?.close()
|
withAnimation(self.animationLibrary.animation) {
|
||||||
|
self.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,98 @@ let downloadSneakSize: CGSize = .init(width: 65, height: 1)
|
||||||
let batterySneakSize: CGSize = .init(width: 160, height: 1)
|
let batterySneakSize: CGSize = .init(width: 160, height: 1)
|
||||||
|
|
||||||
var openNotchSize: CGSize {
|
var openNotchSize: CGSize {
|
||||||
let width = Defaults[.openNotchWidth]
|
let storedWidth = Defaults[.openNotchWidth]
|
||||||
return .init(width: width, height: 200) // Adjusted to a better middle ground
|
let minWidth = currentRecommendedMinimumNotchWidth()
|
||||||
|
let maxWidth = maxAllowedNotchWidth()
|
||||||
|
let width = min(max(storedWidth, minWidth), maxWidth)
|
||||||
|
return .init(width: width, height: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum notch width based on the current screen's point width.
|
||||||
|
/// Prevents the notch from extending beyond the screen on scaled displays.
|
||||||
|
func maxAllowedNotchWidth(for screenName: String? = nil) -> CGFloat {
|
||||||
|
let screen: NSScreen?
|
||||||
|
if let screenName {
|
||||||
|
screen = NSScreen.screens.first { $0.localizedName == screenName }
|
||||||
|
} else {
|
||||||
|
screen = NSScreen.main
|
||||||
|
}
|
||||||
|
guard let screenWidth = screen?.frame.width, screenWidth > 0 else {
|
||||||
|
return 900
|
||||||
|
}
|
||||||
|
return max(screenWidth - 60, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for the main screen.
|
||||||
|
func maxAllowedNotchWidth() -> CGFloat {
|
||||||
|
maxAllowedNotchWidth(for: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab-Based Notch Width
|
||||||
|
|
||||||
|
/// Counts the number of currently enabled standard notch tabs.
|
||||||
|
/// Mirrors the tab-building logic in ``TabSelectionView``.
|
||||||
|
func enabledStandardTabCount() -> Int {
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
// Home tab
|
||||||
|
if Defaults[.showStandardMediaControls] || Defaults[.showCalendar] || Defaults[.showMirror] {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shelf tab
|
||||||
|
if Defaults[.dynamicShelf] {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer tab (only in .tab display mode)
|
||||||
|
if Defaults[.enableTimerFeature] && Defaults[.timerDisplayMode] == .tab {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats tab
|
||||||
|
if Defaults[.enableStatsFeature] {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes / Clipboard tab
|
||||||
|
if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal tab
|
||||||
|
if Defaults[.enableTerminalFeature] {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the recommended minimum notch width for the given tab count.
|
||||||
|
func recommendedMinimumNotchWidth(forTabCount count: Int) -> CGFloat {
|
||||||
|
if count >= 6 { return 770 }
|
||||||
|
if count >= 5 { return 690 }
|
||||||
|
return 640
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the recommended minimum notch width for the current tab configuration.
|
||||||
|
func currentRecommendedMinimumNotchWidth() -> CGFloat {
|
||||||
|
recommendedMinimumNotchWidth(forTabCount: enabledStandardTabCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces the minimum notch width based on current tab count.
|
||||||
|
/// Also clamps to screen width so the notch never exceeds the display.
|
||||||
|
/// Only adjusts when not in minimalistic mode.
|
||||||
|
func enforceMinimumNotchWidth() {
|
||||||
|
guard !Defaults[.enableMinimalisticUI] else { return }
|
||||||
|
let minWidth = currentRecommendedMinimumNotchWidth()
|
||||||
|
let maxWidth = maxAllowedNotchWidth()
|
||||||
|
var width = Defaults[.openNotchWidth]
|
||||||
|
if width < minWidth { width = minWidth }
|
||||||
|
if width > maxWidth { width = maxWidth }
|
||||||
|
if Defaults[.openNotchWidth] != width {
|
||||||
|
Defaults[.openNotchWidth] = width
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180)
|
private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180)
|
||||||
private let minimalisticLyricsExtraHeight: CGFloat = 40
|
private let minimalisticLyricsExtraHeight: CGFloat = 40
|
||||||
|
|
@ -104,6 +194,51 @@ func addShadowPadding(to size: CGSize, isMinimalistic: Bool) -> CGSize {
|
||||||
CGSize(width: size.width, height: size.height + notchShadowPaddingValue(isMinimalistic: isMinimalistic))
|
CGSize(width: size.width, height: size.height + notchShadowPaddingValue(isMinimalistic: isMinimalistic))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether a specific screen should render the Dynamic Island pill
|
||||||
|
/// shape instead of the standard notch shape.
|
||||||
|
///
|
||||||
|
/// Returns `true` only when ALL of these conditions are met:
|
||||||
|
/// 1. The user has selected `.dynamicIsland` in `externalDisplayStyle`
|
||||||
|
/// 2. The screen does NOT have a physical notch (safeAreaInsets.top == 0)
|
||||||
|
///
|
||||||
|
/// Screens with a physical notch always use the standard notch shape.
|
||||||
|
func shouldUseDynamicIslandMode(for screenName: String?) -> Bool {
|
||||||
|
guard Defaults[.externalDisplayStyle] == .dynamicIsland else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedScreen: NSScreen? = NSScreen.main
|
||||||
|
if let screenName {
|
||||||
|
selectedScreen = NSScreen.screens.first(where: { $0.localizedName == screenName })
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let screen = selectedScreen else {
|
||||||
|
// No screen found — fallback to standard notch
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical notch screens always use standard notch shape
|
||||||
|
return screen.safeAreaInsets.top <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corner radius insets for the Dynamic Island pill shape.
|
||||||
|
/// - closed: half the closed notch height for a true capsule look
|
||||||
|
/// - opened: generous radius for smooth expanded pill
|
||||||
|
let dynamicIslandPillCornerRadiusInsets: (opened: CGFloat, closed: (standard: CGFloat, minimalistic: CGFloat)) = (
|
||||||
|
opened: 24,
|
||||||
|
closed: (standard: 16, minimalistic: 16)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Vertical offset from the top screen edge for the Dynamic Island pill.
|
||||||
|
/// Creates a visual gap so the pill floats below the menu bar, mimicking
|
||||||
|
/// the iPhone's Dynamic Island detachment from the physical screen edge.
|
||||||
|
let dynamicIslandTopOffset: CGFloat = 6
|
||||||
|
|
||||||
|
/// Extra horizontal padding applied OUTSIDE the pill clip shape in Dynamic
|
||||||
|
/// Island mode so the drop shadow has room to render without being clipped
|
||||||
|
/// by the outer frame constraint.
|
||||||
|
let dynamicIslandShadowInset: CGFloat = 14
|
||||||
|
|
||||||
enum MusicPlayerImageSizes {
|
enum MusicPlayerImageSizes {
|
||||||
static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0)
|
static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0)
|
||||||
static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20))
|
static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20))
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ struct CPUFrequencyMetrics: Equatable {
|
||||||
|
|
||||||
struct CPUTemperatureMetrics: Equatable {
|
struct CPUTemperatureMetrics: Equatable {
|
||||||
let celsius: Double?
|
let celsius: Double?
|
||||||
|
|
||||||
|
var fahrenheit: Double? {
|
||||||
|
guard let celsius else { return nil }
|
||||||
|
return celsius * 9.0 / 5.0 + 32.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class CPUSensorCollector {
|
final class CPUSensorCollector {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,17 @@
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Atoll</title>
|
<title>Atoll</title>
|
||||||
|
<item>
|
||||||
|
<title>2.0.0</title>
|
||||||
|
<pubDate>Fri, 06 Mar 2026 20:16:36 +0530</pubDate>
|
||||||
|
<sparkle:version>7</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2.0.0</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||||
|
<enclosure url="https://raw.githubusercontent.com/Ebullioscopic/Atoll/main/Updates/Atoll%202.0.0.dmg" length="23248911" type="application/octet-stream" sparkle:edSignature="kthVqCjb8GEpLC4HCduALBE8J0oag4nSSyVPfLkU8UEM6LFKkAUtfmfJPRf6SMuef8iplgPzSbzffGYcgcmpDA=="/>
|
||||||
|
<sparkle:deltas>
|
||||||
|
<enclosure url="https://raw.githubusercontent.com/Ebullioscopic/Atoll/main/Updates/Atoll7-4.delta" sparkle:deltaFrom="4" length="7293230" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="862304" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="B+Rh9QMG8T/1zP7cS9Rc23kt543iklE+35ZoZgRe5gzIlFWZWIMFx6dJCMTHJnUSC1RPi8ajEaFYDRO+gWBYAg=="/>
|
||||||
|
</sparkle:deltas>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>1.3.0-beta</title>
|
<title>1.3.0-beta</title>
|
||||||
<pubDate>Sat, 31 Jan 2026 23:54:50 +0530</pubDate>
|
<pubDate>Sat, 31 Jan 2026 23:54:50 +0530</pubDate>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue